diff --git a/.DS_Store b/.DS_Store index 250fc4d0..8a655fcc 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 2eec05c3..69c4c5f2 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ upmpdcli/ test_upnp*.cargo/ .cargo/ setup-env.sh +cache +gupnp-tools +pmocontrol_[0_9]*.txt +webapp_[0_9]*.txt \ No newline at end of file diff --git a/BROWSEMETADATA_FIX.md b/BROWSEMETADATA_FIX.md new file mode 100644 index 00000000..a3a19100 --- /dev/null +++ b/BROWSEMETADATA_FIX.md @@ -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 + + Unknown Title + object.item.audioItem.audioBroadcast + http://.../radioparadise/stream/mellow/flac + +``` + +### History Container Metadata ✅ +```bash +curl -X POST -H "SOAPAction: ..." BrowseMetadata radio-paradise:channel:mellow:history +``` +Returns: +```xml + + Mellow Mix - History + object.container.playlistContainer + +``` + +### 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/ diff --git a/Cargo.lock b/Cargo.lock index 0cee647c..41782122 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,12 +6,13 @@ version = 4 name = "PMOMusic" version = "0.1.0" dependencies = [ - "axum 0.8.6", + "axum 0.8.7", "console-subscriber", "pmoapp", "pmoaudio-ext", "pmoaudiocache", "pmoconfig", + "pmocontrol", "pmocovers", "pmomediarenderer", "pmomediaserver", @@ -49,6 +50,12 @@ dependencies = [ "equator", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "alsa" version = "0.9.1" @@ -103,7 +110,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -130,7 +137,7 @@ checksum = "f548ad2c4031f2902e3edc1f29c29e835829437de49562d8eb5dc5584d3a1043" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -163,7 +170,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -174,7 +181,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -252,11 +259,12 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" dependencies = [ "axum-core 0.5.5", + "axum-macros", "bytes", "form_urlencoded", "futures-util", @@ -337,10 +345,21 @@ dependencies = [ ] [[package]] -name = "axum-server" -version = "0.7.2" +name = "axum-macros" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "495c05f60d6df0093e8fb6e74aa5846a0ad06abaf96d76166283720bf740f8ab" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "axum-server" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9" dependencies = [ "bytes", "fs-err", @@ -373,7 +392,7 @@ dependencies = [ "parking_lot", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", "toml_edit 0.23.7", ] @@ -439,7 +458,7 @@ dependencies = [ "indexmap 2.12.0", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", "uuid", ] @@ -470,7 +489,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -509,6 +528,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "built" version = "0.7.1" @@ -538,7 +566,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -555,9 +583,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "c_linked_list" @@ -566,10 +594,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4964518bd3b4a8190e832886cdc0da9794f12e8e6c1613a9e90ff331c4c8724b" [[package]] -name = "cc" -version = "1.2.44" +name = "cassowary" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" dependencies = [ "find-msvc-tools", "jobserver", @@ -608,6 +651,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.42" @@ -619,7 +668,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -673,6 +722,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -882,6 +944,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi 0.3.9", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi 0.3.9", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -890,9 +977,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -945,7 +1032,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -965,7 +1052,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -999,6 +1086,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.10.0", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1007,7 +1104,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -1063,7 +1160,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -1074,9 +1171,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" dependencies = [ "serde", "serde_core", @@ -1116,9 +1213,9 @@ dependencies = [ [[package]] name = "exr" -version = "1.73.0" +version = "1.74.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" dependencies = [ "bit_field", "half", @@ -1170,7 +1267,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -1184,9 +1281,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "flacenc" @@ -1325,7 +1422,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -1366,9 +1463,9 @@ checksum = "8f5f3913fa0bfe7ee1fd8248b6b9f42a5af4b9d65ec2dd2c3c26132b950ecfc2" [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -1495,6 +1592,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", ] @@ -1619,9 +1718,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", @@ -1687,9 +1786,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" dependencies = [ "base64 0.22.1", "bytes", @@ -1907,7 +2006,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -2066,7 +2165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2168,7 +2267,7 @@ checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -2186,6 +2285,15 @@ dependencies = [ "imgref", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "mach2" version = "0.4.3" @@ -2294,6 +2402,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.1.0" @@ -2479,6 +2599,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -2557,7 +2689,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -2618,7 +2750,166 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", ] [[package]] @@ -2670,9 +2961,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openssl" -version = "0.10.74" +version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ "bitflags 2.10.0", "cfg-if", @@ -2691,7 +2982,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -2702,9 +2993,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.110" +version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", @@ -2730,14 +3021,18 @@ dependencies = [ [[package]] name = "os_info" -version = "3.12.0" +version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3" +checksum = "7c39b5918402d564846d5aba164c09a66cc88d232179dfd3e3c619a25a268392" dependencies = [ + "android_system_properties", "log", - "plist", + "nix", + "objc2", + "objc2-foundation", + "objc2-ui-kit", "serde", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2766,7 +3061,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2798,7 +3093,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -2819,19 +3114,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "plist" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" -dependencies = [ - "base64 0.22.1", - "indexmap 2.12.0", - "quick-xml 0.38.3", - "serde", - "time", -] - [[package]] name = "pmoapp" version = "0.1.0" @@ -2848,6 +3130,7 @@ dependencies = [ "bytemuck", "cpal", "futures-util", + "once_cell", "paste", "pmoflac", "pmometadata", @@ -2877,6 +3160,7 @@ dependencies = [ "pmoplaylist", "rand 0.8.5", "serde", + "serde_json", "tokio", "tokio-util", "tracing", @@ -2888,7 +3172,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.6", + "axum 0.8.7", "bytes", "chrono", "futures-util", @@ -2917,7 +3201,7 @@ name = "pmocache" version = "0.1.0" dependencies = [ "anyhow", - "axum 0.8.6", + "axum 0.8.7", "bytes", "chrono", "futures-util", @@ -2943,7 +3227,7 @@ name = "pmoconfig" version = "0.1.0" dependencies = [ "anyhow", - "axum 0.8.6", + "axum 0.8.7", "dirs", "lazy_static", "log", @@ -2957,12 +3241,44 @@ dependencies = [ "uuid", ] +[[package]] +name = "pmocontrol" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "axum 0.8.7", + "chrono", + "crossbeam-channel", + "crossterm", + "percent-encoding", + "pmodidl", + "pmoserver", + "pmoupnp", + "quick-xml 0.38.4", + "ratatui", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "tracing-log 0.1.4", + "tracing-subscriber", + "ureq", + "utoipa", + "xmltree 0.11.0", +] + [[package]] name = "pmocovers" version = "0.1.0" dependencies = [ "anyhow", - "axum 0.8.6", + "async-trait", + "axum 0.8.7", "image", "once_cell", "pmocache", @@ -2983,10 +3299,12 @@ version = "0.1.0" dependencies = [ "bevy_reflect", "bevy_reflect_derive", - "quick-xml 0.38.3", + "pmoutils", + "quick-xml 0.38.4", "serde", "utoipa", "utoipa-swagger-ui", + "xmltree 0.10.3", ] [[package]] @@ -3017,29 +3335,35 @@ dependencies = [ "once_cell", "pmodidl", "pmoupnp", - "quick-xml 0.38.3", + "quick-xml 0.38.4", ] [[package]] name = "pmomediaserver" version = "0.1.0" dependencies = [ + "anyhow", "async-trait", - "axum 0.8.6", + "axum 0.8.7", "bevy_reflect", "once_cell", + "pmoaudiocache", "pmoconfig", + "pmocovers", "pmodidl", "pmoparadise", + "pmoplaylist", "pmoqobuz", "pmoserver", "pmosource", "pmoupnp", - "quick-xml 0.38.3", + "pmoutils", + "quick-xml 0.38.4", "serde", "serde_json", "thiserror 1.0.69", "tokio", + "tokio-util", "tracing", "utoipa", ] @@ -3062,7 +3386,7 @@ dependencies = [ "anyhow", "async-stream", "async-trait", - "axum 0.8.6", + "axum 0.8.7", "bytes", "chrono", "claxon", @@ -3070,6 +3394,7 @@ dependencies = [ "futures", "futures-util", "hex", + "once_cell", "pmoaudio", "pmoaudio-ext", "pmoaudiocache", @@ -3103,19 +3428,23 @@ name = "pmoplaylist" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", + "axum 0.8.7", + "chrono", "once_cell", "pmoaudiocache", "pmocache", "pmoconfig", "pmodidl", "pmometadata", - "pmoupnp", "rusqlite", "serde", "serde_json", "thiserror 1.0.69", "tokio", + "tokio-stream", "tracing", + "utoipa", ] [[package]] @@ -3123,7 +3452,7 @@ name = "pmoqobuz" version = "0.1.0" dependencies = [ "anyhow", - "axum 0.8.6", + "axum 0.8.7", "chrono", "hex", "mockito", @@ -3152,7 +3481,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-stream", - "axum 0.8.6", + "axum 0.8.7", "axum-embed", "axum-server", "futures", @@ -3164,6 +3493,7 @@ dependencies = [ "serde_json", "tokio", "tokio-stream", + "tokio-util", "tracing", "tracing-subscriber", "utoipa", @@ -3176,7 +3506,8 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.6", + "axum 0.8.7", + "futures", "lazy_static", "pmoaudiocache", "pmoconfig", @@ -3189,6 +3520,7 @@ dependencies = [ "serde_json", "thiserror 1.0.69", "tokio", + "tokio-stream", "tracing", "utoipa", ] @@ -3198,12 +3530,14 @@ name = "pmoupnp" version = "0.1.0" dependencies = [ "anyhow", - "axum 0.8.6", + "axum 0.8.7", "base64 0.22.1", "bevy_reflect", "bevy_reflect_derive", "chrono", + "get_if_addrs", "hex", + "libc", "once_cell", "parking_lot", "pmoaudiocache", @@ -3211,12 +3545,14 @@ dependencies = [ "pmoconfig", "pmocovers", "pmodidl", + "pmoplaylist", "pmoserver", "pmoutils", "quick-xml 0.37.5", "reqwest", "serde", "serde_json", + "socket2 0.5.10", "thiserror 2.0.17", "tokio", "tracing", @@ -3224,7 +3560,7 @@ dependencies = [ "url", "utoipa", "uuid", - "xmltree", + "xmltree 0.11.0", ] [[package]] @@ -3234,8 +3570,10 @@ dependencies = [ "get_if_addrs", "netstat2", "os_info", + "quick-xml 0.38.4", "sysinfo", "users", + "xmltree 0.10.3", ] [[package]] @@ -3297,7 +3635,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -3334,7 +3672,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" dependencies = [ "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -3357,7 +3695,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -3421,9 +3759,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.38.3" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", "serde", @@ -3431,9 +3769,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -3503,6 +3841,26 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +dependencies = [ + "bitflags 2.10.0", + "cassowary", + "compact_str", + "crossterm", + "itertools 0.12.1", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + [[package]] name = "rav1e" version = "0.7.1" @@ -3722,7 +4080,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.108", + "syn 2.0.110", "walkdir", ] @@ -3770,7 +4128,9 @@ version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3895,7 +4255,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -3993,6 +4353,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.6" @@ -4095,12 +4476,50 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote", + "syn 2.0.110", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.110", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4315,9 +4734,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.108" +version = "2.0.110" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" dependencies = [ "proc-macro2", "quote", @@ -4341,7 +4760,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -4444,7 +4863,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -4455,7 +4874,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -4545,7 +4964,7 @@ checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ "bytes", "libc", - "mio", + "mio 1.1.0", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -4563,7 +4982,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -4595,6 +5014,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -4803,7 +5223,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -4816,6 +5236,17 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-log" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f751112709b4e791d8ce53e32c4ed2d353565a795ce84da2285393f41557bdf2" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -4842,7 +5273,7 @@ dependencies = [ "thread_local", "tracing", "tracing-core", - "tracing-log", + "tracing-log 0.2.0", ] [[package]] @@ -4875,6 +5306,29 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4887,6 +5341,35 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf-8", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.7" @@ -4909,6 +5392,12 @@ dependencies = [ "log", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4936,7 +5425,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -4945,7 +5434,7 @@ version = "9.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" dependencies = [ - "axum 0.8.6", + "axum 0.8.7", "base64 0.22.1", "mime_guess", "regex", @@ -5001,7 +5490,7 @@ checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -5101,7 +5590,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", "wasm-bindgen-shared", ] @@ -5148,10 +5637,19 @@ dependencies = [ ] [[package]] -name = "weezl" -version = "0.1.10" +name = "webpki-roots" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "wgpu-types" @@ -5252,9 +5750,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", + "windows-link", "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-strings", ] [[package]] @@ -5265,7 +5763,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -5276,15 +5774,9 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" @@ -5293,13 +5785,13 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-registry" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-link", + "windows-result 0.4.1", + "windows-strings", ] [[package]] @@ -5311,31 +5803,13 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -5344,7 +5818,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -5356,6 +5830,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5389,7 +5872,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -5407,6 +5890,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5429,7 +5927,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -5446,6 +5944,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5464,6 +5968,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5482,6 +5992,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5512,6 +6028,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5530,6 +6052,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5548,6 +6076,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5566,6 +6100,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5628,6 +6168,15 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + [[package]] name = "xmltree" version = "0.11.0" @@ -5656,7 +6205,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", "synstructure", ] @@ -5677,7 +6226,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -5697,7 +6246,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", "synstructure", ] @@ -5737,7 +6286,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 68467e41..8575c4c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,5 +19,5 @@ members = [ "pmosource", "pmoplaylist", "pmoflac", - "pmometadata", + "pmometadata", "pmocontrol", ] diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md new file mode 100644 index 00000000..9f79ccda --- /dev/null +++ b/DEPENDENCIES.md @@ -0,0 +1,72 @@ +## Diagramme des dépendances PMOMusic (crates internes) + +```mermaid +graph TD + + PMOMusic --> pmoapp + PMOMusic --> pmoaudio_ext + PMOMusic --> pmoaudiocache + PMOMusic --> pmocovers + PMOMusic --> pmoconfig + PMOMusic --> pmoserver + PMOMusic --> pmosource + PMOMusic --> pmoupnp + PMOMusic --> pmomediaserver + PMOMusic --> pmomediarenderer + PMOMusic --> pmoqobuz + PMOMusic --> pmoparadise + + pmoaudio_ext --> pmoaudio + pmoaudio_ext --> pmocovers + pmoaudio_ext --> pmocache + pmoaudio_ext --> pmoaudiocache + pmoaudio_ext --> pmometadata + pmoaudio_ext --> pmoplaylist + + pmoaudiocache --> pmocache + pmoaudiocache --> pmometadata + + pmocovers --> pmocache + + pmoplaylist --> pmocache + pmoplaylist --> pmoaudiocache + pmoplaylist --> pmometadata + pmoplaylist --> pmodidl + + pmosource --> pmoaudiocache + pmosource --> pmocovers + pmosource --> pmocache + pmosource --> pmoplaylist + pmosource --> pmodidl + pmosource --> pmoconfig + pmosource --> pmoserver + pmosource --> pmoupnp + + pmoparadise --> pmosource + pmoparadise --> pmoaudiocache + pmoparadise --> pmoplaylist + pmoparadise --> pmoserver + pmoparadise --> pmoconfig + + pmoqobuz --> pmosource + pmoqobuz --> pmoaudiocache + pmoqobuz --> pmocovers + pmoqobuz --> pmoserver + pmoqobuz --> pmoconfig + + pmomediaserver --> pmoserver + pmomediaserver --> pmosource + pmomediaserver --> pmoconfig + pmomediaserver --> pmocovers + pmomediaserver --> pmoaudiocache + pmomediaserver --> pmoplaylist + + pmoupnp --> pmoserver + pmoupnp --> pmocovers + pmoupnp --> pmoaudiocache + pmoupnp --> pmoplaylist + pmoupnp --> pmocache + pmoupnp --> pmoconfig +``` + +> Flèches = “dépend de”. Dépendances externes non représentées. Cette vue correspond aux features activées par défaut dans la workspace. diff --git a/PLAYER_PMOSOURCE_README.md b/PLAYER_PMOSOURCE_README.md new file mode 100644 index 00000000..838823ac --- /dev/null +++ b/PLAYER_PMOSOURCE_README.md @@ -0,0 +1,285 @@ +# Player Générique PMO Music + +## Vue d'ensemble + +Ce document décrit l'implémentation d'un nouveau player web générique qui utilise **uniquement** l'API du trait `pmosource` sans dépendre d'aucune implémentation spécifique (comme `pmoparadise`). + +## Objectifs + +L'objectif principal est de **tester l'API `pmosource` dans un cas d'application concret** afin d'identifier ce qui manque ou pourrait être amélioré dans l'API générique. + +## Architecture + +### 1. Service API TypeScript (`pmoapp/webapp/src/services/pmosource.ts`) + +Service qui encapsule toutes les interactions avec l'API REST de pmosource : + +```typescript +// Endpoints utilisés +GET /api/sources // Liste les sources +GET /api/sources/{id} // Info sur une source +GET /api/sources/{id}/root // Container racine +GET /api/sources/{id}/browse // Parcourt un container +GET /api/sources/{id}/resolve // Résout l'URI d'un item +GET /api/sources/{id}/image // Image de la source +GET /api/sources/{id}/capabilities // Capacités de la source +``` + +**Fonctions implémentées :** +- `listSources()` - Liste toutes les sources enregistrées +- `getSource(id)` - Récupère une source spécifique +- `getSourceRoot(id)` - Récupère le container racine +- `browseSource(id, objectId?, pagination?)` - Navigation dans les containers +- `resolveUri(sourceId, objectId)` - Résout l'URI de streaming +- `getSourceImageUrl(id)` - URL de l'image de la source + +### 2. Composant Player (`pmoapp/webapp/src/components/GenericMusicPlayer.vue`) + +Composant Vue.js qui implémente : + +#### Fonctionnalités implémentées + +1. **Sélection de sources** + - Affichage de toutes les sources disponibles + - Affichage du logo de chaque source + - Affichage des capacités (FIFO, Search, Favorites) + +2. **Navigation dans les containers** + - Breadcrumb pour remonter dans la hiérarchie + - Affichage des sous-containers (dossiers) + - Navigation par clic dans les containers + +3. **Liste des morceaux** + - Affichage de tous les items audio d'un container + - Métadonnées : titre, artiste, album, cover art + - Numérotation des morceaux + +4. **Lecteur audio** + - Lecture d'un morceau via résolution d'URI + - Contrôles audio natifs HTML5 + - Section "Now Playing" avec métadonnées + - Gestion des erreurs de lecture + +5. **Interface utilisateur** + - Design moderne avec dégradés et animations + - Responsive design + - Indicateurs visuels (morceau actif, en cours de lecture) + - Messages d'erreur clairs + +### 3. Intégration + +Le player a été configuré comme **page d'accueil par défaut** de l'application web PMO : + +```typescript +// router/index.ts +const routes = [ + { path: "/", name: "home", component: GenericMusicPlayer }, + // ... autres routes +] +``` + +## Ce qui fonctionne + +✅ **Complètement fonctionnel avec l'API actuelle de pmosource :** + +1. Découverte des sources disponibles +2. Navigation complète dans la hiérarchie des containers +3. Affichage des métadonnées des morceaux +4. Résolution des URIs et lecture audio +5. Affichage des images de sources +6. **Métadonnées temps réel via Server-Sent Events (SSE)** 🆕 + - Mise à jour automatique toutes les 3 secondes + - Pas de polling, push serveur + - Reconnexion automatique + +## Limitations identifiées et améliorations possibles + +### 1. Métadonnées de couverture d'album + +**Problème :** Le trait `MusicSource` n'expose pas directement de méthode pour résoudre les URIs de couvertures d'album. + +**État actuel :** +- Le champ `album_art` dans `Item` contient parfois une URI +- Le champ `album_art_pk` contient une clé primaire mais pas d'URL exploitable directement +- Certaines implémentations (pmoparadise) utilisent `/cache/cover/{pk}` mais ce n'est pas standardisé + +**Proposition :** +```rust +/// Résout l'URI de la couverture d'album pour un item +async fn resolve_cover_uri(&self, object_id: &str) -> Result>; +``` + +### 2. Recherche globale + +**Problème :** La méthode `search()` existe mais retourne `SearchNotSupported` par défaut. + +**État actuel :** +- Pas d'interface standardisée pour la recherche dans l'UI +- Pas de retour clair sur les capacités de recherche + +**Proposition :** +- Utiliser `capabilities().supports_search` pour afficher/masquer l'UI de recherche +- Documenter clairement le format attendu des requêtes de recherche + +### 3. Pagination + +**Problème :** L'API supporte la pagination mais les métadonnées ne permettent pas de connaître le nombre total d'items. + +**État actuel :** +- `BrowseResponse.total` retourne le nombre d'items retournés, pas le total disponible +- Pas de méthode `get_total_count(object_id)` dans le trait + +**Proposition :** +```rust +/// Retourne le nombre total d'items dans un container +async fn get_total_count(&self, object_id: &str) -> Result; +``` + +Ou ajouter `total_available` dans `BrowseResponse` : +```rust +pub struct SourceBrowseResponse { + // ... champs existants + pub total_available: Option, // Total disponible (pas juste retourné) +} +``` + +### 4. Métadonnées de stream en temps réel ✅ **IMPLÉMENTÉ** + +**Solution implémentée :** +- ✅ Méthode `get_item(object_id)` dans le trait `MusicSource` +- ✅ Endpoint REST `GET /api/sources/{id}/item?object_id={id}` pour récupérer les métadonnées d'un item +- ✅ Endpoint SSE `GET /api/sources/{id}/item/stream?object_id={id}` pour recevoir les mises à jour en temps réel +- ✅ Le player web utilise Server-Sent Events (SSE) pour les métadonnées temps réel + +**Comment ça fonctionne :** +1. Le serveur envoie automatiquement les métadonnées à jour toutes les 3 secondes via SSE +2. Le client se connecte avec `EventSource` (API browser native) +3. Les métadonnées sont automatiquement mises à jour dans l'interface sans polling + +**Pour RadioParadise :** +- La méthode `get_item()` pour les live streams récupère les métadonnées depuis `/radioparadise/metadata/{slug}` +- Le SSE permet d'avoir les métadonnées à jour en moins de 3 secondes (au lieu de 10 secondes avec le polling) + +### 5. Playlists utilisateur + +**Problème :** Les méthodes existent (`get_user_playlists()`, `add_to_playlist()`) mais retournent `NotSupported` par défaut. + +**État actuel :** +- Pas encore testé dans le player +- Nécessiterait une UI dédiée + +**Proposition :** +- Créer une section "Playlists" dans le player +- Tester l'API avec une implémentation qui supporte les playlists (ex: Qobuz) + +### 6. Favoris + +**Problème :** Similaire aux playlists, l'API existe mais n'est pas testée. + +**Proposition :** +- Ajouter un bouton "⭐ Favoris" sur chaque morceau +- Afficher visuellement les morceaux favoris +- Créer une section "Mes Favoris" + +### 7. Auto-play / Queue + +**Problème :** Il n'y a pas de méthode pour gérer une file d'attente de lecture. + +**Proposition :** +```rust +/// Interface pour gérer une queue de lecture +pub trait Playable: MusicSource { + async fn get_next_track(&self) -> Result>; + async fn get_previous_track(&self) -> Result>; + async fn add_to_queue(&self, item: Item) -> Result<()>; + async fn clear_queue(&self) -> Result<()>; + async fn get_queue(&self) -> Result>; +} +``` + +### 8. Durée totale d'un container + +**Problème :** Pour afficher "Album: 45:32 min, 12 morceaux", il faut parcourir tous les items. + +**Proposition :** +```rust +/// Statistiques d'un container spécifique +async fn get_container_stats(&self, object_id: &str) -> Result; + +pub struct ContainerStats { + pub item_count: usize, + pub total_duration_ms: Option, + pub total_size_bytes: Option, +} +``` + +### 9. Formats audio disponibles + +**Problème :** La méthode `get_available_formats()` existe mais n'est pas exploitée dans l'UI. + +**Proposition :** +- Ajouter un sélecteur de qualité dans le player +- Afficher les formats disponibles (FLAC 24/96, MP3 320, etc.) + +### 10. État du cache + +**Problème :** Les méthodes existent (`get_cache_status()`, `cache_item()`) mais ne sont pas intégrées. + +**Proposition :** +- Afficher un indicateur de cache sur chaque morceau +- Bouton "📥 Télécharger" pour mettre en cache +- Barre de progression pour le téléchargement + +## Prochaines étapes + +### Court terme +1. ✅ Tester le player avec `pmoparadise` (déjà implémenté) +2. 🔄 Identifier les bugs et limitations pratiques +3. 🔄 Tester avec une deuxième source (ex: `pmoqobuz`) pour valider la généricité + +### Moyen terme +1. Implémenter les fonctionnalités manquantes identifiées ci-dessus +2. Ajouter la gestion de queue et auto-play +3. Ajouter la recherche si supportée +4. Intégrer la gestion du cache + +### Long terme +1. Support des playlists utilisateur +2. Support des favoris +3. Égaliseur et effets audio +4. Visualisations audio +5. Mode hors-ligne avec cache + +## Conclusion + +Le player générique démontre que **l'API `pmosource` est déjà très utilisable** pour créer une application musicale fonctionnelle. Les principales limitations concernent : + +1. **Les métadonnées de couvertures** (pas d'URL standardisée) +2. **La pagination avancée** (pas de compte total) +3. **Les métadonnées temps réel** (pour les streams live) +4. **La gestion de queue** (pas d'API dédiée) + +Ces limitations ne sont pas bloquantes mais leur résolution améliorerait significativement l'expérience utilisateur et la complétude de l'API. + +## Utilisation + +Pour tester le player : + +1. Lancer le serveur backend avec au moins une source enregistrée : + ```bash + cargo run --example single_channel_server --features full + ``` + +2. Accéder à l'application web : + ``` + http://localhost:8080/app/ + ``` + +3. Le player devrait afficher automatiquement les sources disponibles et permettre la navigation et la lecture. + +## Remarques importantes + +- ✅ Le player **n'utilise QUE l'API pmosource générique** +- ✅ Aucune dépendance sur `pmoparadise` ou toute autre implémentation spécifique +- ✅ Tout est basé sur les endpoints REST de `pmosource::api` +- ✅ Le code est totalement réutilisable pour toute nouvelle source (Qobuz, Spotify, etc.) diff --git a/audio_cache/audio_cache.db b/PMOMusic/.pmomusic/cache_audio/cache.db similarity index 60% rename from audio_cache/audio_cache.db rename to PMOMusic/.pmomusic/cache_audio/cache.db index 68743070..185f730c 100644 Binary files a/audio_cache/audio_cache.db and b/PMOMusic/.pmomusic/cache_audio/cache.db differ diff --git a/PMOMusic/.pmomusic/cache_covers/cache.db b/PMOMusic/.pmomusic/cache_covers/cache.db new file mode 100644 index 00000000..185f730c Binary files /dev/null and b/PMOMusic/.pmomusic/cache_covers/cache.db differ diff --git a/PMOMusic/.pmomusic/config.yaml b/PMOMusic/.pmomusic/config.yaml new file mode 100644 index 00000000..a24c45a7 --- /dev/null +++ b/PMOMusic/.pmomusic/config.yaml @@ -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 diff --git a/pmoapp/webapp/.pmomusic_audio/cache.db b/PMOMusic/.pmomusic/playlists/playlists.db similarity index 79% rename from pmoapp/webapp/.pmomusic_audio/cache.db rename to PMOMusic/.pmomusic/playlists/playlists.db index 6bb75df6..0573c144 100644 Binary files a/pmoapp/webapp/.pmomusic_audio/cache.db and b/PMOMusic/.pmomusic/playlists/playlists.db differ diff --git a/PMOMusic/Cargo.toml b/PMOMusic/Cargo.toml index d31b3de9..b17201d9 100644 --- a/PMOMusic/Cargo.toml +++ b/PMOMusic/Cargo.toml @@ -14,6 +14,7 @@ pmocovers = { path = "../pmocovers", features = ["pmoserver"] } pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"]} pmoaudio-ext = { path = "../pmoaudio-ext", features = ["all"] } pmoapp = { path = "../pmoapp", features = ["pmoserver"] } +pmocontrol = { path = "../pmocontrol", features = ["pmoserver"] } tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] } tracing = "0.1.41" diff --git a/PMOMusic/src/main.rs b/PMOMusic/src/main.rs index f17b1220..9db7cac2 100644 --- a/PMOMusic/src/main.rs +++ b/PMOMusic/src/main.rs @@ -1,6 +1,9 @@ use pmoapp::{WebAppExt, Webapp}; +use pmocontrol::ControlPointExt; use pmomediarenderer::MEDIA_RENDERER; -use pmomediaserver::{MEDIA_SERVER, sources::SourcesExt}; +use pmomediaserver::{ + MEDIA_SERVER, MediaServerDeviceExt, ParadiseStreamingExt, sources::SourcesExt, +}; use pmoserver::Server; use pmosource::MusicSourceExt; use pmoupnp::UpnpServerExt; @@ -36,13 +39,19 @@ async fn main() -> Result<(), Box> { info!("🎵 Registering music sources..."); // // Enregistrer Qobuz - // if let Err(e) = server.register_qobuz().await { + // if let Err(e) = server.write().await.register_qobuz().await { // tracing::warn!("⚠️ Failed to register Qobuz: {}", e); // } - // Enregistrer Radio Paradise (inclut l'initialisation de l'API) - if let Err(e) = server.write().await.register_paradise().await { - tracing::warn!("⚠️ Failed to register Radio Paradise: {}", e); + // Initialiser les canaux de streaming Radio Paradise (pipelines + routes HTTP) + info!("📻 Initializing Radio Paradise streaming channels..."); + if let Err(e) = server.write().await.init_paradise_streaming().await { + tracing::warn!("⚠️ Failed to initialize Paradise streaming: {}", e); + } else { + // Enregistrer la source Radio Paradise UPnP (inclut l'initialisation de l'API) + if let Err(e) = server.write().await.register_paradise().await { + tracing::warn!("⚠️ Failed to register Radio Paradise source: {}", e); + } } // Lister toutes les sources enregistrées @@ -75,12 +84,29 @@ async fn main() -> Result<(), Box> { .await .expect("Failed to register MediaServer"); + // Enregistrer l'instance ContentDirectory pour les notifications GENA + if let Some(cd_service) = server_instance.get_service("ContentDirectory") { + pmomediaserver::contentdirectory::state::register_instance(&cd_service); + } + + // Initialiser les ProtocolInfo du MediaServer + server_instance.init_protocol_info(); + info!( "✅ MediaServer ready at {}{}", server_instance.base_url(), server_instance.description_route() ); + // Enregistrer le Control Point (découverte renderers/serveurs + API REST + SSE) + info!("🎛️ Registering Control Point..."); + let _control_point = server + .write() + .await + .register_control_point(5) + .await + .expect("Failed to register Control Point"); + // Ajouter la webapp via le trait WebAppExt info!("📡 Registering Web application..."); server @@ -96,7 +122,16 @@ async fn main() -> Result<(), Box> { info!("✅ PMOMusic is ready!"); info!("Press Ctrl+C to stop..."); + + // Attendre le signal Ctrl+C et l'arrêt du serveur HTTP server.write().await.wait().await; - Ok(()) + // Le serveur HTTP est arrêté, mais des threads (ControlPoint, etc.) peuvent encore tourner + // Attendre 2 secondes pour laisser le temps aux threads de se terminer + info!("Waiting for background threads to finish..."); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + // Forcer l'arrêt du processus (les threads du ControlPoint tournent en boucle infinie) + info!("✅ PMOMusic stopped"); + std::process::exit(0); } diff --git a/Plan_d_implementation_webui_control_point.md b/Plan_d_implementation_webui_control_point.md new file mode 100644 index 00000000..a4774778 --- /dev/null +++ b/Plan_d_implementation_webui_control_point.md @@ -0,0 +1,447 @@ +# PMOControl WebUI - Design Recommendations & Implementation Plan + +## Executive Summary + +Based on my analysis of the existing codebase, I'm providing comprehensive recommendations for implementing a Vue.js WebUI for PMOControl. The system already has: +- A complete REST API with OpenAPI documentation (`/api/control/*`) +- SSE endpoints for real-time updates (`/api/control/events/*`) +- Vue 3 + TypeScript + Vite setup +- Existing components (GenericMusicPlayer, UpnpExplorer, Cache Managers, LogView) + +--- + +## Design Decisions & Recommendations + +### 1. State Management: **Use Pinia** + +**Recommendation: Pinia (Vue 3's official state management)** + +**Rationale:** +- **Centralized real-time state**: Essential for managing SSE updates from multiple sources (renderers, media servers) +- **Multi-client synchronization**: Single source of truth for renderer states, volumes, playback positions +- **TypeScript native**: Better type inference than Vuex +- **DevTools integration**: Built-in debugging for SSE event flows +- **Composition API friendly**: Matches existing Vue 3 patterns in codebase +- **Performance**: Lightweight (~1KB), modular stores +- **Official Vue 3 recommendation**: Future-proof choice + +**Store Architecture:** +```typescript +// stores/renderers.ts - Renderer state (SSE updates) +// stores/mediaServers.ts - Media server state (SSE updates) +// stores/playback.ts - Current playback session +// stores/ui.ts - UI state (selected renderer, view preferences) +``` + +**Benefits for your use case:** +- Handle 20+ concurrent clients with shared state +- Real-time SSE event synchronization across all views +- Easy to scale with multi-renderer, multi-server, multi-session architecture + +--- + +### 2. UI Component Library: **Headless UI + Custom Components** + +**Recommendation: Hybrid approach - Headless UI components + custom styling** + +**Component Library: Shadcn-vue (Headless UI primitives)** + +**Rationale:** +- **Lightweight & performant**: Only import what you need +- **Full style control**: Match "carte uniforme, responsive, colorée selon statut" spec exactly +- **TypeScript-first**: Perfect type safety +- **Accessibility built-in**: ARIA compliance out of the box +- **No theme lock-in**: Complete CSS freedom +- **Composable primitives**: Card, Dialog, Dropdown, Slider components + +**Why NOT a full framework (Vuetify, Element Plus)?** +- Heavy bundle size (100-500KB vs ~10KB for headless) +- Theme customization overhead +- Your spec requires custom status-based coloring +- Performance critical with 20 concurrent clients + +**Alternative if you prefer pre-styled:** PrimeVue +- Good performance +- Customizable themes +- Strong TypeScript support +- But: 150KB+ bundle size + +**Custom Components to Build:** +- `RendererCard` - Status-colored cards for each renderer +- `TransportControls` - Play/Pause/Stop/Next buttons +- `VolumeControl` - Slider with mute toggle +- `QueueViewer` - Playlist display with drag-drop +- `MediaServerBrowser` - Container navigation + +--- + +### 3. Existing Components: **Reorganize into Debug Section** + +**Recommendation: Keep existing components, create new PMOControl home** + +**Structure:** +``` +/app (root) → PMOControl Dashboard (NEW) +/app/debug → Dropdown menu + ├─ /logs → LogView + ├─ /upnp → UpnpExplorer + ├─ /covers-cache → CoverCacheManager + ├─ /audio-cache → AudioCacheManager + ├─ /api-dashboard → APIDashboard + └─ /radio-paradise → RadioParadiseExplorer +``` + +**Rationale:** +- Existing components are valuable for development/debugging +- Don't break existing functionality +- PMOControl becomes primary interface as specified +- Debug tools remain accessible but not prominent +- Matches current App.vue dropdown pattern + +**Home Screen (/) - PMOControl Dashboard:** +- Grid of renderer cards (status-colored) +- Active playback session viewer +- Quick controls (play/pause/volume) +- Media server browser panel + +--- + +### 4. Responsive Design: **Mobile-first with 3 breakpoints** + +**Recommendation: Follow existing 768px pattern + add tablet/desktop** + +**Breakpoints:** +```css +/* Mobile: < 768px (existing pattern) */ +- Single column layout +- Stacked renderer cards +- Bottom-fixed playback controls +- Collapsible media browser + +/* Tablet: 768px - 1024px */ +- Two column layout +- Grid of renderer cards (2 columns) +- Side panel for media browser +- Floating playback controls + +/* Desktop: > 1024px */ +- Three column layout +- Renderer cards grid (3-4 columns) +- Persistent media browser sidebar +- Always-visible playback controls +``` + +**Target Devices:** +- **Primary**: Desktop browsers (control station) +- **Secondary**: Tablets (remote control) +- **Tertiary**: Mobile phones (quick controls) + +**Performance considerations:** +- Virtualized lists for 20+ renderers (use vue-virtual-scroller) +- Lazy load album art +- Throttle SSE position updates (max 1/sec per renderer) + +--- + +### 5. Icons: **Lucide Icons (SVG library)** + +**Recommendation: Lucide Icons (NOT emoji)** + +**Rationale:** +- **Professional appearance**: Emojis inconsistent across platforms +- **Customizable**: Size, color, stroke width +- **Lightweight**: Tree-shakeable SVG imports (~1KB per icon) +- **Status coloring**: Icons can match card status colors +- **Accessibility**: Proper ARIA labels +- **Vue components**: `lucide-vue-next` package + +**Icon mapping:** +```typescript +Play → PlayCircle +Pause → PauseCircle +Stop → StopCircle +Next → SkipForward +Volume → Volume2 / VolumeX (muted) +Renderer → Speaker / MonitorSpeaker +Server → Server / Database +Queue → ListMusic +``` + +**Alternative if you prefer minimal bundle:** Heroicons +- Smaller set (fewer icons) +- Tailwind CSS integration +- But: less comprehensive for music player needs + +**Why NOT emoji:** +- Platform inconsistencies (iOS ≠ Android ≠ Windows) +- No color control +- Accessibility issues +- Unprofessional for production UI + +--- + +## Technical Architecture + +### Real-time SSE Integration + +**SSE Event Handling:** +```typescript +// services/controlPointSSE.ts +class ControlPointSSE { + private eventSource: EventSource + private renderersStore: ReturnType + + connect() { + this.eventSource = new EventSource('/api/control/events') + + this.eventSource.addEventListener('control', (e) => { + const event = JSON.parse(e.data) + + if (event.category === 'renderer') { + this.handleRendererEvent(event) + } else if (event.category === 'media_server') { + this.handleServerEvent(event) + } + }) + } + + handleRendererEvent(event: RendererEventPayload) { + switch (event.type) { + case 'state_changed': + this.renderersStore.updateState(event.renderer_id, event.state) + break + case 'volume_changed': + this.renderersStore.updateVolume(event.renderer_id, event.volume) + break + // ... handle all event types + } + } +} +``` + +**Store Integration:** +```typescript +// stores/renderers.ts +export const useRenderersStore = defineStore('renderers', () => { + const renderers = ref>(new Map()) + + // SSE updates + function updateState(id: string, state: string) { + const renderer = renderers.value.get(id) + if (renderer) { + renderer.transport_state = state + } + } + + // REST API calls + async function play(id: string) { + await fetch(`/api/control/renderers/${id}/play`, { method: 'POST' }) + // SSE will update state automatically + } + + return { renderers, updateState, play } +}) +``` + +### Performance Optimizations + +**For 20+ concurrent clients:** + +1. **Throttle position updates**: + ```typescript + const throttledPositionUpdate = throttle((id, pos) => { + store.updatePosition(id, pos) + }, 1000) // Max 1 update/second + ``` + +2. **Virtual scrolling** for renderer lists: + ```bash + npm install vue-virtual-scroller + ``` + +3. **Lazy load album art**: + ```vue + + ``` + +4. **Debounce volume sliders**: + ```typescript + const debouncedVolumeChange = debounce((id, vol) => { + api.setVolume(id, vol) + }, 300) + ``` + +5. **Memoize computed properties**: + ```typescript + const activeRenderers = computed(() => + renderers.value.filter(r => r.online) + ) + ``` + +--- + +## Implementation Roadmap + +### Phase 1: Core Infrastructure (Week 1) +1. Install Pinia + configure stores +2. Install Lucide Icons +3. Create SSE service layer +4. Setup store structure (renderers, servers, playback, ui) +5. Connect SSE events to stores + +### Phase 2: UI Components (Week 2) +5. Build RendererCard component (status-colored) +6. Build TransportControls component +7. Build VolumeControl component +8. Build QueueViewer component +9. Create responsive grid layouts + +### Phase 3: Dashboard Assembly (Week 3) +10. Create PMOControl home view +11. Integrate all components +12. Add media server browser panel +13. Implement responsive breakpoints +14. Add loading states & error handling + +### Phase 4: Polish & Testing (Week 4) +15. Test with 20+ concurrent clients +16. Performance profiling & optimization +17. Accessibility audit (ARIA, keyboard nav) +18. Cross-browser testing +19. Mobile/tablet testing +20. Documentation + +--- + +## Dependencies to Install + +```json +{ + "dependencies": { + "pinia": "^2.2.8", + "lucide-vue-next": "^0.470.0", + "vue-virtual-scroller": "^2.0.0-beta.8" + }, + "devDependencies": { + // Already installed: vue, vue-router, typescript, vite + } +} +``` + +**Total bundle size estimate:** +15KB gzipped (Pinia + Lucide + Virtual Scroller) + +--- + +## Status-based Coloring Scheme + +Based on "carte uniforme, responsive, colorée selon statut" spec: + +```css +/* Renderer Card Status Colors */ +.renderer-card.playing { + border-color: #22c55e; /* green */ + background: linear-gradient(135deg, #22c55e10, transparent); +} + +.renderer-card.paused { + border-color: #f59e0b; /* amber */ + background: linear-gradient(135deg, #f59e0b10, transparent); +} + +.renderer-card.stopped { + border-color: #6b7280; /* gray */ + background: linear-gradient(135deg, #6b728010, transparent); +} + +.renderer-card.offline { + border-color: #ef4444; /* red */ + background: linear-gradient(135deg, #ef444410, transparent); + opacity: 0.6; +} + +.renderer-card.transitioning { + border-color: #3b82f6; /* blue */ + background: linear-gradient(135deg, #3b82f610, transparent); + animation: pulse 2s infinite; +} +``` + +--- + +## Answers to Your Specific Questions + +### 1. State Management? +**Answer: Pinia** - Vue 3 official, perfect for SSE real-time updates, TypeScript native, lightweight + +### 2. UI Component Library? +**Answer: Headless UI (Shadcn-vue) + Custom Components** - Full control over status-based styling, lightweight, no theme lock-in + +### 3. Keep existing components? +**Answer: Yes, reorganize into Debug section** - Keep valuable dev tools, make PMOControl the new home screen + +### 4. Responsive breakpoints? +**Answer: Mobile-first with 3 breakpoints** - <768px (mobile), 768-1024px (tablet), >1024px (desktop) + +### 5. Icons? +**Answer: Lucide Icons (SVG library)** - Professional, customizable, status-colored, NOT emoji + +--- + +## Risk Mitigation + +**Potential challenges:** + +1. **SSE connection management across tabs** + - Solution: Use BroadcastChannel API for cross-tab sync + - Fallback: LocalStorage events + +2. **20+ renderers performance** + - Solution: Virtual scrolling + throttled updates + - Monitor: Chrome DevTools Performance profiler + +3. **Network reliability (SSE reconnection)** + - Solution: Exponential backoff reconnection + - UI indicator for connection status + +4. **Album art loading (CORS, 404s)** + - Solution: Proxy through backend + - Fallback: Default placeholder image + +5. **Browser compatibility (SSE support)** + - Chrome/Edge: Native support ✅ + - Firefox: Native support ✅ + - Safari: Native support ✅ + - IE11: Use EventSource polyfill + +--- + +## Success Metrics + +**Performance targets:** +- Initial load: <2s (FCP) +- SSE event latency: <100ms +- UI interaction: <16ms (60fps) +- Memory usage: <50MB with 20 renderers +- Bundle size: <250KB gzipped + +**Functionality checklist:** +- [ ] Display all discovered renderers in real-time +- [ ] Show accurate playback state (play/pause/stop) +- [ ] Volume control works across all renderer types +- [ ] Queue display syncs with server +- [ ] Media server browsing functional +- [ ] Playlist attachment working +- [ ] Responsive on mobile/tablet/desktop +- [ ] Accessible (WCAG AA compliance) +- [ ] 20+ concurrent clients supported + +--- + +## Next Steps + +1. **Review & approve** this plan with stakeholders +2. **Clarify any ambiguities** in requirements +3. **Set up development environment** (install dependencies) +4. **Begin Phase 1** (Core Infrastructure) + +Would you like me to proceed with implementation, or do you have questions about any of these recommendations? diff --git a/UPNP_ANALYSIS_REPORT.md b/UPNP_ANALYSIS_REPORT.md new file mode 100644 index 00000000..7882f90f --- /dev/null +++ b/UPNP_ANALYSIS_REPORT.md @@ -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 + + + + 1 + 0 + + + urn:schemas-upnp-org:device:MediaServer:1 + PMOMusic Media Server + PMOMusic + PMOMusic Media Server + uuid:8b8e9b19-9c65-4d59-b127-b34717658085 + + + + urn:schemas-upnp-org:service:ContentDirectory:1 + urn:upnp-org:serviceId:ContentDirectory + /device/.../service/ContentDirectory/desc.xml + /device/.../service/ContentDirectory/control + /device/.../service/ContentDirectory/event + + + urn:schemas-upnp-org:service:ConnectionManager:1 + ... + + + + +``` + +### Upmpdcli (Fonctionnel) + +```xml + + + + 1 + 1 + + + urn:schemas-upnp-org:device:MediaServer:1 + lesbonscomptes.com/upmpdcli + Upmpdcli Media Server + pizzicato-Music-mediaserver + + + image/png + 64 + 64 + 32 + /uuid-.../icon.png + + + uuid:c110358f-d885-b44a-d6d3-dca6329ead0d + + + + + +``` + +### 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 + + + + + + ⚠️ Vide + ⚠️ Vide + + + +``` + +#### Upmpdcli +```http +Status: 200 OK +Content-Type: text/xml; charset="utf-8" ✅ Présent + + + + + + + http-get:*:audio/flac:*,http-get:*:audio/mp3:*,... ✅ Formats listés + + + +``` + +### 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 + + + image/png + 64 + 64 + 32 + /icon.png + + +``` + +## 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. diff --git a/UPNP_FIX_SUMMARY.md b/UPNP_FIX_SUMMARY.md new file mode 100644 index 00000000..98296baf --- /dev/null +++ b/UPNP_FIX_SUMMARY.md @@ -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 + + + + +``` + +## Solution Implémentée + +### 1. Nouveau Module : `device_ext.rs` + +Création d'un trait d'extension `MediaServerDeviceExt` pour `Arc` 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 + + 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:* + + +``` + +## 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 ' + + + + +' \ + 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/ diff --git a/bubble_upmpdcli.pcap b/bubble_upmpdcli.pcap new file mode 100644 index 00000000..0d3d2c54 Binary files /dev/null and b/bubble_upmpdcli.pcap differ diff --git a/media_ b/media_ new file mode 100644 index 00000000..04dc376a --- /dev/null +++ b/media_ @@ -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> + 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; + | ^^^^^ + | + = 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; +97 + fn add_from_url(&self, url: &str, collection: Option<&str>) -> impl std::future::Future> + 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; + | ^^^^^ + | + = 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; +111 + fn add_from_file(&self, path: &str, collection: Option<&str>) -> impl std::future::Future> + 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; + | ^^^^^ + | + = 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; +118 + fn get(&self, pk: &str) -> impl std::future::Future> + 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>; + | ^^^^^ + | + = 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>; +125 + fn get_collection(&self, collection: &str) -> impl std::future::Future>> + 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> + 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> + 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 + 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( + | ^^^^^ + | + = 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( +392 | &mut self, +... +395 | content_type: &'static str, +396 ~ ) -> impl std::future::Future>>> + 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>> + 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>; + | ^^^^^ + | + = 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>; +197 + fn init_audio_cache_configured(&mut self) -> impl std::future::Future>> + 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>; + | ^^^^^ + | + = 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>; +36 + fn get_title(&self, pk: &str) -> impl std::future::Future>> + 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>; + | ^^^^^ + | + = 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>; +37 + fn get_artist(&self, pk: &str) -> impl std::future::Future>> + 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>; + | ^^^^^ + | + = 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>; +38 + fn get_album(&self, pk: &str) -> impl std::future::Future>> + 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>; + | ^^^^^ + | + = 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>; +39 + fn get_duration_secs(&self, pk: &str) -> impl std::future::Future>> + 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> { + | ^^^^^^^^^^^^^^^^^ +... +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 + 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, +99 ~ ) -> impl std::future::Future, 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, 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, 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, Arc), 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, Arc), anyhow::Error>; +158 + fn init_caches(&mut self) -> impl std::future::Future, Arc), 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>, 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>, anyhow::Error>; +225 + fn create_upnp_server() -> impl std::future::Future>, 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. diff --git a/package.json b/package.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/pmoapp/src/pmoserver_impl.rs b/pmoapp/src/pmoserver_impl.rs index d66f4d31..bbbbdd41 100644 --- a/pmoapp/src/pmoserver_impl.rs +++ b/pmoapp/src/pmoserver_impl.rs @@ -36,18 +36,44 @@ impl WebAppExt for Server { where W: RustEmbed + Clone + Send + Sync + 'static, { - let path = path.to_string(); - - self.add_spa::(&path).await; + let mount_path = normalize_mount_path(path); + mount_spa_with_trailing_slash_redirect::(self, &mount_path).await; } async fn add_webapp_with_redirect(&mut self, path: &str) where W: RustEmbed + Clone + Send + Sync + 'static, { - let path = path.to_string(); + let mount_path = normalize_mount_path(path); - self.add_spa::(&path).await; - self.add_redirect("/", &path).await; + mount_spa_with_trailing_slash_redirect::(self, &mount_path).await; + self.add_redirect("/", &mount_path).await; + } +} + +/// S'assure que les chemins SPA sont cohérents : `"/app"` devient `"/app"`, +/// tandis que `"/"` reste tel quel. Les espaces ou slashs multiples sont +/// nettoyés pour éviter des routes dupliquées. +fn normalize_mount_path(path: &str) -> String { + let trimmed = path.trim(); + + if trimmed.is_empty() || trimmed == "/" { + "/".to_string() + } else { + format!("/{}", trimmed.trim_matches('/')) + } +} + +/// Monte la SPA et ajoute automatiquement une redirection `"/app/" -> "/app"` +/// afin que les URLs avec slash final servent également l'application. +async fn mount_spa_with_trailing_slash_redirect(server: &mut Server, path: &str) +where + W: RustEmbed + Clone + Send + Sync + 'static, +{ + server.add_spa::(path).await; + + if path != "/" { + let trailing = format!("{}/", path.trim_end_matches('/')); + server.add_redirect(&trailing, path).await; } } diff --git a/pmoapp/webapp/package-lock.json b/pmoapp/webapp/package-lock.json index e8814061..09afc506 100644 --- a/pmoapp/webapp/package-lock.json +++ b/pmoapp/webapp/package-lock.json @@ -9,9 +9,12 @@ "version": "0.0.0", "dependencies": { "dompurify": "^3.2.7", + "lucide-vue-next": "^0.555.0", "marked": "^16.3.0", + "pinia": "^3.0.4", "vue": "^3.5.21", - "vue-router": "^4.5.1" + "vue-router": "^4.5.1", + "vue-virtual-scroller": "^2.0.0-beta.8" }, "devDependencies": { "@types/dompurify": "^3.0.5", @@ -957,6 +960,36 @@ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "license": "MIT" }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-kit/node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, "node_modules/@vue/language-core": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.1.0.tgz", @@ -1057,6 +1090,30 @@ "dev": true, "license": "MIT" }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -1165,6 +1222,33 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/lucide-vue-next": { + "version": "0.555.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.555.0.tgz", + "integrity": "sha512-7hczPsiMD/y+VNLpal5Q5Wv09kQxlHS0l/cM1xagrd+MA3i5umMm+PUXqllvsbgwAl3PHv27fo59h4PN02GM5A==", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -1186,6 +1270,12 @@ "node": ">= 20" } }, + "node_modules/mitt": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz", + "integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==", + "license": "MIT" + }, "node_modules/muggle-string": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", @@ -1218,6 +1308,12 @@ "dev": true, "license": "MIT" }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1230,6 +1326,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1237,6 +1334,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -1265,6 +1392,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.52.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", @@ -1316,6 +1449,27 @@ "node": ">=0.10.0" } }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1339,6 +1493,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1353,6 +1508,7 @@ "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -1434,6 +1590,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz", "integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.22", "@vue/compiler-sfc": "3.5.22", @@ -1450,6 +1607,24 @@ } } }, + "node_modules/vue-observe-visibility": { + "version": "2.0.0-alpha.1", + "resolved": "https://registry.npmjs.org/vue-observe-visibility/-/vue-observe-visibility-2.0.0-alpha.1.tgz", + "integrity": "sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-resize": { + "version": "2.0.0-alpha.1", + "resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz", + "integrity": "sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/vue-router": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz", @@ -1481,6 +1656,20 @@ "peerDependencies": { "typescript": ">=5.0.0" } + }, + "node_modules/vue-virtual-scroller": { + "version": "2.0.0-beta.8", + "resolved": "https://registry.npmjs.org/vue-virtual-scroller/-/vue-virtual-scroller-2.0.0-beta.8.tgz", + "integrity": "sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==", + "license": "MIT", + "dependencies": { + "mitt": "^2.1.0", + "vue-observe-visibility": "^2.0.0-alpha.1", + "vue-resize": "^2.0.0-alpha.1" + }, + "peerDependencies": { + "vue": "^3.2.0" + } } } } diff --git a/pmoapp/webapp/package.json b/pmoapp/webapp/package.json index bffcc82e..e71fba0b 100644 --- a/pmoapp/webapp/package.json +++ b/pmoapp/webapp/package.json @@ -10,9 +10,12 @@ }, "dependencies": { "dompurify": "^3.2.7", + "lucide-vue-next": "^0.555.0", "marked": "^16.3.0", + "pinia": "^3.0.4", "vue": "^3.5.21", - "vue-router": "^4.5.1" + "vue-router": "^4.5.1", + "vue-virtual-scroller": "^2.0.0-beta.8" }, "devDependencies": { "@types/dompurify": "^3.0.5", diff --git a/pmoapp/webapp/src/App.vue b/pmoapp/webapp/src/App.vue index f78be3a9..882ae346 100644 --- a/pmoapp/webapp/src/App.vue +++ b/pmoapp/webapp/src/App.vue @@ -1,7 +1,7 @@ @@ -63,6 +68,25 @@ const isDebugRoute = computed(() => { box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); } +.nav-logo { + font-size: 1.25rem; + font-weight: 700; + color: #fff !important; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 0.5rem 1rem !important; + border-radius: 6px; + text-decoration: none; + white-space: nowrap; + transition: all 0.2s; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.nav-logo:hover { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); +} + .main-nav a { color: #eee; padding: 0.5rem 1rem; @@ -77,7 +101,7 @@ const isDebugRoute = computed(() => { color: #fff; } -.main-nav a.router-link-active { +.main-nav a.router-link-active:not(.nav-logo) { background: #569cd6; color: #fff; font-weight: bold; diff --git a/pmoapp/webapp/src/assets/styles/pmocontrol.css b/pmoapp/webapp/src/assets/styles/pmocontrol.css new file mode 100644 index 00000000..00cd4dbb --- /dev/null +++ b/pmoapp/webapp/src/assets/styles/pmocontrol.css @@ -0,0 +1,436 @@ +/* Styles PMOControl spécifiques */ +@import './variables.css'; + +/* ======================================== + Reset & Base Styles + ======================================== */ +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + font-family: var(--font-sans); + color: var(--color-text); + background-color: var(--color-bg); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Touch-friendly tap targets on mobile */ +@media (max-width: 768px) { + button, a, [role="button"] { + min-height: 44px; + min-width: 44px; + } +} + +/* Focus visible for accessibility */ +*:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +/* ======================================== + Status Badge Styles + ======================================== */ +.status-badge { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.status-badge.playing { + background-color: var(--status-playing-bg); + color: var(--status-playing); + border: 1px solid var(--status-playing); +} + +.status-badge.paused { + background-color: var(--status-paused-bg); + color: var(--status-paused); + border: 1px solid var(--status-paused); +} + +.status-badge.stopped { + background-color: var(--status-stopped-bg); + color: var(--status-stopped); + border: 1px solid var(--status-stopped); +} + +.status-badge.offline { + background-color: var(--status-offline-bg); + color: var(--status-offline); + border: 1px solid var(--status-offline); +} + +.status-badge.transitioning { + background-color: var(--status-transitioning-bg); + color: var(--status-transitioning); + border: 1px solid var(--status-transitioning); + animation: pulse 2s infinite; +} + +/* ======================================== + Card Styles + ======================================== */ +.pmo-card { + background-color: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + box-shadow: var(--shadow-sm); + transition: box-shadow var(--transition-base), transform var(--transition-base); +} + +.pmo-card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.pmo-card.active { + border-color: var(--status-playing); + box-shadow: 0 0 0 2px var(--status-playing-bg); +} + +/* ======================================== + Renderer Card Status Borders + ======================================== */ +.renderer-card { + position: relative; +} + +.renderer-card.playing { + border-left: 4px solid var(--status-playing); +} + +.renderer-card.paused { + border-left: 4px solid var(--status-paused); +} + +.renderer-card.stopped { + border-left: 4px solid var(--status-stopped); +} + +.renderer-card.offline { + border-left: 4px solid var(--status-offline); + opacity: 0.6; +} + +/* ======================================== + Queue Item Styles + ======================================== */ +.queue-item { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-sm); + border-radius: var(--radius-md); + transition: background-color var(--transition-fast); +} + +.queue-item:hover { + background-color: var(--color-bg-secondary); +} + +.queue-item.current { + background-color: var(--status-playing-bg); + border: 1px solid var(--status-playing); + font-weight: 600; +} + +.queue-item.current::before { + content: '▶'; + color: var(--status-playing); + font-size: var(--text-lg); + margin-right: var(--spacing-xs); +} + +/* ======================================== + Button Styles + ======================================== */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-lg); + border: none; + border-radius: var(--radius-md); + font-size: var(--text-base); + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); + background-color: var(--color-bg-secondary); + color: var(--color-text); +} + +.btn:hover:not(:disabled) { + background-color: var(--color-bg-tertiary); + transform: translateY(-1px); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background-color: var(--status-playing); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background-color: #16a34a; +} + +.btn-icon { + padding: var(--spacing-sm); + aspect-ratio: 1; +} + +/* ======================================== + Grid Layouts (Responsive) + ======================================== */ +.grid-cards { + display: grid; + gap: var(--spacing-lg); + grid-template-columns: 1fr; +} + +/* Tablet: 2 columns */ +@media (min-width: 768px) { + .grid-cards { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Desktop: 3 columns */ +@media (min-width: 1024px) { + .grid-cards { + grid-template-columns: repeat(3, 1fr); + } +} + +/* ======================================== + Animations + ======================================== */ +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.fade-in { + animation: fadeIn var(--transition-base); +} + +.spin { + animation: spin 1s linear infinite; +} + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* ======================================== + Performance Optimizations + ======================================== */ +/* Use will-change for frequently animated elements */ +.progress-bar-fill, +input[type="range"]::-webkit-slider-thumb, +input[type="range"]::-moz-range-thumb { + will-change: transform; +} + +.btn:hover, +.pmo-card:hover { + will-change: transform, box-shadow; +} + +/* GPU acceleration for smoother animations */ +.fade-in, +.spin, +.status-badge.transitioning { + transform: translateZ(0); + backface-visibility: hidden; +} + +/* ======================================== + Volume Slider + ======================================== */ +input[type="range"] { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 6px; + border-radius: var(--radius-full); + background-color: var(--color-bg-tertiary); + outline: none; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--status-playing); + cursor: pointer; + transition: transform var(--transition-fast); +} + +input[type="range"]::-webkit-slider-thumb:hover { + transform: scale(1.2); +} + +input[type="range"]::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--status-playing); + border: none; + cursor: pointer; + transition: transform var(--transition-fast); +} + +input[type="range"]::-moz-range-thumb:hover { + transform: scale(1.2); +} + +/* ======================================== + Progress Bar (Seekbar) + ======================================== */ +.progress-bar { + width: 100%; + height: 6px; + background-color: var(--color-bg-tertiary); + border-radius: var(--radius-full); + overflow: hidden; + position: relative; +} + +.progress-bar-fill { + height: 100%; + background-color: var(--status-playing); + transition: width 100ms linear; +} + +/* ======================================== + Notification Toast + ======================================== */ +.toast { + position: fixed; + bottom: var(--spacing-lg); + right: var(--spacing-lg); + z-index: var(--z-toast); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + max-width: 400px; +} + +.toast-item { + padding: var(--spacing-md); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + animation: fadeIn var(--transition-base); +} + +.toast-item.success { + background-color: var(--status-playing); + color: white; +} + +.toast-item.error { + background-color: var(--status-offline); + color: white; +} + +.toast-item.warning { + background-color: var(--status-paused); + color: white; +} + +.toast-item.info { + background-color: var(--status-transitioning); + color: white; +} + +/* ======================================== + Utilities + ======================================== */ +.text-center { + text-align: center; +} + +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} diff --git a/pmoapp/webapp/src/assets/styles/variables.css b/pmoapp/webapp/src/assets/styles/variables.css new file mode 100644 index 00000000..e3569395 --- /dev/null +++ b/pmoapp/webapp/src/assets/styles/variables.css @@ -0,0 +1,113 @@ +/* Variables CSS globales pour PMOControl */ + +:root { + /* ======================================== + Status Colors + ======================================== */ + --status-playing: #22c55e; /* Vert pour en lecture */ + --status-playing-bg: #22c55e15; /* Background playing */ + --status-paused: #f59e0b; /* Orange pour en pause */ + --status-paused-bg: #f59e0b15; /* Background paused */ + --status-stopped: #6b7280; /* Gris pour arrêté */ + --status-stopped-bg: #6b728015; /* Background stopped */ + --status-offline: #ef4444; /* Rouge pour offline */ + --status-offline-bg: #ef444415; /* Background offline */ + --status-transitioning: #3b82f6; /* Bleu pour transition */ + --status-transitioning-bg: #3b82f615; /* Background transitioning */ + + /* ======================================== + Breakpoints (pour media queries) + ======================================== */ + --breakpoint-mobile: 768px; + --breakpoint-tablet: 1024px; + + /* ======================================== + Spacing Scale + ======================================== */ + --spacing-xs: 0.25rem; /* 4px */ + --spacing-sm: 0.5rem; /* 8px */ + --spacing-md: 1rem; /* 16px */ + --spacing-lg: 1.5rem; /* 24px */ + --spacing-xl: 2rem; /* 32px */ + --spacing-2xl: 3rem; /* 48px */ + + /* ======================================== + Border Radius + ======================================== */ + --radius-sm: 0.25rem; /* 4px */ + --radius-md: 0.5rem; /* 8px */ + --radius-lg: 1rem; /* 16px */ + --radius-full: 9999px; /* Pill shape */ + + /* ======================================== + Shadows + ======================================== */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1); + + /* ======================================== + Typography + ======================================== */ + --font-sans: system-ui, -apple-system, sans-serif; + --font-mono: ui-monospace, monospace; + + --text-xs: 0.75rem; /* 12px */ + --text-sm: 0.875rem; /* 14px */ + --text-base: 1rem; /* 16px */ + --text-lg: 1.125rem; /* 18px */ + --text-xl: 1.25rem; /* 20px */ + --text-2xl: 1.5rem; /* 24px */ + --text-3xl: 1.875rem; /* 30px */ + + /* ======================================== + Colors (neutral palette) + ======================================== */ + --color-bg: #ffffff; + --color-bg-secondary: #f3f4f6; + --color-bg-tertiary: #e5e7eb; + + --color-text: #111827; + --color-text-secondary: #6b7280; + --color-text-tertiary: #9ca3af; + + --color-border: #d1d5db; + --color-border-light: #e5e7eb; + + /* Primary color (brand) */ + --color-primary: #667eea; + --color-primary-hover: #5568d3; + + /* ======================================== + Transitions + ======================================== */ + --transition-fast: 150ms ease-in-out; + --transition-normal: 250ms ease-in-out; + --transition-base: 300ms ease-in-out; + --transition-slow: 500ms ease-in-out; + + /* ======================================== + Z-index layers + ======================================== */ + --z-dropdown: 100; + --z-modal: 200; + --z-toast: 300; + --z-tooltip: 400; +} + +/* Dark mode support (optionnel pour l'avenir) */ +@media (prefers-color-scheme: dark) { + :root { + --color-bg: #111827; + --color-bg-secondary: #1f2937; + --color-bg-tertiary: #374151; + + --color-text: #f9fafb; + --color-text-secondary: #d1d5db; + --color-text-tertiary: #9ca3af; + + --color-border: #4b5563; + --color-border-light: #374151; + } +} diff --git a/pmoapp/webapp/src/components/APIDashboard.vue b/pmoapp/webapp/src/components/APIDashboard.vue index c73f11f6..3d6e5159 100644 --- a/pmoapp/webapp/src/components/APIDashboard.vue +++ b/pmoapp/webapp/src/components/APIDashboard.vue @@ -44,9 +44,7 @@
-

- {{ api.description }} -

+

Aucune description disponible

@@ -86,6 +84,14 @@ diff --git a/pmoapp/webapp/src/components/GenericMusicPlayer.vue b/pmoapp/webapp/src/components/GenericMusicPlayer.vue new file mode 100644 index 00000000..86ba969f --- /dev/null +++ b/pmoapp/webapp/src/components/GenericMusicPlayer.vue @@ -0,0 +1,1627 @@ + + + + + diff --git a/pmoapp/webapp/src/components/NotificationToast.vue b/pmoapp/webapp/src/components/NotificationToast.vue new file mode 100644 index 00000000..e2a55222 --- /dev/null +++ b/pmoapp/webapp/src/components/NotificationToast.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/pmoapp/webapp/src/components/RadioParadiseExplorer.vue b/pmoapp/webapp/src/components/RadioParadiseExplorer.vue index c52ed2cb..821b08c9 100644 --- a/pmoapp/webapp/src/components/RadioParadiseExplorer.vue +++ b/pmoapp/webapp/src/components/RadioParadiseExplorer.vue @@ -16,7 +16,6 @@
-
- - {{ bitrate.name }} - + +
+

🎚️ Stream Configuration

+
+
+ +
+ +
+
{{ currentFormatDescription }}
+
+
+ +
+ + +
+
+
+ + +
+
-
- ❌ {{ bitratesError }} + +
+
+

🔧 Stream Diagnostics

+ +
+
+
+
Current Stream URL
+
+ {{ currentStreamUrl }} +
+
+
+
Format
+
{{ selectedFormat.toUpperCase() }}
+
+
+
Mode
+
{{ playbackMode === 'live' ? 'Live Stream' : 'Historical Playback' }}
+
+
+
Channel Slug
+
{{ currentChannelSlug }}
+
+
+
+ +
- -
- -

{{ audioError }}

- + +
+
+

📡 Playlist SSE (Radio Paradise)

+
+ + {{ playlistSseConnected ? 'Connected' : 'Disconnected' }} + + +
+
+ +
+ ❌ {{ playlistSseError }} +
+
+ Waiting for playlist events… +
+
+
+
+ {{ event.kind }} + {{ event.timestamp ? formatTimestamp(new Date(event.timestamp)) : '—' }} + from {{ event.source_client }} +
+
+
+ Playlist + {{ event.playlist_id }} +
+
+ Cache PK + {{ event.cache_pk }} +
+
+ Qualifier + {{ event.qualifier }} +
+
+ Received + {{ formatTimestamp(event.received_at) }} +
+
+
+
+
+ + + + + +
+

🧪 Quick Test Commands

+

Copy and paste these commands to test streams with external players

+
+
+
+ ffplay (FLAC) + +
+ {{ ffplayFlacCommand }} +
+
+
+ ffplay (OGG) + +
+ {{ ffplayOggCommand }} +
+
+
+ VLC + +
+ {{ vlcCommand }} +
+
+
+ curl (Download to file) + +
+ {{ curlCommand }} +
+
+
+ + +
+
+

🎵 Now Playing on Radio Paradise

+
+ + +
+
+ +
+ ❌ {{ audioError }} +
+ +
+ +
+
+ +
+ + +
+ + +
+ +
+ + +
+
+ Channel: + {{ channels.find(c => c.id === selectedChannel)?.name || 'Unknown' }} +
+
+ Format: + {{ selectedFormat.toUpperCase() }} +
+
+ Mode: + {{ playbackMode === 'live' ? 'Live' : 'Historic' }} +
+
+ Updated: + {{ formatTimestamp(playerMetadataLastUpdated) }} +
+
+
@@ -166,225 +399,6 @@
-
-
-

📡 Channel Status

- -
-
- ❌ {{ channelStatusError }} -
-
-
-
-
Channel
-
{{ channelStatus.slug }}
-
-
-
Active Clients
-
{{ channelStatus.active_clients }}
-
-
-
Queue Length
-
{{ channelStatus.queue_length }}
-
-
-
Update ID
-
{{ channelStatus.update_id }}
-
-
-
Last Change
-
- {{ channelStatus.last_change ? formatTimestamp(new Date(channelStatus.last_change)) : '—' }} -
-
-
-
History Entries
-
- {{ channelStatus.history_entries }} / {{ channelStatus.history_max_tracks }} -
-
-
-
Cache Collection
-
{{ channelStatus.cache_collection_id }}
-
-
-
Cache Tracks
-
- {{ channelStatus.cache_cached_tracks }} / {{ channelStatus.cache_total_tracks }} -
-
-
- -
-
- -
-
-

🎧 Live Playlist

-
- - -
-
- -
- ❌ {{ channelPlaylistError }} -
-
- ⏳ Loading playlist… -
-
-
-
-
- - {{ cacheStatusLabel(item.cache_status) }} - - -
-
-
{{ item.title }}
-
{{ item.artist || 'Unknown artist' }}
-
-
- {{ item.album }} - ⏱ {{ formatDuration(item.duration_ms) }} - ▶️ @{{ formatDuration(item.elapsed_ms) }} - 🕒 {{ formatTimestamp(new Date(item.started_at)) }} - 💾 {{ formatBytes(item.cache_status.size_bytes) }} -
-
- - - - - - Open resolved URI - -
-
- ✅ {{ trackExtrasFor(item).cacheRequestMessage }} -
-
- ❌ {{ trackExtrasFor(item).cacheError }} -
-
- ❌ Resolve error: {{ trackExtrasFor(item).resolveError }} -
-
- ❌ Formats error: {{ trackExtrasFor(item).formatsError }} -
-
-
- {{ format.format_id }} - {{ format.mime_type }} - {{ format.sample_rate }} Hz - {{ format.bit_depth }} bit - {{ format.bitrate }} kbps - {{ format.channels }} ch -
-
-
-
-
- No tracks currently queued. Try refreshing after playback starts. -
-
-
- -
-
-

🕰️ Recent History

- -
- -
- ❌ {{ channelHistoryError }} -
-
- ⏳ Loading history… -
-
-
- No history entries yet. -
-
-
{{ entry.title }}
-
- {{ entry.artist }} - • {{ entry.album }} - • {{ formatDuration(entry.duration_ms) }} - • {{ formatTimestamp(new Date(entry.started_at)) }} -
-
-
-
-

⏭️ Next Block Preview

@@ -477,6 +491,144 @@
+ +
+

🧪 Test Nouveaux Endpoints

+
+ +
+

Test: Cover URL avec Fallback

+
+ + + +
+
+
+ Source: {{ testCoverResult.cover_type }} +
+ +
+ URL: None (fallback manquant) +
+
+ Cover preview +
+
+
+ ❌ {{ testCoverError }} +
+
+ + +
+

Test: Stream URL Direct

+
+ + +
+
+
+ Event: {{ testStreamResult.event }} +
+
+ Duration: {{ formatDuration(testStreamResult.length_ms) }} +
+ +
+
+ ❌ {{ testStreamError }} +
+
+ + +
+

Test: Morceau par Index

+
+ + + +
+
+
+ Title: {{ testSongResult.title }} +
+
+ Artist: {{ testSongResult.artist }} +
+
+ Album: {{ testSongResult.album }} +
+
+ Duration: {{ formatDuration(testSongResult.duration_ms) }} +
+
+ Song cover +
+
+
+ ❌ {{ testSongError }} +
+
+
+
+
❌ {{ channelsError }} @@ -500,24 +652,117 @@ @@ -1951,31 +2025,724 @@ onUnmounted(() => { flex-wrap: wrap; } -.audio-player-container { - margin: 12px 0 24px; - padding: 16px; - border-radius: 8px; - background: rgba(0, 0, 0, 0.2); - border: 1px solid #333; +/* Enhanced Media Player Section */ +.enhanced-player-section { + background: linear-gradient(135deg, #1a1a2e 0%, #0f0f1e 100%); + border-radius: 12px; + padding: 24px; + margin: 20px 0 32px; + border: 2px solid rgba(0, 212, 255, 0.4); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.player-header { display: flex; - gap: 12px; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid rgba(0, 212, 255, 0.2); +} + +.player-header h3 { + margin: 0; + color: #00d4ff; + font-size: 1.3rem; +} + +.player-controls-header { + display: flex; + gap: 10px; align-items: center; } -.audio-error { - margin: 0; - color: #ff6b6b; - flex: 1; +.btn-refresh-meta { + background: rgba(0, 212, 255, 0.15); + border: 1px solid rgba(0, 212, 255, 0.4); + color: #00d4ff; + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 1.1rem; + transition: all 0.2s; + min-width: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.btn-refresh-meta:hover:not(:disabled) { + background: rgba(0, 212, 255, 0.25); + border-color: rgba(0, 212, 255, 0.6); +} + +.btn-refresh-meta:disabled { + opacity: 0.5; + cursor: not-allowed; } .btn-stop { padding: 8px 16px; - border-radius: 4px; - border: none; + border-radius: 6px; + border: 1px solid rgba(231, 76, 60, 0.4); cursor: pointer; - background: rgba(231, 76, 60, 0.2); + background: rgba(231, 76, 60, 0.15); color: #e74c3c; font-weight: bold; + transition: all 0.2s; +} + +.btn-stop:hover { + background: rgba(231, 76, 60, 0.25); + border-color: rgba(231, 76, 60, 0.6); +} + +.audio-error-banner { + background: rgba(255, 68, 68, 0.15); + border: 1px solid rgba(255, 68, 68, 0.4); + color: #ff6b6b; + padding: 16px; + border-radius: 8px; + margin-bottom: 16px; + font-weight: 500; +} + +.player-content { + display: flex; + flex-direction: column; + gap: 20px; +} + +.player-info { + display: flex; + gap: 24px; + align-items: flex-start; +} + +.player-cover { + flex-shrink: 0; +} + +.player-cover img { + width: 180px; + height: 180px; + object-fit: cover; + border-radius: 12px; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.6); + border: 2px solid rgba(0, 212, 255, 0.2); +} + +.player-metadata { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; + min-width: 0; +} + +.metadata-display { + display: flex; + flex-direction: column; + gap: 8px; +} + +.player-title { + font-size: 1.8rem; + font-weight: bold; + color: #ffffff; + line-height: 1.2; + word-wrap: break-word; +} + +.player-artist { + font-size: 1.4rem; + color: #00d4ff; + font-weight: 600; + line-height: 1.3; +} + +.player-album { + font-size: 1.1rem; + color: #9aa0a6; + font-style: italic; +} + +.player-year { + font-size: 0.95rem; + color: #666; + background: rgba(255, 255, 255, 0.05); + padding: 4px 10px; + border-radius: 4px; + display: inline-block; + align-self: flex-start; + margin-top: 4px; +} + +.metadata-loading { + color: #9aa0a6; + font-size: 1rem; + font-style: italic; +} + +.player-audio-controls { + width: 100%; +} + +.player-audio-controls audio { + width: 100%; + border-radius: 8px; + background: #0a0a0a; + outline: none; +} + +.player-stream-info { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + padding: 16px; + background: rgba(0, 0, 0, 0.3); + border-radius: 8px; + border: 1px solid rgba(0, 212, 255, 0.15); +} + +.stream-info-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.info-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #9aa0a6; + font-weight: 600; +} + +.info-value { + font-size: 1rem; + color: #f5f5f5; + font-weight: 500; +} + +/* Stream Controls Section */ +.stream-controls-section { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + border: 1px solid rgba(0, 212, 255, 0.3); +} + +.stream-controls-section h3 { + margin-top: 0; + color: #00d4ff; + margin-bottom: 16px; +} + +.stream-controls-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 10px; +} + +.control-group label { + font-weight: 600; + color: #9aa0a6; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.format-chips, +.mode-chips { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.format-chip, +.mode-chip { + padding: 10px 18px; + border-radius: 6px; + border: 2px solid rgba(0, 212, 255, 0.3); + background: rgba(0, 212, 255, 0.08); + color: #00d4ff; + cursor: pointer; + font-weight: 600; + transition: all 0.2s; + font-size: 0.9rem; +} + +.format-chip:hover, +.mode-chip:hover { + background: rgba(0, 212, 255, 0.15); + border-color: rgba(0, 212, 255, 0.5); + transform: translateY(-2px); +} + +.format-chip.active, +.mode-chip.active { + background: rgba(0, 212, 255, 0.25); + border-color: #00d4ff; + box-shadow: 0 0 12px rgba(0, 212, 255, 0.3); +} + +.format-description { + padding: 8px 12px; + background: rgba(0, 0, 0, 0.3); + border-radius: 4px; + font-size: 0.85rem; + color: #9aa0a6; + font-style: italic; +} + +.client-id-input { + padding: 10px 12px; + border-radius: 6px; + border: 1px solid #333; + background: #0f0f0f; + color: #fff; + font-family: 'Courier New', monospace; + font-size: 0.9rem; +} + +.client-id-input:focus { + outline: none; + border-color: #00d4ff; + box-shadow: 0 0 8px rgba(0, 212, 255, 0.2); +} + +/* Stream Diagnostics Section */ +.stream-diagnostics-section { + background: #1a1a1a; + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + border: 1px solid #333; +} + +.stream-diagnostics-section h3 { + margin-top: 0; + color: #00d4ff; +} + +/* Stream Metadata Section */ +.stream-metadata-section { + background: #1a1a1a; + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + border: 1px solid #333; +} + +.stream-metadata-section h3 { + margin-top: 0; + color: #00d4ff; +} + +.playlist-events-section { + background: #141414; + border-radius: 8px; + padding: 20px; + margin: 20px 0; + border: 1px solid #333; +} + +.status-chip { + padding: 6px 10px; + border-radius: 999px; + border: 1px solid #555; + color: #bbb; + font-size: 0.8rem; +} + +.status-chip.connected { + border-color: rgba(46, 204, 113, 0.6); + color: #2ecc71; + background: rgba(46, 204, 113, 0.1); +} + +.events-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 14px; +} + +.event-item { + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 12px; +} + +.event-header { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.event-kind { + text-transform: uppercase; + letter-spacing: 0.05em; + color: #00d4ff; + font-weight: 700; +} + +.event-time { + color: #9aa0a6; + font-size: 0.9rem; +} + +.event-source { + color: #9aa0a6; + font-style: italic; + font-size: 0.85rem; +} + +.event-body { + display: flex; + flex-direction: column; + gap: 6px; +} + +.event-row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.event-row .label { + min-width: 80px; +} + +.metadata-content { + margin-top: 12px; +} + +.metadata-json { + background: #0f0f0f; + border: 1px solid #333; + border-radius: 6px; + padding: 16px; + overflow-x: auto; + font-family: 'Courier New', monospace; + font-size: 0.85rem; + color: #00d4ff; + line-height: 1.5; + max-height: 400px; + overflow-y: auto; +} + +.metadata-json::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.metadata-json::-webkit-scrollbar-track { + background: #1a1a1a; + border-radius: 4px; +} + +.metadata-json::-webkit-scrollbar-thumb { + background: #333; + border-radius: 4px; +} + +.metadata-json::-webkit-scrollbar-thumb:hover { + background: #555; +} + +/* Quick Test Section */ +.quick-test-section { + background: linear-gradient(135deg, #2a1a1a 0%, #1a1a1a 100%); + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + border: 1px solid rgba(255, 193, 7, 0.3); +} + +.quick-test-section h3 { + margin-top: 0; + color: #ffc107; + margin-bottom: 8px; +} + +.section-description { + color: #9aa0a6; + font-size: 0.9rem; + margin-bottom: 16px; +} + +.test-commands { + display: flex; + flex-direction: column; + gap: 16px; +} + +.command-group { + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 193, 7, 0.2); + border-radius: 6px; + padding: 12px; +} + +.command-label { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.command-label strong { + color: #ffc107; + font-size: 0.9rem; +} + +.btn-copy-small { + background: rgba(255, 193, 7, 0.15); + border: 1px solid rgba(255, 193, 7, 0.3); + color: #ffc107; + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; + font-size: 0.85rem; + transition: all 0.2s; +} + +.btn-copy-small:hover { + background: rgba(255, 193, 7, 0.25); + border-color: rgba(255, 193, 7, 0.5); +} + +.command-text { + display: block; + background: #0a0a0a; + padding: 10px 12px; + border-radius: 4px; + font-family: 'Courier New', monospace; + font-size: 0.85rem; + color: #00d4ff; + overflow-x: auto; + white-space: nowrap; + border: 1px solid #222; +} + +.command-text::-webkit-scrollbar { + height: 6px; +} + +.command-text::-webkit-scrollbar-track { + background: #111; + border-radius: 3px; +} + +.command-text::-webkit-scrollbar-thumb { + background: #333; + border-radius: 3px; +} + +.command-text::-webkit-scrollbar-thumb:hover { + background: #555; +} + +.diagnostics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.diagnostic-card { + background: rgba(0, 212, 255, 0.05); + border: 1px solid rgba(0, 212, 255, 0.15); + border-radius: 6px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.diagnostic-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #9aa0a6; + font-weight: 600; +} + +.diagnostic-value { + font-size: 1rem; + font-weight: 600; + color: #f5f5f5; +} + +.diagnostic-value code { + background: rgba(0, 0, 0, 0.4); + padding: 4px 8px; + border-radius: 4px; + font-family: 'Courier New', monospace; + font-size: 0.85rem; + color: #00d4ff; + word-break: break-all; + display: block; + margin-top: 4px; +} + +.stream-urls-list { + display: flex; + flex-direction: column; + gap: 10px; + background: rgba(0, 0, 0, 0.3); + padding: 16px; + border-radius: 6px; +} + +.url-item { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + padding: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.url-item:last-child { + border-bottom: none; +} + +.url-item strong { + color: #00d4ff; + min-width: 120px; +} + +/* Test Endpoints Section */ +.test-endpoints-section { + background: linear-gradient(135deg, #1a2a1a 0%, #1a1a1a 100%); + border-radius: 8px; + padding: 20px; + margin: 30px 0; + border: 1px solid rgba(46, 204, 113, 0.3); +} + +.test-endpoints-section h3 { + margin-top: 0; + color: #2ecc71; + margin-bottom: 16px; +} + +.test-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 20px; +} + +.test-card { + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(46, 204, 113, 0.2); + border-radius: 8px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.test-card h4 { + margin: 0 0 8px 0; + color: #2ecc71; + font-size: 1rem; + font-weight: 600; +} + +.test-controls { + display: flex; + flex-direction: column; + gap: 10px; +} + +.test-input { + padding: 8px 12px; + border-radius: 4px; + border: 1px solid #333; + background: #111; + color: #fff; + font-size: 0.9rem; +} + +.test-input:focus { + outline: none; + border-color: #2ecc71; + box-shadow: 0 0 6px rgba(46, 204, 113, 0.2); +} + +.test-result { + background: rgba(46, 204, 113, 0.05); + border: 1px solid rgba(46, 204, 113, 0.15); + border-radius: 6px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 4px; +} + +.result-item { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.9rem; +} + +.result-item strong { + color: #2ecc71; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.text-muted { + color: #666; + font-style: italic; +} + +.cover-preview { + margin-top: 8px; + display: flex; + justify-content: center; +} + +.cover-preview img { + max-width: 100%; + max-height: 300px; + border-radius: 8px; + border: 2px solid rgba(46, 204, 113, 0.3); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); +} + +.cover-preview-small { + margin-top: 8px; + display: flex; + justify-content: center; +} + +.cover-preview-small img { + max-width: 150px; + max-height: 150px; + border-radius: 6px; + border: 2px solid rgba(46, 204, 113, 0.3); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); } @media (max-width: 768px) { @@ -2005,5 +2772,61 @@ onUnmounted(() => { flex-direction: column; align-items: stretch; } + + .stream-controls-grid { + grid-template-columns: 1fr; + } + + .diagnostics-grid { + grid-template-columns: 1fr; + } + + /* Enhanced Player Responsive */ + .player-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .player-controls-header { + width: 100%; + justify-content: flex-end; + } + + .player-info { + flex-direction: column; + align-items: center; + text-align: center; + } + + .player-cover img { + width: 150px; + height: 150px; + } + + .player-title { + font-size: 1.4rem; + } + + .player-artist { + font-size: 1.1rem; + } + + .player-album { + font-size: 1rem; + } + + .player-stream-info { + grid-template-columns: 1fr; + } + + /* Test Section Responsive */ + .test-grid { + grid-template-columns: 1fr; + } + + .cover-preview img { + max-height: 200px; + } } diff --git a/pmoapp/webapp/src/components/pmocontrol/ActionMenu.vue b/pmoapp/webapp/src/components/pmocontrol/ActionMenu.vue new file mode 100644 index 00000000..0ffb6cc6 --- /dev/null +++ b/pmoapp/webapp/src/components/pmocontrol/ActionMenu.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/pmoapp/webapp/src/components/pmocontrol/Breadcrumb.vue b/pmoapp/webapp/src/components/pmocontrol/Breadcrumb.vue new file mode 100644 index 00000000..0011b839 --- /dev/null +++ b/pmoapp/webapp/src/components/pmocontrol/Breadcrumb.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/pmoapp/webapp/src/components/pmocontrol/ContainerItem.vue b/pmoapp/webapp/src/components/pmocontrol/ContainerItem.vue new file mode 100644 index 00000000..51036efe --- /dev/null +++ b/pmoapp/webapp/src/components/pmocontrol/ContainerItem.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue b/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue new file mode 100644 index 00000000..77e9ef71 --- /dev/null +++ b/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue b/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue new file mode 100644 index 00000000..10af54c8 --- /dev/null +++ b/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue @@ -0,0 +1,331 @@ + + + + + diff --git a/pmoapp/webapp/src/components/pmocontrol/MediaItem.vue b/pmoapp/webapp/src/components/pmocontrol/MediaItem.vue new file mode 100644 index 00000000..1a309db3 --- /dev/null +++ b/pmoapp/webapp/src/components/pmocontrol/MediaItem.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/pmoapp/webapp/src/components/pmocontrol/MediaServerCard.vue b/pmoapp/webapp/src/components/pmocontrol/MediaServerCard.vue new file mode 100644 index 00000000..fba4e0bd --- /dev/null +++ b/pmoapp/webapp/src/components/pmocontrol/MediaServerCard.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/pmoapp/webapp/src/components/pmocontrol/PlaylistBindingPanel.vue b/pmoapp/webapp/src/components/pmocontrol/PlaylistBindingPanel.vue new file mode 100644 index 00000000..788c399a --- /dev/null +++ b/pmoapp/webapp/src/components/pmocontrol/PlaylistBindingPanel.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/pmoapp/webapp/src/components/pmocontrol/QueueItem.vue b/pmoapp/webapp/src/components/pmocontrol/QueueItem.vue new file mode 100644 index 00000000..8dd7e808 --- /dev/null +++ b/pmoapp/webapp/src/components/pmocontrol/QueueItem.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue b/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue new file mode 100644 index 00000000..16c0eb90 --- /dev/null +++ b/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/pmoapp/webapp/src/components/pmocontrol/RendererCard.vue b/pmoapp/webapp/src/components/pmocontrol/RendererCard.vue new file mode 100644 index 00000000..3d411c80 --- /dev/null +++ b/pmoapp/webapp/src/components/pmocontrol/RendererCard.vue @@ -0,0 +1,332 @@ + + + + + diff --git a/pmoapp/webapp/src/components/pmocontrol/StatusBadge.vue b/pmoapp/webapp/src/components/pmocontrol/StatusBadge.vue new file mode 100644 index 00000000..39ecfdb3 --- /dev/null +++ b/pmoapp/webapp/src/components/pmocontrol/StatusBadge.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/pmoapp/webapp/src/components/pmocontrol/TransportControls.vue b/pmoapp/webapp/src/components/pmocontrol/TransportControls.vue new file mode 100644 index 00000000..1b5c8fd4 --- /dev/null +++ b/pmoapp/webapp/src/components/pmocontrol/TransportControls.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/pmoapp/webapp/src/components/pmocontrol/VolumeControl.vue b/pmoapp/webapp/src/components/pmocontrol/VolumeControl.vue new file mode 100644 index 00000000..572248cd --- /dev/null +++ b/pmoapp/webapp/src/components/pmocontrol/VolumeControl.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/pmoapp/webapp/src/composables/useMediaServers.ts b/pmoapp/webapp/src/composables/useMediaServers.ts new file mode 100644 index 00000000..299b0e68 --- /dev/null +++ b/pmoapp/webapp/src/composables/useMediaServers.ts @@ -0,0 +1,183 @@ +/** + * Composable pour gérer les media servers + * Architecture simple : l'API est la source de vérité, SSE invalide le cache + */ +import { ref, computed } from 'vue' +import { api } from '../services/pmocontrol/api' +import { sse } from '../services/pmocontrol/sse' +import type { + MediaServerSummary, + BrowseResponse +} from '../services/pmocontrol/types' + +export interface BreadcrumbItem { + id: string + title: string +} + +// Cache global partagé +const serversCache = ref>(new Map()) +const browseCache = ref>(new Map()) +const currentPath = ref([]) + +// Timestamps +const lastFetch = { + servers: 0 +} + +const CACHE_DURATION_MS = 2000 + +// Connecter SSE une seule fois +let sseConnected = false +function ensureSSEConnected() { + if (sseConnected) return + + sse.onMediaServerEvent((event) => { + const serverId = event.server_id + + switch (event.type) { + case 'global_updated': + // Invalider tout le cache de ce serveur + console.log(`[useMediaServers] GlobalUpdated pour ${serverId}`) + const keysToDelete: string[] = [] + browseCache.value.forEach((_, key) => { + if (key.startsWith(serverId + '/')) { + keysToDelete.push(key) + } + }) + keysToDelete.forEach(key => browseCache.value.delete(key)) + break + + case 'containers_updated': + // Invalider les containers spécifiques + console.log(`[useMediaServers] ContainersUpdated pour ${serverId}:`, event.container_ids) + event.container_ids.forEach(containerId => { + const key = `${serverId}/${containerId}` + browseCache.value.delete(key) + }) + break + } + }) + + sseConnected = true +} + +/** + * Composable principal pour gérer les media servers + */ +export function useMediaServers() { + ensureSSEConnected() + + const loading = ref(false) + const error = ref(null) + + // Getters computed + const allServers = computed(() => Array.from(serversCache.value.values())) + const onlineServers = computed(() => allServers.value.filter(s => s.online)) + + // Fetch servers list + async function fetchServers(force = false) { + const now = Date.now() + if (!force && now - lastFetch.servers < CACHE_DURATION_MS) { + return // Cache encore valide + } + + try { + loading.value = true + error.value = null + const data = await api.getServers() + + serversCache.value.clear() + data.forEach(s => serversCache.value.set(s.id, s)) + lastFetch.servers = now + } catch (e) { + error.value = e instanceof Error ? e.message : 'Erreur fetch servers' + console.error('[useMediaServers] Erreur fetch:', e) + } finally { + loading.value = false + } + } + + // Browse container (avec cache automatique) + async function browseContainer(serverId: string, containerId: string, useCache = true) { + const key = `${serverId}/${containerId}` + + // Vérifier le cache + if (useCache && browseCache.value.has(key)) { + return browseCache.value.get(key)! + } + + try { + loading.value = true + error.value = null + + const data = await api.browseContainer(serverId, containerId) + + // Mettre en cache + browseCache.value.set(key, data) + + return data + } catch (e) { + error.value = e instanceof Error ? e.message : 'Erreur browse container' + console.error(`[useMediaServers] Erreur browse ${serverId}/${containerId}:`, e) + throw e + } finally { + loading.value = false + } + } + + // Getters + function getServerById(id: string) { + return serversCache.value.get(id) + } + + function getBrowseCached(serverId: string, containerId: string) { + const key = `${serverId}/${containerId}` + return browseCache.value.get(key) + } + + // Breadcrumb path management + function setPath(path: BreadcrumbItem[]) { + currentPath.value = path + } + + function clearPath() { + currentPath.value = [] + } + + // Invalidation du cache + function invalidateCache(serverId: string, containerId?: string) { + if (containerId) { + // Invalider un container spécifique + const key = `${serverId}/${containerId}` + browseCache.value.delete(key) + } else { + // Invalider tous les containers d'un serveur + const keysToDelete: string[] = [] + browseCache.value.forEach((_, key) => { + if (key.startsWith(serverId + '/')) { + keysToDelete.push(key) + } + }) + keysToDelete.forEach(key => browseCache.value.delete(key)) + } + } + + return { + // État + loading, + error, + currentPath, + // Getters + allServers, + onlineServers, + getServerById, + getBrowseCached, + // Actions + fetchServers, + browseContainer, + setPath, + clearPath, + invalidateCache + } +} diff --git a/pmoapp/webapp/src/composables/useRenderers.ts b/pmoapp/webapp/src/composables/useRenderers.ts new file mode 100644 index 00000000..ca6e715c --- /dev/null +++ b/pmoapp/webapp/src/composables/useRenderers.ts @@ -0,0 +1,299 @@ +/** + * Composable pour gérer les renderers. + * Le ControlPoint est la seule source de vérité : + * - Les snapshots complets proviennent de /renderers/{id}/full + * - Les événements SSE ne servent qu'à déclencher un refetch. + */ +import { ref, reactive, computed, type Ref } from 'vue' +import { api } from '../services/pmocontrol/api' +import { sse } from '../services/pmocontrol/sse' +import type { + RendererSummary, + RendererState, + QueueSnapshot, + AttachedPlaylistInfo, + FullRendererSnapshot, +} from '../services/pmocontrol/types' + +interface RendererSnapshotState { + snapshots: Map + lastSnapshotAt: Map + lastEventAt: Map + loadingIds: Set + selectedRendererId: string | null +} + +const renderersCache = ref>(new Map()) +const RENDERERS_CACHE_MS = 2000 +const lastRenderersFetch = ref(0) + +const snapshotState = reactive({ + snapshots: reactive(new Map()), + lastSnapshotAt: reactive(new Map()), + lastEventAt: reactive(new Map()), + loadingIds: reactive(new Set()), + selectedRendererId: null, +}) + +const loading = ref(false) +const error = ref(null) + +let sseConnected = false +function ensureSSEConnected() { + if (sseConnected) return + + sse.onRendererEvent((event) => { + const rendererId = event.renderer_id + const timestamp = Date.parse(event.timestamp ?? '') || Date.now() + snapshotState.lastEventAt.set(rendererId, timestamp) + const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0 + if (!snapshotState.snapshots.has(rendererId) || timestamp > lastSnapshot) { + void fetchRendererSnapshot(rendererId, { force: true }) + } + }) + + sseConnected = true +} + +const allRenderers = computed(() => Array.from(renderersCache.value.values())) +const onlineRenderers = computed(() => allRenderers.value.filter((r) => r.online)) +const allSnapshots = computed(() => Array.from(snapshotState.snapshots.values())) +const playingRenderers = computed(() => + allSnapshots.value + .filter((snapshot) => snapshot.state.transport_state === 'PLAYING') + .map((snapshot) => snapshot.state), +) + +function getRendererById(id: string) { + return renderersCache.value.get(id) +} + +function getSnapshotById(id: string) { + return snapshotState.snapshots.get(id) ?? null +} + +function getStateById(id: string): RendererState | null { + return snapshotState.snapshots.get(id)?.state ?? null +} + +function getQueueById(id: string): QueueSnapshot | null { + return snapshotState.snapshots.get(id)?.queue ?? null +} + +function getBindingById(id: string): AttachedPlaylistInfo | null { + return snapshotState.snapshots.get(id)?.binding ?? null +} + +function isSnapshotLoading(id: string) { + return snapshotState.loadingIds.has(id) +} + +function selectRenderer(id: string | null) { + snapshotState.selectedRendererId = id +} + +async function fetchRenderers(force = false) { + ensureSSEConnected() + + const now = Date.now() + if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) { + return + } + + try { + loading.value = true + error.value = null + const data = await api.getRenderers() + renderersCache.value = new Map(data.map((renderer) => [renderer.id, renderer])) + lastRenderersFetch.value = now + } catch (err) { + error.value = err instanceof Error ? err.message : 'Erreur fetch renderers' + console.error('[useRenderers] Erreur fetch:', err) + } finally { + loading.value = false + } +} + +async function fetchRendererSnapshot(rendererId: string, opts?: { force?: boolean }) { + ensureSSEConnected() + const force = opts?.force ?? false + const hasSnapshot = snapshotState.snapshots.has(rendererId) + + if (!force && hasSnapshot) { + const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0 + const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0 + if (lastEvent <= lastSnapshot) { + return + } + } + + if (snapshotState.loadingIds.has(rendererId)) { + return + } + + snapshotState.loadingIds.add(rendererId) + try { + const snapshot = await api.getRendererFullSnapshot(rendererId) + snapshotState.snapshots.set(rendererId, snapshot) + snapshotState.lastSnapshotAt.set(rendererId, Date.now()) + } catch (err) { + console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err) + } finally { + snapshotState.loadingIds.delete(rendererId) + } +} + +// Transport controls +async function play(id: string) { + await api.play(id) +} + +async function resumeOrPlayFromQueue(id: string) { + const snapshot = snapshotState.snapshots.get(id) + if (!snapshot) { + throw new Error(`Renderer ${id} non trouvé`) + } + + const state = snapshot.state + if (state.transport_state === 'PAUSED') { + return play(id) + } + + if ( + ['STOPPED', 'NO_MEDIA'].includes(state.transport_state) && + snapshot.queue.items.length > 0 + ) { + return api.resume(id) + } + + throw new Error('La file d\'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.') +} + +async function pause(id: string) { + await api.pause(id) +} + +async function stop(id: string) { + await api.stop(id) +} + +async function next(id: string) { + await api.next(id) +} + +// Volume controls +async function setVolume(id: string, volume: number) { + await api.setVolume(id, volume) +} + +async function volumeUp(id: string) { + await api.volumeUp(id) +} + +async function volumeDown(id: string) { + await api.volumeDown(id) +} + +async function toggleMute(id: string) { + await api.toggleMute(id) +} + +// Playlist binding +async function attachPlaylist( + rendererId: string, + serverId: string, + containerId: string, + options?: { autoPlay?: boolean }, +) { + await api.attachPlaylist(rendererId, serverId, containerId, options?.autoPlay ?? false) +} + +async function detachPlaylist(rendererId: string) { + await api.detachPlaylist(rendererId) +} + +async function attachAndPlayPlaylist( + rendererId: string, + serverId: string, + containerId: string, +) { + await attachPlaylist(rendererId, serverId, containerId, { autoPlay: true }) +} + +// Queue content +async function playContent(rendererId: string, serverId: string, objectId: string) { + await api.playContent(rendererId, serverId, objectId) +} + +async function addToQueue(rendererId: string, serverId: string, objectId: string) { + await api.addToQueue(rendererId, serverId, objectId) +} + +export function useRenderers() { + ensureSSEConnected() + + return { + loading, + error, + // Collections + allRenderers, + onlineRenderers, + playingRenderers, + // Accessors + getRendererById, + getSnapshotById, + getStateById, + getQueueById, + getBindingById, + isSnapshotLoading, + selectRenderer, + snapshotState, + // Fetchers + fetchRenderers, + fetchRendererSnapshot, + // Transport controls + play, + resumeOrPlayFromQueue, + pause, + stop, + next, + // Volume controls + setVolume, + volumeUp, + volumeDown, + toggleMute, + // Playlist binding + attachPlaylist, + detachPlaylist, + attachAndPlayPlaylist, + // Queue content + playContent, + addToQueue, + } +} + +export function useRenderer(rendererId: Ref) { + ensureSSEConnected() + + const renderer = computed(() => renderersCache.value.get(rendererId.value)) + const snapshot = computed(() => snapshotState.snapshots.get(rendererId.value) ?? null) + const state = computed(() => snapshot.value?.state ?? null) + const queue = computed(() => snapshot.value?.queue ?? null) + const binding = computed(() => snapshot.value?.binding ?? null) + + async function refresh(force = true) { + await Promise.all([ + fetchRenderers(force), + fetchRendererSnapshot(rendererId.value, { force: true }), + ]) + } + + return { + renderer, + snapshot, + state, + queue, + binding, + refresh, + } +} diff --git a/pmoapp/webapp/src/main.ts b/pmoapp/webapp/src/main.ts index 0e93bac4..db6a5a4a 100644 --- a/pmoapp/webapp/src/main.ts +++ b/pmoapp/webapp/src/main.ts @@ -1,7 +1,44 @@ import { createApp } from "vue"; +import { createPinia } from "pinia"; import App from "./App.vue"; import router from "./router"; -import "./style.css"; +// Service SSE (les composables se connectent automatiquement) +import { sse } from "./services/pmocontrol/sse"; -createApp(App).use(router).mount("#app"); +// Store UI (garde UIStore pour les notifications et état UI global) +import { useUIStore } from "./stores/ui"; + +// Styles +import "./style.css"; +import "./assets/styles/variables.css"; +import "./assets/styles/pmocontrol.css"; + +// Créer l'application Vue +const app = createApp(App); + +// Créer et installer Pinia +const pinia = createPinia(); +app.use(pinia); +app.use(router); + +// Monter l'application +app.mount("#app"); + +// Après montage, initialiser SSE +const uiStore = useUIStore(); + +// Les composables se connectent automatiquement à SSE +// Ils gèrent eux-mêmes le re-fetch lors des événements + +sse.onConnectionChange((connected) => { + uiStore.setSSEConnected(connected); + if (connected) { + console.log("[App] SSE connecté"); + } +}); + +// Démarrer la connexion SSE +sse.connect(); + +console.log("[App] PMOControl initialisé"); diff --git a/pmoapp/webapp/src/router/index.ts b/pmoapp/webapp/src/router/index.ts index a9bc198f..7502dbd9 100644 --- a/pmoapp/webapp/src/router/index.ts +++ b/pmoapp/webapp/src/router/index.ts @@ -1,5 +1,12 @@ import { createRouter, createWebHistory } from "vue-router"; -import HelloWorld from "../components/HelloWorld.vue"; + +// PMOControl Views (nouvelle home) +import DashboardView from "../views/DashboardView.vue"; +import RendererView from "../views/RendererView.vue"; +import MediaServerView from "../views/MediaServerView.vue"; + +// Debug Components (anciennes routes) +import GenericMusicPlayer from "../components/GenericMusicPlayer.vue"; import LogView from "../components/LogView.vue"; import CoverCacheManager from "../components/CoverCacheManager.vue"; import AudioCacheManager from "../components/AudioCacheManager.vue"; @@ -8,13 +15,59 @@ import APIDashboard from "../components/APIDashboard.vue"; import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue"; const routes = [ - { path: "/", name: "home", component: HelloWorld }, - { path: "/logs", name: "logs", component: LogView }, - { path: "/covers-cache", name: "covers-cache", component: CoverCacheManager }, - { path: "/audio-cache", name: "audio-cache", component: AudioCacheManager }, - { path: "/upnp", name: "upnp", component: UpnpExplorer }, - { path: "/api-dashboard", name: "api-dashboard", component: APIDashboard }, - { path: "/radio-paradise", name: "radio-paradise", component: RadioParadiseExplorer }, + // PMOControl (nouvelle home) + { + path: "/", + name: "Dashboard", + component: DashboardView, + }, + { + path: "/renderer/:id", + name: "Renderer", + component: RendererView, + }, + { + path: "/server/:serverId", + name: "MediaServer", + component: MediaServerView, + }, + + // Debug menu (anciennes routes déplacées sous /debug) + { + path: "/debug/generic-player", + name: "GenericPlayer", + component: GenericMusicPlayer, + }, + { + path: "/debug/logs", + name: "Logs", + component: LogView, + }, + { + path: "/debug/covers-cache", + name: "CoversCache", + component: CoverCacheManager, + }, + { + path: "/debug/audio-cache", + name: "AudioCache", + component: AudioCacheManager, + }, + { + path: "/debug/upnp", + name: "UpnpExplorer", + component: UpnpExplorer, + }, + { + path: "/debug/api-dashboard", + name: "APIDashboard", + component: APIDashboard, + }, + { + path: "/debug/radio-paradise", + name: "RadioParadise", + component: RadioParadiseExplorer, + }, ]; const router = createRouter({ diff --git a/pmoapp/webapp/src/services/audioCache.ts b/pmoapp/webapp/src/services/audioCache.ts index 5f35abba..997dc3bb 100644 --- a/pmoapp/webapp/src/services/audioCache.ts +++ b/pmoapp/webapp/src/services/audioCache.ts @@ -19,6 +19,8 @@ export interface AudioCacheMetadata { bitrate?: number; channels?: number; conversion?: ConversionInfo; + cover_pk?: string; + cover_url?: string; [key: string]: unknown; } @@ -228,3 +230,26 @@ export function formatSampleRate(sampleRate?: number): string { if (!sampleRate) return "Unknown"; return `${(sampleRate / 1000).toFixed(1)} kHz`; } + +/** + * Génère l'URL de la cover d'une piste + * Priorité : cover_pk (cache) > cover_url (externe) > undefined + */ +export function getCoverUrl(metadata?: AudioCacheMetadata | null, size?: number): string | undefined { + if (!metadata) return undefined; + + // Priorité 1 : cover en cache via cover_pk + if (metadata.cover_pk) { + if (size) { + return `/covers/image/${metadata.cover_pk}/${size}`; + } + return `/covers/image/${metadata.cover_pk}`; + } + + // Priorité 2 : cover externe via cover_url + if (metadata.cover_url) { + return metadata.cover_url; + } + + return undefined; +} diff --git a/pmoapp/webapp/src/services/coverCache.ts b/pmoapp/webapp/src/services/coverCache.ts index 3605232d..bc391a62 100644 --- a/pmoapp/webapp/src/services/coverCache.ts +++ b/pmoapp/webapp/src/services/coverCache.ts @@ -192,3 +192,42 @@ export function getImageUrl(pk: string, size?: number): string { } return `/covers/image/${pk}`; } + +export function getJpegUrl(pk: string, size?: number): string { + if (size) { + return `/covers/jpeg/${pk}/${size}`; + } + return `/covers/jpeg/${pk}`; +} + +/** + * SVG par défaut pour les images qui ne se chargent pas + */ +const DEFAULT_COVER_SVG = ` + + + + + + + + + + + + + + + + No Image Available + +`; + +/** + * Retourne l'URL de l'image par défaut comme data URL + */ +export function getDefaultImageUrl(): string { + return `data:image/svg+xml;utf8,${encodeURIComponent(DEFAULT_COVER_SVG)}`; +} diff --git a/pmoapp/webapp/src/services/openhomePlaylist.ts b/pmoapp/webapp/src/services/openhomePlaylist.ts new file mode 100644 index 00000000..892fccd4 --- /dev/null +++ b/pmoapp/webapp/src/services/openhomePlaylist.ts @@ -0,0 +1,55 @@ +import type { + OpenHomePlaylistAddRequest, + OpenHomePlaylistSnapshot, +} from '@/services/pmocontrol/types' + +const API_BASE = '/api/control' + +export async function getOpenHomePlaylist(rendererId: string): Promise { + const resp = await fetch( + `${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist`, + ) + if (!resp.ok) { + throw new Error(`Failed to fetch OpenHome playlist: ${resp.status} ${resp.statusText}`) + } + return resp.json() +} + +export async function clearOpenHomePlaylist(rendererId: string): Promise { + const resp = await fetch( + `${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist/clear`, + { method: 'POST' }, + ) + if (!resp.ok) { + throw new Error(`Failed to clear OpenHome playlist: ${resp.status} ${resp.statusText}`) + } +} + +export async function addOpenHomeTrack( + rendererId: string, + payload: OpenHomePlaylistAddRequest, +): Promise { + const resp = await fetch( + `${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist/add`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + ) + if (!resp.ok) { + throw new Error(`Failed to add track to OpenHome playlist: ${resp.status} ${resp.statusText}`) + } +} + +export async function playOpenHomeTrack(rendererId: string, trackId: number): Promise { + const resp = await fetch( + `${API_BASE}/renderers/${encodeURIComponent( + rendererId, + )}/oh/playlist/play/${encodeURIComponent(trackId.toString())}`, + { method: 'POST' }, + ) + if (!resp.ok) { + throw new Error(`Failed to play OpenHome track ${trackId}: ${resp.status} ${resp.statusText}`) + } +} diff --git a/pmoapp/webapp/src/services/pmocontrol/api.ts b/pmoapp/webapp/src/services/pmocontrol/api.ts new file mode 100644 index 00000000..7468126a --- /dev/null +++ b/pmoapp/webapp/src/services/pmocontrol/api.ts @@ -0,0 +1,291 @@ +// Client API REST pour PMOControl +// Communique avec /api/control/* + +import type { + RendererSummary, + RendererState, + FullRendererSnapshot, + QueueSnapshot, + AttachedPlaylistInfo, + MediaServerSummary, + BrowseResponse, + VolumeSetRequest, + AttachPlaylistRequest, + PlayContentRequest, + SuccessResponse, + ErrorResponse +} from './types' + +/** + * Client API REST pour le Control Point PMOMusic + */ +class PMOControlAPI { + private readonly baseURL = '/api/control' + + /** + * Effectue une requête HTTP générique + */ + private async request( + path: string, + options: RequestInit = {} + ): Promise { + const url = `${this.baseURL}${path}` + + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }) + + if (!response.ok) { + const error: ErrorResponse = await response.json().catch(() => ({ + error: `HTTP ${response.status}: ${response.statusText}`, + })) + throw new Error(error.error) + } + + return response.json() + } + + // ============================================================================ + // RENDERERS + // ============================================================================ + + /** + * Liste tous les renderers découverts + * GET /api/control/renderers + */ + async getRenderers(): Promise { + return this.request('/renderers') + } + + /** + * Récupère l'état détaillé d'un renderer + * GET /api/control/renderers/{id} + */ + async getRendererState(id: string): Promise { + return this.request(`/renderers/${encodeURIComponent(id)}`) + } + + /** + * Récupère le snapshot complet d'un renderer + * GET /api/control/renderers/{id}/full + */ + async getRendererFullSnapshot(id: string): Promise { + return this.request(`/renderers/${encodeURIComponent(id)}/full`) + } + + /** + * Récupère la queue d'un renderer (avec current_index) + * GET /api/control/renderers/{id}/queue + */ + async getQueue(id: string): Promise { + return this.request(`/renderers/${encodeURIComponent(id)}/queue`) + } + + /** + * Récupère le binding playlist d'un renderer + * GET /api/control/renderers/{id}/binding + */ + async getBinding(id: string): Promise { + return this.request(`/renderers/${encodeURIComponent(id)}/binding`) + } + + // ============================================================================ + // CONTRÔLE TRANSPORT + // ============================================================================ + + /** + * Démarre la lecture sur un renderer + * POST /api/control/renderers/{id}/play + */ + async play(id: string): Promise { + return this.request(`/renderers/${encodeURIComponent(id)}/play`, { + method: 'POST', + }) + } + + /** + * Met en pause la lecture sur un renderer + * POST /api/control/renderers/{id}/pause + */ + async pause(id: string): Promise { + return this.request(`/renderers/${encodeURIComponent(id)}/pause`, { + method: 'POST', + }) + } + + /** + * Arrête la lecture sur un renderer + * POST /api/control/renderers/{id}/stop + */ + async stop(id: string): Promise { + return this.request(`/renderers/${encodeURIComponent(id)}/stop`, { + method: 'POST', + }) + } + + /** + * Reprend la lecture depuis le morceau actuel de la queue + * POST /api/control/renderers/{id}/resume + */ + async resume(id: string): Promise { + return this.request(`/renderers/${encodeURIComponent(id)}/resume`, { + method: 'POST', + }) + } + + /** + * Passe au morceau suivant dans la queue + * POST /api/control/renderers/{id}/next + */ + async next(id: string): Promise { + return this.request(`/renderers/${encodeURIComponent(id)}/next`, { + method: 'POST', + }) + } + + // ============================================================================ + // CONTRÔLE VOLUME + // ============================================================================ + + /** + * Définit le volume d'un renderer (0-100) + * POST /api/control/renderers/{id}/volume/set + */ + async setVolume(id: string, volume: number): Promise { + const payload: VolumeSetRequest = { volume } + return this.request(`/renderers/${encodeURIComponent(id)}/volume/set`, { + method: 'POST', + body: JSON.stringify(payload), + }) + } + + /** + * Augmente le volume de 5% + * POST /api/control/renderers/{id}/volume/up + */ + async volumeUp(id: string): Promise { + return this.request(`/renderers/${encodeURIComponent(id)}/volume/up`, { + method: 'POST', + }) + } + + /** + * Diminue le volume de 5% + * POST /api/control/renderers/{id}/volume/down + */ + async volumeDown(id: string): Promise { + return this.request(`/renderers/${encodeURIComponent(id)}/volume/down`, { + method: 'POST', + }) + } + + /** + * Bascule le mute d'un renderer + * POST /api/control/renderers/{id}/mute/toggle + */ + async toggleMute(id: string): Promise { + return this.request(`/renderers/${encodeURIComponent(id)}/mute/toggle`, { + method: 'POST', + }) + } + + // ============================================================================ + // PLAYLIST BINDING + // ============================================================================ + + /** + * Attache la queue d'un renderer à une playlist d'un serveur + * POST /api/control/renderers/{id}/binding/attach + */ + async attachPlaylist( + rendererId: string, + serverId: string, + containerId: string, + autoPlay = false + ): Promise { + const payload: AttachPlaylistRequest = { + server_id: serverId, + container_id: containerId, + auto_play: autoPlay, + } + return this.request(`/renderers/${encodeURIComponent(rendererId)}/binding/attach`, { + method: 'POST', + body: JSON.stringify(payload), + }) + } + + /** + * Détache la queue d'un renderer de sa playlist + * POST /api/control/renderers/{id}/binding/detach + */ + async detachPlaylist(rendererId: string): Promise { + return this.request(`/renderers/${encodeURIComponent(rendererId)}/binding/detach`, { + method: 'POST', + }) + } + + // ============================================================================ + // QUEUE CONTENT + // ============================================================================ + + /** + * Lire du contenu immédiatement (clear queue + enqueue + play) + * POST /api/control/renderers/{id}/queue/play + */ + async playContent( + rendererId: string, + serverId: string, + objectId: string + ): Promise { + const payload: PlayContentRequest = { server_id: serverId, object_id: objectId } + return this.request(`/renderers/${encodeURIComponent(rendererId)}/queue/play`, { + method: 'POST', + body: JSON.stringify(payload), + }) + } + + /** + * Ajouter du contenu à la queue (sans démarrer la lecture) + * POST /api/control/renderers/{id}/queue/add + */ + async addToQueue( + rendererId: string, + serverId: string, + objectId: string + ): Promise { + const payload: PlayContentRequest = { server_id: serverId, object_id: objectId } + return this.request(`/renderers/${encodeURIComponent(rendererId)}/queue/add`, { + method: 'POST', + body: JSON.stringify(payload), + }) + } + + // ============================================================================ + // MEDIA SERVERS + // ============================================================================ + + /** + * Liste tous les serveurs de médias découverts + * GET /api/control/servers + */ + async getServers(): Promise { + return this.request('/servers') + } + + /** + * Browse le contenu d'un container sur un serveur + * GET /api/control/servers/{serverId}/containers/{containerId} + */ + async browseContainer(serverId: string, containerId: string): Promise { + return this.request( + `/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}` + ) + } +} + +// Export singleton +export const api = new PMOControlAPI() diff --git a/pmoapp/webapp/src/services/pmocontrol/sse.ts b/pmoapp/webapp/src/services/pmocontrol/sse.ts new file mode 100644 index 00000000..ae2cbdc4 --- /dev/null +++ b/pmoapp/webapp/src/services/pmocontrol/sse.ts @@ -0,0 +1,208 @@ +// Service SSE (Server-Sent Events) pour PMOControl +// Gère la connexion temps réel à /api/control/events + +import type { RendererEventPayload, MediaServerEventPayload, UnifiedEventPayload } from './types' + +type RendererEventCallback = (event: RendererEventPayload) => void +type MediaServerEventCallback = (event: MediaServerEventPayload) => void +type ConnectionCallback = (connected: boolean) => void + +/** + * Service SSE pour recevoir les événements du Control Point en temps réel + */ +export class PMOControlSSE { + private eventSource: EventSource | null = null + private reconnectAttempts = 0 + private maxReconnectDelay = 30000 // 30 secondes max + private reconnectTimer: number | null = null + + private rendererCallbacks: Set = new Set() + private serverCallbacks: Set = new Set() + private connectionCallbacks: Set = new Set() + + private isConnected = false + + /** + * Connecte au flux SSE + */ + connect(): void { + if (this.eventSource) { + console.warn('[SSE] Connexion déjà active') + return + } + + console.log('[SSE] Connexion à /api/control/events...') + + try { + this.eventSource = new EventSource('/api/control/events') + + this.eventSource.onopen = () => { + console.log('[SSE] Connexion établie') + this.reconnectAttempts = 0 + this.isConnected = true + this.notifyConnectionCallbacks(true) + } + + this.eventSource.addEventListener('control', (e: MessageEvent) => { + try { + const event: UnifiedEventPayload = JSON.parse(e.data) + this.handleEvent(event) + } catch (error) { + console.error('[SSE] Erreur parsing événement:', error) + } + }) + + this.eventSource.onerror = () => { + console.error('[SSE] Erreur de connexion') + this.isConnected = false + this.notifyConnectionCallbacks(false) + this.disconnect() + this.scheduleReconnect() + } + } catch (error) { + console.error('[SSE] Erreur création EventSource:', error) + this.scheduleReconnect() + } + } + + /** + * Déconnecte du flux SSE + */ + disconnect(): void { + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + + if (this.eventSource) { + console.log('[SSE] Déconnexion') + this.eventSource.close() + this.eventSource = null + this.isConnected = false + this.notifyConnectionCallbacks(false) + } + } + + /** + * Programme une reconnexion avec backoff exponentiel + */ + private scheduleReconnect(): void { + if (this.reconnectTimer !== null) { + return + } + + this.reconnectAttempts++ + + // Backoff exponentiel: 1s, 2s, 4s, 8s, 16s, 30s (max) + const delay = Math.min( + 1000 * Math.pow(2, this.reconnectAttempts - 1), + this.maxReconnectDelay + ) + + console.log(`[SSE] Reconnexion dans ${delay / 1000}s (tentative ${this.reconnectAttempts})`) + + this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = null + this.connect() + }, delay) + } + + /** + * Dispatch un événement aux callbacks appropriés + */ + private handleEvent(event: UnifiedEventPayload): void { + if (event.category === 'renderer') { + // Extraire le payload renderer (sans le champ category) + const { category, ...rendererEvent } = event + this.notifyRendererCallbacks(rendererEvent as RendererEventPayload) + } else if (event.category === 'media_server') { + // Extraire le payload server (sans le champ category) + const { category, ...serverEvent } = event + this.notifyServerCallbacks(serverEvent as MediaServerEventPayload) + } + } + + /** + * Enregistre un callback pour les événements renderer + */ + onRendererEvent(callback: RendererEventCallback): () => void { + this.rendererCallbacks.add(callback) + // Retourne une fonction de cleanup + return () => { + this.rendererCallbacks.delete(callback) + } + } + + /** + * Enregistre un callback pour les événements media server + */ + onMediaServerEvent(callback: MediaServerEventCallback): () => void { + this.serverCallbacks.add(callback) + // Retourne une fonction de cleanup + return () => { + this.serverCallbacks.delete(callback) + } + } + + /** + * Enregistre un callback pour les changements de connexion + */ + onConnectionChange(callback: ConnectionCallback): () => void { + this.connectionCallbacks.add(callback) + // Appeler immédiatement avec l'état actuel + callback(this.isConnected) + // Retourne une fonction de cleanup + return () => { + this.connectionCallbacks.delete(callback) + } + } + + /** + * Notifie tous les callbacks renderer + */ + private notifyRendererCallbacks(event: RendererEventPayload): void { + this.rendererCallbacks.forEach(callback => { + try { + callback(event) + } catch (error) { + console.error('[SSE] Erreur dans callback renderer:', error) + } + }) + } + + /** + * Notifie tous les callbacks server + */ + private notifyServerCallbacks(event: MediaServerEventPayload): void { + this.serverCallbacks.forEach(callback => { + try { + callback(event) + } catch (error) { + console.error('[SSE] Erreur dans callback server:', error) + } + }) + } + + /** + * Notifie tous les callbacks de connexion + */ + private notifyConnectionCallbacks(connected: boolean): void { + this.connectionCallbacks.forEach(callback => { + try { + callback(connected) + } catch (error) { + console.error('[SSE] Erreur dans callback connexion:', error) + } + }) + } + + /** + * Retourne l'état de connexion actuel + */ + isConnectedState(): boolean { + return this.isConnected + } +} + +// Export singleton +export const sse = new PMOControlSSE() diff --git a/pmoapp/webapp/src/services/pmocontrol/types.ts b/pmoapp/webapp/src/services/pmocontrol/types.ts new file mode 100644 index 00000000..39fbcd02 --- /dev/null +++ b/pmoapp/webapp/src/services/pmocontrol/types.ts @@ -0,0 +1,203 @@ +// Types TypeScript pour l'API PMOControl +// Synchronisés avec pmocontrol/src/openapi.rs + +// ============================================================================ +// RENDERERS +// ============================================================================ + +export type RendererProtocolSummary = 'upnp' | 'openhome' | 'hybrid' + +export interface RendererCapabilitiesSummary { + has_avtransport: boolean + has_avtransport_set_next: boolean + has_rendering_control: boolean + has_connection_manager: boolean + has_linkplay_http: boolean + has_arylic_tcp: boolean + has_oh_playlist: boolean + has_oh_volume: boolean + has_oh_info: boolean + has_oh_time: boolean + has_oh_radio: boolean +} + +export interface RendererSummary { + id: string + friendly_name: string + model_name: string + protocol: RendererProtocolSummary + capabilities: RendererCapabilitiesSummary + online: boolean +} + +export interface RendererState { + id: string + friendly_name: string + transport_state: 'PLAYING' | 'PAUSED' | 'STOPPED' | 'TRANSITIONING' | 'NO_MEDIA' | 'UNKNOWN' + position_ms: number | null + duration_ms: number | null + volume: number | null // 0-100 + mute: boolean | null + queue_len: number + attached_playlist: AttachedPlaylistInfo | null + current_track: CurrentTrackMetadata | null +} + +export interface CurrentTrackMetadata { + title: string | null + artist: string | null + album: string | null + album_art_uri: string | null +} + +export interface AttachedPlaylistInfo { + server_id: string + container_id: string + has_seen_update: boolean +} + +// ============================================================================ +// QUEUE (avec current_index) +// ============================================================================ + +export interface QueueItem { + index: number // 0-based + uri: string + title: string | null + artist: string | null + album: string | null + album_art_uri: string | null + server_id: string | null + object_id: string | null +} + +export interface QueueSnapshot { + renderer_id: string + items: QueueItem[] + current_index: number | null // Index de la piste en cours (null si rien en lecture) +} + +export interface FullRendererSnapshot { + state: RendererState + queue: QueueSnapshot + binding: AttachedPlaylistInfo | null +} + +// ============================================================================ +// OPENHOME PLAYLIST +// ============================================================================ + +export interface OpenHomePlaylistTrack { + id: number + uri: string + title: string | null + artist: string | null + album: string | null + album_art_uri: string | null +} + +export interface OpenHomePlaylistSnapshot { + renderer_id: string + current_id: number | null + tracks: OpenHomePlaylistTrack[] +} + +export interface OpenHomePlaylistAddRequest { + uri: string + metadata: string + after_id?: number | null + play?: boolean +} + +// ============================================================================ +// MEDIA SERVERS +// ============================================================================ + +export interface MediaServerSummary { + id: string + friendly_name: string + model_name: string + online: boolean +} + +export interface ContainerEntry { + id: string + title: string + class: string // UPnP class + is_container: boolean + child_count: number | null + artist: string | null + album: string | null + album_art_uri: string | null // ⚠️ Nom exact: album_art_uri +} + +export interface BrowseResponse { + container_id: string + entries: ContainerEntry[] +} + +// ============================================================================ +// COMMANDES +// ============================================================================ + +export interface VolumeSetRequest { + volume: number // 0-100 +} + +export interface AttachPlaylistRequest { + server_id: string + container_id: string + auto_play?: boolean +} + +export interface PlayContentRequest { + server_id: string + object_id: string +} + +export interface SuccessResponse { + message: string +} + +export interface ErrorResponse { + error: string +} + +// ============================================================================ +// ÉVÉNEMENTS SSE +// ============================================================================ + +export type RendererEventPayload = + | { type: 'state_changed'; renderer_id: string; state: string; timestamp: string } + | { type: 'position_changed'; renderer_id: string; track: number | null; rel_time: string | null; track_duration: string | null; timestamp: string } + | { type: 'volume_changed'; renderer_id: string; volume: number; timestamp: string } + | { type: 'mute_changed'; renderer_id: string; mute: boolean; timestamp: string } + | { type: 'metadata_changed'; renderer_id: string; title: string | null; artist: string | null; album: string | null; album_art_uri: string | null; timestamp: string } + | { type: 'queue_updated'; renderer_id: string; queue_length: number; timestamp: string } + | { type: 'binding_changed'; renderer_id: string; server_id: string | null; container_id: string | null; timestamp: string } + +export type MediaServerEventPayload = + | { type: 'global_updated'; server_id: string; system_update_id: number | null; timestamp: string } + | { type: 'containers_updated'; server_id: string; container_ids: string[]; timestamp: string } + +export type UnifiedEventPayload = + | { category: 'renderer' } & RendererEventPayload + | { category: 'media_server' } & MediaServerEventPayload + +// ============================================================================ +// MÉTADONNÉES PISTE +// ============================================================================ + +export interface TrackMetadata { + title: string | null + artist: string | null + album: string | null + album_art_uri: string | null + duration_ms: number | null +} + +export interface PositionInfo { + track: number | null + rel_time: string | null // Format HH:MM:SS + track_duration: string | null // Format HH:MM:SS +} diff --git a/pmoapp/webapp/src/services/pmosource.ts b/pmoapp/webapp/src/services/pmosource.ts new file mode 100644 index 00000000..71931968 --- /dev/null +++ b/pmoapp/webapp/src/services/pmosource.ts @@ -0,0 +1,200 @@ +/** + * Service pour interagir avec l'API pmosource générique + * + * Ce service utilise uniquement l'API REST définie dans pmosource::api + * et ne dépend d'aucune implémentation spécifique (comme pmoparadise) + */ + +const API_BASE = '/api/sources' + +// Types correspondant aux structures de l'API pmosource + +export interface SourceInfo { + id: string + name: string + supports_fifo: boolean + capabilities: SourceCapabilities +} + +export interface SourceCapabilities { + supports_search: boolean + supports_favorites: boolean + supports_playlists: boolean + supports_user_content: boolean + supports_high_res_audio: boolean + max_sample_rate: number | null + supports_multiple_formats: boolean + supports_advanced_search: boolean + supports_pagination: boolean +} + +export interface SourcesList { + count: number + sources: SourceInfo[] +} + +export interface BrowseContainer { + id: string + parent_id: string + title: string + class: string + child_count: string | null + restricted: string | null +} + +export interface BrowseItemResource { + url: string + protocol_info: string + duration: string | null +} + +export interface BrowseItem { + id: string + parent_id: string + title: string + class: string + artist: string | null + album: string | null + creator: string | null + album_art: string | null + resources: BrowseItemResource[] +} + +export interface BrowseResponse { + object_id: string + containers: BrowseContainer[] + items: BrowseItem[] + returned_containers: number + returned_items: number + total: number + update_id: number +} + +export interface ResolveUriResponse { + object_id: string + uri: string +} + +export interface SourceRootContainer { + id: string + parent_id: string + title: string + class: string + child_count: string | null + searchable: string | null +} + +/** + * Liste toutes les sources musicales enregistrées + */ +export async function listSources(): Promise { + const response = await fetch(`${API_BASE}`) + if (!response.ok) { + throw new Error(`Failed to list sources: ${response.status} ${response.statusText}`) + } + return response.json() +} + +/** + * Récupère les informations d'une source spécifique + */ +export async function getSource(sourceId: string): Promise { + const response = await fetch(`${API_BASE}/${sourceId}`) + if (!response.ok) { + throw new Error(`Failed to get source: ${response.status} ${response.statusText}`) + } + return response.json() +} + +/** + * Récupère le container racine d'une source + */ +export async function getSourceRoot(sourceId: string): Promise { + const response = await fetch(`${API_BASE}/${sourceId}/root`) + if (!response.ok) { + throw new Error(`Failed to get source root: ${response.status} ${response.statusText}`) + } + return response.json() +} + +/** + * Parcourt un container d'une source + * + * @param sourceId - ID de la source + * @param objectId - ID de l'objet à parcourir (optionnel, par défaut utilise la racine) + * @param startingIndex - Index de départ pour la pagination + * @param requestedCount - Nombre d'éléments demandés + */ +export async function browseSource( + sourceId: string, + objectId?: string, + startingIndex?: number, + requestedCount?: number +): Promise { + const params = new URLSearchParams() + if (objectId) params.set('object_id', objectId) + if (startingIndex !== undefined) params.set('starting_index', startingIndex.toString()) + if (requestedCount !== undefined) params.set('requested_count', requestedCount.toString()) + + const url = `${API_BASE}/${sourceId}/browse?${params.toString()}` + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to browse source: ${response.status} ${response.statusText}`) + } + return response.json() +} + +/** + * Résout l'URI réelle d'un objet (pour le streaming) + * + * @param sourceId - ID de la source + * @param objectId - ID de l'objet à résoudre + */ +export async function resolveUri(sourceId: string, objectId: string): Promise { + const params = new URLSearchParams({ object_id: objectId }) + const url = `${API_BASE}/${sourceId}/resolve?${params.toString()}` + + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to resolve URI: ${response.status} ${response.statusText}`) + } + return response.json() +} + +/** + * Récupère l'URL de l'image par défaut d'une source + * + * @param sourceId - ID de la source + * @returns L'URL de l'image + */ +export function getSourceImageUrl(sourceId: string): string { + return `${API_BASE}/${sourceId}/image` +} + +/** + * Récupère les capacités d'une source + */ +export async function getSourceCapabilities(sourceId: string): Promise { + const response = await fetch(`${API_BASE}/${sourceId}/capabilities`) + if (!response.ok) { + throw new Error(`Failed to get source capabilities: ${response.status} ${response.statusText}`) + } + return response.json() +} + +/** + * Récupère les métadonnées détaillées d'un item spécifique + * + * @param sourceId - ID de la source + * @param objectId - ID de l'item à récupérer + */ +export async function getItem(sourceId: string, objectId: string): Promise { + const params = new URLSearchParams({ object_id: objectId }) + const url = `${API_BASE}/${sourceId}/item?${params.toString()}` + + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to get item: ${response.status} ${response.statusText}`) + } + return response.json() +} diff --git a/pmoapp/webapp/src/services/radioParadise.ts b/pmoapp/webapp/src/services/radioParadise.ts new file mode 100644 index 00000000..556e9d5a --- /dev/null +++ b/pmoapp/webapp/src/services/radioParadise.ts @@ -0,0 +1,233 @@ +/** + * Service API pour interagir avec Radio Paradise + */ + +export interface ChannelInfo { + id: number; + name: string; + description: string; +} + +export interface SongInfo { + index: number; + artist: string; + title: string; + album: string; + year?: number; + elapsed_ms: number; + duration_ms: number; + cover_url?: string; + rating?: number; +} + +export interface BlockResponse { + event: number; + end_event: number; + url: string; + length_ms: number; + songs: SongInfo[]; +} + +export interface NowPlayingResponse { + event: number; + end_event: number; + stream_url: string; + block_length_ms: number; + current_song_index?: number; + current_song?: SongInfo; + songs: SongInfo[]; +} + +export interface StreamUrlResponse { + event: number; + stream_url: string; + length_ms: number; +} + +export interface CoverUrlResponse { + event: number; + song_index: number; + cover_url?: string; + cover_type: string; +} + +export interface ApiError { + error: string; + message: string; +} + +/** + * Liste tous les canaux disponibles + */ +export async function listChannels(): Promise { + const response = await fetch("/api/radioparadise/channels"); + if (!response.ok) { + throw new Error("Failed to fetch channels"); + } + return response.json(); +} + +/** + * Récupère le morceau en cours de lecture + */ +export async function getNowPlaying(channel?: number): Promise { + const url = channel !== undefined + ? `/api/radioparadise/now-playing?channel=${channel}` + : "/api/radioparadise/now-playing"; + + const response = await fetch(url); + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to fetch now playing"); + } + return response.json(); +} + +/** + * Récupère le block actuel + */ +export async function getCurrentBlock(channel?: number): Promise { + const url = channel !== undefined + ? `/api/radioparadise/block/current?channel=${channel}` + : "/api/radioparadise/block/current"; + + const response = await fetch(url); + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to fetch current block"); + } + return response.json(); +} + +/** + * Récupère un block spécifique par son event ID + */ +export async function getBlockById(eventId: number, channel?: number): Promise { + const url = channel !== undefined + ? `/api/radioparadise/block/${eventId}?channel=${channel}` + : `/api/radioparadise/block/${eventId}`; + + const response = await fetch(url); + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to fetch block"); + } + return response.json(); +} + +/** + * Récupère un morceau spécifique d'un block + */ +export async function getSongByIndex( + eventId: number, + index: number, + channel?: number +): Promise { + const url = channel !== undefined + ? `/api/radioparadise/block/${eventId}/song/${index}?channel=${channel}` + : `/api/radioparadise/block/${eventId}/song/${index}`; + + const response = await fetch(url); + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to fetch song"); + } + return response.json(); +} + +/** + * Récupère l'URL de la pochette d'un morceau + */ +export async function getCoverUrl( + eventId: number, + songIndex: number, + channel?: number +): Promise { + const url = channel !== undefined + ? `/api/radioparadise/cover-url/${eventId}/${songIndex}?channel=${channel}` + : `/api/radioparadise/cover-url/${eventId}/${songIndex}`; + + const response = await fetch(url); + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to fetch cover URL"); + } + return response.json(); +} + +/** + * Récupère l'URL de streaming d'un block + */ +export async function getStreamUrl( + eventId: number, + channel?: number +): Promise { + const url = channel !== undefined + ? `/api/radioparadise/stream-url/${eventId}?channel=${channel}` + : `/api/radioparadise/stream-url/${eventId}`; + + const response = await fetch(url); + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to fetch stream URL"); + } + return response.json(); +} + +/** + * Formate une durée en millisecondes en format MM:SS + */ +export function formatDuration(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +/** + * Formate une durée en millisecondes en format H:MM:SS si >= 1h, sinon MM:SS + */ +export function formatDurationLong(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + } + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +/** + * Récupère l'URL de la pochette d'un morceau, avec fallback vers l'image par défaut + */ +export function getSongCoverUrl(song: SongInfo): string | undefined { + return song.cover_url; +} + +/** + * Retourne le nom complet d'un canal + */ +export function getChannelName(channelId: number): string { + const channelNames: Record = { + 0: "Main Mix", + 1: "Mellow Mix", + 2: "Rock Mix", + 3: "Eclectic Mix", + }; + return channelNames[channelId] || "Unknown"; +} + +/** + * Retourne la description d'un canal + */ +export function getChannelDescription(channelId: number): string { + const descriptions: Record = { + 0: "Eclectic mix of rock, world, electronica, and more", + 1: "Mellower, less aggressive music", + 2: "Heavier, more guitar-driven music", + 3: "Curated worldwide selection", + }; + return descriptions[channelId] || ""; +} diff --git a/pmoapp/webapp/src/stores/ui.ts b/pmoapp/webapp/src/stores/ui.ts new file mode 100644 index 00000000..029fea22 --- /dev/null +++ b/pmoapp/webapp/src/stores/ui.ts @@ -0,0 +1,111 @@ +// Store Pinia pour l'état UI global +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export interface Notification { + id: string + type: 'info' | 'success' | 'warning' | 'error' + message: string + duration?: number // ms, undefined = permanent +} + +export const useUIStore = defineStore('ui', () => { + // État + const selectedRendererId = ref(null) + const selectedServerId = ref(null) + const showEventLog = ref(false) + const sseConnected = ref(false) + const notifications = ref([]) + + // Actions + function selectRenderer(id: string | null) { + selectedRendererId.value = id + } + + function selectServer(id: string | null) { + selectedServerId.value = id + } + + function toggleEventLog() { + showEventLog.value = !showEventLog.value + } + + function setSSEConnected(connected: boolean) { + sseConnected.value = connected + } + + function addNotification( + type: Notification['type'], + message: string, + duration?: number + ) { + const id = `notif-${Date.now()}-${Math.random()}` + const notification: Notification = { + id, + type, + message, + duration, + } + + notifications.value.push(notification) + + // Auto-remove après duration (défaut: 5s) + const timeout = duration !== undefined ? duration : 5000 + if (timeout > 0) { + setTimeout(() => { + removeNotification(id) + }, timeout) + } + + return id + } + + function removeNotification(id: string) { + const index = notifications.value.findIndex(n => n.id === id) + if (index !== -1) { + notifications.value.splice(index, 1) + } + } + + function clearNotifications() { + notifications.value = [] + } + + // Raccourcis pour les types de notifications + function notifySuccess(message: string, duration?: number) { + return addNotification('success', message, duration) + } + + function notifyError(message: string, duration?: number) { + return addNotification('error', message, duration || 7000) // 7s pour erreurs + } + + function notifyWarning(message: string, duration?: number) { + return addNotification('warning', message, duration) + } + + function notifyInfo(message: string, duration?: number) { + return addNotification('info', message, duration) + } + + return { + // État + selectedRendererId, + selectedServerId, + showEventLog, + sseConnected, + notifications, + // Actions + selectRenderer, + selectServer, + toggleEventLog, + setSSEConnected, + addNotification, + removeNotification, + clearNotifications, + notifySuccess, + notifyError, + notifyWarning, + notifyInfo, + } +}) diff --git a/pmoapp/webapp/src/views/DashboardView.vue b/pmoapp/webapp/src/views/DashboardView.vue new file mode 100644 index 00000000..52a77a40 --- /dev/null +++ b/pmoapp/webapp/src/views/DashboardView.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/pmoapp/webapp/src/views/MediaServerView.vue b/pmoapp/webapp/src/views/MediaServerView.vue new file mode 100644 index 00000000..63e98a6d --- /dev/null +++ b/pmoapp/webapp/src/views/MediaServerView.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/pmoapp/webapp/src/views/RendererView.vue b/pmoapp/webapp/src/views/RendererView.vue new file mode 100644 index 00000000..03048da5 --- /dev/null +++ b/pmoapp/webapp/src/views/RendererView.vue @@ -0,0 +1,516 @@ + + + + + diff --git a/pmoapp/webapp/tsconfig.app.json b/pmoapp/webapp/tsconfig.app.json index 8d16e425..44b0bf67 100644 --- a/pmoapp/webapp/tsconfig.app.json +++ b/pmoapp/webapp/tsconfig.app.json @@ -3,6 +3,10 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, /* Linting */ "strict": true, diff --git a/pmoapp/webapp/vite.config.ts b/pmoapp/webapp/vite.config.ts index 540c11ef..90695f9f 100644 --- a/pmoapp/webapp/vite.config.ts +++ b/pmoapp/webapp/vite.config.ts @@ -1,10 +1,16 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' // https://vite.dev/config/ export default defineConfig({ plugins: [vue()], base: '/app/', // Base path pour le déploiement + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, server: { proxy: { '/api': { diff --git a/pmoaudio-ext/Cargo.toml b/pmoaudio-ext/Cargo.toml index 112c8a8a..e78cc9a9 100644 --- a/pmoaudio-ext/Cargo.toml +++ b/pmoaudio-ext/Cargo.toml @@ -29,10 +29,11 @@ rand = "0.8" # HTTP streaming dependencies bytes = { version = "1.0", optional = true } serde = { version = "1.0", features = ["derive"], optional = true } +serde_json = { version = "1.0", optional = true } [features] default = [] -cache-sink = ["dep:pmoaudiocache", "dep:pmoflac", "dep:pmometadata"] +cache-sink = ["dep:pmoaudiocache", "dep:pmoflac", "dep:pmometadata", "dep:serde_json"] playlist = ["cache-sink", "dep:pmoplaylist", "dep:pmocache"] http-stream = ["dep:pmoflac", "dep:pmometadata", "dep:bytes", "dep:serde"] all = ["cache-sink", "playlist", "http-stream"] diff --git a/pmoaudio-ext/src/lib.rs b/pmoaudio-ext/src/lib.rs index e9003a3e..3828c88d 100755 --- a/pmoaudio-ext/src/lib.rs +++ b/pmoaudio-ext/src/lib.rs @@ -22,15 +22,21 @@ //! Aucune des crates ci-dessus ne dépend de `pmoaudio-ext`, évitant ainsi //! tout cycle de dépendances. -#[cfg(feature = "cache-sink")] +#[cfg(any(feature = "cache-sink", feature = "http-stream"))] pub mod sinks; +#[cfg(any(feature = "cache-sink", feature = "http-stream"))] +pub mod nodes; + #[cfg(feature = "playlist")] pub mod sources; // Re-exports pour faciliter l'utilisation -#[cfg(feature = "cache-sink")] +#[cfg(any(feature = "cache-sink", feature = "http-stream"))] pub use sinks::*; +#[cfg(any(feature = "cache-sink", feature = "http-stream"))] +pub use nodes::*; + #[cfg(feature = "playlist")] pub use sources::*; diff --git a/pmoaudio-ext/src/nodes/mod.rs b/pmoaudio-ext/src/nodes/mod.rs new file mode 100644 index 00000000..17a38e71 --- /dev/null +++ b/pmoaudio-ext/src/nodes/mod.rs @@ -0,0 +1,5 @@ +#[cfg(any(feature = "cache-sink", feature = "http-stream"))] +pub mod track_boundary_cover_node; + +#[cfg(any(feature = "cache-sink", feature = "http-stream"))] +pub use track_boundary_cover_node::TrackBoundaryCoverNode; diff --git a/pmoaudio-ext/src/nodes/track_boundary_cover_node.rs b/pmoaudio-ext/src/nodes/track_boundary_cover_node.rs new file mode 100644 index 00000000..d22f7332 --- /dev/null +++ b/pmoaudio-ext/src/nodes/track_boundary_cover_node.rs @@ -0,0 +1,168 @@ +//! Node de conversion qui s'assure que chaque `TrackBoundary` possède un `cover_pk`. +//! +//! Il laisse passer tous les segments audio de manière transparente. Lorsqu'un +//! `TrackBoundary` est détecté, il vérifie si ses métadonnées contiennent déjà +//! un `cover_pk`. Si ce n'est pas le cas mais qu'une `cover_url` est disponible, +//! l'image est sauvegardée dans le cache de couvertures puis la clé primaire est +//! écrite dans les métadonnées avant de poursuivre la propagation. + +use std::sync::Arc; + +use pmoaudio::{ + nodes::{AudioError, DEFAULT_CHANNEL_SIZE}, + pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic, PipelineHandle}, + AudioSegment, TypeRequirement, TypedAudioNode, +}; +use pmocovers::Cache as CoverCache; +use pmometadata::TrackMetadata; +use tokio::select; +use tokio::sync::{mpsc, RwLock}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, warn}; + +/// Node enveloppe qui applique [`TrackBoundaryCoverLogic`]. +pub struct TrackBoundaryCoverNode { + inner: Node, +} + +impl TrackBoundaryCoverNode { + /// Crée un nouveau node. + pub fn new(cover_cache: Arc) -> Self { + let logic = TrackBoundaryCoverLogic::new(cover_cache); + Self { + inner: Node::new_with_input(logic, DEFAULT_CHANNEL_SIZE), + } + } +} + +#[async_trait::async_trait] +impl AudioPipelineNode for TrackBoundaryCoverNode { + fn get_tx(&self) -> Option>> { + self.inner.get_tx() + } + + fn register(&mut self, child: Box) { + self.inner.register(child); + } + + async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { + Box::new(self.inner).run(stop_token).await + } + + fn start(self: Box) -> PipelineHandle { + Box::new(self.inner).start() + } +} + +impl TypedAudioNode for TrackBoundaryCoverNode { + fn input_type(&self) -> Option { + Some(TypeRequirement::any()) + } + + fn output_type(&self) -> Option { + Some(TypeRequirement::any()) + } +} + +struct TrackBoundaryCoverLogic { + cover_cache: Arc, +} + +impl TrackBoundaryCoverLogic { + fn new(cover_cache: Arc) -> Self { + Self { cover_cache } + } + + async fn ensure_cover_pk(&self, metadata: Arc>) { + let cover_url = { + let guard = metadata.read().await; + + match guard.get_cover_pk().await { + Ok(Some(pk)) => { + debug!("TrackBoundaryCoverNode: cover_pk already set ({})", pk); + return; + } + Ok(None) => {} + Err(err) => warn!("TrackBoundaryCoverNode: cannot read cover_pk: {}", err), + } + + match guard.get_cover_url().await { + Ok(url) => url, + Err(err) => { + warn!("TrackBoundaryCoverNode: cannot read cover_url: {}", err); + return; + } + } + }; + + let cover_url = match cover_url { + Some(url) => url, + None => { + debug!("TrackBoundaryCoverNode: no cover_url present, skipping cache"); + return; + } + }; + + match self.cover_cache.add_from_url(&cover_url, None).await { + Ok(pk) => { + debug!( + "TrackBoundaryCoverNode: cached cover for url={}, pk={}", + cover_url, pk + ); + let mut guard = metadata.write().await; + if let Err(err) = guard.set_cover_pk(Some(pk.clone())).await { + warn!( + "TrackBoundaryCoverNode: failed to set cover_pk {}: {}", + pk, err + ); + } + } + Err(err) => { + warn!( + "TrackBoundaryCoverNode: failed to cache cover from {}: {}", + cover_url, err + ); + } + } + } +} + +#[async_trait::async_trait] +impl NodeLogic for TrackBoundaryCoverLogic { + async fn process( + &mut self, + input: Option>>, + output: Vec>>, + stop_token: CancellationToken, + ) -> Result<(), AudioError> { + let mut input = input.ok_or_else(|| { + AudioError::ProcessingError( + "TrackBoundaryCoverNode requires an upstream input channel".into(), + ) + })?; + let node_name = std::any::type_name::(); + + loop { + let segment = select! { + _ = stop_token.cancelled() => { + debug!("TrackBoundaryCoverNode: stop requested"); + break; + } + segment = input.recv() => segment, + }; + + let Some(segment) = segment else { + debug!("TrackBoundaryCoverNode: upstream closed"); + break; + }; + + if let Some(metadata) = segment.as_track_metadata() { + self.ensure_cover_pk(Arc::clone(metadata)).await; + } + + send_to_children(node_name, &output, segment).await?; + } + + Ok(()) + } +} diff --git a/pmoaudio-ext/src/sinks/broadcast_pacing.rs b/pmoaudio-ext/src/sinks/broadcast_pacing.rs new file mode 100644 index 00000000..dcf28bf2 --- /dev/null +++ b/pmoaudio-ext/src/sinks/broadcast_pacing.rs @@ -0,0 +1,60 @@ +//! Shared broadcast pacing logic for streaming sinks. +//! +//! Provides intelligent backpressure based on audio timing: +//! - Detects TopZeroSync (when audio timestamp resets to 0) +//! - Drops frames that are late (audio_ts < elapsed) +//! - Paces broadcast to match audio playback rate + +use std::time::Instant; +use tracing::trace; + +/// Error returned when a frame should be skipped (too late) +#[derive(Debug)] +pub struct SkipFrame; + +/// Manages broadcast pacing with TopZeroSync detection +#[allow(dead_code)] +pub struct BroadcastPacer { + /// Start time (reset on TopZeroSync) + start_time: Instant, + /// Maximum allowed lead time before sleeping (0 = no pacing) + max_lead_time: f64, + /// Label for logging (e.g., "FLAC" or "OGG") + label: String, + /// Pending reset flag - will reset timer on next chunk + pending_reset: bool, +} + +impl BroadcastPacer { + /// Create a new broadcast pacer + /// + /// # Arguments + /// + /// * `max_lead_time` - Maximum lead time in seconds (0 = no pacing) + /// * `label` - Label for logging + pub fn new(max_lead_time: f64, label: impl Into) -> Self { + Self { + start_time: Instant::now(), + max_lead_time: max_lead_time.max(0.0), + label: label.into(), + pending_reset: false, + } + } + + /// Check timing and apply pacing - NO-OP VERSION + /// + /// Pacing is now handled entirely by the expiration-based system in + /// TimedBroadcast. This method is kept for backward compatibility + /// but always returns Ok(()). + /// + /// # Returns + /// + /// - Always returns `Ok(())` + pub async fn check_and_pace(&mut self, audio_timestamp: f64) -> Result<(), SkipFrame> { + trace!( + "{} broadcaster: check_and_pace called with audio_ts={:.3}s (no-op - pacing handled by TimedBroadcast)", + self.label, audio_timestamp + ); + Ok(()) + } +} diff --git a/pmoaudio-ext/src/sinks/byte_stream_reader.rs b/pmoaudio-ext/src/sinks/byte_stream_reader.rs new file mode 100644 index 00000000..acee39fe --- /dev/null +++ b/pmoaudio-ext/src/sinks/byte_stream_reader.rs @@ -0,0 +1,98 @@ +use std::io; +use std::{ + collections::VecDeque, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; + +use tokio::{ + io::{AsyncRead, ReadBuf}, + sync::{mpsc, RwLock}, +}; + +/// PCM chunk with audio data and timestamp for precise pacing. +#[derive(Debug)] +pub struct PcmChunk { + /// Raw PCM audio bytes + pub bytes: Vec, + /// Timestamp in seconds (from AudioSegment) + pub timestamp_sec: f64, + /// Duration in seconds of this PCM chunk (samples / sample_rate) + pub duration_sec: f64, +} + +/// AsyncRead adapter for mpsc::Receiver. +/// Extracts bytes from PcmChunk and provides them to the FLAC encoder. +pub struct ByteStreamReader { + rx: mpsc::Receiver, + buffer: VecDeque, + finished: bool, + /// Shared timestamp for broadcaster pacing + current_timestamp: Arc>, + /// Shared duration for broadcaster pacing + current_duration: Arc>, +} + +impl ByteStreamReader { + pub fn new( + rx: mpsc::Receiver, + current_timestamp: Arc>, + current_duration: Arc>, + ) -> Self { + Self { + rx, + buffer: VecDeque::new(), + finished: false, + current_timestamp, + current_duration, + } + } +} + +impl AsyncRead for ByteStreamReader { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + if !self.buffer.is_empty() { + let to_copy = self.buffer.len().min(buf.remaining()); + if to_copy == 0 { + return Poll::Ready(Ok(())); + } + + let slice = self.buffer.make_contiguous(); + buf.put_slice(&slice[..to_copy]); + self.buffer.drain(..to_copy); + return Poll::Ready(Ok(())); + } + + if self.finished { + return Poll::Ready(Ok(())); + } + + match Pin::new(&mut self.rx).poll_recv(cx) { + Poll::Ready(Some(chunk)) => { + if chunk.bytes.is_empty() { + continue; + } + // Update shared timestamp and duration for broadcaster pacing + if let Ok(mut ts) = self.current_timestamp.try_write() { + *ts = chunk.timestamp_sec; + } + if let Ok(mut dur) = self.current_duration.try_write() { + *dur = chunk.duration_sec; + } + self.buffer.extend(chunk.bytes); + } + Poll::Ready(None) => { + self.finished = true; + return Poll::Ready(Ok(())); + } + Poll::Pending => return Poll::Pending, + } + } + } +} diff --git a/pmoaudio-ext/src/sinks/chunk_to_pcm.rs b/pmoaudio-ext/src/sinks/chunk_to_pcm.rs new file mode 100644 index 00000000..829110f9 --- /dev/null +++ b/pmoaudio-ext/src/sinks/chunk_to_pcm.rs @@ -0,0 +1,97 @@ +use pmoaudio::{AudioChunk, AudioError}; + +/// Convert an AudioChunk to PCM bytes with specified bit depth. +pub(crate) fn chunk_to_pcm_bytes( + chunk: &AudioChunk, + bits_per_sample: u8, +) -> Result, AudioError> { + match chunk { + AudioChunk::F32(_) | AudioChunk::F64(_) => { + return Err(AudioError::ProcessingError( + "StreamingFlacSink only supports integer audio chunks".into(), + )); + } + _ => {} + } + + let len = chunk.len(); + let bytes_per_frame = (bits_per_sample / 8) as usize * 2; + let mut bytes = Vec::with_capacity(len * bytes_per_frame); + + match (chunk, bits_per_sample) { + (AudioChunk::I16(data), 16) => { + for frame in data.get_frames() { + bytes.extend_from_slice(&frame[0].to_le_bytes()); + bytes.extend_from_slice(&frame[1].to_le_bytes()); + } + } + (AudioChunk::I16(data), 24) => { + for frame in data.get_frames() { + let left = (frame[0] as i32) << 8; + let right = (frame[1] as i32) << 8; + bytes.extend_from_slice(&left.to_le_bytes()[..3]); + bytes.extend_from_slice(&right.to_le_bytes()[..3]); + } + } + (AudioChunk::I16(data), 32) => { + for frame in data.get_frames() { + let left = (frame[0] as i32) << 16; + let right = (frame[1] as i32) << 16; + bytes.extend_from_slice(&left.to_le_bytes()); + bytes.extend_from_slice(&right.to_le_bytes()); + } + } + (AudioChunk::I24(data), 16) => { + for frame in data.get_frames() { + let left = (frame[0].as_i32() >> 8) as i16; + let right = (frame[1].as_i32() >> 8) as i16; + bytes.extend_from_slice(&left.to_le_bytes()); + bytes.extend_from_slice(&right.to_le_bytes()); + } + } + (AudioChunk::I24(data), 24) => { + for frame in data.get_frames() { + bytes.extend_from_slice(&frame[0].as_i32().to_le_bytes()[..3]); + bytes.extend_from_slice(&frame[1].as_i32().to_le_bytes()[..3]); + } + } + (AudioChunk::I24(data), 32) => { + for frame in data.get_frames() { + let left = frame[0].as_i32() << 8; + let right = frame[1].as_i32() << 8; + bytes.extend_from_slice(&left.to_le_bytes()); + bytes.extend_from_slice(&right.to_le_bytes()); + } + } + (AudioChunk::I32(data), 16) => { + for frame in data.get_frames() { + let left = (frame[0] >> 16) as i16; + let right = (frame[1] >> 16) as i16; + bytes.extend_from_slice(&left.to_le_bytes()); + bytes.extend_from_slice(&right.to_le_bytes()); + } + } + (AudioChunk::I32(data), 24) => { + for frame in data.get_frames() { + let left = frame[0] >> 8; + let right = frame[1] >> 8; + bytes.extend_from_slice(&left.to_le_bytes()[..3]); + bytes.extend_from_slice(&right.to_le_bytes()[..3]); + } + } + (AudioChunk::I32(data), 32) => { + for frame in data.get_frames() { + bytes.extend_from_slice(&frame[0].to_le_bytes()); + bytes.extend_from_slice(&frame[1].to_le_bytes()); + } + } + _ => { + return Err(AudioError::ProcessingError(format!( + "Unsupported bits_per_sample: {}", + bits_per_sample + ))); + } + } + + Ok(bytes) +} diff --git a/pmoaudio-ext/src/sinks/flac_cache_sink.rs b/pmoaudio-ext/src/sinks/flac_cache_sink.rs index 50add148..32a99b42 100755 --- a/pmoaudio-ext/src/sinks/flac_cache_sink.rs +++ b/pmoaudio-ext/src/sinks/flac_cache_sink.rs @@ -34,13 +34,6 @@ use tokio_util::sync::CancellationToken; // FlacCacheSinkLogic - Logique métier pure // ═══════════════════════════════════════════════════════════════════════════ -/// Signal retourné par pump_segments indiquant pourquoi l'encodage s'est arrêté. -enum StopReason { - TrackBoundary(Arc>), - EndOfStream, - ChannelClosed, -} - /// Logique pure d'encodage FLAC vers le cache pub struct FlacCacheSinkLogic { cache: Arc, @@ -93,10 +86,16 @@ impl NodeLogic for FlacCacheSinkLogic { loop { // Attendre le premier chunk audio pour cette track - tracing::debug!("FlacCacheSink: Waiting for first audio chunk (track_number={})", track_number); - let (first_segment, track_metadata) = if let Some(metadata) = next_track_metadata.take() { + tracing::debug!( + "FlacCacheSink: Waiting for first audio chunk (track_number={})", + track_number + ); + let (first_segment, track_metadata) = if let Some(metadata) = next_track_metadata.take() + { // On a déjà reçu le TrackBoundary en Phase 3 de la track précédente - tracing::debug!("FlacCacheSink: Using TrackBoundary metadata from previous track's Phase 3"); + tracing::debug!( + "FlacCacheSink: Using TrackBoundary metadata from previous track's Phase 3" + ); // Attendre juste le premier chunk match wait_for_first_audio_chunk(&mut rx, &stop_token).await { Ok(chunk) => { @@ -194,6 +193,7 @@ impl NodeLogic for FlacCacheSinkLogic { // Phase 1: Dispatcher jusqu'à ce que le prebuffer soit terminé let mut end_of_stream_received = false; + let mut early_track_boundary_received = false; let mut track_tx_opt = Some(track_tx); let pk = loop { tokio::select! { @@ -215,9 +215,9 @@ impl NodeLogic for FlacCacheSinkLogic { result = rx.recv() => { match result { Some(segment) => { - // Si EndOfStream a été reçu, ignorer tous les segments suivants + // Si EndOfStream ou TrackBoundary a été reçu, ignorer tous les segments suivants // et continuer à attendre cache_future - if end_of_stream_received { + if end_of_stream_received || early_track_boundary_received { continue; } @@ -233,10 +233,18 @@ impl NodeLogic for FlacCacheSinkLogic { } } _AudioSegment::Sync(marker) => match &**marker { - SyncMarker::TrackBoundary { .. } => { - // TrackBoundary avant fin du prebuffer - track trop courte - tracing::error!("FlacCacheSink: TrackBoundary received before prebuffer complete - track too short"); - return Err(AudioError::ProcessingError("Track too short for prebuffer".to_string())); + SyncMarker::TrackBoundary { metadata } => { + // TrackBoundary pendant le prebuffer - track courte (< 512KB) + tracing::warn!( + "FlacCacheSink: TrackBoundary received before prebuffer complete - track shorter than 512KB, closing pump and waiting for ingestion" + ); + // Stocker les métadonnées pour la prochaine track + next_track_metadata = Some(metadata.clone()); + // Fermer le track_tx pour que le pump se termine proprement + track_tx_opt = None; + // Marquer qu'on a reçu un TrackBoundary précoce + early_track_boundary_received = true; + // Continuer à attendre cache_future } SyncMarker::EndOfStream => { tracing::debug!("FlacCacheSink: EndOfStream during prebuffer - closing pump and waiting for ingestion to complete"); @@ -256,7 +264,7 @@ impl NodeLogic for FlacCacheSinkLogic { } None => { // EOF sur rx pendant le prebuffer - attendre que cache_future se termine - if !end_of_stream_received { + if !end_of_stream_received && !early_track_boundary_received { tracing::debug!("FlacCacheSink: EOF on rx during prebuffer, waiting for ingestion to complete"); track_tx_opt = None; end_of_stream_received = true; @@ -274,6 +282,95 @@ impl NodeLogic for FlacCacheSinkLogic { } }; + if let Some(transform) = self.cache.transform_metadata(&pk).await { + tracing::debug!( + "FlacCacheSink: Got transform metadata for pk {}: sr={:?}, bps={:?}, ch={:?}, ts={:?}", + pk, + transform.sample_rate, + transform.bits_per_sample, + transform.channels, + transform.total_samples + ); + + // Persister les métadonnées techniques via l'interface TrackMetadata + let track_meta = self.cache.track_metadata(&pk); + let mut meta = track_meta.write().await; + + if let Some(sr) = transform.sample_rate { + if let Err(e) = meta.set_sample_rate(Some(sr)).await { + tracing::error!( + "FlacCacheSink: Failed to set sample_rate for pk {}: {:?}", + pk, + e + ); + } else { + tracing::debug!("FlacCacheSink: Set sample_rate={} for pk {}", sr, pk); + } + } + if let Some(bps) = transform.bits_per_sample { + if let Err(e) = meta.set_bits_per_sample(Some(bps)).await { + tracing::error!( + "FlacCacheSink: Failed to set bits_per_sample for pk {}: {:?}", + pk, + e + ); + } else { + tracing::debug!("FlacCacheSink: Set bits_per_sample={} for pk {}", bps, pk); + } + } + if let Some(ch) = transform.channels { + if let Err(e) = meta.set_channels(Some(ch)).await { + tracing::error!( + "FlacCacheSink: Failed to set channels for pk {}: {:?}", + pk, + e + ); + } else { + tracing::debug!("FlacCacheSink: Set channels={} for pk {}", ch, pk); + } + } + if let Some(ts) = transform.total_samples { + if let Err(e) = meta.set_total_samples(Some(ts)).await { + tracing::error!( + "FlacCacheSink: Failed to set total_samples for pk {}: {:?}", + pk, + e + ); + } else { + tracing::debug!("FlacCacheSink: Set total_samples={} for pk {}", ts, pk); + } + + // Calculer la durée à partir de total_samples et sample_rate + if let Some(sr) = transform.sample_rate { + if sr > 0 { + use std::time::Duration; + let secs = (ts as f64 / sr as f64).round() as u64; + if let Err(e) = meta.set_duration(Some(Duration::from_secs(secs))).await + { + tracing::error!( + "FlacCacheSink: Failed to set duration for pk {}: {:?}", + pk, + e + ); + } else { + tracing::debug!( + "FlacCacheSink: Set duration={} secs for pk {}", + secs, + pk + ); + } + } + } + } + + drop(meta); // Libérer le lock explicitement + } else { + tracing::warn!( + "FlacCacheSink: No transform metadata available for pk {}", + pk + ); + } + // Phase 2: Prebuffer terminé! Copier les métadonnées et pusher à la playlist // Copier les métadonnées du TrackBoundary dans le cache // IMPORTANT: Faire ceci AVANT d'ajouter à la playlist pour que les métadonnées soient disponibles @@ -290,24 +387,72 @@ impl NodeLogic for FlacCacheSinkLogic { )) })?; - let url = match dest_metadata.read().await.get_cover_url().await { - Ok(url) => { - tracing::debug!("FlacCacheSink: Got cover URL for pk {}: {:?}", pk, url); - url + let cover_pk_present = match dest_metadata.read().await.get_cover_pk().await { + Ok(Some(existing_pk)) => { + tracing::debug!( + "FlacCacheSink: cover_pk already set for audio asset {} ({})", + pk, + existing_pk + ); + true } + Ok(None) => false, Err(e) if e.is_transient() => { - tracing::debug!("FlacCacheSink: Transient error getting cover URL for pk {}: {}", pk, e); - None + tracing::debug!( + "FlacCacheSink: Transient error getting cover_pk for pk {}: {}", + pk, + e + ); + false } Err(e) => { - tracing::warn!("FlacCacheSink: Cannot obtain cover URL for audio asset {}: {}", pk, e); - None + tracing::warn!( + "FlacCacheSink: Cannot obtain cover_pk for audio asset {}: {}", + pk, + e + ); + false + } + }; + + let url = if cover_pk_present { + None + } else { + match dest_metadata.read().await.get_cover_url().await { + Ok(url) => { + tracing::debug!( + "FlacCacheSink: Got cover URL for pk {}: {:?}", + pk, + url + ); + url + } + Err(e) if e.is_transient() => { + tracing::debug!( + "FlacCacheSink: Transient error getting cover URL for pk {}: {}", + pk, + e + ); + None + } + Err(e) => { + tracing::warn!( + "FlacCacheSink: Cannot obtain cover URL for audio asset {}: {}", + pk, + e + ); + None + } } }; if let Some(cover_url) = url { - tracing::debug!("FlacCacheSink: Attempting to cache cover from URL: {}", cover_url); - match self.covers + tracing::debug!( + "FlacCacheSink: Attempting to cache cover from URL: {}", + cover_url + ); + match self + .covers .add_from_url(&cover_url, self.collection.as_deref()) .await { @@ -323,7 +468,11 @@ impl NodeLogic for FlacCacheSinkLogic { } } Err(e) => { - tracing::warn!("FlacCacheSink: Failed to cache cover for audio asset {}: {}", pk, e); + tracing::warn!( + "FlacCacheSink: Failed to cache cover for audio asset {}: {}", + pk, + e + ); } } } else { @@ -339,20 +488,37 @@ impl NodeLogic for FlacCacheSinkLogic { playlist_handle.push(pk.clone()).await.map_err(|e| { AudioError::ProcessingError(format!("Failed to add to playlist: {}", e)) })?; - tracing::info!("FlacCacheSink: Successfully pushed to playlist in {:?}", push_start.elapsed()); + tracing::info!( + "FlacCacheSink: Successfully pushed to playlist in {:?}", + push_start.elapsed() + ); } // Si EndOfStream a été reçu pendant le prebuffer, on a déjà tout traité // Il faut juste attendre que le pump se termine et retourner if end_of_stream_received { - tracing::debug!("FlacCacheSink: EndOfStream was received during prebuffer, track complete"); + tracing::debug!( + "FlacCacheSink: EndOfStream was received during prebuffer, track complete" + ); drop(pump_handle); track_number += 1; continue; // Passer à la track suivante (qui n'arrivera pas car EndOfStream) } + // Si TrackBoundary précoce a été reçu pendant le prebuffer, passer à la track suivante + if early_track_boundary_received { + tracing::debug!( + "FlacCacheSink: TrackBoundary was received during prebuffer, track complete, moving to next track" + ); + drop(pump_handle); + track_number += 1; + continue; // Passer à la track suivante (métadonnées déjà stockées dans next_track_metadata) + } + // Phase 3: Continuer à dispatcher jusqu'au TrackBoundary - tracing::debug!("FlacCacheSink: Continuing dispatch until TrackBoundary (pump runs in background)"); + tracing::debug!( + "FlacCacheSink: Continuing dispatch until TrackBoundary (pump runs in background)" + ); let mut track_tx = track_tx_opt; // track_tx_opt contient Some(track_tx) car end_of_stream_received est false let mut pump_handle = Some(pump_handle); let mut pump_closed = false; @@ -384,7 +550,9 @@ impl NodeLogic for FlacCacheSinkLogic { if tx.send(segment).await.is_err() { // Le pump a fermé son channel - cela peut arriver si le fichier // était déjà en cache (add_from_reader retourne immédiatement) - tracing::debug!("FlacCacheSink: pump closed track_tx, checking pump status"); + tracing::debug!( + "FlacCacheSink: pump closed track_tx, checking pump status" + ); drop(track_tx.take()); // Attendre que le pump se termine et vérifier le résultat @@ -397,13 +565,21 @@ impl NodeLogic for FlacCacheSinkLogic { } Ok(Err(e)) => { // Le pump a rencontré une erreur - tracing::error!("FlacCacheSink: pump died with error: {}", e); + tracing::error!( + "FlacCacheSink: pump died with error: {}", + e + ); return Err(e); } Err(e) => { // Le pump task a paniqué - tracing::error!("FlacCacheSink: pump task panicked: {}", e); - return Err(AudioError::ProcessingError("Pump task panicked".to_string())); + tracing::error!( + "FlacCacheSink: pump task panicked: {}", + e + ); + return Err(AudioError::ProcessingError( + "Pump task panicked".to_string(), + )); } } } @@ -534,7 +710,9 @@ async fn wait_for_first_audio_chunk( _AudioSegment::Sync(marker) => match &**marker { SyncMarker::TrackBoundary { .. } => { // On ne devrait pas recevoir de TrackBoundary ici car on l'a déjà - tracing::warn!("FlacCacheSink: Unexpected TrackBoundary while waiting for first chunk"); + tracing::warn!( + "FlacCacheSink: Unexpected TrackBoundary while waiting for first chunk" + ); continue; } SyncMarker::EndOfStream => { @@ -600,144 +778,6 @@ async fn wait_for_first_audio_chunk_with_metadata( } } -/// Draine tous les segments jusqu'au prochain TrackBoundary ou EndOfStream -/// -/// Cette fonction est utilisée quand le fichier était déjà en cache et que -/// nous devons ignorer les segments restants pour rester synchronisé avec la source. -async fn drain_until_track_boundary( - rx: &mut mpsc::Receiver>, - stop_token: &CancellationToken, -) -> Result { - loop { - let segment = tokio::select! { - result = rx.recv() => { - match result { - Some(seg) => seg, - None => { - return Ok(StopReason::ChannelClosed); - } - } - } - _ = stop_token.cancelled() => { - return Ok(StopReason::ChannelClosed); - } - }; - - match &segment.segment { - _AudioSegment::Chunk(_) => { - // Ignorer les chunks audio - continue; - } - _AudioSegment::Sync(marker) => match &**marker { - SyncMarker::TrackBoundary { metadata, .. } => { - return Ok(StopReason::TrackBoundary(metadata.clone())); - } - SyncMarker::EndOfStream => { - return Ok(StopReason::EndOfStream); - } - _ => { - // Ignorer les autres syncmarkers - continue; - } - }, - } - } -} - -/// Pompe les segments pour une seule track (s'arrête au TrackBoundary). -async fn pump_track_segments( - first_segment: Arc, - rx: &mut mpsc::Receiver>, - pcm_tx: mpsc::Sender>, - bits_per_sample: u8, - expected_rate: u32, - stop_token: &CancellationToken, -) -> Result<(u64, u64, f64, StopReason), AudioError> { - let mut chunks = 0u64; - let mut samples = 0u64; - let mut duration_sec = 0.0f64; - - // Traiter le premier segment - if let Some(chunk) = first_segment.as_chunk() { - let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?; - if !pcm_bytes.is_empty() { - // Si le send échoue, c'est que le receiver est fermé - // (par exemple, le fichier était déjà en cache et add_from_reader a retourné immédiatement) - if pcm_tx.send(pcm_bytes).await.is_err() { - drop(pcm_tx); - return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed)); - } - chunks += 1; - samples += chunk.len() as u64; - duration_sec += chunk.len() as f64 / expected_rate as f64; - } - } - - // Boucle sur les segments suivants - loop { - let segment = tokio::select! { - result = rx.recv() => { - match result { - Some(seg) => seg, - None => { - drop(pcm_tx); // Fermer le channel PCM - return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed)); - } - } - } - _ = stop_token.cancelled() => { - drop(pcm_tx); // Fermer le channel PCM - return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed)); - } - }; - - match &segment.segment { - _AudioSegment::Chunk(chunk) => { - // Vérifier la cohérence du sample rate - if chunk.sample_rate() != expected_rate { - return Err(AudioError::ProcessingError(format!( - "FlacCacheSink: inconsistent sample rate ({} vs {})", - chunk.sample_rate(), - expected_rate - ))); - } - - let pcm_bytes = chunk_to_pcm_bytes(&chunk, bits_per_sample)?; - if pcm_bytes.is_empty() { - continue; - } - - // Si le send échoue, c'est que le receiver est fermé - // (par exemple, le fichier était déjà en cache et add_from_reader a retourné immédiatement) - if pcm_tx.send(pcm_bytes).await.is_err() { - drop(pcm_tx); - return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed)); - } - - chunks += 1; - samples += chunk.len() as u64; - duration_sec += chunk.len() as f64 / expected_rate as f64; - } - _AudioSegment::Sync(marker) => match &**marker { - SyncMarker::TrackBoundary { metadata, .. } => { - drop(pcm_tx); // Fermer le channel PCM - return Ok(( - chunks, - samples, - duration_sec, - StopReason::TrackBoundary(metadata.clone()), - )); - } - SyncMarker::EndOfStream => { - drop(pcm_tx); // Fermer le channel PCM - return Ok((chunks, samples, duration_sec, StopReason::EndOfStream)); - } - _ => {} // Ignorer les autres syncmarkers - }, - } - } -} - /// Pompe les segments pour une seule track depuis un channel dédié. /// /// Cette version permet d'avoir plusieurs pumps en parallèle (pour cache progressif), diff --git a/pmoaudio-ext/src/sinks/flac_frame_utils.rs b/pmoaudio-ext/src/sinks/flac_frame_utils.rs index 99145761..6c7cf0b6 100644 --- a/pmoaudio-ext/src/sinks/flac_frame_utils.rs +++ b/pmoaudio-ext/src/sinks/flac_frame_utils.rs @@ -7,6 +7,16 @@ //! Frame header validation includes CRC-8 verification as per FLAC specification //! to eliminate false positives that would cause decoder errors. +use pmoaudio::AudioError; +use pmoflac::FlacEncodedStream; +use tokio::io::AsyncReadExt; + +/// State for FLAC stream subscription. +pub(crate) enum FlacStreamState { + SendingHeader, + Streaming, +} + /// Validate and parse FLAC block size from frame header /// /// Returns the number of samples in the frame if the header is valid, or None if: @@ -365,6 +375,103 @@ pub(crate) fn find_complete_frames_with_samples(data: &[u8]) -> (usize, u64) { } } +/// Extract sample rate from STREAMINFO block in FLAC header +pub(crate) fn extract_sample_rate_from_streaminfo(flac_header: &[u8]) -> Result { + // Verify we have at least "fLaC" magic + STREAMINFO block header + if flac_header.len() < 8 { + return Err(AudioError::ProcessingError("FLAC header too short".into())); + } + + if &flac_header[0..4] != b"fLaC" { + return Err(AudioError::ProcessingError("Invalid FLAC magic".into())); + } + + // First metadata block should be STREAMINFO (type 0) + let block_type = flac_header[4] & 0x7F; + if block_type != 0 { + return Err(AudioError::ProcessingError( + "First block is not STREAMINFO".into(), + )); + } + + // STREAMINFO data starts at offset 8 (after magic + block header) + // Sample rate is at offset 10-12 of STREAMINFO data (bytes 18-20 of header) + if flac_header.len() < 21 { + return Err(AudioError::ProcessingError( + "STREAMINFO block truncated".into(), + )); + } + + // Sample rate: 20 bits starting at byte 10 of STREAMINFO + // Format: [byte10: SSSSSSSS] [byte11: SSSSSSSS] [byte12: SSSSCCCC] + // S = sample rate bits, C = channels bits + let byte10 = flac_header[18] as u32; + let byte11 = flac_header[19] as u32; + let byte12 = flac_header[20] as u32; + + // Extract 20 bits for sample rate (top 20 bits of 3 bytes) + let sample_rate = (byte10 << 12) | (byte11 << 4) | (byte12 >> 4); + + if sample_rate == 0 { + return Err(AudioError::ProcessingError( + "Invalid sample rate (0)".into(), + )); + } + + Ok(sample_rate) +} + +/// Read FLAC header (fLaC + all metadata blocks until first frame) +pub(crate) async fn read_flac_header( + stream: &mut FlacEncodedStream, +) -> Result, AudioError> { + let mut header = Vec::new(); + let mut buffer = [0u8; 4]; + + // Read "fLaC" magic + stream + .read_exact(&mut buffer) + .await + .map_err(|e| AudioError::ProcessingError(format!("Failed to read FLAC magic: {}", e)))?; + + if &buffer != b"fLaC" { + return Err(AudioError::ProcessingError( + "Invalid FLAC stream: missing fLaC magic".into(), + )); + } + + header.extend_from_slice(&buffer); + + // Read metadata blocks + loop { + // Read metadata block header (1 byte type + 3 bytes length) + let mut block_header = [0u8; 4]; + stream.read_exact(&mut block_header).await.map_err(|e| { + AudioError::ProcessingError(format!("Failed to read metadata block header: {}", e)) + })?; + + let is_last = (block_header[0] & 0x80) != 0; + let block_length = + u32::from_be_bytes([0, block_header[1], block_header[2], block_header[3]]) as usize; + + header.extend_from_slice(&block_header); + + // Read metadata block data + let mut block_data = vec![0u8; block_length]; + stream.read_exact(&mut block_data).await.map_err(|e| { + AudioError::ProcessingError(format!("Failed to read metadata block data: {}", e)) + })?; + + header.extend_from_slice(&block_data); + + if is_last { + break; + } + } + + Ok(header) +} + #[cfg(test)] mod tests { use super::*; @@ -374,8 +481,8 @@ mod tests { // Real-world example: first frame at 0, false positive at 7 let data = vec![ 0xFF, 0xF8, 0xC9, 0xA8, // Valid frame header at position 0 - 0x00, 0x8D, 0x4C, - 0xFF, 0xFE, 0x00, 0x00, // False positive at position 7 (0xFE has reserved bit set) + 0x00, 0x8D, 0x4C, 0xFF, 0xFE, 0x00, + 0x00, // False positive at position 7 (0xFE has reserved bit set) ]; // Position 0 should be valid diff --git a/pmoaudio-ext/src/sinks/mod.rs b/pmoaudio-ext/src/sinks/mod.rs index e8241678..0aa776ca 100755 --- a/pmoaudio-ext/src/sinks/mod.rs +++ b/pmoaudio-ext/src/sinks/mod.rs @@ -4,23 +4,42 @@ //! et ne peuvent pas être placés directement dans pmoaudio sans créer //! de dépendances cycliques. +pub mod byte_stream_reader; +pub mod chunk_to_pcm; +pub mod streaming_icyflac_sink; + #[cfg(feature = "cache-sink")] mod flac_cache_sink; #[cfg(feature = "cache-sink")] pub use flac_cache_sink::{FlacCacheSink, FlacCacheSinkStats, TrackStats}; +#[cfg(feature = "http-stream")] +mod broadcast_pacing; + #[cfg(feature = "http-stream")] mod flac_frame_utils; +#[cfg(feature = "http-stream")] +mod timed_broadcast; + #[cfg(feature = "http-stream")] mod streaming_flac_sink; #[cfg(feature = "http-stream")] -pub use streaming_flac_sink::{StreamingFlacSink, StreamHandle, MetadataSnapshot, FlacClientStream, IcyClientStream}; +mod streaming_sink_common; + +#[cfg(feature = "http-stream")] +pub use streaming_flac_sink::{FlacClientStream, StreamHandle, StreamingFlacSink}; + +#[cfg(feature = "http-stream")] +pub use streaming_icyflac_sink::IcyClientStream; #[cfg(feature = "http-stream")] mod streaming_ogg_flac_sink; #[cfg(feature = "http-stream")] -pub use streaming_ogg_flac_sink::{StreamingOggFlacSink, OggFlacStreamHandle, OggFlacClientStream}; +pub use streaming_ogg_flac_sink::{OggFlacClientStream, OggFlacStreamHandle, StreamingOggFlacSink}; + +#[cfg(feature = "http-stream")] +pub use streaming_sink_common::{MetadataSnapshot, StreamingSinkOptions}; diff --git a/pmoaudio-ext/src/sinks/streaming_flac_sink.rs b/pmoaudio-ext/src/sinks/streaming_flac_sink.rs index 16cd6ea3..fa46346d 100644 --- a/pmoaudio-ext/src/sinks/streaming_flac_sink.rs +++ b/pmoaudio-ext/src/sinks/streaming_flac_sink.rs @@ -19,7 +19,7 @@ //! ↓ //! [Broadcaster Task] //! ↓ -//! broadcast::channel (FLAC bytes) +//! timed_broadcast::channel (FLAC bytes) //! ↓ //! Multiple clients via StreamHandle::subscribe() //! ├─ FLAC pure (for standard renderers) @@ -54,187 +54,109 @@ //! } //! ``` -use std::collections::VecDeque; use std::io; use std::pin::Pin; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::task::{Context, Poll}; use std::time::Duration; -use super::flac_frame_utils; +use super::{ + broadcast_pacing::BroadcastPacer, + flac_frame_utils, + timed_broadcast::{self, SendError}, +}; use async_trait::async_trait; use bytes::Bytes; use pmoaudio::{ pipeline::{AudioPipelineNode, Node, NodeLogic, PipelineHandle, StopReason}, - AudioChunk, AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, - _AudioSegment, + AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, _AudioSegment, }; -use pmoflac::{encode_flac_stream, EncoderOptions, FlacEncodedStream, PcmFormat}; -use pmometadata::TrackMetadata; +use pmoflac::{EncoderOptions, FlacEncodedStream}; use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf}; -use tokio::sync::{broadcast, mpsc, RwLock}; +use tokio::sync::{mpsc, RwLock}; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, trace, warn}; +use crate::byte_stream_reader::PcmChunk; +use crate::chunk_to_pcm::chunk_to_pcm_bytes; +use crate::sinks::streaming_sink_common::{ + MetadataSnapshot, SharedClientStream, SharedSinkContext, SharedStreamHandleInner, + StreamingSinkOptions, +}; +use crate::sinks::timed_broadcast::{ + calculate_broadcast_capacity, DEFAULT_BROADCAST_MAX_LEAD_TIME, +}; +use crate::streaming_icyflac_sink::IcyClientStream; + /// Default ICY metadata interval (bytes of audio between metadata blocks). /// Standard value used by most streaming servers. const DEFAULT_ICY_METAINT: usize = 16000; -/// Broadcast channel capacity for FLAC bytes. -/// Set to 128 to provide ~10 seconds of buffer for network jitter. -/// With TimerNode pacing the stream to real-time, this is sufficient -/// while keeping metadata synchronized (larger buffers cause metadata drift). -const BROADCAST_CAPACITY: usize = 128; - -/// Maximum lead time for HTTP broadcast pacing (in seconds). -/// The broadcaster will sleep if it's ahead of real-time by more than this amount. -/// This is much smaller than the pipeline TimerNode's 3.0s to provide tighter control. -const BROADCAST_MAX_LEAD_TIME: f64 = 0.5; - -/// PCM chunk with audio data and timestamp for precise pacing. -#[derive(Debug)] -struct PcmChunk { - /// Raw PCM audio bytes - bytes: Vec, - /// Timestamp in seconds (from AudioSegment) - timestamp_sec: f64, -} - -/// Snapshot of track metadata at a point in time. -/// -/// This structure is shared between the sink and clients to provide -/// real-time metadata updates as tracks change in a continuous stream. -#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] -pub struct MetadataSnapshot { - /// Track title - pub title: Option, - /// Artist name - pub artist: Option, - /// Album name - pub album: Option, - /// Track duration - #[serde(skip_serializing_if = "Option::is_none")] - pub duration: Option, - /// Cover image URL (external/original) - #[serde(skip_serializing_if = "Option::is_none")] - pub cover_url: Option, - /// Cover primary key in local cache (for constructing server URL) - #[serde(skip_serializing_if = "Option::is_none")] - pub cover_pk: Option, - /// Track number - #[serde(skip_serializing_if = "Option::is_none")] - pub track_number: Option, - /// Album artist - #[serde(skip_serializing_if = "Option::is_none")] - pub album_artist: Option, - /// Genre - #[serde(skip_serializing_if = "Option::is_none")] - pub genre: Option, - /// Year - #[serde(skip_serializing_if = "Option::is_none")] - pub year: Option, - /// Audio timestamp where this metadata became active (seconds) - pub audio_timestamp_sec: f64, - /// Version counter incremented on each update (for client-side change detection) - pub version: u64, -} - /// Handle for accessing the FLAC stream and metadata from HTTP handlers. -/// -/// This handle is designed to be cloned and used by multiple HTTP clients -/// simultaneously. Each client gets its own independent stream by subscribing. #[derive(Clone)] pub struct StreamHandle { - /// Broadcast sender for FLAC bytes (pure mode) - flac_broadcast: broadcast::Sender, - - /// Current track metadata (read-only for consumers) - metadata: Arc>, - - /// Active client counter - active_clients: Arc, - - /// Stop token to signal pipeline shutdown - stop_token: CancellationToken, - - /// Cached FLAC header (sent to new subscribers first) - flac_header: Arc>>, + inner: Arc, } impl StreamHandle { - /// Subscribe to the FLAC stream in pure mode (no ICY metadata). - /// - /// Returns an `AsyncRead` stream suitable for use with `tokio_util::io::ReaderStream`. - pub fn subscribe_flac(&self) -> FlacClientStream { - let count = self.active_clients.fetch_add(1, Ordering::SeqCst); - debug!("New FLAC client subscribed (total: {})", count + 1); - - FlacClientStream { - rx: self.flac_broadcast.subscribe(), - buffer: VecDeque::new(), - finished: false, - handle: self.clone(), - state: FlacStreamState::SendingHeader, - } + pub fn new(inner: Arc) -> Self { + Self { inner } + } + + pub fn subscribe_flac(&self) -> FlacClientStream { + let total = self.inner.client_connected(); + let rx = self.inner.register_client(); + debug!("New FLAC client subscribed (total: {})", total); + FlacClientStream::new(rx, self.inner.clone()) } - /// Subscribe to the FLAC stream with ICY metadata injection. - /// - /// Returns an `AsyncRead` stream that injects ICY metadata blocks - /// at regular intervals (default: every 16000 bytes). pub fn subscribe_icy(&self) -> IcyClientStream { self.subscribe_icy_with_interval(DEFAULT_ICY_METAINT) } - /// Subscribe to the FLAC stream with custom ICY metadata interval. pub fn subscribe_icy_with_interval(&self, metaint: usize) -> IcyClientStream { - let count = self.active_clients.fetch_add(1, Ordering::SeqCst); - debug!("New ICY client subscribed (total: {}, metaint: {})", count + 1, metaint); + let total = self.inner.client_connected(); + let rx = self.inner.register_client(); + debug!( + "New ICY client subscribed (total: {}, metaint: {})", + total, metaint + ); - IcyClientStream { - rx: self.flac_broadcast.subscribe(), - metadata: self.metadata.clone(), - metaint, - byte_count: 0, - buffer: VecDeque::new(), - current_metadata_version: 0, - cached_icy_metadata: Bytes::new(), - finished: false, - handle: self.clone(), - state: FlacStreamState::SendingHeader, + IcyClientStream::new(rx, self.inner.clone(), metaint) + } + + pub async fn get_metadata(&self) -> MetadataSnapshot { + self.inner.metadata.read().await.clone() + } + + pub fn active_client_count(&self) -> usize { + self.inner.active_clients.load(Ordering::SeqCst) + } + + pub fn should_stop(&self) -> bool { + self.inner.active_clients.load(Ordering::SeqCst) == 0 + } + + pub fn set_auto_stop(&self, enabled: bool) { + self.inner.auto_stop.store(enabled, Ordering::SeqCst); + } +} + +pub struct FlacClientStream { + inner: SharedClientStream, +} + +impl FlacClientStream { + fn new(rx: timed_broadcast::Receiver, handle: Arc) -> Self { + Self { + inner: SharedClientStream::new(rx, handle), } } - /// Get the current metadata snapshot. - pub async fn get_metadata(&self) -> MetadataSnapshot { - self.metadata.read().await.clone() + pub fn current_epoch(&self) -> u64 { + self.inner.current_epoch() } - - /// Get the number of active clients. - pub fn active_client_count(&self) -> usize { - self.active_clients.load(Ordering::SeqCst) - } - - /// Check if the stream should be stopped (no more clients). - pub fn should_stop(&self) -> bool { - self.active_clients.load(Ordering::SeqCst) == 0 - } -} - -/// State for FLAC stream subscription. -enum FlacStreamState { - SendingHeader, - Streaming, -} - -/// Pure FLAC client stream (implements AsyncRead). -pub struct FlacClientStream { - rx: broadcast::Receiver, - buffer: VecDeque, - finished: bool, - handle: StreamHandle, - state: FlacStreamState, } impl AsyncRead for FlacClientStream { @@ -243,389 +165,19 @@ impl AsyncRead for FlacClientStream { cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { - loop { - // If in header state, send the header first - if matches!(self.state, FlacStreamState::SendingHeader) { - let header_opt = if let Ok(guard) = self.handle.flac_header.try_read() { - guard.clone() - } else { - None - }; - - if let Some(header) = header_opt { - self.buffer.extend(header.iter()); - info!("Sending cached FLAC header to new client ({} bytes)", header.len()); - self.state = FlacStreamState::Streaming; - continue; // Now copy header to output buffer - } else { - // Header not yet captured or can't acquire lock, skip to streaming - self.state = FlacStreamState::Streaming; - } - } - - // If we have buffered data, copy it - if !self.buffer.is_empty() { - let to_copy = self.buffer.len().min(buf.remaining()); - if to_copy == 0 { - return Poll::Ready(Ok(())); - } - - let slice = self.buffer.make_contiguous(); - buf.put_slice(&slice[..to_copy]); - self.buffer.drain(..to_copy); - return Poll::Ready(Ok(())); - } - - if self.finished { - return Poll::Ready(Ok(())); - } - - // Try to receive more data - match self.rx.try_recv() { - Ok(bytes) => { - self.buffer.extend(bytes.iter()); - } - Err(broadcast::error::TryRecvError::Empty) => { - // No data available right now. - // Schedule a wakeup after a small delay to avoid busy-loop polling. - let waker = cx.waker().clone(); - tokio::spawn(async move { - tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; - waker.wake(); - }); - return Poll::Pending; - } - Err(broadcast::error::TryRecvError::Lagged(skipped)) => { - warn!("FLAC client lagged, skipped {} messages", skipped); - // Continue to try receiving again - } - Err(broadcast::error::TryRecvError::Closed) => { - self.finished = true; - return Poll::Ready(Ok(())); - } - } - } + Pin::new(&mut self.inner).poll_read(cx, buf) } } impl Drop for FlacClientStream { fn drop(&mut self) { - let count = self.handle.active_clients.fetch_sub(1, Ordering::SeqCst); - debug!("FLAC client disconnected (remaining: {})", count - 1); - - if count == 1 { - info!("Last client disconnected, signaling pipeline stop"); - self.handle.stop_token.cancel(); - } + let remaining = self.inner.handle().client_disconnected(); + debug!("FLAC client disconnected (remaining: {})", remaining); } } -/// ICY-wrapped FLAC client stream (implements AsyncRead). -/// -/// This stream injects ICY metadata blocks at regular intervals, -/// allowing clients to display "Now Playing" information. -pub struct IcyClientStream { - rx: broadcast::Receiver, - metadata: Arc>, - metaint: usize, - byte_count: usize, - buffer: VecDeque, - current_metadata_version: u64, - cached_icy_metadata: Bytes, - finished: bool, - handle: StreamHandle, - state: FlacStreamState, -} - -impl IcyClientStream { - /// Format metadata as ICY metadata block. - /// - /// ICY format: StreamTitle='Artist - Title';StreamUrl='url'; - /// Padded to multiple of 16 bytes, prefixed with length byte. - /// - /// If cover_pk is available, constructs a URL for the cover image: - /// - If pmoserver is initialized: http://server/covers/image/{pk}/256 - /// - Otherwise: relative URL /covers/image/{pk}/256 - fn format_icy_metadata(meta: &MetadataSnapshot) -> Bytes { - let title = meta.title.as_deref().unwrap_or("Unknown"); - let artist = meta.artist.as_deref().unwrap_or("Unknown Artist"); - - // Build ICY metadata string with cover URL if available - let mut metadata_str = format!("StreamTitle='{} - {}';", artist, title); - - // Add cover URL if we have a cover_pk - if let Some(pk) = &meta.cover_pk { - // Use relative URL /covers/image/{pk}/256 - // This works when streaming from the same server that serves covers - // VLC and other players will resolve relative URLs correctly - metadata_str.push_str(&format!("StreamUrl='/covers/image/{}/256';", pk)); - } else if let Some(url) = &meta.cover_url { - // Fallback to external cover URL if no local pk - metadata_str.push_str(&format!("StreamUrl='{}';", url)); - } - - // ICY metadata is padded to multiple of 16 bytes - let metadata_bytes = metadata_str.as_bytes(); - let length = metadata_bytes.len(); - let padded_length = ((length + 15) / 16) * 16; - let length_byte = (padded_length / 16) as u8; - - let mut result = Vec::with_capacity(1 + padded_length); - result.push(length_byte); - result.extend_from_slice(metadata_bytes); - result.resize(1 + padded_length, 0); // Pad with zeros - - Bytes::from(result) - } - - /// Get metadata block if it needs to be inserted. - async fn get_metadata_if_changed(&mut self) -> Option { - let meta = self.metadata.read().await; - if meta.version > self.current_metadata_version { - self.current_metadata_version = meta.version; - let icy_meta = Self::format_icy_metadata(&meta); - self.cached_icy_metadata = icy_meta.clone(); - Some(icy_meta) - } else if self.byte_count == 0 { - // Always send metadata at the start - Some(self.cached_icy_metadata.clone()) - } else { - // No change, send empty metadata block - Some(Bytes::from(vec![0u8])) - } - } -} - -impl AsyncRead for IcyClientStream { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - loop { - // If in header state, send the header first - if matches!(self.state, FlacStreamState::SendingHeader) { - let header_opt = if let Ok(guard) = self.handle.flac_header.try_read() { - guard.clone() - } else { - None - }; - - if let Some(header) = header_opt { - self.buffer.extend(header.iter()); - info!("Sending cached FLAC header to new ICY client ({} bytes)", header.len()); - self.state = FlacStreamState::Streaming; - continue; // Now copy header to output buffer - } else { - // Header not yet captured or can't acquire lock, skip to streaming - self.state = FlacStreamState::Streaming; - } - } - - // If we have buffered data, copy it - if !self.buffer.is_empty() { - let to_copy = self.buffer.len().min(buf.remaining()); - if to_copy == 0 { - return Poll::Ready(Ok(())); - } - - let slice = self.buffer.make_contiguous(); - buf.put_slice(&slice[..to_copy]); - self.buffer.drain(..to_copy); - return Poll::Ready(Ok(())); - } - - if self.finished { - return Poll::Ready(Ok(())); - } - - // Check if we need to insert metadata - if self.byte_count % self.metaint == 0 && self.byte_count > 0 { - // Time to insert ICY metadata - // Use try_read to avoid blocking in poll context - let update = { - if let Ok(meta) = self.metadata.try_read() { - if meta.version > self.current_metadata_version { - Some((meta.version, Self::format_icy_metadata(&meta))) - } else { - None - } - } else { - None - } - }; - - if let Some((new_version, new_metadata)) = update { - self.current_metadata_version = new_version; - self.cached_icy_metadata = new_metadata; - } - - let icy_data = self.cached_icy_metadata.clone(); - self.buffer.extend(icy_data.iter()); - self.byte_count = 0; // Reset counter after metadata - continue; - } - - // Try to receive audio data - match self.rx.try_recv() { - Ok(bytes) => { - // Calculate how many bytes until next metadata block - let until_metadata = self.metaint - (self.byte_count % self.metaint); - let to_buffer = bytes.len().min(until_metadata); - - self.buffer.extend(bytes[..to_buffer].iter()); - self.byte_count += to_buffer; - - // If we have more data, we'll process it in the next iteration - if to_buffer < bytes.len() { - // Save remaining for next iteration - // For now, we'll just drop it and get it again - // TODO: Improve this - } - } - Err(broadcast::error::TryRecvError::Empty) => { - // No data available right now. - // Schedule a wakeup after a small delay to avoid busy-loop polling. - let waker = cx.waker().clone(); - tokio::spawn(async move { - tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; - waker.wake(); - }); - return Poll::Pending; - } - Err(broadcast::error::TryRecvError::Lagged(skipped)) => { - warn!("ICY client lagged, skipped {} messages", skipped); - } - Err(broadcast::error::TryRecvError::Closed) => { - self.finished = true; - return Poll::Ready(Ok(())); - } - } - } - } -} - -impl Drop for IcyClientStream { - fn drop(&mut self) { - let count = self.handle.active_clients.fetch_sub(1, Ordering::SeqCst); - debug!("ICY client disconnected (remaining: {})", count - 1); - - if count == 1 { - info!("Last client disconnected, signaling pipeline stop"); - self.handle.stop_token.cancel(); - } - } -} - -/// Internal state for encoder initialization. -struct EncoderState { - broadcaster_task: tokio::task::JoinHandle<()>, -} - -/// Logic for the streaming FLAC sink. struct StreamingFlacSinkLogic { - encoder_options: EncoderOptions, - bits_per_sample: u8, - pcm_tx: mpsc::Sender, - pcm_rx: Option>, - metadata: Arc>, - flac_broadcast: broadcast::Sender, - flac_header: Arc>>, - encoder_state: Option, - sample_rate: Option, -} - -impl StreamingFlacSinkLogic { - /// Initialize the FLAC encoder once we know the sample rate. - async fn initialize_encoder(&mut self, sample_rate: u32) -> Result<(), AudioError> { - if self.encoder_state.is_some() { - return Ok(()); // Already initialized - } - - info!("Initializing FLAC encoder with sample rate: {} Hz", sample_rate); - - // Take the PCM receiver (we only initialize once) - let pcm_rx = self.pcm_rx.take().ok_or_else(|| { - AudioError::ProcessingError("PCM receiver already consumed".into()) - })?; - - // Create shared timestamp for pacing - let current_timestamp = Arc::new(RwLock::new(0.0f64)); - - // Create ByteStreamReader for the encoder - let pcm_reader = ByteStreamReader::new(pcm_rx, current_timestamp.clone()); - - // Create PCM format - let pcm_format = PcmFormat { - sample_rate, - channels: 2, - bits_per_sample: self.bits_per_sample, - }; - - // Start the FLAC encoder - let flac_stream = encode_flac_stream(pcm_reader, pcm_format, self.encoder_options.clone()) - .await - .map_err(|e| AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e)))?; - - info!("FLAC encoder initialized successfully"); - - // Spawn broadcaster task with timestamp for pacing - let flac_broadcast = self.flac_broadcast.clone(); - let flac_header = self.flac_header.clone(); - let broadcaster_task = tokio::spawn(async move { - if let Err(e) = broadcast_flac_stream(flac_stream, flac_broadcast, flac_header, current_timestamp).await { - error!("Broadcaster task error: {}", e); - } - }); - - self.encoder_state = Some(EncoderState { broadcaster_task }); - - info!("Broadcaster task spawned"); - - Ok(()) - } - - /// Update metadata from a TrackBoundary marker. - async fn update_metadata( - &mut self, - metadata_lock: &Arc>, - timestamp_sec: f64, - ) -> Result<(), AudioError> { - let metadata = metadata_lock.read().await; - - let mut snapshot = self.metadata.write().await; - - // Extract all metadata fields - snapshot.title = metadata.get_title().await.ok().flatten(); - snapshot.artist = metadata.get_artist().await.ok().flatten(); - snapshot.album = metadata.get_album().await.ok().flatten(); - snapshot.duration = metadata.get_duration().await.ok().flatten(); - snapshot.cover_url = metadata.get_cover_url().await.ok().flatten(); - snapshot.cover_pk = metadata.get_cover_pk().await.ok().flatten(); - snapshot.year = metadata.get_year().await.ok().flatten(); - - // Extract extra fields - if let Ok(Some(extra)) = metadata.get_extra().await { - snapshot.genre = extra.get("genre").cloned(); - snapshot.track_number = extra - .get("track_number") - .and_then(|s| s.parse::().ok()); - } - - snapshot.audio_timestamp_sec = timestamp_sec; - snapshot.version += 1; - - debug!( - "Metadata updated: v{} @ {:.2}s - {} - {} (cover_pk: {:?})", - snapshot.version, - timestamp_sec, - snapshot.artist.as_deref().unwrap_or("?"), - snapshot.title.as_deref().unwrap_or("?"), - snapshot.cover_pk - ); - - Ok(()) - } + ctx: SharedSinkContext, } #[async_trait] @@ -640,7 +192,7 @@ impl NodeLogic for StreamingFlacSinkLogic { AudioError::ProcessingError("StreamingFlacSink requires an input".into()) })?; - info!("StreamingFlacSink started"); + debug!("StreamingFlacSink started"); // We'll initialize the encoder lazily when we get the first chunk // For now, just process segments @@ -648,7 +200,7 @@ impl NodeLogic for StreamingFlacSinkLogic { loop { tokio::select! { _ = stop_token.cancelled() => { - info!("StreamingFlacSink stopped by cancellation"); + debug!("StreamingFlacSink stopped by cancellation"); break; } @@ -657,47 +209,190 @@ impl NodeLogic for StreamingFlacSinkLogic { Some(seg) => { match &seg.segment { _AudioSegment::Chunk(chunk) => { - // Detect sample rate from first chunk and initialize encoder - if self.sample_rate.is_none() { - let sample_rate = chunk.sample_rate(); - self.sample_rate = Some(sample_rate); - info!("Detected sample rate: {} Hz", sample_rate); + if !self.ctx.first_chunk_timestamp_checked { + self.ctx.first_chunk_timestamp_checked = true; + if seg.timestamp_sec.abs() > 1e-6 { + warn!( + "StreamingFlacSink: first chunk timestamp is {:.3}ms (expected 0.0)", + seg.timestamp_sec * 1000.0 + ); + } else { + trace!("StreamingFlacSink: first chunk timestamp verified at 0.0s"); + } + } - // Initialize the FLAC encoder now - self.initialize_encoder(sample_rate).await?; + // Detect sample rate from first chunk and initialize encoder + if self.ctx.sample_rate.is_none() { + let sample_rate = chunk.sample_rate(); + self.ctx.sample_rate = Some(sample_rate); + debug!("Detected sample rate: {} Hz", sample_rate); + + // If a duration is already known for this track, fill total_samples now. + self.ctx.refresh_total_samples_with_sample_rate(); + + // Initialize the FLAC encoder now (first track starts at 0.0) + self.ctx + .initialize_encoder( + sample_rate, + 0.0, + |flac_stream, + broadcast, + header, + current_timestamp, + current_duration, + max_lead, + sample_rate, + timestamp_offset_sec| { + broadcast_flac_stream( + flac_stream, + broadcast, + header, + current_timestamp, + current_duration, + max_lead, + sample_rate, + timestamp_offset_sec, + ) + }, + ) + .await?; } // Convert chunk to PCM bytes - let pcm_bytes = chunk_to_pcm_bytes(&chunk, self.bits_per_sample)?; + let pcm_bytes = chunk_to_pcm_bytes(&chunk, self.ctx.bits_per_sample)?; + + // Calculate exact duration from samples and sample rate + let sample_rate = self.ctx.sample_rate + .expect("sample_rate should be initialized"); + let duration_sec = chunk.len() as f64 / sample_rate as f64; trace!( - "Sending PCM chunk: {} bytes, {} samples @ {:.2}s", + "Sending PCM chunk: {} bytes, {} samples @ {:.2}s (duration={:.3}s)", pcm_bytes.len(), chunk.len(), - seg.timestamp_sec + seg.timestamp_sec, + duration_sec ); - // Send to FLAC encoder with timestamp + // Send to FLAC encoder with timestamp and duration let pcm_chunk = PcmChunk { bytes: pcm_bytes, timestamp_sec: seg.timestamp_sec, + duration_sec, }; - if let Err(e) = self.pcm_tx.send(pcm_chunk).await { + let send_start = std::time::Instant::now(); + + // Get the sender (it should always be Some after initialization) + let pcm_tx = match &self.ctx.pcm_tx { + Some(tx) => tx, + None => { + error!("PCM sender not initialized"); + break; + } + }; + + if let Err(e) = pcm_tx.send(pcm_chunk).await { warn!("Failed to send PCM data to encoder: {}", e); break; } + let send_duration = send_start.elapsed(); + if send_duration.as_millis() >= 50 { + trace!( + "StreamingFlacSink: pcm_tx send blocked for {:.3}s (ts={:.3}s)", + send_duration.as_secs_f64(), + seg.timestamp_sec + ); + } } _AudioSegment::Sync(marker) => { match marker.as_ref() { SyncMarker::TrackBoundary { metadata } => { - if let Err(e) = self.update_metadata(metadata, seg.timestamp_sec).await { + // Prepare encoder options (metadata + duration) for the upcoming track. + if let Err(e) = + self.ctx.prepare_encoder_options_for_track(metadata).await + { + error!("Failed to prepare encoder options for new track: {}", e); + } + + debug!("StreamingFlacSink: SyncMarker::TrackBoundary {:?}",metadata.read().await.get_duration().await); + let current_ts = *self.ctx.current_timestamp.read().await; + // Durée attendue du morceau qui se termine : on lit les métadonnées courantes du sink + let (prev_title, prev_artist, prev_expected) = { + let meta = self.ctx.metadata.read().await; + let title = meta.title.clone().unwrap_or_else(|| "Unknown".into()); + let artist = meta.artist.clone().unwrap_or_else(|| "Unknown".into()); + let expected = meta + .duration + .map(|d| format!("{:.3}s", d.as_secs_f64())) + .unwrap_or_else(|| "unknown".into()); + (title, artist, expected) + }; + info!( + "StreamingFlacSink: track complete ts={:.3}s (title=\"{}\" artist=\"{}\" expected={})", + current_ts, + prev_title, + prev_artist, + prev_expected + ); + + if self.ctx.restart_encoder_on_track_boundary { + // Only restart encoder if it's already initialized (not the first track) + if self.ctx.sample_rate.is_some() + && self.ctx.encoder_state.is_some() + { + // Restart encoder to emit new header and reset timestamps + if let Err(e) = self + .ctx + .restart_encoder_for_new_track( + |flac_stream, + broadcast, + header, + current_timestamp, + current_duration, + max_lead, + sample_rate, + timestamp_offset_sec| { + broadcast_flac_stream( + flac_stream, + broadcast, + header, + current_timestamp, + current_duration, + max_lead, + sample_rate, + timestamp_offset_sec, + ) + }, + ) + .await + { + error!( + "Failed to restart encoder for new track: {}", + e + ); + break; + } + } else { + trace!("Skipping encoder restart for first track (encoder not yet initialized)"); + } + } else { + // For raw FLAC streaming we keep a single continuous encoder. + // Restarting would insert a new STREAMINFO header mid-stream and many + // clients treat that as end-of-file. + trace!( + "StreamingFlacSink: keeping encoder alive across track boundary" + ); + } + + // Update metadata for the new track + if let Err(e) = self.ctx.update_metadata(metadata, seg.timestamp_sec).await { error!("Failed to update metadata: {}", e); } } SyncMarker::EndOfStream => { - info!("End of stream marker received"); + debug!("End of stream marker received"); break; } @@ -710,7 +405,7 @@ impl NodeLogic for StreamingFlacSinkLogic { } None => { - info!("Input channel closed"); + debug!("Input channel closed"); break; } } @@ -718,127 +413,16 @@ impl NodeLogic for StreamingFlacSinkLogic { } } - info!("StreamingFlacSink processing complete"); + debug!("StreamingFlacSink processing complete"); Ok(()) } async fn cleanup(&mut self, reason: StopReason) -> Result<(), AudioError> { - info!("StreamingFlacSink cleanup: {:?}", reason); + debug!("StreamingFlacSink cleanup: {:?}", reason); Ok(()) } } - -/// Broadcaster task: reads FLAC bytes from encoder and broadcasts to all clients. -/// Implements precise real-time pacing based on audio timestamps. -/// Ensures data is sent at FLAC frame boundaries to prevent sync errors in strict decoders like FFPlay. -async fn broadcast_flac_stream( - mut flac_stream: FlacEncodedStream, - broadcast_tx: broadcast::Sender, - header_cache: Arc>>, - current_timestamp: Arc>, -) -> Result<(), AudioError> { - info!("Broadcaster task started with FLAC frame boundary detection"); - - // Use larger read buffer (16KB) to reduce syscalls and accumulator for frame boundary detection - // The accumulator is necessary to ensure we only send complete FLAC frames - let mut read_buffer = vec![0u8; 16384]; - let mut accumulator = Vec::with_capacity(32768); // Pre-allocate to reduce reallocations - let mut total_bytes = 0u64; - let mut header_captured = false; - let start_time = std::time::Instant::now(); - - loop { - match flac_stream.read(&mut read_buffer).await { - Ok(0) => { - // EOF - send any remaining data - if !accumulator.is_empty() { - let bytes = Bytes::from(std::mem::take(&mut accumulator)); - let _ = broadcast_tx.send(bytes); - } - info!("FLAC encoder stream ended, total bytes: {}", total_bytes); - break; - } - Ok(n) => { - total_bytes += n as u64; - if total_bytes % 100000 == 0 || total_bytes < 10000 { - trace!("Read {} bytes from FLAC encoder (total: {})", n, total_bytes); - } - - // Append to accumulator - accumulator.extend_from_slice(&read_buffer[..n]); - - // Find where to split: position of last sync code (start of last incomplete frame) - // Everything before this position contains only complete frames - let boundary = flac_frame_utils::find_complete_frames_boundary(&accumulator); - - trace!( - "Buffer state: accumulator={} bytes, boundary={} bytes, will_send={}", - accumulator.len(), - boundary, - boundary >= 1024 - ); - - // Only broadcast if we have at least one complete frame (1KB minimum to avoid excessive small sends) - if boundary >= 1024 { - // Precise pacing based on audio timestamp - let audio_timestamp = *current_timestamp.read().await; - let elapsed = start_time.elapsed().as_secs_f64(); - let lead_time = audio_timestamp - elapsed; - - if lead_time > BROADCAST_MAX_LEAD_TIME { - let sleep_duration = lead_time - BROADCAST_MAX_LEAD_TIME; - debug!( - "Broadcaster pacing: sleeping {:.3}s (audio_ts={:.3}s, elapsed={:.3}s, lead={:.3}s)", - sleep_duration, audio_timestamp, elapsed, lead_time - ); - tokio::time::sleep(tokio::time::Duration::from_secs_f64(sleep_duration)).await; - } - - // Split at boundary to avoid copying - extract prefix, keep suffix - let remaining = accumulator.split_off(boundary); - let to_send = std::mem::replace(&mut accumulator, remaining); - let bytes = Bytes::from(to_send); - - // Capture first chunk as header if it contains "fLaC" - if !header_captured && bytes.len() >= 4 && &bytes[0..4] == b"fLaC" { - *header_cache.write().await = Some(bytes.clone()); - header_captured = true; - info!("FLAC header captured ({} bytes)", bytes.len()); - } - - let num_receivers = broadcast_tx.receiver_count(); - if let Err(e) = broadcast_tx.send(bytes.clone()) { - // No receivers, but that's okay - clients may not be connected yet - trace!("No active receivers for FLAC broadcast: {}", e); - } else if num_receivers > 0 { - trace!("Broadcasted {} bytes to {} receivers", bytes.len(), num_receivers); - } - } - } - Err(e) => { - error!("Error reading from FLAC encoder: {}", e); - return Err(AudioError::ProcessingError(format!( - "FLAC encoder read error: {}", - e - ))); - } - } - } - - // Wait for the encoder to finish cleanly - if let Err(e) = flac_stream.wait().await { - error!("FLAC encoder error during cleanup: {}", e); - return Err(AudioError::ProcessingError(format!( - "FLAC encoder error: {}", - e - ))); - } - - info!("Broadcaster task completed successfully"); - Ok(()) -} - /// Streaming FLAC sink for multi-client HTTP streaming. pub struct StreamingFlacSink { inner: Node, @@ -857,49 +441,99 @@ impl StreamingFlacSink { /// A tuple of `(sink, handle)` where: /// - `sink` is added to the audio pipeline /// - `handle` is used by HTTP handlers to serve streams - pub fn new( + pub fn new(encoder_options: EncoderOptions, bits_per_sample: u8) -> (Self, StreamHandle) { + Self::with_max_broadcast_lead( + encoder_options, + bits_per_sample, + DEFAULT_BROADCAST_MAX_LEAD_TIME, + ) + } + + /// Create a sink with a custom broadcast pacing limit. + pub fn with_max_broadcast_lead( encoder_options: EncoderOptions, bits_per_sample: u8, + broadcast_max_lead_time: f64, + ) -> (Self, StreamHandle) { + Self::with_options( + encoder_options, + bits_per_sample, + broadcast_max_lead_time, + StreamingSinkOptions::flac_defaults(), + ) + } + + /// Create a sink with a custom broadcast pacing limit and options. + pub fn with_options( + mut encoder_options: EncoderOptions, + bits_per_sample: u8, + broadcast_max_lead_time: f64, + options: StreamingSinkOptions, ) -> (Self, StreamHandle) { // Validate bit depth if ![16, 24, 32].contains(&bits_per_sample) { panic!("bits_per_sample must be 16, 24, or 32"); } + // Transfer server_base_url from StreamingSinkOptions to EncoderOptions + encoder_options.server_base_url = options.server_base_url.clone(); + // Create PCM channel (bounded for backpressure) let (pcm_tx, pcm_rx) = mpsc::channel::(16); // Shared metadata let metadata = Arc::new(RwLock::new(MetadataSnapshot::default())); + // Capacity calculated from max_lead_time to ensure enough buffering + let broadcast_capacity = calculate_broadcast_capacity(broadcast_max_lead_time); + debug!( + "Streaming Sink: using broadcast capacity of {} items (max_lead_time={:.1}s)", + broadcast_capacity, broadcast_max_lead_time + ); + // Broadcast channel for FLAC bytes - let (flac_broadcast, _) = broadcast::channel(BROADCAST_CAPACITY); + let (broadcast, _) = timed_broadcast::channel("Flac", broadcast_capacity); // FLAC header cache - let flac_header = Arc::new(RwLock::new(None)); + let header = Arc::new(RwLock::new(None)); // Stop token and client counter let stop_token = CancellationToken::new(); - let active_clients = Arc::new(AtomicUsize::new(0)); + let auto_stop = Arc::new(AtomicBool::new(true)); - let handle = StreamHandle { - flac_broadcast: flac_broadcast.clone(), - metadata: metadata.clone(), - active_clients, - stop_token: stop_token.clone(), - flac_header: flac_header.clone(), - }; + let shared_handle = Arc::new(SharedStreamHandleInner::new( + broadcast.clone(), + metadata.clone(), + stop_token.clone(), + header.clone(), + auto_stop.clone(), + )); + + let handle = StreamHandle::new(shared_handle.clone()); let logic = StreamingFlacSinkLogic { - encoder_options, - bits_per_sample, - pcm_tx, - pcm_rx: Some(pcm_rx), - metadata, - flac_broadcast, - flac_header, - encoder_state: None, - sample_rate: None, + ctx: SharedSinkContext { + encoder_options, + bits_per_sample, + enable_total_samples: options.enable_total_samples, + restart_encoder_on_track_boundary: options.restart_encoder_on_track_boundary, + default_title: options.default_title.clone(), + default_artist: options.default_artist.clone(), + use_only_default_metadata: options.use_only_default_metadata, + pcm_tx: Some(pcm_tx), + pcm_rx: Some(pcm_rx), + metadata, + broadcast, + header, + encoder_state: None, + sample_rate: None, + broadcast_max_lead_time: broadcast_max_lead_time.max(0.0), + first_chunk_timestamp_checked: false, + timestamp_offset_sec: 0.0, + current_timestamp: Arc::new(RwLock::new(0.0)), + pending_track_duration: None, + pending_total_samples: None, + }, }; let sink = Self { @@ -939,160 +573,250 @@ impl TypedAudioNode for StreamingFlacSink { } } -/// Convert an AudioChunk to PCM bytes with specified bit depth. -fn chunk_to_pcm_bytes(chunk: &AudioChunk, bits_per_sample: u8) -> Result, AudioError> { - match chunk { - AudioChunk::F32(_) | AudioChunk::F64(_) => { - return Err(AudioError::ProcessingError( - "StreamingFlacSink only supports integer audio chunks".into(), - )); - } - _ => {} - } - - let len = chunk.len(); - let bytes_per_frame = (bits_per_sample / 8) as usize * 2; - let mut bytes = Vec::with_capacity(len * bytes_per_frame); - - match (chunk, bits_per_sample) { - (AudioChunk::I16(data), 16) => { - for frame in data.get_frames() { - bytes.extend_from_slice(&frame[0].to_le_bytes()); - bytes.extend_from_slice(&frame[1].to_le_bytes()); - } - } - (AudioChunk::I16(data), 24) => { - for frame in data.get_frames() { - let left = (frame[0] as i32) << 8; - let right = (frame[1] as i32) << 8; - bytes.extend_from_slice(&left.to_le_bytes()[..3]); - bytes.extend_from_slice(&right.to_le_bytes()[..3]); - } - } - (AudioChunk::I16(data), 32) => { - for frame in data.get_frames() { - let left = (frame[0] as i32) << 16; - let right = (frame[1] as i32) << 16; - bytes.extend_from_slice(&left.to_le_bytes()); - bytes.extend_from_slice(&right.to_le_bytes()); - } - } - (AudioChunk::I24(data), 16) => { - for frame in data.get_frames() { - let left = (frame[0].as_i32() >> 8) as i16; - let right = (frame[1].as_i32() >> 8) as i16; - bytes.extend_from_slice(&left.to_le_bytes()); - bytes.extend_from_slice(&right.to_le_bytes()); - } - } - (AudioChunk::I24(data), 24) => { - for frame in data.get_frames() { - bytes.extend_from_slice(&frame[0].as_i32().to_le_bytes()[..3]); - bytes.extend_from_slice(&frame[1].as_i32().to_le_bytes()[..3]); - } - } - (AudioChunk::I24(data), 32) => { - for frame in data.get_frames() { - let left = frame[0].as_i32() << 8; - let right = frame[1].as_i32() << 8; - bytes.extend_from_slice(&left.to_le_bytes()); - bytes.extend_from_slice(&right.to_le_bytes()); - } - } - (AudioChunk::I32(data), 16) => { - for frame in data.get_frames() { - let left = (frame[0] >> 16) as i16; - let right = (frame[1] >> 16) as i16; - bytes.extend_from_slice(&left.to_le_bytes()); - bytes.extend_from_slice(&right.to_le_bytes()); - } - } - (AudioChunk::I32(data), 24) => { - for frame in data.get_frames() { - let left = frame[0] >> 8; - let right = frame[1] >> 8; - bytes.extend_from_slice(&left.to_le_bytes()[..3]); - bytes.extend_from_slice(&right.to_le_bytes()[..3]); - } - } - (AudioChunk::I32(data), 32) => { - for frame in data.get_frames() { - bytes.extend_from_slice(&frame[0].to_le_bytes()); - bytes.extend_from_slice(&frame[1].to_le_bytes()); - } - } - _ => { - return Err(AudioError::ProcessingError(format!( - "Unsupported bits_per_sample: {}", - bits_per_sample - ))); - } - } - - Ok(bytes) -} - -/// AsyncRead adapter for mpsc::Receiver. -/// Extracts bytes from PcmChunk and provides them to the FLAC encoder. -struct ByteStreamReader { - rx: mpsc::Receiver, - buffer: VecDeque, - finished: bool, - /// Shared timestamp for broadcaster pacing +/// Broadcaster task: reads FLAC bytes from encoder and broadcasts to all clients. +/// Implements precise real-time pacing based on audio timestamps. +/// Ensures data is sent at FLAC frame boundaries to prevent sync errors in strict decoders like FFPlay. +async fn broadcast_flac_stream( + mut flac_stream: FlacEncodedStream, + broadcast_tx: timed_broadcast::Sender, + header_cache: Arc>>, current_timestamp: Arc>, -} + current_duration: Arc>, + broadcast_max_lead_time: f64, + sample_rate: u32, + timestamp_offset_sec: f64, +) -> Result<(), AudioError> { + trace!( + "Broadcaster task started with FLAC frame boundary detection (max_lead={:.3}s)", + broadcast_max_lead_time + ); -impl ByteStreamReader { - fn new(rx: mpsc::Receiver, current_timestamp: Arc>) -> Self { - Self { - rx, - buffer: VecDeque::new(), - finished: false, - current_timestamp, - } - } -} + // Use larger read buffer (16KB) to reduce syscalls and accumulator for frame boundary detection + // The accumulator is necessary to ensure we only send complete FLAC frames + let mut read_buffer = vec![0u8; 16384]; + let mut accumulator = Vec::with_capacity(32768); // Pre-allocate to reduce reallocations + let mut total_bytes = 0u64; + let mut header_captured = false; + let mut pacer = BroadcastPacer::new(broadcast_max_lead_time, "FLAC"); + let mut stats_last_log = std::time::Instant::now(); -impl AsyncRead for ByteStreamReader { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - loop { - if !self.buffer.is_empty() { - let to_copy = self.buffer.len().min(buf.remaining()); - if to_copy == 0 { - return Poll::Ready(Ok(())); + // Timing instrumentation for burst detection + let mut last_broadcast_time = std::time::Instant::now(); + let mut broadcast_count = 0u64; + let mut total_read_time = 0.0f64; + let mut read_count = 0u64; + let sample_rate_f64 = sample_rate as f64; + + // Sample counter for calculating accurate timestamps (reset on new headers) + let mut encoded_samples = 0u64; + + loop { + let read_start = std::time::Instant::now(); + match flac_stream.read(&mut read_buffer).await { + Ok(0) => { + // EOF - send any remaining data + if !accumulator.is_empty() { + let bytes = Bytes::from(std::mem::take(&mut accumulator)); + let audio_ts = *current_timestamp.read().await; + let segment_dur = *current_duration.read().await; + match broadcast_tx + .send(bytes.clone(), audio_ts, segment_dur) + .await + { + Ok(_) => {} + Err(SendError::Expired(_)) => { + trace!("Broadcast expired before sending final FLAC data"); + } + Err(SendError::Closed(_)) => { + trace!("Broadcast closed before sending final FLAC data"); + } + } + } + trace!("FLAC encoder stream ended, total bytes: {}", total_bytes); + break; + } + Ok(n) => { + let read_duration = read_start.elapsed().as_secs_f64(); + read_count += 1; + total_read_time += read_duration; + + if read_duration > 0.01 { + trace!( + "FLAC: flac_stream.read() took {:.3}s for {} bytes (avg: {:.3}s over {} reads)", + read_duration, + n, + total_read_time / read_count as f64, + read_count + ); } - let slice = self.buffer.make_contiguous(); - buf.put_slice(&slice[..to_copy]); - self.buffer.drain(..to_copy); - return Poll::Ready(Ok(())); - } + total_bytes += n as u64; + if total_bytes % 100000 == 0 || total_bytes < 10000 { + trace!( + "Read {} bytes from FLAC encoder (total: {})", + n, + total_bytes + ); + } - if self.finished { - return Poll::Ready(Ok(())); - } + // Append to accumulator + accumulator.extend_from_slice(&read_buffer[..n]); - match Pin::new(&mut self.rx).poll_recv(cx) { - Poll::Ready(Some(chunk)) => { - if chunk.bytes.is_empty() { + trace!( + "FLAC: accumulator now {} bytes after reading {} bytes", + accumulator.len(), + n + ); + + // Locate complete audio frames and total samples + // Since encoder restarts on TrackBoundary, we only see one header per encoder instance + let (boundary, total_samples) = + flac_frame_utils::find_complete_frames_with_samples(&accumulator); + + trace!( + "Buffer state: accumulator={} bytes, boundary={} bytes, total_samples={}, will_send={}", + accumulator.len(), + boundary, + total_samples, + boundary >= 1024 && total_samples > 0 + ); + + // Only broadcast if we have at least one complete frame (keep 1KB minimum to avoid tiny sends) + if boundary >= 1024 && total_samples > 0 { + // ╔═══════════════════════════════════════════════════════════════╗ + // ║ BACKPRESSURE INTELLIGENTE BASÉE SUR LE TIMING ║ + // ║ ║ + // ║ BroadcastPacer gère : ║ + // ║ 1. Détection TopZeroSync (audio_ts < 0.1) ║ + // ║ 2. Drop des chunks en retard (audio_ts < elapsed) ║ + // ║ 3. Pacing pour contrôler le débit (max_lead_time) ║ + // ║ ║ + // ║ Cela crée la backpressure vers TimerBufferNode tout en ║ + // ║ permettant de dropper les chunks vraiment périmés. ║ + // ╚═══════════════════════════════════════════════════════════════╝ + + // Calculer le timestamp de cette FLAC frame (avec offset pour continuité entre tracks) + let frame_start_samples = encoded_samples; + encoded_samples = encoded_samples.saturating_add(total_samples); + let audio_timestamp = + timestamp_offset_sec + (frame_start_samples as f64 / sample_rate_f64); + let segment_duration = total_samples as f64 / sample_rate_f64; + + if stats_last_log.elapsed() >= Duration::from_secs(1) { + trace!( + "Broadcaster pacing snapshot: audio_ts={:.3}s buffer_bytes={} samples={} ", + audio_timestamp, + accumulator.len(), + total_samples + ); + stats_last_log = std::time::Instant::now(); + } + + // Check timing et apply pacing (skip si en retard) + if pacer.check_and_pace(audio_timestamp).await.is_err() { + // Chunk en retard : vider l'accumulator et continuer + accumulator.clear(); continue; } - // Update shared timestamp for broadcaster pacing - if let Ok(mut ts) = self.current_timestamp.try_write() { - *ts = chunk.timestamp_sec; + + if let Ok(mut ts) = current_timestamp.try_write() { + *ts = audio_timestamp; + } + if let Ok(mut dur) = current_duration.try_write() { + *dur = segment_duration; + } + + // Split at boundary to avoid copying - extract prefix, keep suffix + let remaining = accumulator.split_off(boundary); + let to_send = std::mem::replace(&mut accumulator, remaining); + let bytes = Bytes::from(to_send); + + // Measure broadcast interval for burst detection + let broadcast_interval = last_broadcast_time.elapsed().as_secs_f64(); + last_broadcast_time = std::time::Instant::now(); + broadcast_count += 1; + + // Log if interval is unusual (too short = burst, too long = stall) + if broadcast_interval < 0.01 || broadcast_interval > 0.1 { + trace!( + "FLAC: broadcast interval {:.3}s ({}ms) - size={} bytes (count={})", + broadcast_interval, + (broadcast_interval * 1000.0) as u32, + bytes.len(), + broadcast_count + ); + } + + // Periodic stats + if broadcast_count % 100 == 0 { + trace!( + "FLAC: {} broadcasts sent, accumulator={} bytes remaining", + broadcast_count, + accumulator.len() + ); + } + + // Cache FLAC header "fLaC" for late-joining clients + // Each encoder instance emits exactly one header at the start + if !header_captured && bytes.len() >= 4 && &bytes[0..4] == b"fLaC" { + header_captured = true; + *header_cache.write().await = Some(bytes.clone()); + trace!( + "FLAC header captured and cached ({} bytes) for late-joining clients", + bytes.len() + ); + } + + let num_receivers = broadcast_tx.receiver_count(); + match broadcast_tx + .send(bytes.clone(), audio_timestamp, segment_duration) + .await + { + Ok(_) => { + if num_receivers > 0 { + trace!( + "Broadcasted {} bytes to {} receivers (ts={:.3}s, dur={:.3}s)", + bytes.len(), + num_receivers, + audio_timestamp, + segment_duration + ); + } + } + Err(SendError::Expired(_)) => { + trace!( + "FLAC broadcast dropped expired packet (ts={:.3}s, dur={:.3}s)", + audio_timestamp, + segment_duration + ); + continue; + } + Err(SendError::Closed(_)) => { + trace!("No active receivers for FLAC broadcast, terminating"); + return Ok(()); + } } - self.buffer.extend(chunk.bytes); } - Poll::Ready(None) => { - self.finished = true; - return Poll::Ready(Ok(())); - } - Poll::Pending => return Poll::Pending, + } + Err(e) => { + error!("Error reading from FLAC encoder: {}", e); + return Err(AudioError::ProcessingError(format!( + "FLAC encoder read error: {}", + e + ))); } } } + + // Wait for the encoder to finish cleanly + if let Err(e) = flac_stream.wait().await { + error!("FLAC encoder error during cleanup: {}", e); + return Err(AudioError::ProcessingError(format!( + "FLAC encoder error: {}", + e + ))); + } + + trace!("Broadcaster task completed successfully"); + Ok(()) } diff --git a/pmoaudio-ext/src/sinks/streaming_icyflac_sink.rs b/pmoaudio-ext/src/sinks/streaming_icyflac_sink.rs new file mode 100644 index 00000000..57e9717d --- /dev/null +++ b/pmoaudio-ext/src/sinks/streaming_icyflac_sink.rs @@ -0,0 +1,255 @@ +use std::{ + collections::VecDeque, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; + +use tokio::{ + io::{AsyncRead, ReadBuf}, + sync::RwLock, +}; + +use crate::{ + sinks::{ + flac_frame_utils::FlacStreamState, + streaming_sink_common::SharedStreamHandleInner, + timed_broadcast::{self, TryRecvError}, + }, + MetadataSnapshot, +}; +use bytes::Bytes; +use std::io; + +use tracing::{debug, warn}; + +/// ICY-wrapped FLAC client stream (implements AsyncRead). +/// +/// This stream injects ICY metadata blocks at regular intervals, +/// allowing clients to display "Now Playing" information. +/// As with [`FlacClientStream`], hitting [`TryRecvError::Lagged`] +/// simply indicates that the timed broadcast discarded a stale chunk; +/// the client resumes with fresh data to avoid wedging the HTTP response. +pub struct IcyClientStream { + rx: timed_broadcast::Receiver, + metadata: Arc>, + metaint: usize, + byte_count: usize, + buffer: VecDeque, + current_metadata_version: u64, + cached_icy_metadata: Bytes, + finished: bool, + handle: Arc, + state: FlacStreamState, + current_epoch: u64, +} + +impl IcyClientStream { + pub(crate) fn new( + rx: timed_broadcast::Receiver, + handle: Arc, + metaint: usize, + ) -> Self { + Self { + rx, + metadata: handle.metadata.clone(), + metaint, + byte_count: 0, + buffer: VecDeque::new(), + current_metadata_version: 0, + cached_icy_metadata: Bytes::new(), + finished: false, + handle, + state: FlacStreamState::SendingHeader, + current_epoch: 0, + } + } + + pub fn current_epoch(&self) -> u64 { + self.current_epoch + } +} + +impl IcyClientStream { + /// Format metadata as ICY metadata block. + /// + /// ICY format: StreamTitle='Artist - Title';StreamUrl='url'; + /// Padded to multiple of 16 bytes, prefixed with length byte. + /// + /// If cover_pk is available, constructs a URL for the cover image: + /// - If pmoserver is initialized: http://server/covers/image/{pk}/256 + /// - Otherwise: relative URL /covers/image/{pk}/256 + fn format_icy_metadata(meta: &MetadataSnapshot) -> Bytes { + let title = meta.title.as_deref().unwrap_or("Unknown"); + let artist = meta.artist.as_deref().unwrap_or("Unknown Artist"); + + // Build ICY metadata string with cover URL if available + let mut metadata_str = format!("StreamTitle='{} - {}';", artist, title); + + // Add cover URL if we have a cover_pk + if let Some(pk) = &meta.cover_pk { + // Use relative URL /covers/image/{pk}/256 + // This works when streaming from the same server that serves covers + // VLC and other players will resolve relative URLs correctly + metadata_str.push_str(&format!("StreamUrl='/covers/image/{}/256';", pk)); + } else if let Some(url) = &meta.cover_url { + // Fallback to external cover URL if no local pk + metadata_str.push_str(&format!("StreamUrl='{}';", url)); + } + + // ICY metadata is padded to multiple of 16 bytes + let metadata_bytes = metadata_str.as_bytes(); + let length = metadata_bytes.len(); + let padded_length = ((length + 15) / 16) * 16; + let length_byte = (padded_length / 16) as u8; + + let mut result = Vec::with_capacity(1 + padded_length); + result.push(length_byte); + result.extend_from_slice(metadata_bytes); + result.resize(1 + padded_length, 0); // Pad with zeros + + Bytes::from(result) + } + + /// Get metadata block if it needs to be inserted. + #[allow(dead_code)] + async fn get_metadata_if_changed(&mut self) -> Option { + let meta = self.metadata.read().await; + if meta.version > self.current_metadata_version { + self.current_metadata_version = meta.version; + let icy_meta = Self::format_icy_metadata(&meta); + self.cached_icy_metadata = icy_meta.clone(); + Some(icy_meta) + } else if self.byte_count == 0 { + // Always send metadata at the start + Some(self.cached_icy_metadata.clone()) + } else { + // No change, send empty metadata block + Some(Bytes::from(vec![0u8])) + } + } +} + +impl AsyncRead for IcyClientStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + // If in header state, send the header first + if matches!(self.state, FlacStreamState::SendingHeader) { + let header_opt = if let Ok(guard) = self.handle.header.try_read() { + guard.clone() + } else { + None + }; + + if let Some(header) = header_opt { + self.buffer.extend(header.iter()); + debug!( + "Sending cached FLAC header to new ICY client ({} bytes)", + header.len() + ); + self.state = FlacStreamState::Streaming; + continue; // Now copy header to output buffer + } else { + // Header not yet captured - client will receive it via broadcast + // Skip directly to streaming to avoid blocking + debug!( + "FLAC header not yet available, ICY client will receive it via broadcast" + ); + self.state = FlacStreamState::Streaming; + } + } + + // If we have buffered data, copy it + if !self.buffer.is_empty() { + let to_copy = self.buffer.len().min(buf.remaining()); + if to_copy == 0 { + return Poll::Ready(Ok(())); + } + + let slice = self.buffer.make_contiguous(); + buf.put_slice(&slice[..to_copy]); + self.buffer.drain(..to_copy); + return Poll::Ready(Ok(())); + } + + if self.finished { + return Poll::Ready(Ok(())); + } + + // Check if we need to insert metadata + if self.byte_count % self.metaint == 0 && self.byte_count > 0 { + // Time to insert ICY metadata + // Use try_read to avoid blocking in poll context + let update = { + if let Ok(meta) = self.metadata.try_read() { + if meta.version > self.current_metadata_version { + Some((meta.version, Self::format_icy_metadata(&meta))) + } else { + None + } + } else { + None + } + }; + + if let Some((new_version, new_metadata)) = update { + self.current_metadata_version = new_version; + self.cached_icy_metadata = new_metadata; + } + + let icy_data = self.cached_icy_metadata.clone(); + self.buffer.extend(icy_data.iter()); + self.byte_count = 0; // Reset counter after metadata + continue; + } + + // Try to receive audio data + match self.rx.try_recv() { + Ok(packet) => { + self.current_epoch = packet.epoch; + // Calculate how many bytes until next metadata block + let until_metadata = self.metaint - (self.byte_count % self.metaint); + let to_buffer = packet.payload.len().min(until_metadata); + + self.buffer.extend(packet.payload[..to_buffer].iter()); + self.byte_count += to_buffer; + + // If we have more data, we'll process it in the next iteration + if to_buffer < packet.payload.len() { + // Save remaining for next iteration + // For now, we'll just drop it and get it again + // TODO: Improve this + } + } + Err(TryRecvError::Empty) => { + // No data available right now. + // Schedule a wakeup after a small delay to avoid busy-loop polling. + let waker = cx.waker().clone(); + tokio::spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + waker.wake(); + }); + return Poll::Pending; + } + Err(TryRecvError::Lagged(skipped)) => { + warn!("ICY client lagged, skipped {} messages", skipped); + } + Err(TryRecvError::Closed) => { + self.finished = true; + return Poll::Ready(Ok(())); + } + } + } + } +} + +impl Drop for IcyClientStream { + fn drop(&mut self) { + let remaining = self.handle.client_disconnected(); + debug!("ICY client disconnected (remaining: {})", remaining); + } +} diff --git a/pmoaudio-ext/src/sinks/streaming_ogg_flac_sink.rs b/pmoaudio-ext/src/sinks/streaming_ogg_flac_sink.rs index 54e751a0..73d233a3 100644 --- a/pmoaudio-ext/src/sinks/streaming_ogg_flac_sink.rs +++ b/pmoaudio-ext/src/sinks/streaming_ogg_flac_sink.rs @@ -21,7 +21,7 @@ //! ↓ //! [OGG Wrapper Task] - wraps FLAC frames in OGG pages //! ↓ -//! broadcast::channel (OGG-FLAC bytes) +//! timed_broadcast::channel (OGG-FLAC bytes) //! ↓ //! Multiple HTTP clients //! ``` @@ -46,108 +46,86 @@ //! - Pages are broadcast immediately to connected clients //! - TrackBoundary only triggers encoder flush (no data accumulation) -use std::collections::VecDeque; use std::io; use std::pin::Pin; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::task::{Context, Poll}; -use super::flac_frame_utils; +use super::{ + broadcast_pacing::BroadcastPacer, + flac_frame_utils, + timed_broadcast::{self, SendError}, +}; use async_trait::async_trait; use bytes::Bytes; use pmoaudio::{ pipeline::{AudioPipelineNode, Node, NodeLogic, PipelineHandle, StopReason}, - AudioChunk, AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, - _AudioSegment, + AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, _AudioSegment, }; -use pmoflac::{encode_flac_stream, EncoderOptions, FlacEncodedStream, PcmFormat}; -use pmometadata::TrackMetadata; +use pmoflac::{EncoderOptions, FlacEncodedStream}; use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf}; -use tokio::sync::{broadcast, mpsc, RwLock}; +use tokio::sync::{mpsc, RwLock}; use tokio_util::sync::CancellationToken; -use tracing::{debug, error, info, trace, warn}; +use tracing::{debug, error, trace, warn}; -/// Broadcast channel capacity for OGG-FLAC bytes. -/// Same as StreamingFlacSink for consistency. -const BROADCAST_CAPACITY: usize = 128; - -/// Maximum lead time for HTTP broadcast pacing (in seconds). -/// The broadcaster will sleep if it's ahead of real-time by more than this amount. -const BROADCAST_MAX_LEAD_TIME: f64 = 0.5; - -/// PCM chunk with audio data and timestamp for precise pacing. -#[derive(Debug)] -struct PcmChunk { - /// Raw PCM audio bytes - bytes: Vec, - /// Timestamp in seconds (from AudioSegment) - timestamp_sec: f64, -} - -/// Snapshot of track metadata (reuse from streaming_flac_sink) -pub use super::streaming_flac_sink::MetadataSnapshot; +use crate::byte_stream_reader::PcmChunk; +use crate::chunk_to_pcm::chunk_to_pcm_bytes; +use crate::sinks::flac_frame_utils::{extract_sample_rate_from_streaminfo, read_flac_header}; +use crate::sinks::streaming_sink_common::{ + MetadataSnapshot, SharedClientStream, SharedSinkContext, SharedStreamHandleInner, + StreamingSinkOptions, +}; +use crate::sinks::timed_broadcast::{ + calculate_broadcast_capacity, DEFAULT_BROADCAST_MAX_LEAD_TIME, +}; /// Handle for accessing the OGG-FLAC stream and metadata from HTTP handlers. #[derive(Clone)] pub struct OggFlacStreamHandle { - /// Broadcast sender for OGG-FLAC bytes - ogg_broadcast: broadcast::Sender, - - /// Current track metadata - metadata: Arc>, - - /// Active client counter - active_clients: Arc, - - /// Stop token to signal pipeline shutdown - stop_token: CancellationToken, - - /// Cached OGG-FLAC header (sent to new subscribers first) - ogg_header: Arc>>, + inner: Arc, } impl OggFlacStreamHandle { - /// Subscribe to the OGG-FLAC stream. - /// - /// Returns an `AsyncRead` stream suitable for use with `tokio_util::io::ReaderStream`. + pub fn new(inner: Arc) -> Self { + Self { inner } + } + pub fn subscribe(&self) -> OggFlacClientStream { - let count = self.active_clients.fetch_add(1, Ordering::SeqCst); - debug!("New OGG-FLAC client subscribed (total: {})", count + 1); - - OggFlacClientStream { - rx: self.ogg_broadcast.subscribe(), - buffer: VecDeque::new(), - finished: false, - handle: self.clone(), - state: OggFlacStreamState::SendingHeader, - } + let total = self.inner.client_connected(); + let rx = self.inner.register_client(); + debug!("New OGG-FLAC client subscribed (total: {})", total); + OggFlacClientStream::new(rx, self.inner.clone()) } - /// Get the current metadata snapshot. pub async fn get_metadata(&self) -> MetadataSnapshot { - self.metadata.read().await.clone() + self.inner.metadata.read().await.clone() } - /// Get the number of active clients. pub fn active_client_count(&self) -> usize { - self.active_clients.load(Ordering::SeqCst) + self.inner.active_clients.load(Ordering::SeqCst) } -} -/// State for OGG-FLAC stream subscription. -enum OggFlacStreamState { - SendingHeader, - Streaming, + pub fn set_auto_stop(&self, enabled: bool) { + self.inner.auto_stop.store(enabled, Ordering::SeqCst); + } } /// OGG-FLAC client stream (implements AsyncRead). pub struct OggFlacClientStream { - rx: broadcast::Receiver, - buffer: VecDeque, - finished: bool, - handle: OggFlacStreamHandle, - state: OggFlacStreamState, + inner: SharedClientStream, +} + +impl OggFlacClientStream { + fn new(rx: timed_broadcast::Receiver, handle: Arc) -> Self { + Self { + inner: SharedClientStream::new(rx, handle), + } + } + + pub fn current_epoch(&self) -> u64 { + self.inner.current_epoch() + } } impl AsyncRead for OggFlacClientStream { @@ -156,190 +134,19 @@ impl AsyncRead for OggFlacClientStream { cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { - loop { - // If in header state, send the header first - if matches!(self.state, OggFlacStreamState::SendingHeader) { - let header_opt = if let Ok(guard) = self.handle.ogg_header.try_read() { - guard.clone() - } else { - None - }; - - if let Some(header) = header_opt { - self.buffer.extend(header.iter()); - info!("Sending cached OGG-FLAC header to new client ({} bytes)", header.len()); - self.state = OggFlacStreamState::Streaming; - continue; // Now copy header to output buffer - } else { - // Header not yet captured, skip to streaming - self.state = OggFlacStreamState::Streaming; - } - } - - // If we have buffered data, copy it - if !self.buffer.is_empty() { - let to_copy = self.buffer.len().min(buf.remaining()); - if to_copy == 0 { - return Poll::Ready(Ok(())); - } - - let slice = self.buffer.make_contiguous(); - buf.put_slice(&slice[..to_copy]); - self.buffer.drain(..to_copy); - return Poll::Ready(Ok(())); - } - - if self.finished { - return Poll::Ready(Ok(())); - } - - // Try to receive more data - match self.rx.try_recv() { - Ok(bytes) => { - self.buffer.extend(bytes.iter()); - } - Err(broadcast::error::TryRecvError::Empty) => { - // No data available right now. - // Schedule a wakeup after a small delay to avoid busy-loop polling. - let waker = cx.waker().clone(); - tokio::spawn(async move { - tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; - waker.wake(); - }); - return Poll::Pending; - } - Err(broadcast::error::TryRecvError::Lagged(skipped)) => { - warn!("OGG-FLAC client lagged, skipped {} messages", skipped); - } - Err(broadcast::error::TryRecvError::Closed) => { - self.finished = true; - return Poll::Ready(Ok(())); - } - } - } + Pin::new(&mut self.inner).poll_read(cx, buf) } } impl Drop for OggFlacClientStream { fn drop(&mut self) { - let count = self.handle.active_clients.fetch_sub(1, Ordering::SeqCst); - debug!("OGG-FLAC client disconnected (remaining: {})", count - 1); - - if count == 1 { - info!("Last OGG-FLAC client disconnected, signaling pipeline stop"); - self.handle.stop_token.cancel(); - } + let remaining = self.inner.handle().client_disconnected(); + debug!("OGG-FLAC client disconnected (remaining: {})", remaining); } } - -/// Internal state for encoder initialization. -struct EncoderState { - broadcaster_task: tokio::task::JoinHandle<()>, -} - /// Logic for the streaming OGG-FLAC sink. struct StreamingOggFlacSinkLogic { - encoder_options: EncoderOptions, - bits_per_sample: u8, - pcm_tx: mpsc::Sender, - pcm_rx: Option>, - metadata: Arc>, - ogg_broadcast: broadcast::Sender, - ogg_header: Arc>>, - encoder_state: Option, - sample_rate: Option, -} - -impl StreamingOggFlacSinkLogic { - /// Initialize the FLAC encoder once we know the sample rate. - async fn initialize_encoder(&mut self, sample_rate: u32) -> Result<(), AudioError> { - if self.encoder_state.is_some() { - return Ok(()); // Already initialized - } - - info!("Initializing OGG-FLAC encoder with sample rate: {} Hz", sample_rate); - - // Take the PCM receiver (we only initialize once) - let pcm_rx = self.pcm_rx.take().ok_or_else(|| { - AudioError::ProcessingError("PCM receiver already consumed".into()) - })?; - - // Create shared timestamp for pacing - let current_timestamp = Arc::new(RwLock::new(0.0f64)); - - // Create ByteStreamReader for the encoder - let pcm_reader = ByteStreamReader::new(pcm_rx, current_timestamp.clone()); - - // Create PCM format - let pcm_format = PcmFormat { - sample_rate, - channels: 2, - bits_per_sample: self.bits_per_sample, - }; - - // Start the FLAC encoder - let flac_stream = encode_flac_stream(pcm_reader, pcm_format, self.encoder_options.clone()) - .await - .map_err(|e| AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e)))?; - - info!("OGG-FLAC encoder initialized successfully"); - - // Spawn OGG wrapper + broadcaster task with timestamp for pacing - let ogg_broadcast = self.ogg_broadcast.clone(); - let ogg_header = self.ogg_header.clone(); - let broadcaster_task = tokio::spawn(async move { - if let Err(e) = broadcast_ogg_flac_stream(flac_stream, ogg_broadcast, ogg_header, current_timestamp).await { - error!("OGG broadcaster task error: {}", e); - } - }); - - self.encoder_state = Some(EncoderState { broadcaster_task }); - - info!("OGG broadcaster task spawned"); - - Ok(()) - } - - /// Update metadata from a TrackBoundary marker. - async fn update_metadata( - &mut self, - metadata_lock: &Arc>, - timestamp_sec: f64, - ) -> Result<(), AudioError> { - let metadata = metadata_lock.read().await; - - let mut snapshot = self.metadata.write().await; - - // Extract all metadata fields - snapshot.title = metadata.get_title().await.ok().flatten(); - snapshot.artist = metadata.get_artist().await.ok().flatten(); - snapshot.album = metadata.get_album().await.ok().flatten(); - snapshot.duration = metadata.get_duration().await.ok().flatten(); - snapshot.cover_url = metadata.get_cover_url().await.ok().flatten(); - snapshot.cover_pk = metadata.get_cover_pk().await.ok().flatten(); - snapshot.year = metadata.get_year().await.ok().flatten(); - - // Extract extra fields - if let Ok(Some(extra)) = metadata.get_extra().await { - snapshot.genre = extra.get("genre").cloned(); - snapshot.track_number = extra - .get("track_number") - .and_then(|s| s.parse::().ok()); - } - - snapshot.audio_timestamp_sec = timestamp_sec; - snapshot.version += 1; - - debug!( - "OGG-FLAC metadata updated: v{} @ {:.2}s - {} - {}", - snapshot.version, - timestamp_sec, - snapshot.artist.as_deref().unwrap_or("?"), - snapshot.title.as_deref().unwrap_or("?") - ); - - Ok(()) - } + ctx: SharedSinkContext, } #[async_trait] @@ -354,7 +161,7 @@ impl NodeLogic for StreamingOggFlacSinkLogic { AudioError::ProcessingError("StreamingOggFlacSink requires an input".into()) })?; - info!("StreamingOggFlacSink started"); + debug!("StreamingOggFlacSink started"); // TODO: Implement OGG-FLAC encoding logic // For now, just process segments without encoding @@ -362,7 +169,7 @@ impl NodeLogic for StreamingOggFlacSinkLogic { loop { tokio::select! { _ = stop_token.cancelled() => { - info!("StreamingOggFlacSink stopped by cancellation"); + debug!("StreamingOggFlacSink stopped by cancellation"); break; } @@ -371,33 +178,87 @@ impl NodeLogic for StreamingOggFlacSinkLogic { Some(seg) => { match &seg.segment { _AudioSegment::Chunk(chunk) => { - // Detect sample rate from first chunk and initialize encoder - if self.sample_rate.is_none() { - let sample_rate = chunk.sample_rate(); - self.sample_rate = Some(sample_rate); - info!("Detected sample rate: {} Hz", sample_rate); + if !self.ctx.first_chunk_timestamp_checked { + self.ctx.first_chunk_timestamp_checked = true; + if seg.timestamp_sec.abs() > 1e-6 { + warn!( + "StreamingFlacSink: first chunk timestamp is {:.6}s (expected 0.0)", + seg.timestamp_sec + ); + } else { + trace!("StreamingFlacSink: first chunk timestamp verified at 0.0s"); + } + } - // Initialize the FLAC encoder now - self.initialize_encoder(sample_rate).await?; + // Detect sample rate from first chunk and initialize encoder + if self.ctx.sample_rate.is_none() { + let sample_rate = chunk.sample_rate(); + self.ctx.sample_rate = Some(sample_rate); + debug!("Detected sample rate: {} Hz", sample_rate); + + // Populate total_samples if we already know the track duration. + self.ctx.refresh_total_samples_with_sample_rate(); + + // Initialize the FLAC encoder now (first track starts at 0.0) + self.ctx + .initialize_encoder( + sample_rate, + 0.0, + |flac_stream, + broadcast, + header, + current_timestamp, + current_duration, + max_lead, + _sample_rate, + timestamp_offset_sec| { + broadcast_ogg_flac_stream( + flac_stream, + broadcast, + header, + current_timestamp, + current_duration, + max_lead, + timestamp_offset_sec, + ) + }, + ) + .await?; } // Convert chunk to PCM bytes - let pcm_bytes = chunk_to_pcm_bytes(&chunk, self.bits_per_sample)?; + let pcm_bytes = chunk_to_pcm_bytes(&chunk, self.ctx.bits_per_sample)?; + + // Calculate exact duration from samples and sample rate + let sample_rate = self.ctx.sample_rate.expect("sample_rate should be initialized"); + let duration_sec = chunk.len() as f64 / sample_rate as f64; trace!( - "Sending PCM chunk: {} bytes, {} samples @ {:.2}s", + "Sending PCM chunk: {} bytes, {} samples @ {:.2}s (duration={:.3}s)", pcm_bytes.len(), chunk.len(), - seg.timestamp_sec + seg.timestamp_sec, + duration_sec ); - // Send to FLAC encoder with timestamp + // Send to FLAC encoder with timestamp and duration let pcm_chunk = PcmChunk { bytes: pcm_bytes, timestamp_sec: seg.timestamp_sec, + duration_sec, }; - if let Err(e) = self.pcm_tx.send(pcm_chunk).await { - warn!("Failed to send PCM data to encoder: {}", e); + + // Get the sender (it should always be Some after initialization) + let pcm_tx = match &self.ctx.pcm_tx { + Some(tx) => tx, + None => { + error!("OGG PCM sender not initialized"); + break; + } + }; + + if let Err(e) = pcm_tx.send(pcm_chunk).await { + warn!("Failed to send PCM data to OGG encoder: {}", e); break; } } @@ -405,14 +266,61 @@ impl NodeLogic for StreamingOggFlacSinkLogic { _AudioSegment::Sync(marker) => { match marker.as_ref() { SyncMarker::TrackBoundary { metadata } => { - if let Err(e) = self.update_metadata(metadata, seg.timestamp_sec).await { + // Inject per-track metadata and duration into the next FLAC header. + if let Err(e) = + self.ctx.prepare_encoder_options_for_track(metadata).await + { + error!("Failed to prepare encoder options for new track: {}", e); + } + + if self.ctx.restart_encoder_on_track_boundary { + // Only restart encoder if it's already initialized (not the first track) + if self.ctx.sample_rate.is_some() + && self.ctx.encoder_state.is_some() + { + // Restart encoder to emit new OGG stream header and reset timestamps + if let Err(e) = self + .ctx + .restart_encoder_for_new_track( + |flac_stream, + broadcast, + header, + current_timestamp, + current_duration, + max_lead, + _sample_rate, + timestamp_offset_sec| { + broadcast_ogg_flac_stream( + flac_stream, + broadcast, + header, + current_timestamp, + current_duration, + max_lead, + timestamp_offset_sec, + ) + }, + ) + .await + { + error!("Failed to restart OGG encoder for new track: {}", e); + break; + } + } else { + trace!("Skipping OGG encoder restart for first track (encoder not yet initialized)"); + } + } else { + trace!("StreamingOggFlacSink: restart disabled; continuing encoder across track boundary"); + } + + // Update metadata for the new track + if let Err(e) = self.ctx.update_metadata(metadata, seg.timestamp_sec).await { error!("Failed to update metadata: {}", e); } - // TODO: Implement OGG chaining (EOS → new BOS) } SyncMarker::EndOfStream => { - info!("End of stream marker received"); + debug!("End of stream marker received"); break; } @@ -425,7 +333,7 @@ impl NodeLogic for StreamingOggFlacSinkLogic { } None => { - info!("Input channel closed"); + debug!("Input channel closed"); break; } } @@ -433,12 +341,12 @@ impl NodeLogic for StreamingOggFlacSinkLogic { } } - info!("StreamingOggFlacSink processing complete"); + debug!("StreamingOggFlacSink processing complete"); Ok(()) } async fn cleanup(&mut self, reason: StopReason) -> Result<(), AudioError> { - info!("StreamingOggFlacSink cleanup: {:?}", reason); + debug!("StreamingOggFlacSink cleanup: {:?}", reason); Ok(()) } } @@ -464,46 +372,97 @@ impl StreamingOggFlacSink { pub fn new( encoder_options: EncoderOptions, bits_per_sample: u8, + ) -> (Self, OggFlacStreamHandle) { + Self::with_max_broadcast_lead( + encoder_options, + bits_per_sample, + DEFAULT_BROADCAST_MAX_LEAD_TIME, + ) + } + + /// Create a sink with a custom broadcast pacing limit. + pub fn with_max_broadcast_lead( + encoder_options: EncoderOptions, + bits_per_sample: u8, + broadcast_max_lead_time: f64, + ) -> (Self, OggFlacStreamHandle) { + Self::with_options( + encoder_options, + bits_per_sample, + broadcast_max_lead_time, + StreamingSinkOptions::ogg_defaults(), + ) + } + + /// Create a sink with a custom broadcast pacing limit and options. + pub fn with_options( + mut encoder_options: EncoderOptions, + bits_per_sample: u8, + broadcast_max_lead_time: f64, + options: StreamingSinkOptions, ) -> (Self, OggFlacStreamHandle) { // Validate bit depth if ![16, 24, 32].contains(&bits_per_sample) { panic!("bits_per_sample must be 16, 24, or 32"); } + // Transfer server_base_url from StreamingSinkOptions to EncoderOptions + encoder_options.server_base_url = options.server_base_url.clone(); + // Create PCM channel (bounded for backpressure) let (pcm_tx, pcm_rx) = mpsc::channel::(16); // Shared metadata let metadata = Arc::new(RwLock::new(MetadataSnapshot::default())); - // Broadcast channel for OGG-FLAC bytes - let (ogg_broadcast, _) = broadcast::channel(BROADCAST_CAPACITY); + // Capacity calculated from max_lead_time to ensure enough buffering + let broadcast_capacity = calculate_broadcast_capacity(broadcast_max_lead_time); + debug!( + "Streaming Sink: using broadcast capacity of {} items (max_lead_time={:.1}s)", + broadcast_capacity, broadcast_max_lead_time + ); + let (broadcast, _) = timed_broadcast::channel("Ogg-Flac", broadcast_capacity); // OGG-FLAC header cache - let ogg_header = Arc::new(RwLock::new(None)); + let header = Arc::new(RwLock::new(None)); - // Stop token and client counter + // Stop token and client control let stop_token = CancellationToken::new(); - let active_clients = Arc::new(AtomicUsize::new(0)); + let auto_stop = Arc::new(AtomicBool::new(true)); - let handle = OggFlacStreamHandle { - ogg_broadcast: ogg_broadcast.clone(), - metadata: metadata.clone(), - active_clients, - stop_token: stop_token.clone(), - ogg_header: ogg_header.clone(), - }; + let shared_handle = Arc::new(SharedStreamHandleInner::new( + broadcast.clone(), + metadata.clone(), + stop_token.clone(), + header.clone(), + auto_stop.clone(), + )); + + let handle = OggFlacStreamHandle::new(shared_handle.clone()); let logic = StreamingOggFlacSinkLogic { - encoder_options, - bits_per_sample, - pcm_tx, - pcm_rx: Some(pcm_rx), - metadata, - ogg_broadcast, - ogg_header, - encoder_state: None, - sample_rate: None, + ctx: SharedSinkContext { + encoder_options, + bits_per_sample, + enable_total_samples: options.enable_total_samples, + restart_encoder_on_track_boundary: options.restart_encoder_on_track_boundary, + default_title: options.default_title.clone(), + default_artist: options.default_artist.clone(), + use_only_default_metadata: options.use_only_default_metadata, + pcm_tx: Some(pcm_tx), + pcm_rx: Some(pcm_rx), + metadata, + broadcast, + header, + encoder_state: None, + sample_rate: None, + broadcast_max_lead_time: broadcast_max_lead_time.max(0.0), + first_chunk_timestamp_checked: false, + timestamp_offset_sec: 0.0, + current_timestamp: Arc::new(RwLock::new(0.0)), + pending_track_duration: None, + pending_total_samples: None, + }, }; let sink = Self { @@ -543,201 +502,61 @@ impl TypedAudioNode for StreamingOggFlacSink { } } -/// AsyncRead adapter for mpsc::Receiver. -/// Extracts bytes from PcmChunk and provides them to the FLAC encoder. -struct ByteStreamReader { - rx: mpsc::Receiver, - buffer: VecDeque, - finished: bool, - /// Shared timestamp for broadcaster pacing - current_timestamp: Arc>, -} - -impl ByteStreamReader { - fn new(rx: mpsc::Receiver, current_timestamp: Arc>) -> Self { - Self { - rx, - buffer: VecDeque::new(), - finished: false, - current_timestamp, - } - } -} - -impl AsyncRead for ByteStreamReader { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - loop { - if !self.buffer.is_empty() { - let to_copy = self.buffer.len().min(buf.remaining()); - if to_copy == 0 { - return Poll::Ready(Ok(())); - } - - let slice = self.buffer.make_contiguous(); - buf.put_slice(&slice[..to_copy]); - self.buffer.drain(..to_copy); - return Poll::Ready(Ok(())); - } - - if self.finished { - return Poll::Ready(Ok(())); - } - - match Pin::new(&mut self.rx).poll_recv(cx) { - Poll::Ready(Some(chunk)) => { - if chunk.bytes.is_empty() { - continue; - } - // Update shared timestamp for broadcaster pacing - if let Ok(mut ts) = self.current_timestamp.try_write() { - *ts = chunk.timestamp_sec; - } - self.buffer.extend(chunk.bytes); - } - Poll::Ready(None) => { - self.finished = true; - return Poll::Ready(Ok(())); - } - Poll::Pending => return Poll::Pending, - } - } - } -} - -/// Convert an AudioChunk to PCM bytes with specified bit depth. -fn chunk_to_pcm_bytes(chunk: &AudioChunk, bits_per_sample: u8) -> Result, AudioError> { - match chunk { - AudioChunk::F32(_) | AudioChunk::F64(_) => { - return Err(AudioError::ProcessingError( - "StreamingOggFlacSink only supports integer audio chunks".into(), - )); - } - _ => {} - } - - let len = chunk.len(); - let bytes_per_frame = (bits_per_sample / 8) as usize * 2; - let mut bytes = Vec::with_capacity(len * bytes_per_frame); - - match (chunk, bits_per_sample) { - (AudioChunk::I16(data), 16) => { - for frame in data.get_frames() { - bytes.extend_from_slice(&frame[0].to_le_bytes()); - bytes.extend_from_slice(&frame[1].to_le_bytes()); - } - } - (AudioChunk::I16(data), 24) => { - for frame in data.get_frames() { - let left = (frame[0] as i32) << 8; - let right = (frame[1] as i32) << 8; - bytes.extend_from_slice(&left.to_le_bytes()[..3]); - bytes.extend_from_slice(&right.to_le_bytes()[..3]); - } - } - (AudioChunk::I16(data), 32) => { - for frame in data.get_frames() { - let left = (frame[0] as i32) << 16; - let right = (frame[1] as i32) << 16; - bytes.extend_from_slice(&left.to_le_bytes()); - bytes.extend_from_slice(&right.to_le_bytes()); - } - } - (AudioChunk::I24(data), 16) => { - for frame in data.get_frames() { - let left = (frame[0].as_i32() >> 8) as i16; - let right = (frame[1].as_i32() >> 8) as i16; - bytes.extend_from_slice(&left.to_le_bytes()); - bytes.extend_from_slice(&right.to_le_bytes()); - } - } - (AudioChunk::I24(data), 24) => { - for frame in data.get_frames() { - bytes.extend_from_slice(&frame[0].as_i32().to_le_bytes()[..3]); - bytes.extend_from_slice(&frame[1].as_i32().to_le_bytes()[..3]); - } - } - (AudioChunk::I24(data), 32) => { - for frame in data.get_frames() { - let left = frame[0].as_i32() << 8; - let right = frame[1].as_i32() << 8; - bytes.extend_from_slice(&left.to_le_bytes()); - bytes.extend_from_slice(&right.to_le_bytes()); - } - } - (AudioChunk::I32(data), 16) => { - for frame in data.get_frames() { - let left = (frame[0] >> 16) as i16; - let right = (frame[1] >> 16) as i16; - bytes.extend_from_slice(&left.to_le_bytes()); - bytes.extend_from_slice(&right.to_le_bytes()); - } - } - (AudioChunk::I32(data), 24) => { - for frame in data.get_frames() { - let left = frame[0] >> 8; - let right = frame[1] >> 8; - bytes.extend_from_slice(&left.to_le_bytes()[..3]); - bytes.extend_from_slice(&right.to_le_bytes()[..3]); - } - } - (AudioChunk::I32(data), 32) => { - for frame in data.get_frames() { - bytes.extend_from_slice(&frame[0].to_le_bytes()); - bytes.extend_from_slice(&frame[1].to_le_bytes()); - } - } - _ => { - return Err(AudioError::ProcessingError(format!( - "Unsupported bits_per_sample: {}", - bits_per_sample - ))); - } - } - - Ok(bytes) -} - /// OGG wrapper + broadcaster task: reads FLAC bytes from encoder, wraps in OGG pages, and broadcasts. /// Implements precise real-time pacing based on audio timestamps. /// Ensures FLAC frames are only sent at frame boundaries to prevent sync errors in strict decoders like FFPlay. async fn broadcast_ogg_flac_stream( mut flac_stream: FlacEncodedStream, - broadcast_tx: broadcast::Sender, + broadcast_tx: timed_broadcast::Sender, header_cache: Arc>>, current_timestamp: Arc>, + current_duration: Arc>, + broadcast_max_lead_time: f64, + timestamp_offset_sec: f64, ) -> Result<(), AudioError> { - info!("OGG-FLAC broadcaster task started with FLAC frame boundary detection"); + trace!( + "Broadcaster task started with FLAC frame boundary detection (max_lead={:.3}s)", + broadcast_max_lead_time + ); let stream_serial = rand::random::(); let mut ogg_writer = OggPageWriter::new(stream_serial); - let mut total_ogg_bytes = 0u64; - let mut header_captured = false; - let start_time = std::time::Instant::now(); - let mut last_granule_update_time = 0.0f64; + let mut total_bytes = 0u64; + let mut pacer = BroadcastPacer::new(broadcast_max_lead_time, "OGG"); + + // Timing instrumentation for burst detection + let mut last_broadcast_time = std::time::Instant::now(); + let mut broadcast_count = 0u64; + let mut total_read_time = 0.0f64; + let mut read_count = 0u64; // Step 1: Read FLAC header (fLaC + metadata blocks) let flac_header = read_flac_header(&mut flac_stream).await?; - info!("Read FLAC header: {} bytes", flac_header.len()); + trace!("Read FLAC header: {} bytes", flac_header.len()); // Extract sample rate from STREAMINFO for granule position calculation let sample_rate = extract_sample_rate_from_streaminfo(&flac_header)?; - info!("Extracted sample rate from STREAMINFO: {} Hz", sample_rate); + let sample_rate_f64 = sample_rate as f64; + + // Sample counter for calculating accurate timestamps (reset on new headers) + let mut encoded_samples = 0u64; + trace!("Extracted sample rate from STREAMINFO: {} Hz", sample_rate); // Step 2: Create OGG-FLAC identification packet (BOS) // Format according to https://xiph.org/flac/ogg_mapping.html let ogg_flac_id = create_ogg_flac_identification(&flac_header)?; - info!("Created OGG-FLAC identification packet: {} bytes", ogg_flac_id.len()); + trace!( + "Created OGG-FLAC identification packet: {} bytes", + ogg_flac_id.len() + ); let bos_page = ogg_writer.create_page(&ogg_flac_id, true, false, false); let bos_bytes = Bytes::from(bos_page); - // Step 3: Create Vorbis Comment page (empty for now, metadata comes from /metadata endpoint) - let vorbis_comment = create_empty_vorbis_comment(); + // Step 3: Create Vorbis Comment page (reuse FLAC metadata blocks when available) + let vorbis_comment = extract_comment_packet_from_flac_header(&flac_header) + .unwrap_or_else(create_empty_vorbis_comment); let comment_page = ogg_writer.create_page(&vorbis_comment, false, false, false); let comment_bytes = Bytes::from(comment_page); @@ -746,65 +565,132 @@ async fn broadcast_ogg_flac_stream( cached_header.extend_from_slice(&bos_bytes); cached_header.extend_from_slice(&comment_bytes); *header_cache.write().await = Some(Bytes::from(cached_header)); - header_captured = true; - info!("OGG-FLAC header cached ({} bytes: BOS + Vorbis Comment)", bos_bytes.len() + comment_bytes.len()); + trace!( + "OGG-FLAC header cached ({} bytes: BOS + Vorbis Comment)", + bos_bytes.len() + comment_bytes.len() + ); - // Broadcast header - let _ = broadcast_tx.send(bos_bytes); - total_ogg_bytes += comment_bytes.len() as u64; - let _ = broadcast_tx.send(comment_bytes); + // Broadcast header (BOS and comment are metadata, not audio, so duration=0.0) + match broadcast_tx.send(bos_bytes.clone(), 0.0, 0.0).await { + Ok(_) => {} + Err(SendError::Expired(_)) => { + trace!("Broadcast closed before sending BOS page (expired)"); + return Ok(()); + } + Err(SendError::Closed(_)) => { + trace!("No receivers for BOS page, terminating broadcast"); + return Ok(()); + } + } + total_bytes += comment_bytes.len() as u64; + match broadcast_tx.send(comment_bytes.clone(), 0.0, 0.0).await { + Ok(_) => {} + Err(SendError::Expired(_)) => { + trace!("Broadcast closed before sending comment page (expired)"); + return Ok(()); + } + Err(SendError::Closed(_)) => { + trace!("No receivers for comment page, terminating broadcast"); + return Ok(()); + } + } // Step 4: Read FLAC stream and create OGG packets // Use larger read buffer (16KB) to reduce syscalls and accumulator for frame boundary detection // The accumulator is necessary to ensure we only send complete FLAC frames let mut read_buffer = vec![0u8; 16384]; - let mut flac_accumulator = Vec::with_capacity(32768); + let mut accumulator = Vec::with_capacity(32768); loop { + let read_start = std::time::Instant::now(); match flac_stream.read(&mut read_buffer).await { Ok(0) => { // EOF - create final page with EOS flag and any remaining data - if !flac_accumulator.is_empty() { - let eos_page = ogg_writer.create_page(&flac_accumulator, false, true, false); + if !accumulator.is_empty() { + let eos_page = ogg_writer.create_page(&accumulator, false, true, false); let eos_bytes = Bytes::from(eos_page); - total_ogg_bytes += eos_bytes.len() as u64; - let _ = broadcast_tx.send(eos_bytes); - info!("Sent final EOS page with {} bytes of data", flac_accumulator.len()); + total_bytes += eos_bytes.len() as u64; + let eos_ts = *current_timestamp.read().await; + let eos_dur = *current_duration.read().await; + match broadcast_tx.send(eos_bytes.clone(), eos_ts, eos_dur).await { + Ok(_) => {} + Err(SendError::Expired(_)) => { + trace!("Broadcast closed before sending final EOS page (expired)"); + } + Err(SendError::Closed(_)) => { + trace!("Broadcast closed before sending final EOS page"); + } + } + trace!( + "Sent final EOS page with {} bytes of data", + accumulator.len() + ); } else { - // Send empty EOS page + // Send empty EOS page (metadata page, duration=0.0) let eos_page = ogg_writer.create_page(&[], false, true, false); let eos_bytes = Bytes::from(eos_page); - total_ogg_bytes += eos_bytes.len() as u64; - let _ = broadcast_tx.send(eos_bytes); - info!("Sent empty EOS page"); + total_bytes += eos_bytes.len() as u64; + let eos_ts = *current_timestamp.read().await; + match broadcast_tx.send(eos_bytes.clone(), eos_ts, 0.0).await { + Ok(_) => {} + Err(SendError::Expired(_)) => { + trace!("Broadcast closed before sending empty EOS page (expired)"); + } + Err(SendError::Closed(_)) => { + trace!("Broadcast closed before sending empty EOS page"); + } + } + trace!("Sent empty EOS page"); } - info!("OGG-FLAC stream ended, total OGG bytes: {}", total_ogg_bytes); + trace!("OGG-FLAC stream ended, total OGG bytes: {}", total_bytes); break; } Ok(n) => { + let read_duration = read_start.elapsed().as_secs_f64(); + read_count += 1; + total_read_time += read_duration; + + if read_duration > 0.01 { + trace!( + "OGG: flac_stream.read() took {:.3}s for {} bytes (avg: {:.3}s over {} reads)", + read_duration, + n, + total_read_time / read_count as f64, + read_count + ); + } + // Append to accumulator - flac_accumulator.extend_from_slice(&read_buffer[..n]); + accumulator.extend_from_slice(&read_buffer[..n]); + + trace!( + "OGG: accumulator now {} bytes after reading {} bytes", + accumulator.len(), + n + ); // Process complete FLAC frames one at a time // OGG-FLAC spec requires: "Each audio data packet contains one complete FLAC frame" loop { // Find all complete frames in the accumulator - if flac_accumulator.len() < 4 { + if accumulator.len() < 4 { break; // Need at least 4 bytes for sync code check } // Find all sync positions with their sample counts // Use CRC-8 validation to eliminate false positives let mut sync_data = Vec::new(); - for i in 0..flac_accumulator.len() - 1 { - let byte1 = flac_accumulator[i]; - let byte2 = flac_accumulator[i + 1]; + for i in 0..accumulator.len() - 1 { + let byte1 = accumulator[i]; + let byte2 = accumulator[i + 1]; if byte1 == 0xFF && byte2 >= 0xF8 && byte2 <= 0xFE { // Validate frame header with CRC-8 to avoid false positives - if flac_frame_utils::validate_frame_header_crc(&flac_accumulator, i) { - if let Some(samples) = flac_frame_utils::parse_flac_block_size(&flac_accumulator, i) { + if flac_frame_utils::validate_frame_header_crc(&accumulator, i) { + if let Some(samples) = + flac_frame_utils::parse_flac_block_size(&accumulator, i) + { sync_data.push((i, samples)); } } @@ -823,26 +709,49 @@ async fn broadcast_ogg_flac_stream( // Verify first frame starts at position 0 (otherwise we have garbage data) if first_frame_start != 0 { - warn!("OGG-FLAC: Skipping {} bytes of garbage data before first frame", first_frame_start); - flac_accumulator.drain(0..first_frame_start); + warn!( + "OGG-FLAC: Skipping {} bytes of garbage data before first frame", + first_frame_start + ); + accumulator.drain(0..first_frame_start); continue; } // Extract just the first frame - let first_frame: Vec = flac_accumulator.drain(0..second_frame_start).collect(); + let first_frame: Vec = accumulator.drain(0..second_frame_start).collect(); - // Precise pacing based on audio timestamp - let audio_timestamp = *current_timestamp.read().await; - let elapsed = start_time.elapsed().as_secs_f64(); - let lead_time = audio_timestamp - elapsed; + // ╔═══════════════════════════════════════════════════════════════╗ + // ║ BACKPRESSURE INTELLIGENTE BASÉE SUR LE TIMING ║ + // ║ ║ + // ║ BroadcastPacer gère : ║ + // ║ 1. Détection TopZeroSync (audio_ts < 0.1) ║ + // ║ 2. Drop des chunks en retard (audio_ts < elapsed) ║ + // ║ 3. Pacing pour contrôler le débit (max_lead_time) ║ + // ║ ║ + // ║ Cela crée la backpressure vers TimerBufferNode tout en ║ + // ║ permettant de dropper les chunks vraiment périmés. ║ + // ╚═══════════════════════════════════════════════════════════════╝ - if lead_time > BROADCAST_MAX_LEAD_TIME { - let sleep_duration = lead_time - BROADCAST_MAX_LEAD_TIME; - debug!( - "OGG broadcaster pacing: sleeping {:.3}s (audio_ts={:.3}s, elapsed={:.3}s, lead={:.3}s)", - sleep_duration, audio_timestamp, elapsed, lead_time + // Detect FLAC header "fLaC" in frame - indicates new track + if first_frame.len() >= 4 && &first_frame[0..4] == b"fLaC" { + // New track detected: reset sample counter + encoded_samples = 0; + trace!( + "New FLAC header detected in OGG stream ({} bytes), sample counter reset for new track", + first_frame.len() ); - tokio::time::sleep(tokio::time::Duration::from_secs_f64(sleep_duration)).await; + } + + // Calculer le timestamp de cette FLAC frame (avec offset pour continuité entre tracks) + let frame_start_samples = encoded_samples; + encoded_samples = encoded_samples.saturating_add(first_frame_samples as u64); + let audio_timestamp = + timestamp_offset_sec + (frame_start_samples as f64 / sample_rate_f64); + let segment_duration = first_frame_samples as f64 / sample_rate_f64; + + // Check timing et apply pacing (skip si en retard) + if pacer.check_and_pace(audio_timestamp).await.is_err() { + continue; // Skip ce chunk (trop en retard) } // Update granule position (cumulative sample count) @@ -850,13 +759,56 @@ async fn broadcast_ogg_flac_stream( // Wrap this single FLAC frame in ONE OGG page (per OGG-FLAC spec) let ogg_page = ogg_writer.create_page(&first_frame, false, false, false); - let ogg_bytes = Bytes::from(ogg_page); - total_ogg_bytes += ogg_bytes.len() as u64; + let bytes = Bytes::from(ogg_page); + total_bytes += bytes.len() as u64; - if let Err(e) = broadcast_tx.send(ogg_bytes.clone()) { - trace!("No active receivers for OGG-FLAC broadcast: {}", e); - } else { - trace!("Broadcasted OGG page with 1 FLAC frame ({} bytes), {} samples ({} bytes total with OGG overhead)", first_frame.len(), first_frame_samples, ogg_bytes.len()); + // Measure broadcast interval for burst detection + let broadcast_interval = last_broadcast_time.elapsed().as_secs_f64(); + last_broadcast_time = std::time::Instant::now(); + broadcast_count += 1; + + // Log if interval is unusual (too short = burst, too long = stall) + if broadcast_interval < 0.01 || broadcast_interval > 0.1 { + trace!( + "OGG: broadcast interval {:.3}s ({}ms) - frame_size={} bytes, samples={} (count={})", + broadcast_interval, + (broadcast_interval * 1000.0) as u32, + first_frame.len(), + first_frame_samples, + broadcast_count + ); + } + + // Periodic stats + if broadcast_count % 100 == 0 { + trace!( + "OGG: {} broadcasts sent, avg_interval={:.3}s, accumulator={} bytes", + broadcast_count, + last_broadcast_time.elapsed().as_secs_f64() / broadcast_count as f64, + accumulator.len() + ); + } + + // Envoyer au broadcast + match broadcast_tx + .send(bytes.clone(), audio_timestamp, segment_duration) + .await + { + Ok(n) => { + trace!("Broadcasted OGG page with 1 FLAC frame ({} bytes), {} samples ({} bytes total with OGG overhead) to {} receivers (ts={:.3}s, dur={:.3}s)", first_frame.len(), first_frame_samples, bytes.len(), n, audio_timestamp, segment_duration); + } + Err(SendError::Expired(_)) => { + trace!( + "OGG-FLAC broadcast dropped expired page (ts={:.3}s, dur={:.3}s)", + audio_timestamp, + segment_duration + ); + continue; + } + Err(SendError::Closed(_)) => { + trace!("No active receivers for OGG-FLAC broadcast, terminating loop"); + return Ok(()); + } } } } @@ -879,95 +831,10 @@ async fn broadcast_ogg_flac_stream( ))); } - info!("OGG-FLAC broadcaster task completed successfully"); + trace!("Broadcaster task completed successfully"); Ok(()) } -/// Extract sample rate from STREAMINFO block in FLAC header -fn extract_sample_rate_from_streaminfo(flac_header: &[u8]) -> Result { - // Verify we have at least "fLaC" magic + STREAMINFO block header - if flac_header.len() < 8 { - return Err(AudioError::ProcessingError("FLAC header too short".into())); - } - - if &flac_header[0..4] != b"fLaC" { - return Err(AudioError::ProcessingError("Invalid FLAC magic".into())); - } - - // First metadata block should be STREAMINFO (type 0) - let block_type = flac_header[4] & 0x7F; - if block_type != 0 { - return Err(AudioError::ProcessingError("First block is not STREAMINFO".into())); - } - - // STREAMINFO data starts at offset 8 (after magic + block header) - // Sample rate is at offset 10-12 of STREAMINFO data (bytes 18-20 of header) - if flac_header.len() < 21 { - return Err(AudioError::ProcessingError("STREAMINFO block truncated".into())); - } - - // Sample rate: 20 bits starting at byte 10 of STREAMINFO - // Format: [byte10: SSSSSSSS] [byte11: SSSSSSSS] [byte12: SSSSCCCC] - // S = sample rate bits, C = channels bits - let byte10 = flac_header[18] as u32; - let byte11 = flac_header[19] as u32; - let byte12 = flac_header[20] as u32; - - // Extract 20 bits for sample rate (top 20 bits of 3 bytes) - let sample_rate = (byte10 << 12) | (byte11 << 4) | (byte12 >> 4); - - if sample_rate == 0 { - return Err(AudioError::ProcessingError("Invalid sample rate (0)".into())); - } - - Ok(sample_rate) -} - -/// Read FLAC header (fLaC + all metadata blocks until first frame) -async fn read_flac_header(stream: &mut FlacEncodedStream) -> Result, AudioError> { - let mut header = Vec::new(); - let mut buffer = [0u8; 4]; - - // Read "fLaC" magic - stream.read_exact(&mut buffer).await.map_err(|e| { - AudioError::ProcessingError(format!("Failed to read FLAC magic: {}", e)) - })?; - - if &buffer != b"fLaC" { - return Err(AudioError::ProcessingError("Invalid FLAC stream: missing fLaC magic".into())); - } - - header.extend_from_slice(&buffer); - - // Read metadata blocks - loop { - // Read metadata block header (1 byte type + 3 bytes length) - let mut block_header = [0u8; 4]; - stream.read_exact(&mut block_header).await.map_err(|e| { - AudioError::ProcessingError(format!("Failed to read metadata block header: {}", e)) - })?; - - let is_last = (block_header[0] & 0x80) != 0; - let block_length = u32::from_be_bytes([0, block_header[1], block_header[2], block_header[3]]) as usize; - - header.extend_from_slice(&block_header); - - // Read metadata block data - let mut block_data = vec![0u8; block_length]; - stream.read_exact(&mut block_data).await.map_err(|e| { - AudioError::ProcessingError(format!("Failed to read metadata block data: {}", e)) - })?; - - header.extend_from_slice(&block_data); - - if is_last { - break; - } - } - - Ok(header) -} - /// Create OGG-FLAC identification packet (first packet in BOS page) /// Format: https://xiph.org/flac/ogg_mapping.html fn create_ogg_flac_identification(flac_header: &[u8]) -> Result, AudioError> { @@ -984,13 +851,16 @@ fn create_ogg_flac_identification(flac_header: &[u8]) -> Result, AudioEr let first_block_type = flac_header[4] & 0x7F; // Remove last-metadata-block flag if first_block_type != 0 { - return Err(AudioError::ProcessingError("First FLAC metadata block is not STREAMINFO".into())); + return Err(AudioError::ProcessingError( + "First FLAC metadata block is not STREAMINFO".into(), + )); } // Extract block length (3 bytes big-endian after type byte) - let block_length = u32::from_be_bytes([0, flac_header[5], flac_header[6], flac_header[7]]) as usize; + let block_length = + u32::from_be_bytes([0, flac_header[5], flac_header[6], flac_header[7]]) as usize; - info!("STREAMINFO block_length = {} bytes", block_length); + trace!("STREAMINFO block_length = {} bytes", block_length); // STREAMINFO should be exactly 34 bytes of data if block_length != 34 { @@ -1007,22 +877,46 @@ fn create_ogg_flac_identification(flac_header: &[u8]) -> Result, AudioEr // Extract just the STREAMINFO block (type + length + data) let streaminfo = &flac_header[4..4 + streaminfo_size]; - info!("Extracted STREAMINFO: {} bytes (type+length+data)", streaminfo.len()); + trace!( + "Extracted STREAMINFO: {} bytes (type+length+data)", + streaminfo.len() + ); let mut packet = Vec::new(); // OGG-FLAC identification header - packet.push(0x7F); // Byte 0: 0x7F - packet.extend_from_slice(b"FLAC"); // Bytes 1-4: "FLAC" - packet.push(0x01); // Byte 5: Major version - packet.push(0x00); // Byte 6: Minor version + packet.push(0x7F); // Byte 0: 0x7F + packet.extend_from_slice(b"FLAC"); // Bytes 1-4: "FLAC" + packet.push(0x01); // Byte 5: Major version + packet.push(0x00); // Byte 6: Minor version packet.extend_from_slice(&1u16.to_be_bytes()); // Bytes 7-8: 1 header packet (Vorbis Comment) - packet.extend_from_slice(b"fLaC"); // Bytes 9-12: Native FLAC signature - packet.extend_from_slice(streaminfo); // Bytes 13+: STREAMINFO block only + packet.extend_from_slice(b"fLaC"); // Bytes 9-12: Native FLAC signature + packet.extend_from_slice(streaminfo); // Bytes 13+: STREAMINFO block only Ok(packet) } +/// Extract the concatenated FLAC metadata blocks after STREAMINFO to use as the OGG comment packet. +/// Returns `None` if the FLAC header only contains STREAMINFO. +fn extract_comment_packet_from_flac_header(flac_header: &[u8]) -> Option> { + if flac_header.len() < 8 { + return None; + } + + // STREAMINFO block length is stored in bytes 5-7 (after type byte at 4) + let block_length = + u32::from_be_bytes([0, flac_header[5], flac_header[6], flac_header[7]]) as usize; + let streaminfo_total = 4 + block_length; // block header + data + + // Skip "fLaC" + STREAMINFO block. + let offset = 4 + streaminfo_total; + if flac_header.len() <= offset { + return None; + } + + Some(flac_header[offset..].to_vec()) +} + /// Create empty Vorbis Comment block as a proper FLAC metadata block fn create_empty_vorbis_comment() -> Vec { let mut vorbis_data = Vec::new(); @@ -1075,7 +969,13 @@ impl OggPageWriter { self.granule_position += samples; } - fn create_page(&mut self, packet_data: &[u8], is_bos: bool, is_eos: bool, is_continuation: bool) -> Vec { + fn create_page( + &mut self, + packet_data: &[u8], + is_bos: bool, + is_eos: bool, + is_continuation: bool, + ) -> Vec { use std::io::Write; let mut segments = Vec::new(); @@ -1117,7 +1017,8 @@ impl OggPageWriter { page.write_all(&[header_type]).unwrap(); // Granule position - page.write_all(&self.granule_position.to_le_bytes()).unwrap(); + page.write_all(&self.granule_position.to_le_bytes()) + .unwrap(); // Stream serial number page.write_all(&self.stream_serial.to_le_bytes()).unwrap(); diff --git a/pmoaudio-ext/src/sinks/streaming_sink_common.rs b/pmoaudio-ext/src/sinks/streaming_sink_common.rs new file mode 100644 index 00000000..43b47ddb --- /dev/null +++ b/pmoaudio-ext/src/sinks/streaming_sink_common.rs @@ -0,0 +1,576 @@ +use std::collections::VecDeque; +use std::future::Future; +use std::io; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::Duration; + +use bytes::Bytes; +use pmoaudio::AudioError; +use pmoflac::{encode_flac_stream, EncoderOptions, FlacEncodedStream, PcmFormat}; +use pmometadata::TrackMetadata; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncRead, ReadBuf}; +use tokio::sync::{mpsc, RwLock}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::{debug, error, info, trace, warn}; + +use crate::byte_stream_reader::{ByteStreamReader, PcmChunk}; +use crate::sinks::timed_broadcast::{self, TryRecvError}; + +/// Snapshot of track metadata shared across streaming sinks. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MetadataSnapshot { + pub title: Option, + pub artist: Option, + pub album: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_pk: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub track_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub album_artist: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub genre: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub year: Option, + pub audio_timestamp_sec: f64, + pub version: u64, +} + +/// Configuration options shared by streaming sinks. +#[derive(Clone, Debug)] +pub struct StreamingSinkOptions { + pub restart_encoder_on_track_boundary: bool, + pub enable_total_samples: bool, + pub default_title: Option, + pub default_artist: Option, + pub use_only_default_metadata: bool, + pub server_base_url: Option, +} + +impl StreamingSinkOptions { + pub fn flac_defaults() -> Self { + Self { + restart_encoder_on_track_boundary: false, + enable_total_samples: false, + default_title: None, + default_artist: None, + use_only_default_metadata: false, + server_base_url: None, + } + } + + pub fn ogg_defaults() -> Self { + Self { + restart_encoder_on_track_boundary: true, + enable_total_samples: true, + default_title: None, + default_artist: None, + use_only_default_metadata: false, + server_base_url: None, + } + } + + pub fn with_restart(mut self, restart: bool) -> Self { + self.restart_encoder_on_track_boundary = restart; + self + } + + pub fn with_total_samples(mut self, enable: bool) -> Self { + self.enable_total_samples = enable; + self + } + + pub fn with_default_title(mut self, title: impl Into>) -> Self { + self.default_title = title.into(); + self + } + + pub fn with_default_artist(mut self, artist: impl Into>) -> Self { + self.default_artist = artist.into(); + self + } + + pub fn with_only_default_metadata(mut self, only_default: bool) -> Self { + self.use_only_default_metadata = only_default; + self + } + + pub fn with_server_base_url(mut self, url: impl Into>) -> Self { + self.server_base_url = url.into(); + self + } +} + +/// Shared handle state for streaming sinks. +pub struct SharedStreamHandleInner { + pub broadcast: timed_broadcast::Sender, + pub metadata: Arc>, + pub active_clients: Arc, + pub stop_token: CancellationToken, + pub header: Arc>>, + pub auto_stop: Arc, +} + +impl SharedStreamHandleInner { + pub fn new( + broadcast: timed_broadcast::Sender, + metadata: Arc>, + stop_token: CancellationToken, + header: Arc>>, + auto_stop: Arc, + ) -> Self { + Self { + broadcast, + metadata, + active_clients: Arc::new(AtomicUsize::new(0)), + stop_token, + header, + auto_stop, + } + } + + pub fn register_client(&self) -> timed_broadcast::Receiver { + self.broadcast.subscribe() + } + + pub fn client_connected(&self) -> usize { + self.active_clients.fetch_add(1, Ordering::SeqCst) + 1 + } + + pub fn client_disconnected(&self) -> usize { + let prev = self.active_clients.fetch_sub(1, Ordering::SeqCst); + let remaining = prev.saturating_sub(1); + if prev == 1 && self.auto_stop.load(Ordering::SeqCst) { + trace!("Last client disconnected, signaling pipeline stop (shared handle)"); + self.stop_token.cancel(); + } + remaining + } +} + +enum StreamState { + SendingHeader, + Streaming, +} + +pub struct SharedClientStream { + rx: timed_broadcast::Receiver, + buffer: VecDeque, + finished: bool, + handle: Arc, + state: StreamState, + current_epoch: u64, +} + +impl SharedClientStream { + pub fn new(rx: timed_broadcast::Receiver, handle: Arc) -> Self { + Self { + rx, + buffer: VecDeque::new(), + finished: false, + handle, + state: StreamState::SendingHeader, + current_epoch: 0, + } + } + + pub fn current_epoch(&self) -> u64 { + self.current_epoch + } + + pub fn handle(&self) -> &Arc { + &self.handle + } +} + +impl AsyncRead for SharedClientStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + if matches!(self.state, StreamState::SendingHeader) { + let header_opt = if let Ok(guard) = self.handle.header.try_read() { + guard.clone() + } else { + None + }; + + if let Some(header) = header_opt { + self.buffer.extend(header.iter()); + trace!( + "Sending cached header to new client ({} bytes)", + header.len() + ); + self.state = StreamState::Streaming; + continue; + } else { + self.state = StreamState::Streaming; + } + } + + if !self.buffer.is_empty() { + let to_copy = self.buffer.len().min(buf.remaining()); + if to_copy == 0 { + return Poll::Ready(Ok(())); + } + + let slice = self.buffer.make_contiguous(); + buf.put_slice(&slice[..to_copy]); + self.buffer.drain(..to_copy); + return Poll::Ready(Ok(())); + } + + if self.finished { + return Poll::Ready(Ok(())); + } + + match self.rx.try_recv() { + Ok(packet) => { + self.current_epoch = packet.epoch; + self.buffer.extend(packet.payload.iter()); + } + Err(TryRecvError::Empty) => { + let waker = cx.waker().clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + waker.wake(); + }); + return Poll::Pending; + } + Err(TryRecvError::Lagged(skipped)) => { + warn!("Client lagged, skipped {} messages", skipped); + } + Err(TryRecvError::Closed) => { + self.finished = true; + return Poll::Ready(Ok(())); + } + } + } + } +} + +pub struct EncoderState { + pub broadcaster_task: JoinHandle<()>, +} + +pub struct SharedSinkContext { + pub encoder_options: EncoderOptions, + pub bits_per_sample: u8, + /// Whether to propagate total_samples into STREAMINFO. + /// For unbounded live streams (raw FLAC), this must stay false to avoid + /// players stopping after they reach the advertised length. + pub enable_total_samples: bool, + pub restart_encoder_on_track_boundary: bool, + pub default_title: Option, + pub default_artist: Option, + pub use_only_default_metadata: bool, + pub pcm_tx: Option>, + pub pcm_rx: Option>, + pub metadata: Arc>, + pub broadcast: timed_broadcast::Sender, + pub header: Arc>>, + pub encoder_state: Option, + pub sample_rate: Option, + pub broadcast_max_lead_time: f64, + pub first_chunk_timestamp_checked: bool, + pub timestamp_offset_sec: f64, + pub current_timestamp: Arc>, + pub pending_track_duration: Option, + pub pending_total_samples: Option, +} + +impl SharedSinkContext { + pub async fn initialize_encoder( + &mut self, + sample_rate: u32, + timestamp_offset_sec: f64, + broadcaster: F, + ) -> Result<(), AudioError> + where + F: FnOnce( + FlacEncodedStream, + timed_broadcast::Sender, + Arc>>, + Arc>, + Arc>, + f64, + u32, + f64, + ) -> Fut + + Send + + 'static, + Fut: Future> + Send + 'static, + { + if self.encoder_state.is_some() { + return Ok(()); + } + + debug!( + "Initializing FLAC encoder with sample rate: {} Hz", + sample_rate + ); + + let pcm_rx = self + .pcm_rx + .take() + .ok_or_else(|| AudioError::ProcessingError("PCM receiver already consumed".into()))?; + + let current_timestamp = self.current_timestamp.clone(); + let current_duration = Arc::new(RwLock::new(0.0f64)); + + let pcm_reader = + ByteStreamReader::new(pcm_rx, current_timestamp.clone(), current_duration.clone()); + + let pcm_format = PcmFormat { + sample_rate, + channels: 2, + bits_per_sample: self.bits_per_sample, + }; + + let flac_stream = encode_flac_stream(pcm_reader, pcm_format, self.encoder_options.clone()) + .await + .map_err(|e| { + AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e)) + })?; + + debug!("FLAC encoder initialized successfully"); + + let broadcast = self.broadcast.clone(); + let header = self.header.clone(); + let max_lead = self.broadcast_max_lead_time; + let current_timestamp_clone = current_timestamp.clone(); + let current_duration_clone = current_duration.clone(); + + let broadcaster_task = tokio::spawn(async move { + if let Err(e) = broadcaster( + flac_stream, + broadcast, + header, + current_timestamp_clone, + current_duration_clone, + max_lead, + sample_rate, + timestamp_offset_sec, + ) + .await + { + error!("Broadcaster task error: {}", e); + } + }); + + self.encoder_state = Some(EncoderState { broadcaster_task }); + + Ok(()) + } + + /// Prepare encoder options for a new track so the next FLAC header embeds up-to-date metadata + /// and duration (total_samples) when available. + pub async fn prepare_encoder_options_for_track( + &mut self, + metadata_lock: &Arc>, + ) -> Result<(), AudioError> { + debug!("Encoder metadata: preparing metadata"); + // Always pass the metadata handle to the encoder so Vorbis comments are emitted. + self.encoder_options.metadata = Some(metadata_lock.clone()); + + // Capture duration (if any) to set total_samples. + let duration_opt = { + let metadata = metadata_lock.read().await; + metadata.get_duration().await.ok().flatten() + }; + self.pending_track_duration = duration_opt; + self.pending_total_samples = { + let metadata = metadata_lock.read().await; + metadata.get_total_samples().await.ok().flatten() + }; + + debug!( + "Encoder metadata: from TrackBoundary - duration={:?}s, total_samples={:?}", + self.pending_track_duration + .as_ref() + .map(|d| d.as_secs_f64()), + self.pending_total_samples + ); + + // In raw FLAC live streaming we must NOT advertise a total_samples value, + // otherwise players think the stream ends after the first track. + if !self.enable_total_samples { + self.encoder_options.total_samples = None; + debug!("Encoder metadata: total_samples disabled for this sink (enable_total_samples=false)"); + return Ok(()); + } + + // Compute total_samples only when we know the sample rate. + self.refresh_total_samples_with_sample_rate(); + + Ok(()) + } + + /// Refresh total_samples when the sample rate is learned after metadata was already set. + pub fn refresh_total_samples_with_sample_rate(&mut self) { + info!( + "Encoder metadata: refresh_total_samples (pending_total_samples={:?}, pending_duration={:?}, sample_rate={:?})", + self.pending_total_samples, + self.pending_track_duration + .as_ref() + .map(Duration::as_secs_f64), + self.sample_rate + ); + + if !self.enable_total_samples { + self.encoder_options.total_samples = None; + info!("Encoder metadata: total_samples disabled for live streaming"); + return; + } + + if let Some(total) = self.pending_total_samples { + self.encoder_options.total_samples = Some(total); + info!( + "Encoder metadata: using provided total_samples={} from TrackBoundary", + total + ); + return; + } + + if let (Some(duration), Some(sr)) = (self.pending_track_duration, self.sample_rate) { + let samples = (duration.as_secs_f64() * sr as f64).round() as u64; + self.encoder_options.total_samples = Some(samples); + info!( + "Encoder metadata: computed total_samples={} (duration {:.3}s @ {} Hz)", + samples, + duration.as_secs_f64(), + sr + ); + } else { + // Avoid leaking the previous track's length. + self.encoder_options.total_samples = None; + info!("Encoder metadata: no duration/total_samples available; clearing total_samples in encoder options"); + } + } + + pub async fn restart_encoder_for_new_track( + &mut self, + broadcaster: F, + ) -> Result<(), AudioError> + where + F: FnOnce( + FlacEncodedStream, + timed_broadcast::Sender, + Arc>>, + Arc>, + Arc>, + f64, + u32, + f64, + ) -> Fut + + Send + + 'static, + Fut: Future> + Send + 'static, + { + let sample_rate = self + .sample_rate + .ok_or_else(|| AudioError::ProcessingError("Sample rate not initialized".into()))?; + + debug!("Restarting FLAC encoder for new track"); + + let last_timestamp = *self.current_timestamp.read().await; + debug!("Last timestamp before restart: {:.3}s", last_timestamp); + + if let Some(tx) = self.pcm_tx.take() { + drop(tx); + trace!("Dropped PCM sender to signal encoder finish"); + } + + if let Some(state) = self.encoder_state.take() { + trace!("Waiting for broadcaster task to finish..."); + match state.broadcaster_task.await { + Ok(_) => trace!("Broadcaster task finished successfully"), + Err(e) => warn!("Broadcaster task error during restart: {:?}", e), + } + } + + self.timestamp_offset_sec += last_timestamp; + debug!("New timestamp offset: {:.3}s", self.timestamp_offset_sec); + + let (pcm_tx, pcm_rx) = mpsc::channel::(16); + self.pcm_tx = Some(pcm_tx); + self.pcm_rx = Some(pcm_rx); + + // self.initialize_encoder(sample_rate, self.timestamp_offset_sec, broadcaster) + // .await?; + + self.initialize_encoder(sample_rate, 0.0, broadcaster) + .await?; + + debug!("FLAC encoder restarted successfully for new track"); + Ok(()) + } + + pub async fn update_metadata( + &mut self, + metadata_lock: &Arc>, + timestamp_sec: f64, + ) -> Result<(), AudioError> { + let metadata = metadata_lock.read().await; + let mut snapshot = self.metadata.write().await; + + // Title / artist with default fallback or forced default. + if self.use_only_default_metadata { + snapshot.title = self.default_title.clone(); + snapshot.artist = self.default_artist.clone(); + } else { + snapshot.title = metadata + .get_title() + .await + .ok() + .flatten() + .or_else(|| self.default_title.clone()); + snapshot.artist = metadata + .get_artist() + .await + .ok() + .flatten() + .or_else(|| self.default_artist.clone()); + } + snapshot.album = metadata.get_album().await.ok().flatten(); + snapshot.duration = metadata.get_duration().await.ok().flatten(); + snapshot.cover_url = metadata.get_cover_url().await.ok().flatten(); + snapshot.cover_pk = metadata.get_cover_pk().await.ok().flatten(); + snapshot.year = metadata.get_year().await.ok().flatten(); + + if let Ok(Some(extra)) = metadata.get_extra().await { + snapshot.genre = extra.get("genre").cloned(); + snapshot.track_number = extra + .get("track_number") + .and_then(|s| s.parse::().ok()); + } else { + snapshot.genre = None; + snapshot.track_number = None; + } + + snapshot.audio_timestamp_sec = timestamp_sec; + snapshot.version += 1; + + debug!( + "Metadata updated: v{} @ {:.2}s - {} - {} (cover_pk: {:?})", + snapshot.version, + timestamp_sec, + snapshot.artist.as_deref().unwrap_or("?"), + snapshot.title.as_deref().unwrap_or("?"), + snapshot.cover_pk + ); + + Ok(()) + } +} diff --git a/pmoaudio-ext/src/sinks/timed_broadcast.rs b/pmoaudio-ext/src/sinks/timed_broadcast.rs new file mode 100644 index 00000000..a0fcdbb1 --- /dev/null +++ b/pmoaudio-ext/src/sinks/timed_broadcast.rs @@ -0,0 +1,606 @@ +//! Broadcast channel avec TTL et propagation de TopZero. +//! Inspiré de `tokio::sync::broadcast` mais ajoute : +//! - Capacité bornée avec blocage des producteurs quand aucun slot n’est libre. +//! - Expiration automatique des messages (TTL) pour libérer les slots. +//! - Propagation d’un compteur `epoch` incrémenté sur chaque TopZeroSync. + +use std::{ + collections::VecDeque, + fmt, + sync::{ + atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, + Arc, Mutex, Weak, + }, + time::{Duration, Instant}, +}; + +use tokio::sync::Notify; +use tracing::{info, trace, warn}; + +/// Tolérance pour détecter un timestamp à zéro (TopZero). +const TOP_ZERO_EPSILON: f64 = 1e-9; + +pub const DEFAULT_BROADCAST_MAX_LEAD_TIME: f64 = 0.5; + +/// Paquet diffusé contenant la charge utile + méta timing. +#[derive(Clone)] +pub struct TimedPacket { + /// Charge utile diffusée aux clients. + pub payload: T, + /// Timestamp audio relatif (en secondes) pour pacing côté client. + pub audio_timestamp: f64, + /// Compteur incrémenté lorsqu'un TopZeroSync est reçu. + pub epoch: u64, +} + +impl fmt::Debug for TimedPacket { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TimedPacket") + .field("audio_timestamp", &self.audio_timestamp) + .field("epoch", &self.epoch) + .finish_non_exhaustive() + } +} + +/// Erreur remontée par `Receiver::try_recv`. +#[derive(Debug)] +pub enum TryRecvError { + /// Aucun paquet n'est disponible pour le moment. + Empty, + /// Le receiver est en retard : le champ contient combien de paquets ont expiré + /// ou ont déjà été consommés par les autres abonnés. + /// + /// Ce cas survient lorsque `purge_expired()` avance `head_seq` et que ce + /// `Receiver` réclamait encore l'un des numéros supprimés. Le client doit + /// donc ignorer les données perdues et se resynchroniser sur les paquets + /// courants. + Lagged(u64), + /// Le channel est fermé et plus aucun paquet n'est disponible. + Closed, +} + +/// Erreur remontée par `Receiver::recv`. +#[derive(Debug)] +pub enum RecvError { + Lagged(u64), + Closed, +} + +/// Erreur remontée par `Sender::send`. +#[derive(Debug)] +/// Erreur de diffusion détaillant la raison pour laquelle un paquet n'a pas été accepté. +pub enum SendError { + Closed(T), + Expired(T), +} + +struct Entry { + seq: u64, + expires_at: Instant, + payload: T, + audio_timestamp: f64, + epoch: u64, +} + +struct State { + name: String, + buffer: VecDeque>, + head_seq: u64, + next_seq: u64, + closed: bool, + epoch: u64, + epoch_start: Instant, + last_segment_end: Option, + cursors: Vec>, + initialized: bool, + last_purge: Instant, +} + +impl State { + fn new(name: &str, capacity: usize, epoch_start: Instant) -> Self { + Self { + name: name.to_string(), + buffer: VecDeque::with_capacity(capacity), + head_seq: 0, + next_seq: 0, + closed: false, + epoch: 0, + epoch_start, + last_segment_end: None, + cursors: Vec::new(), + initialized: false, + last_purge: epoch_start, + } + } + + fn purge_expired(&mut self, now: Instant) -> bool { + // Throttling : purger au maximum toutes les 20ms + if now.duration_since(self.last_purge) < Duration::from_millis(20) { + return false; + } + self.last_purge = now; + + let mut purged = 0u64; + while let Some(entry) = self.buffer.front() { + if entry.expires_at <= now { + let delta = now - entry.expires_at; + trace!( + "TimedBroadcast[{}]: purging expired packet (@{} epoch={},delta={})", + self.name, + entry.seq, + entry.epoch, + delta.as_millis() + ); + self.buffer.pop_front(); + self.head_seq += 1; + purged += 1; + } else { + break; + } + } + if purged > 0 { + trace!( + "TimedBroadcast[{}]: purged {} expired packet(s) (head_seq={})", + self.name, + purged, + self.head_seq + ); + return true; + } + false + } + + fn prune_consumed(&mut self) -> bool { + let mut min_next = self.next_seq; + let mut has_cursor = false; + self.cursors.retain(|weak| { + if let Some(cursor) = weak.upgrade() { + let pos = cursor.next_seq.load(Ordering::SeqCst); + if pos < min_next { + min_next = pos; + } + has_cursor = true; + true + } else { + false + } + }); + + if !has_cursor { + return false; + } + + let removable = min_next.saturating_sub(self.head_seq) as usize; + if removable == 0 { + return false; + } + + for _ in 0..removable { + let oentry = self.buffer.pop_front(); + if oentry.is_some() { + let entry = oentry.unwrap(); + trace!( + "TimedBroadcast[{}]: pruning played packet (@{} epoch={})", + self.name, + entry.seq, + entry.epoch + ); + + self.head_seq += 1; + } + } + true + } +} + +struct Inner { + state: Mutex>, + data_notify: Notify, + space_notify: Notify, + capacity: usize, + sender_count: AtomicUsize, + receiver_count: AtomicUsize, + is_closed: AtomicBool, +} + +impl Inner { + fn new(name: &str, capacity: usize) -> Self { + Self { + state: Mutex::new(State::new(name, capacity, Instant::now())), + data_notify: Notify::new(), + space_notify: Notify::new(), + capacity, + sender_count: AtomicUsize::new(1), + receiver_count: AtomicUsize::new(0), + is_closed: AtomicBool::new(false), + } + } + + fn close(&self) { + if !self.is_closed.swap(true, Ordering::SeqCst) { + if let Ok(mut state) = self.state.lock() { + state.closed = true; + } + self.data_notify.notify_waiters(); + self.space_notify.notify_waiters(); + } + } +} + +/// Créé un channel broadcast temporisé. +pub fn channel(name: &str, capacity: usize) -> (Sender, Receiver) { + assert!(capacity > 0, "capacity must be > 0"); + let inner = Arc::new(Inner::new(name, capacity)); + let next_seq = { + let state = inner.state.lock().expect("timed broadcast mutex poisoned"); + state.next_seq + }; + let sender = Sender { + inner: inner.clone(), + }; + let cursor = Arc::new(ReceiverCursor { + next_seq: AtomicU64::new(next_seq), + }); + { + let mut state = inner.state.lock().expect("timed broadcast mutex poisoned"); + state.cursors.push(Arc::downgrade(&cursor)); + } + inner.receiver_count.store(1, Ordering::SeqCst); + let receiver = Receiver { + inner, + next_seq, + cursor, + }; + (sender, receiver) +} + +/// Sender côté producteur. +pub struct Sender { + inner: Arc>, +} + +impl Clone for Sender { + fn clone(&self) -> Self { + self.inner.sender_count.fetch_add(1, Ordering::SeqCst); + Self { + inner: self.inner.clone(), + } + } +} + +impl Sender { + /// Diffuse un paquet. Bloque si la capacité est atteinte avec des paquets non périmés. + /// + /// Le TTL de chaque paquet est calculé à partir du `epoch_start` courant et du + /// `audio_timestamp` fournis, ce qui signifie qu’un receiver en retard finira + /// par recevoir un [`TryRecvError::Lagged`] lorsque `expires_at` est dépassé. + pub async fn send( + &self, + payload: T, + audio_timestamp: f64, + segment_duration: f64, + ) -> Result> + where + T: Clone, + { + let mut payload = Some(payload); + loop { + let mut wait_deadline = None; + { + let mut state = self + .inner + .state + .lock() + .expect("timed broadcast mutex poisoned"); + + if state.closed { + return Err(SendError::Closed( + payload.expect("payload already consumed"), + )); + } + + // Capturer le temps UNE SEULE FOIS pour cohérence temporelle + let now = Instant::now(); + + // 1. Purger d'abord les paquets expirés et consommés pour libérer l'espace + // (skip pour le tout premier paquet) + if state.buffer.len() > 0 { + let consumed = state.prune_consumed(); + let expired = state.purge_expired(now); + if consumed || expired { + self.inner.space_notify.notify_waiters(); + } + } + + // 2. Vérifier si un slot est disponible et insérer + let is_top_zero = audio_timestamp.abs() < TOP_ZERO_EPSILON + && segment_duration >= TOP_ZERO_EPSILON; + let is_zero_header = + audio_timestamp.abs() < TOP_ZERO_EPSILON && segment_duration < TOP_ZERO_EPSILON; + if state.buffer.len() < self.inner.capacity { + if !state.initialized { + if !is_top_zero && segment_duration >= TOP_ZERO_EPSILON { + warn!( + "TimedBroadcast[{}]: First packet has non-zero timestamp {:.1}ms - Duration={:.1}ms, treating as epoch start anyway", + state.name, + audio_timestamp*1000.0, + segment_duration*1000.0 + ); + } + state.epoch_start = now; + state.epoch = 0; + state.initialized = true; + info!( + "TimedBroadcast[{}]: initialized (epoch=0, ts={:.1}ms - Duration={:.1}ms)", + state.name, + audio_timestamp*1000.0, + segment_duration*1000.0 + ); + } else if is_top_zero || is_zero_header { + // Restart epoch on TopZero relative to current wall-clock time to avoid + // expired packets when there's a long gap between tracks. Also trigger + // on zero-duration headers (OGG BOS/comment) so the epoch is reset + // before testing expiration. + state.epoch_start = state + .last_segment_end + .map(|end| end.max(now)) + .unwrap_or(now); + // state.epoch_start = now; + state.epoch = state.epoch.wrapping_add(1); + info!( + "TimedBroadcast[{}]: new epoch={} (continuous={} - Duration={}ms)", + state.name, + state.epoch, + state.last_segment_end.is_some(), + segment_duration * 1000.0 + ); + } + + let expires_at = state.epoch_start + + Duration::from_secs_f64(audio_timestamp + segment_duration); + + let is_first_packet = state.next_seq == 0; + if !is_first_packet && !is_top_zero && !is_zero_header && expires_at <= now { + let grace_period = Duration::from_millis(50); + if now > expires_at + grace_period { + warn!( + "TimedBroadcast[{}]: rejecting already expired packet (ts={:.3}s, epoch={}, delta={}ms)", + state.name, + audio_timestamp, + state.epoch, + now.duration_since(expires_at).as_millis() + ); + return Err(SendError::Expired( + payload.expect("payload already consumed"), + )); + } + } + + let entry = Entry { + seq: state.next_seq, + expires_at, + payload: payload.take().expect("payload already consumed"), + audio_timestamp, + epoch: state.epoch, + }; + state.next_seq += 1; + state.buffer.push_back(entry); + + // 5. Only advance segment end for real audio (skip 0-duration metadata) + if segment_duration >= TOP_ZERO_EPSILON { + let new_end = expires_at; + state.last_segment_end = Some(match state.last_segment_end.take() { + Some(prev) => prev.max(new_end), + None => new_end, + }); + } + + let receivers = self.inner.receiver_count.load(Ordering::SeqCst); + drop(state); + self.inner.data_notify.notify_waiters(); + return Ok(receivers); + } + + wait_deadline = state.buffer.front().map(|entry| entry.expires_at); + } + + if let Some(deadline) = wait_deadline { + let deadline = tokio::time::Instant::from_std(deadline); + tokio::select! { + _ = self.inner.space_notify.notified() => {}, + _ = tokio::time::sleep_until(deadline) => {}, + } + } else { + self.inner.space_notify.notified().await; + } + } + } + + /// Crée un nouveau receiver abonné au flux. + pub fn subscribe(&self) -> Receiver { + let mut state = self + .inner + .state + .lock() + .expect("timed broadcast mutex poisoned"); + let next_seq = state.next_seq; + let cursor = Arc::new(ReceiverCursor { + next_seq: AtomicU64::new(next_seq), + }); + state.cursors.push(Arc::downgrade(&cursor)); + state.prune_consumed(); + drop(state); + + self.inner.receiver_count.fetch_add(1, Ordering::SeqCst); + + Receiver { + inner: self.inner.clone(), + next_seq, + cursor, + } + } + + /// Nombre actuel de receivers abonnés. + pub fn receiver_count(&self) -> usize { + self.inner.receiver_count.load(Ordering::SeqCst) + } + + /// Ferme explicitement le channel. + pub fn close(&self) { + self.inner.close(); + } +} + +impl Drop for Sender { + fn drop(&mut self) { + if self.inner.sender_count.fetch_sub(1, Ordering::SeqCst) == 1 { + self.inner.close(); + } + } +} + +/// Receiver côté consommateur. +/// +/// Chaque receiver garde son propre curseur `next_seq`. Si le producteur +/// recycle un paquet via `purge_expired()` avant que ce curseur ne l’ait lu, +/// la prochaine tentative de lecture retournera [`TryRecvError::Lagged`]. +pub struct Receiver { + inner: Arc>, + next_seq: u64, + cursor: Arc, +} + +struct ReceiverCursor { + next_seq: AtomicU64, +} + +impl Receiver +where + T: Clone, +{ + fn poll_entry(&mut self) -> Result, TryRecvError> { + let mut state = self + .inner + .state + .lock() + .expect("timed broadcast mutex poisoned"); + + if state.closed && state.buffer.is_empty() { + return Err(TryRecvError::Closed); + } + + let now = Instant::now(); + if state.purge_expired(now) { + self.inner.space_notify.notify_waiters(); + } + + if self.next_seq < state.head_seq { + let skipped = state.head_seq - self.next_seq; + self.next_seq = state.head_seq; + return Err(TryRecvError::Lagged(skipped)); + } + + let offset = (self.next_seq - state.head_seq) as usize; + if offset < state.buffer.len() { + let entry = state.buffer.get(offset).expect("invalid buffer offset"); + let packet = TimedPacket { + payload: entry.payload.clone(), + audio_timestamp: entry.audio_timestamp, + epoch: entry.epoch, + }; + self.next_seq += 1; + self.cursor.next_seq.store(self.next_seq, Ordering::SeqCst); + if state.prune_consumed() { + self.inner.space_notify.notify_waiters(); + } + return Ok(packet); + } + + if state.closed { + Err(TryRecvError::Closed) + } else { + Err(TryRecvError::Empty) + } + } + + /// Version synchrone utilisée dans `poll_read`. + /// + /// # Erreurs + /// + /// * [`TryRecvError::Lagged`] — des paquets ont expiré avant d'être consommés. + /// * [`TryRecvError::Empty`] — la file est vide pour l'instant. + /// * [`TryRecvError::Closed`] — plus aucun paquet n'arrivera. + pub fn try_recv(&mut self) -> Result, TryRecvError> { + self.poll_entry() + } + + /// Attends qu'un paquet soit disponible. + pub async fn recv(&mut self) -> Result, RecvError> { + loop { + match self.try_recv() { + Ok(packet) => return Ok(packet), + Err(TryRecvError::Empty) => { + self.inner.data_notify.notified().await; + } + Err(TryRecvError::Lagged(skipped)) => return Err(RecvError::Lagged(skipped)), + Err(TryRecvError::Closed) => return Err(RecvError::Closed), + } + } + } +} + +impl Clone for Receiver { + fn clone(&self) -> Self { + self.inner.receiver_count.fetch_add(1, Ordering::SeqCst); + let cursor = Arc::new(ReceiverCursor { + next_seq: AtomicU64::new(self.next_seq), + }); + { + let mut state = self + .inner + .state + .lock() + .expect("timed broadcast mutex poisoned"); + state.cursors.push(Arc::downgrade(&cursor)); + } + Self { + inner: self.inner.clone(), + next_seq: self.next_seq, + cursor, + } + } +} + +impl Drop for Receiver { + fn drop(&mut self) { + self.cursor.next_seq.store(self.next_seq, Ordering::SeqCst); + if let Ok(mut state) = self.inner.state.lock() { + if state.prune_consumed() { + self.inner.space_notify.notify_waiters(); + } + } + if self.inner.receiver_count.fetch_sub(1, Ordering::SeqCst) == 1 { + self.inner.space_notify.notify_waiters(); + } + } +} + +/// Calculate broadcast channel capacity based on max_lead_time. +/// +/// Estimates the number of items needed to buffer max_lead_time seconds of audio. +/// Assumes ~20 items per second (50ms per chunk). +/// +/// # Arguments +/// +/// * `max_lead_time` - Maximum lead time in seconds +/// +/// # Returns +/// +/// Broadcast channel capacity (minimum 100 items) +pub(crate) fn calculate_broadcast_capacity(max_lead_time: f64) -> usize { + // Estimation: ~20 items/second (chunks de 50ms en moyenne) + // Pour 10s: 200 items + let estimated_items_per_second = 20.0; + let capacity = (max_lead_time * estimated_items_per_second) as usize; + capacity.max(100) // Minimum 100 items +} diff --git a/pmoaudio-ext/src/sources/playlist_source.rs b/pmoaudio-ext/src/sources/playlist_source.rs index 5bca30a9..d120fcd7 100644 --- a/pmoaudio-ext/src/sources/playlist_source.rs +++ b/pmoaudio-ext/src/sources/playlist_source.rs @@ -57,12 +57,50 @@ //! # } //! ``` //! +//! # Historique des morceaux joués +//! +//! Utilisez `PlaylistSource::with_history()` pour créer une source qui transfère +//! automatiquement les morceaux joués vers une playlist historique : +//! +//! ```rust,no_run +//! use pmoaudio_ext::PlaylistSource; +//! use pmoplaylist::PlaylistManager; +//! use pmoaudiocache::cache::new_cache; +//! use std::sync::Arc; +//! +//! # async fn example() -> Result<(), Box> { +//! let manager = PlaylistManager::get(); +//! let cache = Arc::new(new_cache("./cache", 500)?); +//! +//! // Playlist live (consommée par la source) +//! let live_read = manager.get_read_handle("radio-live").await?; +//! +//! // Playlist historique (capacité 200 morceaux) +//! let history_write = manager.create_persistent_playlist("radio-history".into()).await?; +//! history_write.set_capacity(Some(200)).await?; +//! +//! // Créer la source avec historique +//! let source = PlaylistSource::with_history( +//! live_read, +//! cache, +//! Arc::new(history_write) +//! ); +//! +//! // Les morceaux joués seront automatiquement ajoutés à "radio-history" +//! # Ok(()) +//! # } +//! ``` +//! +//! **Note** : L'historique utilise `push()` sans TTL. Les morceaux restent dans l'historique +//! jusqu'à ce que la capacité maximale soit atteinte (FIFO). +//! //! # Comportement //! //! - **Polling** : Si la playlist est vide, attend `poll_interval_ms` avant de réessayer //! - **TrackBoundary** : Émet un marqueur avec metadata entre chaque piste //! - **Erreurs** : Si un fichier est inaccessible, émet un `Error` marker et continue //! - **Arrêt** : Via `CancellationToken`, émet `EndOfStream` avant de terminer +//! - **Historique** : Si configuré, ajoute chaque piste jouée à la playlist historique //! //! # Synchronisation //! @@ -73,7 +111,7 @@ use pmoaudio::{ nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS}, - pipeline::{AudioPipelineNode, Node, NodeLogic}, + pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic}, type_constraints::TypeRequirement, AudioChunk, AudioChunkData, AudioSegment, I24, }; @@ -98,6 +136,7 @@ pub struct PlaylistSourceLogic { cache: Arc, chunk_frames: usize, poll_interval_ms: u64, + history_playlist: Option>, } impl PlaylistSourceLogic { @@ -112,8 +151,14 @@ impl PlaylistSourceLogic { cache, chunk_frames, poll_interval_ms, + history_playlist: None, } } + + /// Enregistre une playlist historique pour sauvegarder les morceaux joués + pub fn set_history_playlist(&mut self, history: Arc) { + self.history_playlist = Some(history); + } } #[async_trait::async_trait] @@ -130,16 +175,7 @@ impl NodeLogic for PlaylistSourceLogic { output.len() ); - // Macro helper pour envoyer à tous les enfants - macro_rules! send_to_children { - ($segment:expr) => { - for tx in &output { - tx.send($segment.clone()) - .await - .map_err(|_| AudioError::ChildDied)?; - } - }; - } + let node_name = std::any::type_name::(); let mut first_track = true; @@ -148,7 +184,7 @@ impl NodeLogic for PlaylistSourceLogic { if stop_token.is_cancelled() { tracing::info!("PlaylistSourceLogic: stop requested, emitting EndOfStream"); let eos = AudioSegment::new_end_of_stream(0, 0.0); - send_to_children!(eos); + send_to_children(node_name, &output, eos).await?; break; } @@ -157,7 +193,7 @@ impl NodeLogic for PlaylistSourceLogic { _ = stop_token.cancelled() => { tracing::info!("PlaylistSourceLogic: stop cancelled during pop"); let eos = AudioSegment::new_end_of_stream(0, 0.0); - send_to_children!(eos); + send_to_children(node_name, &output, eos).await?; break; } result = self.playlist_handle.pop() => { @@ -167,7 +203,13 @@ impl NodeLogic for PlaylistSourceLogic { t }, Ok(None) => { - // Playlist vide, attendre avant retry + // Playlist vide, attendre avant retry et réinitialiser la synchro + if !first_track { + tracing::debug!( + "PlaylistSourceLogic: playlist drained, resetting top-zero sync" + ); + } + first_track = true; tracing::trace!( "PlaylistSourceLogic: playlist empty, waiting {}ms", self.poll_interval_ms @@ -185,74 +227,134 @@ impl NodeLogic for PlaylistSourceLogic { 0.0, format!("Playlist error: {}", e) ); - send_to_children!(error_marker); + send_to_children(node_name, &output, error_marker).await?; continue; } } } }; - // Émettre TopZeroSync pour la première piste seulement - if first_track { - tracing::debug!("PlaylistSourceLogic: emitting TopZeroSync"); - let top_zero = AudioSegment::new_top_zero_sync(); - send_to_children!(top_zero); - first_track = false; - } - // Émettre TrackBoundary avec metadata du cache let metadata = match track.track_metadata() { Ok(m) => m, Err(e) => { tracing::warn!("PlaylistSourceLogic: failed to get metadata: {}", e); - let error_marker = AudioSegment::new_error( - 0, - 0.0, - format!("Failed to get metadata: {}", e), - ); - send_to_children!(error_marker); + let error_marker = + AudioSegment::new_error(0, 0.0, format!("Failed to get metadata: {}", e)); + send_to_children(node_name, &output, error_marker).await?; continue; } }; + let metadata_guard = metadata.read().await; + let artist = metadata_guard + .get_artist() + .await + .ok() + .flatten() + .unwrap_or_else(|| "Unknown artist".to_string()); + let title = metadata_guard + .get_title() + .await + .ok() + .flatten() + .unwrap_or_else(|| "Untitled".to_string()); + let expected_duration = metadata_guard + .get_duration() + .await + .ok() + .flatten() + .map(|d| d.as_secs_f64()); + drop(metadata_guard); + let remaining = self.playlist_handle.remaining().await.unwrap_or(0); + tracing::info!( + "PlaylistSource: starting track {} - {} ({} remaining)", + artist, + title, + remaining + ); + + let track_start = std::time::Instant::now(); tracing::debug!("PlaylistSourceLogic: emitting TrackBoundary"); - let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata); - send_to_children!(boundary); + let metadata_for_boundary = metadata.clone(); + let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata_for_boundary); + send_to_children(node_name, &output, boundary).await?; // Obtenir le chemin du fichier let file_path = match track.file_path() { Ok(p) => p, Err(e) => { tracing::warn!("PlaylistSourceLogic: failed to get file path: {}", e); - let error_marker = AudioSegment::new_error( - 0, - 0.0, - format!("Failed to get file path: {}", e), - ); - send_to_children!(error_marker); + let error_marker = + AudioSegment::new_error(0, 0.0, format!("Failed to get file path: {}", e)); + send_to_children(node_name, &output, error_marker).await?; continue; } }; - tracing::debug!("PlaylistSourceLogic: decoding track: {:?}", file_path); + let elapsed = track_start.elapsed(); + tracing::info!( + "PlaylistSourceLogic: gap after TrackBoundary = {:.3}s, decoding: {:?}", + elapsed.as_secs_f64(), + file_path + ); // Décoder et émettre les chunks PCM // Passer le cache et pk pour gérer le cache progressif let cache_pk = track.cache_pk(); - if let Err(e) = decode_and_emit_track( + // Réinitialiser la synchro au début de chaque piste + let emit_top_zero = true; + first_track = false; + + match decode_and_emit_track( + node_name, &file_path, self.chunk_frames, &output, &stop_token, &self.cache, cache_pk, + expected_duration, + emit_top_zero, ) .await { - tracing::error!("PlaylistSourceLogic: error decoding track: {}", e); - let error_marker = AudioSegment::new_error(0, 0.0, format!("Decode error: {}", e)); - send_to_children!(error_marker); - // Continue vers la piste suivante + Ok(()) => { + tracing::info!("PlaylistSource: finished track {} - {}", artist, title); + // Piste décodée avec succès, transférer vers l'historique si configuré + tracing::warn!( + "🔍 HISTORY DEBUG: history_playlist is {:?}", + if self.history_playlist.is_some() { + "Some" + } else { + "None" + } + ); + if let Some(ref history) = self.history_playlist { + tracing::warn!( + "🔍 HISTORY DEBUG: Attempting to push cache_pk={} to history", + cache_pk + ); + if let Err(e) = history.push(cache_pk.to_string()).await { + tracing::warn!( + "PlaylistSourceLogic: failed to add track to history: {}", + e + ); + } else { + tracing::debug!( + "PlaylistSourceLogic: added track {} to history", + cache_pk + ); + } + } + } + Err(e) => { + tracing::error!("PlaylistSourceLogic: error decoding track: {}", e); + let error_marker = + AudioSegment::new_error(0, 0.0, format!("Decode error: {}", e)); + send_to_children(node_name, &output, error_marker).await?; + // Continue vers la piste suivante + } } // Boucler pour la piste suivante (pas d'EndOfStream entre pistes !) @@ -272,12 +374,15 @@ impl NodeLogic for PlaylistSourceLogic { /// Gère le cache progressif : si EOF est atteint et que le download est toujours en cours, /// attend et réessaie au lieu de terminer immédiatement. async fn decode_and_emit_track( + node_name: &'static str, path: &PathBuf, chunk_frames: usize, output: &[mpsc::Sender>], stop_token: &CancellationToken, cache: &Arc, cache_pk: &str, + expected_duration_sec: Option, + emit_top_zero: bool, ) -> Result<(), AudioError> { // Attendre que le fichier soit suffisamment gros pour le sniffing // Le cache progressif permet de commencer la lecture après le prebuffer (512 KB) @@ -290,7 +395,10 @@ async fn decode_and_emit_track( const MIN_FILE_SIZE: u64 = 512 * 1024; // 512 KB (prebuffer size) if file_size >= MIN_FILE_SIZE || cache.is_download_complete(cache_pk) { - tracing::trace!("decode_and_emit_track: file ready ({} bytes), starting decode", file_size); + tracing::trace!( + "decode_and_emit_track: file ready ({} bytes), starting decode", + file_size + ); break; } @@ -400,12 +508,14 @@ async fn decode_and_emit_track( timestamp_sec, )?; - for tx in output { - tx.send(segment.clone()) - .await - .map_err(|_| AudioError::ChildDied)?; + if emit_top_zero && total_frames == 0 { + tracing::debug!("decode_and_emit_track: emitting TopZeroSync (first chunk)"); + let top_zero = AudioSegment::new_top_zero_sync(); + send_to_children(node_name, output, top_zero).await?; } + send_to_children(node_name, output, segment).await?; + chunk_index += 1; total_frames += frames_to_emit as u64; } @@ -417,12 +527,9 @@ async fn decode_and_emit_track( let frames = pending.len() / frame_bytes; if frames > 0 { let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64; - let segment = bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?; - for tx in output { - tx.send(segment.clone()) - .await - .map_err(|_| AudioError::ChildDied)?; - } + let segment = + bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?; + send_to_children(node_name, output, segment).await?; } } @@ -432,6 +539,27 @@ async fn decode_and_emit_track( .await .map_err(|e| AudioError::ProcessingError(format!("Decode task failed: {}", e)))?; + if !cache.is_download_complete(cache_pk) { + tracing::warn!( + "PlaylistSource: finished reading cache entry {} but download is not complete", + cache_pk + ); + } + + let actual_duration = total_frames as f64 / stream_info.sample_rate as f64; + let expected_str = expected_duration_sec + .map(|d| format!("{:.3}s", d)) + .unwrap_or_else(|| "unknown".to_string()); + tracing::info!( + "PlaylistSource: emitted pk={} frames={} sr={}Hz bit_depth={} duration={:.3}s (expected={})", + cache_pk, + total_frames, + stream_info.sample_rate, + stream_info.bits_per_sample, + actual_duration, + expected_str, + ); + Ok(()) } @@ -609,7 +737,29 @@ impl PlaylistSource { chunk_frames: usize, poll_interval_ms: u64, ) -> Self { - let logic = PlaylistSourceLogic::new(playlist_handle, cache, chunk_frames, poll_interval_ms); + let logic = + PlaylistSourceLogic::new(playlist_handle, cache, chunk_frames, poll_interval_ms); + Self { + inner: Node::new_source(logic), + } + } + + /// Crée une nouvelle source avec playlist historique + /// + /// * `playlist_handle` - Handle de lecture sur la playlist live + /// * `cache` - Cache audio contenant les fichiers + /// * `history_playlist` - Handle d'écriture pour l'historique des morceaux joués + /// + /// Après avoir joué chaque morceau, il sera automatiquement ajouté à la playlist historique. + /// La playlist historique utilise push() sans TTL, donc les morceaux y restent jusqu'à + /// ce que la capacité maximale soit atteinte (FIFO). + pub fn with_history( + playlist_handle: ReadHandle, + cache: Arc, + history_playlist: Arc, + ) -> Self { + let mut logic = PlaylistSourceLogic::new(playlist_handle, cache, 0, 100); + logic.set_history_playlist(history_playlist); Self { inner: Node::new_source(logic), } @@ -626,10 +776,7 @@ impl AudioPipelineNode for PlaylistSource { self.inner.register(child) } - async fn run( - self: Box, - stop_token: CancellationToken, - ) -> Result<(), AudioError> { + async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { Box::new(self.inner).run(stop_token).await } } @@ -712,9 +859,9 @@ mod tests { // Frame 2: L=300, R=400 let chunk_bytes = vec![ 100u8, 0, // L1 - 200, 0, // R1 - 44, 1, // L2 (300 = 0x012C) - 144, 1, // R2 (400 = 0x0190) + 200, 0, // R1 + 44, 1, // L2 (300 = 0x012C) + 144, 1, // R2 (400 = 0x0190) ]; let info = StreamInfo { @@ -732,18 +879,16 @@ mod tests { assert_eq!(segment.timestamp_sec, 0.0); match &segment.segment { - pmoaudio::_AudioSegment::Chunk(chunk) => { - match chunk.as_ref() { - AudioChunk::I16(data) => { - let frames = data.get_frames(); - assert_eq!(frames.len(), 2); - assert_eq!(frames[0], [100, 200]); - assert_eq!(frames[1], [300, 400]); - assert_eq!(data.get_sample_rate(), 44100); - } - _ => panic!("Expected I16 chunk"), + pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() { + AudioChunk::I16(data) => { + let frames = data.get_frames(); + assert_eq!(frames.len(), 2); + assert_eq!(frames[0], [100, 200]); + assert_eq!(frames[1], [300, 400]); + assert_eq!(data.get_sample_rate(), 44100); } - } + _ => panic!("Expected I16 chunk"), + }, _ => panic!("Expected audio chunk"), } } @@ -753,7 +898,7 @@ mod tests { // Create mock PCM data (2 frames, mono, 16-bit) let chunk_bytes = vec![ 100u8, 0, // Frame 1 - 200, 0, // Frame 2 + 200, 0, // Frame 2 ]; let info = StreamInfo { @@ -808,17 +953,15 @@ mod tests { let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap(); match &segment.segment { - pmoaudio::_AudioSegment::Chunk(chunk) => { - match chunk.as_ref() { - AudioChunk::I24(data) => { - let frames = data.get_frames(); - assert_eq!(frames.len(), 1); - assert_eq!(frames[0][0].as_i32(), 1000); - assert_eq!(frames[0][1].as_i32(), -1000); - } - _ => panic!("Expected I24 chunk"), + pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() { + AudioChunk::I24(data) => { + let frames = data.get_frames(); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0][0].as_i32(), 1000); + assert_eq!(frames[0][1].as_i32(), -1000); } - } + _ => panic!("Expected I24 chunk"), + }, _ => panic!("Expected audio chunk"), } } @@ -843,16 +986,14 @@ mod tests { let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap(); match &segment.segment { - pmoaudio::_AudioSegment::Chunk(chunk) => { - match chunk.as_ref() { - AudioChunk::I32(data) => { - let frames = data.get_frames(); - assert_eq!(frames.len(), 1); - assert_eq!(frames[0], [4096, 8192]); - } - _ => panic!("Expected I32 chunk"), + pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() { + AudioChunk::I32(data) => { + let frames = data.get_frames(); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0], [4096, 8192]); } - } + _ => panic!("Expected I32 chunk"), + }, _ => panic!("Expected audio chunk"), } } diff --git a/pmoaudio/Cargo.toml b/pmoaudio/Cargo.toml index 7d569e02..720d58ce 100755 --- a/pmoaudio/Cargo.toml +++ b/pmoaudio/Cargo.toml @@ -20,6 +20,7 @@ bytemuck = "1.24.0" reqwest = { version = "0.12", features = ["stream"] } tracing = "0.1" cpal = "0.15" +once_cell = "1.20" [dev-dependencies] tokio-test = "0.4" diff --git a/pmoaudio/examples/check_flac_bits.rs b/pmoaudio/examples/check_flac_bits.rs index af56a809..675a91ae 100755 --- a/pmoaudio/examples/check_flac_bits.rs +++ b/pmoaudio/examples/check_flac_bits.rs @@ -6,7 +6,9 @@ use tokio::fs::File; #[tokio::main] async fn main() -> Result<(), Box> { - let path_str = std::env::args().nth(1).expect("Usage: check_flac_bits "); + let path_str = std::env::args() + .nth(1) + .expect("Usage: check_flac_bits "); let path = Path::new(&path_str); println!("Checking: {}", path.display()); diff --git a/pmoaudio/examples/convert_to_flac24.rs b/pmoaudio/examples/convert_to_flac24.rs index 97e563cd..b27b7bb0 100755 --- a/pmoaudio/examples/convert_to_flac24.rs +++ b/pmoaudio/examples/convert_to_flac24.rs @@ -88,7 +88,10 @@ async fn main() -> Result<(), Box> { match result { Ok(()) => { println!(); - println!("✓ Conversion completed successfully in {:.2}s", elapsed.as_secs_f64()); + println!( + "✓ Conversion completed successfully in {:.2}s", + elapsed.as_secs_f64() + ); println!(" Output file: {}", output_path); println!(); diff --git a/pmoaudio/examples/play_audio.rs b/pmoaudio/examples/play_audio.rs index 3a1cfa70..07f2bfbf 100644 --- a/pmoaudio/examples/play_audio.rs +++ b/pmoaudio/examples/play_audio.rs @@ -28,7 +28,7 @@ async fn main() -> Result<(), Box> { println!("Lecture de: {}", file_path); // Créer la source audio (lit le fichier FLAC) - let mut source = FileSource::new(file_path).await?; + let mut source = FileSource::new(file_path); // Créer le sink audio (joue sur la sortie audio) let sink = AudioSink::new(); @@ -45,7 +45,9 @@ async fn main() -> Result<(), Box> { // Gérer Ctrl+C pour arrêt propre tokio::spawn(async move { - tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C"); + tokio::signal::ctrl_c() + .await + .expect("Failed to listen for Ctrl+C"); println!("\nArrêt demandé..."); stop_token_clone.cancel(); }); diff --git a/pmoaudio/examples/play_with_resampling.rs b/pmoaudio/examples/play_with_resampling.rs index b23fa792..2b856672 100644 --- a/pmoaudio/examples/play_with_resampling.rs +++ b/pmoaudio/examples/play_with_resampling.rs @@ -52,8 +52,10 @@ async fn main() -> Result<(), Box> { resampler.register(Box::new(converter)); converter.register(Box::new(sink)); - println!("Pipeline créé: FileSource → Resampling({} Hz) → ToI24 → AudioSink", - target_sample_rate); + println!( + "Pipeline créé: FileSource → Resampling({} Hz) → ToI24 → AudioSink", + target_sample_rate + ); println!("Démarrage de la lecture..."); println!("Appuyez sur Ctrl+C pour arrêter"); @@ -63,7 +65,9 @@ async fn main() -> Result<(), Box> { // Gérer Ctrl+C tokio::spawn(async move { - tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C"); + tokio::signal::ctrl_c() + .await + .expect("Failed to listen for Ctrl+C"); println!("\nArrêt demandé..."); stop_token_clone.cancel(); }); diff --git a/pmoaudio/src/audio_chunk.rs b/pmoaudio/src/audio_chunk.rs index 88dfe268..941a9f04 100755 --- a/pmoaudio/src/audio_chunk.rs +++ b/pmoaudio/src/audio_chunk.rs @@ -678,9 +678,11 @@ impl AudioIntegerChunk { AudioIntegerChunk::I16(d) => { Box::new(d.get_frames().iter().map(|f| [f[0] as i32, f[1] as i32])) } - AudioIntegerChunk::I24(d) => { - Box::new(d.get_frames().iter().map(|f| [f[0].as_i32(), f[1].as_i32()])) - } + AudioIntegerChunk::I24(d) => Box::new( + d.get_frames() + .iter() + .map(|f| [f[0].as_i32(), f[1].as_i32()]), + ), AudioIntegerChunk::I32(d) => Box::new(d.get_frames().iter().map(|f| [f[0], f[1]])), } } diff --git a/pmoaudio/src/dsp/int_float.rs b/pmoaudio/src/dsp/int_float.rs index 15a6ccee..3bcfc355 100755 --- a/pmoaudio/src/dsp/int_float.rs +++ b/pmoaudio/src/dsp/int_float.rs @@ -247,11 +247,7 @@ fn i16_stereo_to_pairs_f32_inner( } /// Convertit deux canaux i16 (L/R) en pairs f32 normalisées [-1.0, 1.0] -pub fn i16_stereo_to_pairs_f32( - left: &[i16], - right: &[i16], - out_pairs: &mut [[f32; 2]], -) { +pub fn i16_stereo_to_pairs_f32(left: &[i16], right: &[i16], out_pairs: &mut [[f32; 2]]) { i16_stereo_to_pairs_f32_inner(left, right, out_pairs, 32768.0); } @@ -332,11 +328,7 @@ fn pairs_f32_to_i16_stereo_inner( } /// Convertit pairs f32 normalisées [-1.0, 1.0] en deux canaux i16 (L/R) -pub fn pairs_f32_to_i16_stereo( - input_pairs: &[[f32; 2]], - left: &mut [i16], - right: &mut [i16], -) { +pub fn pairs_f32_to_i16_stereo(input_pairs: &[[f32; 2]], left: &mut [i16], right: &mut [i16]) { pairs_f32_to_i16_stereo_inner(input_pairs, left, right, 32768.0); } @@ -399,11 +391,7 @@ fn i24_as_i32_stereo_to_pairs_f32_inner( } /// Convertit deux canaux i32 (contenant des valeurs I24) en pairs f32 normalisées -pub fn i24_as_i32_stereo_to_pairs_f32( - left: &[i32], - right: &[i32], - out_pairs: &mut [[f32; 2]], -) { +pub fn i24_as_i32_stereo_to_pairs_f32(left: &[i32], right: &[i32], out_pairs: &mut [[f32; 2]]) { i24_as_i32_stereo_to_pairs_f32_inner(left, right, out_pairs, 8388608.0); } diff --git a/pmoaudio/src/lib.rs b/pmoaudio/src/lib.rs index dd3c9a6c..838d94a4 100755 --- a/pmoaudio/src/lib.rs +++ b/pmoaudio/src/lib.rs @@ -124,6 +124,7 @@ pub use nodes::{ flac_file_sink::{FlacFileSink, FlacFileSinkStats}, http_source::HttpSource, resampling_node::ResamplingNode, + timer_buffer_node::TimerBufferNode, timer_node::TimerNode, AudioError, AudioNode, TypedAudioNode, }; diff --git a/pmoaudio/src/nodes/audio_sink.rs b/pmoaudio/src/nodes/audio_sink.rs index e71798d1..4ba91f19 100644 --- a/pmoaudio/src/nodes/audio_sink.rs +++ b/pmoaudio/src/nodes/audio_sink.rs @@ -220,20 +220,18 @@ impl AudioSinkLogic { chunk.sample_rate() ); } - crate::_AudioSegment::Sync(marker) => { - match **marker { - SyncMarker::TrackBoundary { .. } => { - tracing::debug!("AudioSink (null): TrackBoundary received"); - } - SyncMarker::EndOfStream => { - tracing::debug!("AudioSink (null): EndOfStream received"); - return Ok(()); - } - _ => { - tracing::trace!("AudioSink (null): sync marker"); - } + crate::_AudioSegment::Sync(marker) => match **marker { + SyncMarker::TrackBoundary { .. } => { + tracing::debug!("AudioSink (null): TrackBoundary received"); } - } + SyncMarker::EndOfStream => { + tracing::debug!("AudioSink (null): EndOfStream received"); + return Ok(()); + } + _ => { + tracing::trace!("AudioSink (null): sync marker"); + } + }, } } } @@ -273,12 +271,15 @@ impl NodeLogic for AudioSinkLogic { .default_output_device() .ok_or_else(|| AudioError::ProcessingError("No output device available".to_string()))?; - tracing::debug!("Using audio device: {}", device.name().unwrap_or_else(|_| "Unknown".to_string())); + tracing::debug!( + "Using audio device: {}", + device.name().unwrap_or_else(|_| "Unknown".to_string()) + ); // Obtenir la config par défaut - let config = device - .default_output_config() - .map_err(|e| AudioError::ProcessingError(format!("Failed to get output config: {}", e)))?; + let config = device.default_output_config().map_err(|e| { + AudioError::ProcessingError(format!("Failed to get output config: {}", e)) + })?; let sample_format = config.sample_format(); let sample_rate = config.sample_rate().0; @@ -298,9 +299,9 @@ impl NodeLogic for AudioSinkLogic { let stream_thread = thread::spawn(move || { // Créer le stream selon le format hardware let stream = match sample_format { - cpal::SampleFormat::I16 => { - tracing::debug!("Using I16 output format"); - match device.build_output_stream( + cpal::SampleFormat::I16 => { + tracing::debug!("Using I16 output format"); + match device.build_output_stream( &config.into(), move |data: &mut [i16], _: &cpal::OutputCallbackInfo| { let mut buf = buffer_clone.lock().unwrap(); @@ -323,10 +324,10 @@ impl NodeLogic for AudioSinkLogic { return; } } - } - cpal::SampleFormat::U16 => { - tracing::debug!("Using U16 output format"); - match device.build_output_stream( + } + cpal::SampleFormat::U16 => { + tracing::debug!("Using U16 output format"); + match device.build_output_stream( &config.into(), move |data: &mut [u16], _: &cpal::OutputCallbackInfo| { let mut buf = buffer_clone.lock().unwrap(); @@ -348,10 +349,10 @@ impl NodeLogic for AudioSinkLogic { return; } } - } - cpal::SampleFormat::F32 => { - tracing::debug!("Using F32 output format"); - match device.build_output_stream( + } + cpal::SampleFormat::F32 => { + tracing::debug!("Using F32 output format"); + match device.build_output_stream( &config.into(), move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { let mut buf = buffer_clone.lock().unwrap(); @@ -371,10 +372,10 @@ impl NodeLogic for AudioSinkLogic { return; } } - } - _ => { - tracing::error!("Unsupported sample format: {:?}", sample_format); - return; + } + _ => { + tracing::error!("Unsupported sample format: {:?}", sample_format); + return; } }; @@ -467,7 +468,9 @@ impl NodeLogic for AudioSinkLogic { // Le buffer continue automatiquement - pas besoin d'action } SyncMarker::EndOfStream => { - tracing::debug!("AudioSink: EndOfStream received, waiting for playback to finish"); + tracing::debug!( + "AudioSink: EndOfStream received, waiting for playback to finish" + ); // Marquer la fin et attendre que le buffer se vide buffer.lock().unwrap().mark_end(); @@ -576,10 +579,7 @@ impl AudioPipelineNode for AudioSink { panic!("AudioSink is a terminal node and cannot have children"); } - async fn run( - self: Box, - stop_token: CancellationToken, - ) -> Result<(), AudioError> { + async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { Box::new(self.inner).run(stop_token).await } } diff --git a/pmoaudio/src/nodes/converter_nodes.rs b/pmoaudio/src/nodes/converter_nodes.rs index 402feefe..952cf3ae 100755 --- a/pmoaudio/src/nodes/converter_nodes.rs +++ b/pmoaudio/src/nodes/converter_nodes.rs @@ -15,7 +15,7 @@ use crate::{ nodes::AudioError, - pipeline::{Node, NodeLogic}, + pipeline::{send_to_children, Node, NodeLogic}, AudioChunk, AudioPipelineNode, AudioSegment, }; use std::sync::Arc; @@ -100,12 +100,7 @@ where segment }; - // Envoyer à tous les enfants - for tx in &output { - tx.send(output_segment.clone()) - .await - .map_err(|_| AudioError::ChildDied)?; - } + send_to_children(std::any::type_name::(), &output, output_segment).await?; } Ok(()) diff --git a/pmoaudio/src/nodes/file_source.rs b/pmoaudio/src/nodes/file_source.rs index 84cc4616..ccd7bf07 100755 --- a/pmoaudio/src/nodes/file_source.rs +++ b/pmoaudio/src/nodes/file_source.rs @@ -1,6 +1,6 @@ use crate::{ nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS}, - pipeline::{AudioPipelineNode, Node, NodeLogic}, + pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic}, type_constraints::TypeRequirement, AudioChunk, AudioChunkData, AudioSegment, I24, }; @@ -41,23 +41,16 @@ impl NodeLogic for FileSourceLogic { output: Vec>>, stop_token: CancellationToken, ) -> Result<(), AudioError> { - tracing::debug!("FileSourceLogic::process started, path={:?}, {} children", self.path, output.len()); - - // Macro helper pour envoyer à tous les enfants - macro_rules! send_to_children { - ($segment:expr) => { - for tx in &output { - tx.send($segment.clone()) - .await - .map_err(|_| AudioError::ChildDied)?; - } - }; - } + tracing::debug!( + "FileSourceLogic::process started, path={:?}, {} children", + self.path, + output.len() + ); // Ouvrir le fichier - let file = File::open(&self.path).await.map_err(|e| { - AudioError::IoError(format!("Failed to open {:?}: {}", self.path, e)) - })?; + let file = File::open(&self.path) + .await + .map_err(|e| AudioError::IoError(format!("Failed to open {:?}: {}", self.path, e)))?; // Décoder le flux audio let mut stream = decode_audio_stream(file) @@ -78,7 +71,7 @@ impl NodeLogic for FileSourceLogic { // Émettre TopZeroSync let top_zero = AudioSegment::new_top_zero_sync(); - send_to_children!(top_zero); + send_to_children(std::any::type_name::(), &output, top_zero).await?; // Extraire et émettre les métadonnées du fichier if let Ok(file_metadata) = AudioFileMetadata::from_file(&self.path) { @@ -106,7 +99,7 @@ impl NodeLogic for FileSourceLogic { 0.0, Arc::new(tokio::sync::RwLock::new(metadata)), ); - send_to_children!(track_boundary); + send_to_children(std::any::type_name::(), &output, track_boundary).await?; } // Préparer la lecture des chunks audio @@ -164,7 +157,7 @@ impl NodeLogic for FileSourceLogic { timestamp_sec, )?; - send_to_children!(segment); + send_to_children(std::any::type_name::(), &output, segment).await?; chunk_index += 1; total_frames += frames_to_emit as u64; @@ -179,7 +172,7 @@ impl NodeLogic for FileSourceLogic { let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64; let segment = bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?; - send_to_children!(segment); + send_to_children(std::any::type_name::(), &output, segment).await?; total_frames += frames as u64; chunk_index += 1; } @@ -188,7 +181,7 @@ impl NodeLogic for FileSourceLogic { // Émettre EndOfStream let final_timestamp = total_frames as f64 / stream_info.sample_rate as f64; let eos = AudioSegment::new_end_of_stream(chunk_index, final_timestamp); - send_to_children!(eos); + send_to_children(std::any::type_name::(), &output, eos).await?; // Attendre la fin du décodage stream @@ -256,10 +249,7 @@ impl AudioPipelineNode for FileSource { self.inner.register(child) } - async fn run( - self: Box, - stop_token: CancellationToken, - ) -> Result<(), AudioError> { + async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { Box::new(self.inner).run(stop_token).await } } @@ -399,7 +389,6 @@ fn bytes_to_segment( })) } - impl TypedAudioNode for FileSource { fn input_type(&self) -> Option { // FileSource est une source, elle ne consomme pas d'audio @@ -464,11 +453,7 @@ mod tests { impl TestCollectorNode { fn new(test_tx: mpsc::Sender>) -> Self { let (tx, rx) = mpsc::channel(16); - Self { - tx, - rx, - test_tx, - } + Self { tx, rx, test_tx } } } diff --git a/pmoaudio/src/nodes/flac_file_sink.rs b/pmoaudio/src/nodes/flac_file_sink.rs index 0d7783ec..8b22d8d3 100755 --- a/pmoaudio/src/nodes/flac_file_sink.rs +++ b/pmoaudio/src/nodes/flac_file_sink.rs @@ -71,7 +71,10 @@ impl NodeLogic for FlacFileSinkLogic { let mut rx = input.expect("FlacFileSink must have input"); let mut track_number = 0; - tracing::debug!("FlacFileSinkLogic::process started, base_path={:?}", self.base_path); + tracing::debug!( + "FlacFileSinkLogic::process started, base_path={:?}", + self.base_path + ); loop { // Vérifier si l'arrêt a été demandé @@ -81,13 +84,14 @@ impl NodeLogic for FlacFileSinkLogic { } // Attendre le premier chunk audio pour cette track, en capturant les métadonnées du TrackBoundary - let (first_segment, track_metadata) = match wait_for_first_audio_chunk_with_metadata(&mut rx, &stop_token).await { - Ok(result) => result, - Err(_) => { - // Plus d'audio disponible ou arrêt demandé - return Ok(()); - } - }; + let (first_segment, track_metadata) = + match wait_for_first_audio_chunk_with_metadata(&mut rx, &stop_token).await { + Ok(result) => result, + Err(_) => { + // Plus d'audio disponible ou arrêt demandé + return Ok(()); + } + }; // Extraire les informations du premier chunk let first_chunk = first_segment.as_chunk().unwrap(); @@ -96,7 +100,9 @@ impl NodeLogic for FlacFileSinkLogic { tracing::debug!( "FlacFileSinkLogic: encoding track {} with {}bit @ {}Hz", - track_number, bits_per_sample, sample_rate + track_number, + bits_per_sample, + sample_rate ); let format = PcmFormat { @@ -308,10 +314,7 @@ impl FlacFileSink { /// /// * `base_path` - Chemin de base pour les fichiers FLAC /// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente avant backpressure) - pub fn with_channel_size>( - base_path: P, - channel_size: usize, - ) -> Self { + pub fn with_channel_size>(base_path: P, channel_size: usize) -> Self { Self::with_config(base_path, channel_size, EncoderOptions::default()) } @@ -360,7 +363,13 @@ fn generate_track_path(base_path: &Path, track_number: usize) -> PathBuf { async fn wait_for_first_audio_chunk_with_metadata( rx: &mut mpsc::Receiver>, stop_token: &CancellationToken, -) -> Result<(Arc, Option>>), AudioError> { +) -> Result< + ( + Arc, + Option>>, + ), + AudioError, +> { let mut track_metadata: Option>> = None; loop { @@ -738,10 +747,7 @@ impl AudioPipelineNode for FlacFileSink { panic!("FlacFileSink is a terminal node and cannot have children"); } - async fn run( - self: Box, - stop_token: CancellationToken, - ) -> Result<(), AudioError> { + async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { Box::new(self.inner).run(stop_token).await } } @@ -768,7 +774,6 @@ mod tests { #[tokio::test] async fn test_flac_file_sink_writes_metadata() { - let temp_dir = tempfile::tempdir().unwrap(); let output_path = temp_dir.path().join("output_with_metadata.flac"); @@ -779,9 +784,8 @@ mod tests { let sink = FlacFileSink::with_channel_size(&output_path, 16); let tx = sink.get_tx().unwrap(); let stop_token = CancellationToken::new(); - let sink_handle = tokio::spawn(async move { - Box::new(sink).run(stop_token).await.unwrap() - }); + let sink_handle = + tokio::spawn(async move { Box::new(sink).run(stop_token).await.unwrap() }); // Envoyer des segments avec métadonnées tokio::spawn(async move { @@ -792,13 +796,25 @@ mod tests { // TrackBoundary avec métadonnées let mut metadata = MemoryTrackMetadata::new(); - metadata.set_title(Some("Test Track Title".to_string())).await.unwrap(); - metadata.set_artist(Some("Test Artist".to_string())).await.unwrap(); - metadata.set_album(Some("Test Album".to_string())).await.unwrap(); + metadata + .set_title(Some("Test Track Title".to_string())) + .await + .unwrap(); + metadata + .set_artist(Some("Test Artist".to_string())) + .await + .unwrap(); + metadata + .set_album(Some("Test Album".to_string())) + .await + .unwrap(); metadata.set_year(Some(2024)).await.unwrap(); - let track_boundary = - crate::AudioSegment::new_track_boundary(0, 0.0, std::sync::Arc::new(tokio::sync::RwLock::new(metadata))); + let track_boundary = crate::AudioSegment::new_track_boundary( + 0, + 0.0, + std::sync::Arc::new(tokio::sync::RwLock::new(metadata)), + ); tx.send(track_boundary).await.unwrap(); // Générer et envoyer des chunks audio @@ -900,9 +916,8 @@ mod tests { let sink = FlacFileSink::with_channel_size(&output_path, 16); let tx = sink.get_tx().unwrap(); let stop_token = CancellationToken::new(); - let sink_handle = tokio::spawn(async move { - Box::new(sink).run(stop_token).await.unwrap() - }); + let sink_handle = + tokio::spawn(async move { Box::new(sink).run(stop_token).await.unwrap() }); // Lire le fichier input et envoyer les segments au sink tokio::spawn(async move { diff --git a/pmoaudio/src/nodes/http_source.rs b/pmoaudio/src/nodes/http_source.rs index dec9ffd1..b6718336 100755 --- a/pmoaudio/src/nodes/http_source.rs +++ b/pmoaudio/src/nodes/http_source.rs @@ -1,6 +1,6 @@ use crate::{ nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS}, - pipeline::{Node, NodeLogic}, + pipeline::{send_to_children, Node, NodeLogic}, type_constraints::TypeRequirement, AudioChunk, AudioChunkData, AudioPipelineNode, AudioSegment, I24, }; @@ -114,7 +114,7 @@ impl HttpSourceLogic { self.url.clone() } - pub fn get_chunc_frames(&self) -> usize { + pub fn get_chunc_frames(&self) -> usize { self.chunk_frames } } @@ -127,22 +127,10 @@ impl NodeLogic for HttpSourceLogic { output: Vec>>, stop_token: CancellationToken, ) -> Result<(), AudioError> { - macro_rules! send_to_children { - ($segment:expr) => { - for tx in &output { - tx.send($segment.clone()) - .await - .map_err(|_| AudioError::ChildDied)?; - } - }; - } - // Effectuer la requête HTTP - let response = reqwest::get(&self.url) - .await - .map_err(|e| { - AudioError::ProcessingError(format!("HTTP request failed for {}: {}", self.url, e)) - })?; + let response = reqwest::get(&self.url).await.map_err(|e| { + AudioError::ProcessingError(format!("HTTP request failed for {}: {}", self.url, e)) + })?; // Vérifier le status if !response.status().is_success() { @@ -158,9 +146,10 @@ impl NodeLogic for HttpSourceLogic { // Convertir le stream de bytes en AsyncRead let bytes_stream = response.bytes_stream(); - let stream_reader = StreamReader::new(bytes_stream.map(|result| { - result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) - })); + let stream_reader = + StreamReader::new(bytes_stream.map(|result| { + result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) + })); // Décoder le flux audio let mut stream = decode_audio_stream(stream_reader) @@ -180,15 +169,17 @@ impl NodeLogic for HttpSourceLogic { }; // Émettre TopZeroSync - send_to_children!(AudioSegment::new_top_zero_sync()); + send_to_children( + std::any::type_name::(), + &output, + AudioSegment::new_top_zero_sync(), + ) + .await?; // Émettre TrackBoundary avec les métadonnées HTTP - let track_boundary = AudioSegment::new_track_boundary( - 0, - 0.0, - Arc::new(tokio::sync::RwLock::new(metadata)), - ); - send_to_children!(track_boundary); + let track_boundary = + AudioSegment::new_track_boundary(0, 0.0, Arc::new(tokio::sync::RwLock::new(metadata))); + send_to_children(std::any::type_name::(), &output, track_boundary).await?; // Préparer la lecture des chunks audio let frame_bytes = stream_info.bytes_per_sample() * stream_info.channels as usize; @@ -246,7 +237,7 @@ impl NodeLogic for HttpSourceLogic { timestamp_sec, )?; - send_to_children!(segment); + send_to_children(std::any::type_name::(), &output, segment).await?; chunk_index += 1; total_frames += frames_to_emit as u64; @@ -259,7 +250,7 @@ impl NodeLogic for HttpSourceLogic { let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64; let segment = bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?; - send_to_children!(segment); + send_to_children(std::any::type_name::(), &output, segment).await?; total_frames += frames as u64; chunk_index += 1; } @@ -268,7 +259,7 @@ impl NodeLogic for HttpSourceLogic { // Émettre EndOfStream let final_timestamp = total_frames as f64 / stream_info.sample_rate as f64; let eos = AudioSegment::new_end_of_stream(chunk_index, final_timestamp); - send_to_children!(eos); + send_to_children(std::any::type_name::(), &output, eos).await?; // Attendre la fin du décodage stream @@ -523,10 +514,7 @@ impl AudioPipelineNode for HttpSource { self.inner.register(child) } - async fn run( - self: Box, - stop_token: CancellationToken, - ) -> Result<(), AudioError> { + async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { Box::new(self.inner).run(stop_token).await } } @@ -698,7 +686,10 @@ mod tests { } // Vérifications - assert_eq!(received_frames, frames, "Tous les frames doivent être reçus"); + assert_eq!( + received_frames, frames, + "Tous les frames doivent être reçus" + ); assert!(seen_top_zero, "TopZeroSync doit être émis"); assert!(seen_track_boundary, "TrackBoundary doit être émis"); assert!(seen_eos, "EndOfStream doit être émis"); @@ -725,9 +716,10 @@ mod tests { bits_per_sample: 16, }; - let mut flac_stream = encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default()) - .await - .unwrap(); + let mut flac_stream = + encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default()) + .await + .unwrap(); let mut flac_data = Vec::new(); tokio::io::copy(&mut flac_stream, &mut flac_data) @@ -798,7 +790,10 @@ mod tests { assert!(result.is_err(), "Doit retourner une erreur pour HTTP 404"); if let Err(AudioError::ProcessingError(msg)) = result { - assert!(msg.contains("404"), "Le message d'erreur doit mentionner le code 404"); + assert!( + msg.contains("404"), + "Le message d'erreur doit mentionner le code 404" + ); } else { panic!("Le type d'erreur doit être ProcessingError"); } @@ -854,9 +849,10 @@ mod tests { bits_per_sample: 16, }; - let mut flac_stream = encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default()) - .await - .unwrap(); + let mut flac_stream = + encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default()) + .await + .unwrap(); let mut flac_data = Vec::new(); tokio::io::copy(&mut flac_stream, &mut flac_data) @@ -898,6 +894,9 @@ mod tests { } } - assert!(found_title, "Le nom du fichier doit être utilisé comme titre"); + assert!( + found_title, + "Le nom du fichier doit être utilisé comme titre" + ); } } diff --git a/pmoaudio/src/nodes/mod.rs b/pmoaudio/src/nodes/mod.rs index 877073bf..ddb694cf 100755 --- a/pmoaudio/src/nodes/mod.rs +++ b/pmoaudio/src/nodes/mod.rs @@ -25,6 +25,7 @@ pub mod file_source; pub mod flac_file_sink; pub mod http_source; pub mod resampling_node; +pub mod timer_buffer_node; pub mod timer_node; // Modules temporairement désactivés diff --git a/pmoaudio/src/nodes/resampling_node.rs b/pmoaudio/src/nodes/resampling_node.rs index cc2723cf..7cbac24b 100644 --- a/pmoaudio/src/nodes/resampling_node.rs +++ b/pmoaudio/src/nodes/resampling_node.rs @@ -31,7 +31,7 @@ use crate::{ dsp::resampling::{build_resampler, resampling, Resampler}, nodes::{AudioError, TypedAudioNode}, - pipeline::{AudioPipelineNode, Node, NodeLogic}, + pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic}, type_constraints::TypeRequirement, AudioChunk, AudioChunkData, AudioSegment, BitDepth, I24, }; @@ -95,7 +95,9 @@ impl ResamplingLogic { bit_depth ); let resampler = build_resampler(source_sr, self.target_sample_rate, bit_depth) - .map_err(|e| AudioError::ProcessingError(format!("Resampler init failed: {}", e)))?; + .map_err(|e| { + AudioError::ProcessingError(format!("Resampler init failed: {}", e)) + })?; self.current_resampler = Some(ResamplerState { source_hz: source_sr, resampler, @@ -111,7 +113,12 @@ impl ResamplingLogic { let (resampled_left, resampled_right) = resampling(&left, &right, &mut state.resampler); // Recréer le chunk avec le nouveau sample rate - reconstruct_chunk(chunk, resampled_left, resampled_right, self.target_sample_rate) + reconstruct_chunk( + chunk, + resampled_left, + resampled_right, + self.target_sample_rate, + ) } } @@ -165,12 +172,7 @@ impl NodeLogic for ResamplingLogic { segment }; - // Envoyer à tous les enfants - for tx in &output { - tx.send(output_segment.clone()) - .await - .map_err(|_| AudioError::ChildDied)?; - } + send_to_children(std::any::type_name::(), &output, output_segment).await?; } Ok(()) @@ -360,10 +362,7 @@ impl AudioPipelineNode for ResamplingNode { self.inner.register(child) } - async fn run( - self: Box, - stop_token: CancellationToken, - ) -> Result<(), AudioError> { + async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { Box::new(self.inner).run(stop_token).await } } @@ -418,11 +417,7 @@ mod tests { #[test] fn test_reconstruct_chunk_i16() { - let original = AudioChunk::I16(AudioChunkData::new( - vec![[100, 200]], - 44100, - 0.0, - )); + let original = AudioChunk::I16(AudioChunkData::new(vec![[100, 200]], 44100, 0.0)); let left = vec![100i32, 300i32]; let right = vec![200i32, 400i32]; @@ -495,7 +490,7 @@ mod tests { // Créer un TrackBoundary let metadata = Arc::new(tokio::sync::RwLock::new( - pmometadata::MemoryTrackMetadata::new() + pmometadata::MemoryTrackMetadata::new(), )); let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata); @@ -568,7 +563,11 @@ mod tests { // 100 frames @ 44.1kHz ≈ 109 frames @ 48kHz if let AudioChunk::I16(data) = chunk.as_ref() { let frames = data.get_frames().len(); - assert!(frames >= 105 && frames <= 115, "Expected ~109 frames, got {}", frames); + assert!( + frames >= 105 && frames <= 115, + "Expected ~109 frames, got {}", + frames + ); } } else { panic!("Expected audio chunk"); diff --git a/pmoaudio/src/nodes/timer_buffer_node.rs b/pmoaudio/src/nodes/timer_buffer_node.rs new file mode 100644 index 00000000..f35b4c1b --- /dev/null +++ b/pmoaudio/src/nodes/timer_buffer_node.rs @@ -0,0 +1,350 @@ +//! TimerBufferNode - Maintient un tampon temporel capacitif avant diffusion +//! +//! Ce node implémente un buffer capacitif qui accumule un temps configurable +//! de données audio avant de les diffuser. Une fois le buffer rempli, il +//! maintient ce niveau en diffusant les données au même rythme qu'elles arrivent. +//! +//! # Use Cases +//! +//! - **Buffering initial**: Accumule N secondes de données avant de commencer la lecture +//! - **Smoothing**: Absorbe les variations de débit entre source et sink +//! - **Streaming**: Pré-charge un buffer pour éviter les coupures +//! +//! # Exemple +//! +//! ```no_run +//! use pmoaudio::{HttpSource, TimerBufferNode, AudioSink}; +//! +//! let mut source = HttpSource::new(url); +//! let mut buffer = TimerBufferNode::new(3.0); // Buffer 3s avant de commencer +//! let mut sink = AudioSink::new(); +//! +//! source.register(Box::new(buffer)); +//! buffer.register(Box::new(sink)); +//! ``` +//! +//! # Architecture +//! +//! ```text +//! HttpSource → TimerBufferNode → AudioSink +//! ↓ ↓ ↓ +//! Flux réseau Buffer 3s Lecture stable +//! variable capacitif sans coupures +//! ``` +//! +//! Le TimerBufferNode: +//! 1. Accumule les chunks dans un buffer jusqu'à atteindre `capacity_sec` +//! 2. Une fois plein, diffuse les chunks en mode FIFO +//! 3. Maintient un niveau constant d'environ `capacity_sec` secondes +//! +//! # Markers Supportés +//! +//! - **TopZeroSync**: Vide le buffer et reset le compteur +//! - **TrackBoundary**: Passthrough transparent +//! - **Heartbeat**: Passthrough transparent +//! - **EndOfStream**: Flush le buffer restant avant propagation +//! +//! # Performance +//! +//! - **CPU**: Minimal (VecDeque efficace) +//! - **Latency**: Ajoute `capacity_sec` de buffering initial +//! - **Memory**: Proportionnel à `capacity_sec` (ex: ~3MB pour 3s @ 48kHz stéréo) + +use crate::{ + nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE, DEFAULT_CHUNK_DURATION_MS}, + pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic}, + type_constraints::TypeRequirement, + AudioSegment, SyncMarker, _AudioSegment, +}; +use std::{collections::VecDeque, sync::Arc}; +use tokio::sync::mpsc; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; + +// ═══════════════════════════════════════════════════════════════════════════ +// TimerBufferNodeLogic - Logique pure de buffering capacitif +// ═══════════════════════════════════════════════════════════════════════════ + +/// Logique pure de buffering temporel capacitif +/// +/// Maintient un buffer de taille fixe (en secondes) et diffuse les segments +/// en mode FIFO une fois le buffer rempli. +pub struct TimerBufferNodeLogic { + /// Capacité du buffer en secondes + capacity_sec: f64, + /// Temps actuellement bufferisé en secondes + buffered_time_sec: f64, + /// Durée par défaut d'un chunk (fallback) + default_chunk_duration_sec: f64, + /// Timestamp du chunk précédent (pour estimer les durées) + prev_input_ts: Option, + /// Buffer FIFO de segments avec leurs durées + buffer: VecDeque<(Arc, f64)>, + /// Nombre de chunks traités (pour instrumentation) + chunk_count: u64, + /// Nombre de chunks flushés (pour instrumentation) + flush_count: u64, + /// Dernier log d'instrumentation + last_stats_log: Option, +} + +impl TimerBufferNodeLogic { + pub fn new(capacity_sec: f64) -> Self { + Self { + capacity_sec: capacity_sec.max(0.0), + buffered_time_sec: 0.0, + default_chunk_duration_sec: DEFAULT_CHUNK_DURATION_MS / 1000.0, + prev_input_ts: None, + buffer: VecDeque::new(), + chunk_count: 0, + flush_count: 0, + last_stats_log: None, + } + } + + /// Estime la durée d'un chunk basé sur le delta de timestamps + fn estimate_duration(&mut self, ts: f64) -> f64 { + if let Some(prev) = self.prev_input_ts { + let delta = (ts - prev).clamp(0.0, 10.0); + self.prev_input_ts = Some(ts); + if delta == 0.0 { + self.default_chunk_duration_sec + } else { + delta + } + } else { + self.prev_input_ts = Some(ts); + self.default_chunk_duration_sec + } + } + + /// Flush un segment du buffer vers les outputs + async fn flush_one( + &mut self, + output: &[mpsc::Sender>], + ) -> Result<(), AudioError> { + if let Some((segment, duration)) = self.buffer.pop_front() { + self.flush_count += 1; + self.buffered_time_sec = (self.buffered_time_sec - duration).max(0.0); + + tracing::trace!( + "TimerBufferNode: flushing segment (ts={:.3}s, duration={:.3}s, remaining={:.3}s, {} items in buffer)", + segment.timestamp_sec, + duration, + self.buffered_time_sec, + self.buffer.len() + ); + + send_to_children(std::any::type_name::(), output, segment).await?; + } + Ok(()) + } + + fn maybe_log_stats(&mut self) { + let now = Instant::now(); + let should_log = match self.last_stats_log { + None => true, + Some(last) => now.duration_since(last).as_secs() >= 1, + }; + + if should_log { + self.last_stats_log = Some(now); + tracing::debug!( + "TimerBufferNode stats: chunks_received={} chunks_flushed={} buffered={:.3}s capacity={:.3}s buffer_items={}", + self.chunk_count, + self.flush_count, + self.buffered_time_sec, + self.capacity_sec, + self.buffer.len() + ); + } + } +} + +#[async_trait::async_trait] +impl NodeLogic for TimerBufferNodeLogic { + async fn process( + &mut self, + input: Option>>, + output: Vec>>, + stop_token: CancellationToken, + ) -> Result<(), AudioError> { + let mut rx = input.expect("TimerBufferNode must have input"); + tracing::info!( + "TimerBufferNodeLogic::process started (capacity={:.1}s), {} children", + self.capacity_sec, + output.len() + ); + + loop { + // ╔═══════════════════════════════════════════════════════════════╗ + // ║ LOGIQUE CAPACITIVE PAR BACKPRESSURE NATURELLE ║ + // ║ ║ + // ║ Si le buffer >= capacity, on flush en continu (boucle) ║ + // ║ sans recevoir de nouveaux segments. Cela force la ║ + // ║ backpressure en amont si le sink en aval est lent. ║ + // ╚═══════════════════════════════════════════════════════════════╝ + if self.buffered_time_sec >= self.capacity_sec && !self.buffer.is_empty() { + self.flush_one(&output).await?; + continue; + } + + let segment = tokio::select! { + _ = stop_token.cancelled() => { + tracing::debug!("TimerBufferNode cancelled"); + break; + } + + result = rx.recv() => { + match result { + Some(seg) => seg, + None => { + tracing::debug!("TimerBufferNode received EOF"); + break; + } + } + } + }; + + match &segment.segment { + _AudioSegment::Sync(marker) => { + match &**marker { + SyncMarker::TopZeroSync => { + // Reset le buffer complètement + self.buffer.clear(); + self.buffered_time_sec = 0.0; + self.prev_input_ts = Some(0.0); + self.chunk_count = 0; + self.flush_count = 0; + tracing::debug!("TimerBufferNode: TopZeroSync received, buffer reset"); + } + _ => { + // Autres markers: passthrough transparent + } + } + + // Propager le marker immédiatement + send_to_children(std::any::type_name::(), &output, segment.clone()) + .await?; + } + + _AudioSegment::Chunk(chunk) => { + self.chunk_count += 1; + + // Calculer la durée du chunk + let frames = chunk.len() as f64; + let sample_rate = chunk.sample_rate() as f64; + let duration = if frames > 0.0 && sample_rate > 0.0 { + frames / sample_rate + } else { + self.estimate_duration(segment.timestamp_sec) + }; + + tracing::trace!( + "TimerBufferNode: received chunk (ts={:.3}s, duration={:.3}s, buffered={:.3}s, capacity={:.3}s)", + segment.timestamp_sec, + duration, + self.buffered_time_sec, + self.capacity_sec + ); + + // Ajouter le chunk au buffer + self.buffer.push_back((segment.clone(), duration)); + self.buffered_time_sec += duration; + + // ╔═══════════════════════════════════════════════════════════╗ + // ║ FLUSH IMMÉDIAT : Vider aussi vite que possible ║ + // ║ ║ + // ║ Le send() bloquera si le sink est lent, créant ║ + // ║ naturellement la backpressure. Le buffer se remplit ║ + // ║ pendant que send() attend, jusqu'à atteindre capacity. ║ + // ╚═══════════════════════════════════════════════════════════╝ + self.flush_one(&output).await?; + + self.maybe_log_stats(); + } + } + } + + // EOF reçu, flusher le buffer restant + tracing::info!( + "TimerBufferNode: EOF received, flushing remaining buffer ({:.3}s, {} items)", + self.buffered_time_sec, + self.buffer.len() + ); + while !self.buffer.is_empty() { + self.flush_one(&output).await?; + } + + tracing::debug!("TimerBufferNodeLogic::process finished"); + Ok(()) + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// TimerBufferNode - Wrapper utilisant Node +// ═══════════════════════════════════════════════════════════════════════════ + +pub struct TimerBufferNode { + inner: Node, +} + +impl TimerBufferNode { + /// Crée un TimerBufferNode avec une capacité donnée + /// + /// # Arguments + /// + /// * `capacity_sec` - Capacité du buffer en secondes (ex: 3.0 pour 3s) + /// + /// # Exemples + /// + /// ```no_run + /// use pmoaudio::TimerBufferNode; + /// + /// // Buffer 3 secondes avant de commencer la diffusion + /// let buffer = TimerBufferNode::new(3.0); + /// ``` + pub fn new(capacity_sec: f64) -> Self { + Self::with_channel_size(capacity_sec, DEFAULT_CHANNEL_SIZE) + } + + /// Crée un TimerBufferNode avec une taille de buffer MPSC personnalisée + /// + /// # Arguments + /// + /// * `capacity_sec` - Capacité du buffer en secondes + /// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente) + pub fn with_channel_size(capacity_sec: f64, channel_size: usize) -> Self { + let logic = TimerBufferNodeLogic::new(capacity_sec); + Self { + inner: Node::new_with_input(logic, channel_size), + } + } +} + +#[async_trait::async_trait] +impl AudioPipelineNode for TimerBufferNode { + fn get_tx(&self) -> Option>> { + self.inner.get_tx() + } + + fn register(&mut self, child: Box) { + self.inner.register(child); + } + + async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { + Box::new(self.inner).run(stop_token).await + } +} + +impl TypedAudioNode for TimerBufferNode { + fn input_type(&self) -> Option { + // Accepte n'importe quel type + Some(TypeRequirement::any()) + } + + fn output_type(&self) -> Option { + // Passthrough: produit le même type qu'il consomme + Some(TypeRequirement::any()) + } +} diff --git a/pmoaudio/src/nodes/timer_node.rs b/pmoaudio/src/nodes/timer_node.rs index 6e872907..bc005153 100644 --- a/pmoaudio/src/nodes/timer_node.rs +++ b/pmoaudio/src/nodes/timer_node.rs @@ -53,7 +53,7 @@ use crate::{ nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE}, - pipeline::{AudioPipelineNode, Node, NodeLogic}, + pipeline::{send_to_children_with_timing, AudioPipelineNode, Node, NodeLogic}, type_constraints::TypeRequirement, AudioSegment, SyncMarker, _AudioSegment, }; @@ -73,15 +73,46 @@ use tokio_util::sync::CancellationToken; pub struct TimerNodeLogic { /// Avance maximale tolérée en secondes (buffer) max_lead_time_sec: f64, + /// Tolérance supplémentaire avant de resynchroniser l'horloge + catchup_slack_sec: f64, /// Instant de référence (reset au TopZeroSync) start_time: Option, + /// Nombre de chunks traités (pour instrumentation) + chunk_count: u64, + /// Dernier log d'instrumentation + last_stats_log: Option, } impl TimerNodeLogic { pub fn new(max_lead_time_sec: f64) -> Self { + let max_lead = max_lead_time_sec.max(0.0); + let slack = (max_lead * 0.25).max(0.5); Self { - max_lead_time_sec: max_lead_time_sec.max(0.0), + max_lead_time_sec: max_lead, + catchup_slack_sec: slack, start_time: None, + chunk_count: 0, + last_stats_log: None, + } + } + + fn maybe_log_stats(&mut self, chunk_timestamp: f64, elapsed: f64, lead_time: f64) { + let now = Instant::now(); + let should_log = match self.last_stats_log { + None => true, + Some(last) => now.duration_since(last) >= Duration::from_secs(1), + }; + + if should_log { + self.last_stats_log = Some(now); + tracing::debug!( + "TimerNode stats: chunks={} ts={:.3}s elapsed={:.3}s lead={:.3}s max={:.3}s", + self.chunk_count, + chunk_timestamp, + elapsed, + lead_time, + self.max_lead_time_sec + ); } } } @@ -101,17 +132,6 @@ impl NodeLogic for TimerNodeLogic { output.len() ); - // Macro helper pour envoyer à tous les enfants - macro_rules! send_to_children { - ($segment:expr) => { - for tx in &output { - tx.send($segment.clone()) - .await - .map_err(|_| AudioError::ChildDied)?; - } - }; - } - loop { let segment = tokio::select! { _ = stop_token.cancelled() => { @@ -143,15 +163,56 @@ impl NodeLogic for TimerNodeLogic { // Autres markers: passthrough transparent } } - send_to_children!(segment); + let segment_ts = segment.timestamp_sec; + send_to_children_with_timing( + std::any::type_name::(), + &output, + segment.clone(), + |idx, send_duration, _capacity_before| { + if send_duration.as_millis() >= 50 { + tracing::debug!( + "TimerNode: send to child {} blocked for {:.3}s (segment ts={:.3}s)", + idx, + send_duration.as_secs_f64(), + segment_ts + ); + } + }, + ) + .await?; } _AudioSegment::Chunk(_) => { // Vérifier le pacing seulement si on a un timer de référence if let Some(start) = self.start_time { + self.chunk_count += 1; let chunk_timestamp = segment.timestamp_sec; - let elapsed = start.elapsed().as_secs_f64(); - let lead_time = chunk_timestamp - elapsed; + let mut elapsed = start.elapsed().as_secs_f64(); + let mut lead_time = chunk_timestamp - elapsed; + + // Si on a accumulé beaucoup trop d'avance (source ultra rapide), + // on recale l'horloge pour éviter de dormir pendant des dizaines de secondes. + let catchup_threshold = self.max_lead_time_sec + self.catchup_slack_sec; + if lead_time > catchup_threshold { + let desired_elapsed = + (chunk_timestamp - self.max_lead_time_sec).max(0.0); + let adjust = (desired_elapsed - elapsed).max(0.0); + let new_start = + Instant::now() - Duration::from_secs_f64(desired_elapsed); + self.start_time = Some(new_start); + elapsed = desired_elapsed; + lead_time = chunk_timestamp - elapsed; + tracing::warn!( + "TimerNode: lead {:.3}s > {:.3}s (max {:.3}s + slack {:.3}s) → fast-forward clock by {:.3}s", + chunk_timestamp - start.elapsed().as_secs_f64(), + catchup_threshold, + self.max_lead_time_sec, + self.catchup_slack_sec, + adjust + ); + } + + self.maybe_log_stats(chunk_timestamp, elapsed, lead_time); tracing::trace!( "TimerNodeLogic: chunk received (ts={:.3}s, elapsed={:.3}s, lead_time={:.3}s, max_lead={:.1}s)", @@ -159,9 +220,9 @@ impl NodeLogic for TimerNodeLogic { ); if lead_time > self.max_lead_time_sec { - // On est trop en avance, attendre - let sleep_duration = lead_time - self.max_lead_time_sec; - tracing::debug!( + // On est trop en avance, attendre juste assez pour retomber à max_lead_time + let sleep_duration = (lead_time - self.max_lead_time_sec).max(0.0); + tracing::trace!( "TimerNodeLogic: SLEEPING {:.3}s (lead_time={:.3}s > max={:.1}s, chunk_ts={:.3}s)", sleep_duration, lead_time, @@ -197,7 +258,23 @@ impl NodeLogic for TimerNodeLogic { tracing::warn!("TimerNodeLogic: NO TIMER SET - passthrough without pacing! (ts={:.3}s)", segment.timestamp_sec); } - send_to_children!(segment); + let segment_ts = segment.timestamp_sec; + send_to_children_with_timing( + std::any::type_name::(), + &output, + segment.clone(), + |idx, send_duration, _capacity_before| { + if send_duration.as_millis() >= 50 { + tracing::debug!( + "TimerNode: send to child {} blocked for {:.3}s (segment ts={:.3}s)", + idx, + send_duration.as_secs_f64(), + segment_ts + ); + } + }, + ) + .await?; } } } diff --git a/pmoaudio/src/pipeline.rs b/pmoaudio/src/pipeline.rs index 4c8b9594..eac59ebf 100755 --- a/pmoaudio/src/pipeline.rs +++ b/pmoaudio/src/pipeline.rs @@ -41,7 +41,10 @@ //! ``` use crate::{nodes::AudioError, AudioSegment}; -use std::sync::Arc; +use once_cell::sync::Lazy; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use tokio::sync::mpsc; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; @@ -104,10 +107,7 @@ pub trait AudioPipelineNode: Send + 'static { /// - Un seul `cancel()` par nœud (en sortant de la boucle de travail) /// - L'enfant ne cancel JAMAIS le parent /// - `cancel()` est idempotent (pas de problème si appelé plusieurs fois) - async fn run( - self: Box, - stop_token: CancellationToken, - ) -> Result<(), AudioError>; + async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError>; /// Lance le pipeline en arrière-plan et retourne un handle de contrôle /// @@ -148,9 +148,7 @@ pub trait AudioPipelineNode: Send + 'static { let stop_token = CancellationToken::new(); let token_for_task = stop_token.clone(); - let join_handle = tokio::spawn(async move { - self.run(token_for_task).await - }); + let join_handle = tokio::spawn(async move { self.run(token_for_task).await }); PipelineHandle { stop_token, @@ -309,6 +307,98 @@ pub trait NodeLogic: Send + 'static { } } +/// Envoie un segment à l'ensemble des enfants d'un nœud. +/// +/// Cette fonction gère la logique de clonage d'`Arc` et la +/// conversion de l'erreur `mpsc::error::SendError` en `AudioError::ChildDied`. + +/// Tracker pour vérifier que les chunks audio après TopZeroSync ont timestamp=0 +/// HashMap: true = attente du prochain chunk après TopZeroSync +static FIRST_AUDIO_CHUNK_TRACKER: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); + +const FIRST_CHUNK_EPSILON: f64 = 1e-6; + +fn record_first_audio_chunk_timestamp( + node_name: &'static str, + outputs: &[mpsc::Sender>], + segment: &Arc, +) { + if outputs.is_empty() { + return; + } + + let key = outputs.as_ptr() as usize; + let mut tracker = FIRST_AUDIO_CHUNK_TRACKER + .lock() + .expect("invariant tracker mutex poisoned"); + + // Détecter TopZeroSync: marquer qu'on attend le prochain chunk audio + if segment.is_top_zero_sync() { + tracker.insert(key, true); + return; + } + + // Vérifier les audio chunks + if segment.is_audio_chunk() { + let waiting = tracker.get(&key).copied(); + + // Vérifier ts=0 si c'est le premier chunk absolu (None) ou après TopZeroSync (Some(true)) + if waiting.is_none() || waiting == Some(true) { + if segment.timestamp_sec.abs() > FIRST_CHUNK_EPSILON { + tracing::warn!( + "First audio chunk emitted by {node_name} {} started at {:.6}s (order={}), expected 0s", + if waiting == Some(true) { "after TopZeroSync" } else { "" }, + segment.timestamp_sec, + segment.order, + node_name = node_name, + ); + } + // Marquer comme "ne plus attendre" pour ce node + tracker.insert(key, false); + } + } +} + +pub async fn send_to_children( + node_name: &'static str, + outputs: &[mpsc::Sender>], + segment: Arc, +) -> Result<(), AudioError> { + record_first_audio_chunk_timestamp(node_name, outputs, &segment); + for tx in outputs { + tx.send(segment.clone()) + .await + .map_err(|_| AudioError::ChildDied)?; + } + Ok(()) +} + +/// Variante de [`send_to_children`] qui expose le temps passé à envoyer à chaque enfant. +/// +/// Utile pour les nœuds qui souhaitent instrumenter les blocages éventuels lors +/// de l'envoi (ex: TimerNode). +pub async fn send_to_children_with_timing( + node_name: &'static str, + outputs: &[mpsc::Sender>], + segment: Arc, + mut inspector: F, +) -> Result<(), AudioError> +where + F: FnMut(usize, Duration, usize), +{ + record_first_audio_chunk_timestamp(node_name, outputs, &segment); + for (idx, tx) in outputs.iter().enumerate() { + let capacity_before = tx.capacity(); + let send_start = Instant::now(); + tx.send(segment.clone()) + .await + .map_err(|_| AudioError::ChildDied)?; + inspector(idx, send_start.elapsed(), capacity_before); + } + Ok(()) +} + /// Handle pour contrôler un pipeline en cours d'exécution /// /// Retourné par la méthode `start()`, ce handle permet de : @@ -373,12 +463,14 @@ impl PipelineHandle { pub async fn wait(self) -> Result<(), AudioError> { match self.join_handle.await { Ok(result) => result, - Err(e) if e.is_panic() => Err(AudioError::ProcessingError( - format!("Pipeline task panicked: {}", e) - )), - Err(e) => Err(AudioError::ProcessingError( - format!("Pipeline task cancelled: {}", e) - )), + Err(e) if e.is_panic() => Err(AudioError::ProcessingError(format!( + "Pipeline task panicked: {}", + e + ))), + Err(e) => Err(AudioError::ProcessingError(format!( + "Pipeline task cancelled: {}", + e + ))), } } @@ -506,10 +598,7 @@ impl AudioPipelineNode for Node { self.children.push(child); } - async fn run( - mut self: Box, - stop_token: CancellationToken, - ) -> Result<(), AudioError> { + async fn run(mut self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { let Node { mut logic, rx, @@ -528,9 +617,7 @@ impl AudioPipelineNode for Node { for (i, child) in children.into_iter().enumerate() { tracing::debug!("Spawning child {}", i); let child_token = stop_token.child_token(); - let handle = tokio::spawn(async move { - child.run(child_token).await - }); + let handle = tokio::spawn(async move { child.run(child_token).await }); child_handles.push(handle); } tracing::debug!("All {} children spawned", child_handles.len()); @@ -573,9 +660,10 @@ impl AudioPipelineNode for Node { // Un enfant a paniqué tracing::error!("Child panicked: {}", e); if !has_error { - first_error = Some(AudioError::ProcessingError( - format!("Child task panicked: {}", e) - )); + first_error = Some(AudioError::ProcessingError(format!( + "Child task panicked: {}", + e + ))); has_error = true; } } @@ -595,78 +683,79 @@ impl AudioPipelineNode for Node { // PHASE 3: EXÉCUTER LA LOGIQUE MÉTIER EN RACE AVEC LE MONITORING // ═══════════════════════════════════════════════════════════════════ - let (stop_reason, process_result, child_monitor_consumed) = if let Some(monitor) = &mut child_monitor { - // Il y a des enfants à surveiller - tokio::select! { - // Cancel externe demandé - _ = stop_token.cancelled() => { - tracing::debug!("Node cancelled via stop_token"); - (StopReason::Cancelled, Ok(()), false) - } + let (stop_reason, process_result, child_monitor_consumed) = + if let Some(monitor) = &mut child_monitor { + // Il y a des enfants à surveiller + tokio::select! { + // Cancel externe demandé + _ = stop_token.cancelled() => { + tracing::debug!("Node cancelled via stop_token"); + (StopReason::Cancelled, Ok(()), false) + } - // Monitoring des enfants - retourne quand tous sont terminés ou sur erreur - child_result = monitor => { - match child_result { - Ok(Ok(())) => { - // Tous les enfants terminés avec succès - // Le parent devrait aussi terminer bientôt - tracing::debug!("All children finished successfully"); - (StopReason::Completed, Ok(()), true) + // Monitoring des enfants - retourne quand tous sont terminés ou sur erreur + child_result = monitor => { + match child_result { + Ok(Ok(())) => { + // Tous les enfants terminés avec succès + // Le parent devrait aussi terminer bientôt + tracing::debug!("All children finished successfully"); + (StopReason::Completed, Ok(()), true) + } + Ok(Err(e)) => { + // Un enfant a eu une erreur - arrêter immédiatement + tracing::warn!("Child error: {}", e); + (StopReason::Error(e.clone()), Err(e), true) + } + Err(e) => { + // Le monitor task a paniqué + let error = AudioError::ProcessingError( + format!("Child monitor panicked: {}", e) + ); + (StopReason::Error(error.clone()), Err(error), true) + } } - Ok(Err(e)) => { - // Un enfant a eu une erreur - arrêter immédiatement - tracing::warn!("Child error: {}", e); - (StopReason::Error(e.clone()), Err(e), true) - } - Err(e) => { - // Le monitor task a paniqué - let error = AudioError::ProcessingError( - format!("Child monitor panicked: {}", e) - ); - (StopReason::Error(error.clone()), Err(error), true) + } + + // Logique métier du nœud + process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => { + tracing::info!("Node logic.process() returned"); + match process_result { + Ok(()) => { + tracing::info!("Node process completed successfully"); + (StopReason::Completed, Ok(()), false) + } + Err(e) => { + tracing::error!("Node process error: {}", e); + (StopReason::Error(e.clone()), Err(e), false) + } } } } + } else { + // Pas d'enfants (nœud terminal) - juste exécuter la logique + tokio::select! { + // Cancel externe demandé + _ = stop_token.cancelled() => { + tracing::debug!("Node cancelled via stop_token"); + (StopReason::Cancelled, Ok(()), true) // true car pas de monitor à attendre + } - // Logique métier du nœud - process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => { - tracing::info!("Node logic.process() returned"); - match process_result { - Ok(()) => { - tracing::info!("Node process completed successfully"); - (StopReason::Completed, Ok(()), false) - } - Err(e) => { - tracing::error!("Node process error: {}", e); - (StopReason::Error(e.clone()), Err(e), false) + // Logique métier du nœud + process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => { + match process_result { + Ok(()) => { + tracing::debug!("Node process completed successfully (terminal)"); + (StopReason::Completed, Ok(()), true) // true car pas de monitor + } + Err(e) => { + tracing::error!("Node process error: {}", e); + (StopReason::Error(e.clone()), Err(e), true) // true car pas de monitor + } } } } - } - } else { - // Pas d'enfants (nœud terminal) - juste exécuter la logique - tokio::select! { - // Cancel externe demandé - _ = stop_token.cancelled() => { - tracing::debug!("Node cancelled via stop_token"); - (StopReason::Cancelled, Ok(()), true) // true car pas de monitor à attendre - } - - // Logique métier du nœud - process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => { - match process_result { - Ok(()) => { - tracing::debug!("Node process completed successfully (terminal)"); - (StopReason::Completed, Ok(()), true) // true car pas de monitor - } - Err(e) => { - tracing::error!("Node process error: {}", e); - (StopReason::Error(e.clone()), Err(e), true) // true car pas de monitor - } - } - } - } - }; + }; // ═══════════════════════════════════════════════════════════════════ // PHASE 4: CLEANUP COORDONNÉ diff --git a/pmoaudio/src/sync_marker.rs b/pmoaudio/src/sync_marker.rs index d187cc92..57ba0ec0 100755 --- a/pmoaudio/src/sync_marker.rs +++ b/pmoaudio/src/sync_marker.rs @@ -4,8 +4,13 @@ use tokio::sync::RwLock; use pmometadata::TrackMetadata; pub enum SyncMarker { - TrackBoundary { metadata: Arc> }, - StreamMetadata { key: String, value: String }, + TrackBoundary { + metadata: Arc>, + }, + StreamMetadata { + key: String, + value: String, + }, TopZeroSync, Heartbeat, EndOfStream, diff --git a/pmoaudiocache/examples/test_flac_debug.rs b/pmoaudiocache/examples/test_flac_debug.rs index b18d43cc..e77c9862 100644 --- a/pmoaudiocache/examples/test_flac_debug.rs +++ b/pmoaudiocache/examples/test_flac_debug.rs @@ -18,7 +18,7 @@ async fn main() -> anyhow::Result<()> { let test_url = "https://fr.getsamplefiles.com/download/mp3/sample-3.mp3"; println!("\nDownloading: {}", test_url); - let pk = cache::add_with_metadata_extraction(&cache, test_url, Some("test")).await?; + let pk = cache::add_with_metadata_extraction(cache, test_url, Some("test")).await?; println!("\nPK: {}", pk); let file_path = cache.get_file_path(&pk); diff --git a/pmoaudiocache/src/api.rs b/pmoaudiocache/src/api.rs new file mode 100644 index 00000000..7962b8f2 --- /dev/null +++ b/pmoaudiocache/src/api.rs @@ -0,0 +1,98 @@ +//! API REST handlers spécifiques au cache audio + +use crate::metadata_ext::AudioTrackMetadataExt; +use crate::Cache; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use pmometadata::TrackMetadata; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use utoipa::ToSchema; + +/// Réponse contenant l'URL de la cover avec fallback +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct CoverUrlResponse { + /// PK de la piste + #[schema(example = "1a2b3c4d5e6f7a8b")] + pub pk: String, + /// URL de la cover (cover_pk, cover_url, ou data URL par défaut) + #[schema(example = "https://example.com/cover.jpg")] + pub cover_url: String, + /// Source de l'URL: "cover_pk", "cover_url", ou "default" + #[schema(example = "cover_pk")] + pub source: String, +} + +/// Récupère l'URL de la cover d'une piste avec logique de fallback +/// +/// Cette route retourne l'URL de la cover en appliquant la logique de priorité suivante : +/// 1. Si `cover_pk` est défini dans les métadonnées, retourne la clé du cache de covers +/// 2. Sinon, si `cover_url` est défini, retourne l'URL externe +/// 3. Sinon, retourne une image SVG par défaut (data URL) +/// +/// # Arguments +/// +/// * `pk` - Clé primaire de la piste audio +/// +/// # Responses +/// +/// * `200 OK` - Retourne l'URL de la cover avec la source +/// * `404 NOT_FOUND` - Piste non trouvée +/// * `500 INTERNAL_SERVER_ERROR` - Erreur lors de la lecture des métadonnées +#[utoipa::path( + get, + path = "/{pk}/cover-url", + tag = "audio", + params( + ("pk" = String, Path, description = "Clé primaire de la piste") + ), + responses( + (status = 200, description = "URL de la cover récupérée avec succès", body = CoverUrlResponse), + (status = 404, description = "Piste non trouvée", body = pmocache::api::ErrorResponse), + (status = 500, description = "Erreur interne", body = pmocache::api::ErrorResponse), + ) +)] +pub async fn get_cover_url( + State(cache): State>, + Path(pk): Path, +) -> impl IntoResponse { + // Vérifier que la piste existe + if cache.db.get(&pk, false).is_err() { + return ( + StatusCode::NOT_FOUND, + Json(pmocache::api::ErrorResponse { + error: "NOT_FOUND".to_string(), + message: format!("Track with pk '{}' not found in cache", pk), + }), + ) + .into_response(); + } + + // Récupérer les métadonnées + let metadata = cache.track_metadata(&pk); + let metadata_guard = metadata.read().await; + + // Déterminer la source et l'URL + let (cover_url, source) = match metadata_guard.get_cover_pk().await { + Ok(Some(cover_pk)) if !cover_pk.is_empty() => (cover_pk, "cover_pk".to_string()), + _ => match metadata_guard.get_cover_url().await { + Ok(Some(url)) if !url.is_empty() => (url, "cover_url".to_string()), + _ => (pmometadata::get_default_cover_url(), "default".to_string()), + }, + }; + + ( + StatusCode::OK, + Json(CoverUrlResponse { + pk, + cover_url, + source, + }), + ) + .into_response() +} diff --git a/pmoaudiocache/src/cache.rs b/pmoaudiocache/src/cache.rs index 8c117856..3b0d7ac4 100644 --- a/pmoaudiocache/src/cache.rs +++ b/pmoaudiocache/src/cache.rs @@ -4,7 +4,9 @@ //! spécifiques aux fichiers audio : conversion FLAC automatique et stockage //! des métadonnées en JSON dans la base de données. +use crate::metadata_ext::AudioTrackMetadataExt; use anyhow::Result; +use pmocache::download::TransformMetadata; use pmocache::CacheConfig; use serde_json::Value; use std::sync::Arc; @@ -56,6 +58,31 @@ pub fn new_cache(dir: &str, limit: usize) -> Result { Cache::with_transformer(dir, limit, Some(transformer_factory)) } +async fn persist_transform_streaminfo(cache: Arc, pk: &str, tmeta: &TransformMetadata) { + use std::time::Duration; + + let track_meta = cache.track_metadata(pk); + let mut meta = track_meta.write().await; + + if let Some(sr) = tmeta.sample_rate { + let _ = meta.set_sample_rate(Some(sr)).await; + } + if let Some(bps) = tmeta.bits_per_sample { + let _ = meta.set_bits_per_sample(Some(bps)).await; + } + if let Some(ts) = tmeta.total_samples { + let _ = meta.set_total_samples(Some(ts)).await; + + // Calculer la durée à partir de total_samples et sample_rate + if let Some(sr) = tmeta.sample_rate { + if sr > 0 { + let secs = (ts as f64 / sr as f64).round() as u64; + let _ = meta.set_duration(Some(Duration::from_secs(secs))).await; + } + } + } +} + /// Crée un cache audio et lance la consolidation en arrière-plan /// /// Cette fonction crée le cache et lance immédiatement une consolidation @@ -128,85 +155,140 @@ pub async fn new_cache_with_consolidation(dir: &str, limit: usize) -> Result, url: &str, collection: Option<&str>, ) -> Result { + use std::time::Duration; + // Ajouter au cache (déclenche le download et la conversion) let pk = cache.add_from_url(url, collection).await?; // Attendre que le fichier soit téléchargé et converti cache.wait_until_finished(&pk).await?; + // Persister les métadonnées de transformation (taux d'échantillonnage, bits par échantillon, etc.) + if let Some(transform) = cache.transform_metadata(&pk).await { + persist_transform_streaminfo(cache.clone(), &pk, &transform).await; + } + // Lire le fichier FLAC pour extraire les métadonnées let file_path = cache.get_file_path(&pk); let flac_bytes = tokio::fs::read(&file_path).await?; - // Extraire les métadonnées - let mut metadata = crate::metadata::AudioMetadata::from_bytes(&flac_bytes)?; + // Extraire les métadonnées depuis le fichier audio + let metadata = crate::metadata::AudioMetadata::from_bytes(&flac_bytes)?; - if let Some(transform) = cache.transform_metadata(&pk).await { - if let Some(mode) = transform.mode { - metadata.conversion = Some(crate::metadata::AudioConversionInfo { - mode, - source_codec: transform.input_codec, - }); + // Créer une instance TrackMetadata pour persister via l'interface unifiée + let track_meta = cache.clone().track_metadata(&pk); + let mut meta = track_meta.write().await; + + // Informations techniques issues du flux FLAC (streaminfo) + let streaminfo = parse_flac_streaminfo(&flac_bytes); + if let Some((sr, bps, total_samples)) = streaminfo { + let _ = meta.set_sample_rate(Some(sr)).await; + let _ = meta.set_bits_per_sample(Some(bps)).await; + let _ = meta.set_total_samples(Some(total_samples)).await; + + // Calculer la durée si elle n'est pas disponible depuis les tags + if metadata.duration_secs.is_none() && sr > 0 { + let secs = (total_samples as f64 / sr as f64).round() as u64; + let _ = meta.set_duration(Some(Duration::from_secs(secs))).await; } } - let metadata_json: Value = serde_json::to_value(&metadata) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; - // Stocker dans la DB - cache - .db - .set_metadata(&pk, &metadata_json) - .map_err(|e| anyhow::anyhow!("Database error: {}", e))?; + + // Déterminer la collection automatique avant de move les valeurs + let auto_collection = if collection.is_none() { + metadata.collection_key() + } else { + None + }; + + // Métadonnées descriptives (tags) - en écrasant éventuellement celles du streaminfo + if let Some(d) = metadata.duration_secs { + let _ = meta.set_duration(Some(Duration::from_secs(d))).await; + } + if let Some(title) = metadata.title { + let _ = meta.set_title(Some(title)).await; + } + if let Some(artist) = metadata.artist { + let _ = meta.set_artist(Some(artist)).await; + } + if let Some(album) = metadata.album { + let _ = meta.set_album(Some(album)).await; + } + if let Some(year) = metadata.year { + let _ = meta.set_year(Some(year)).await; + } + if let Some(genre) = metadata.genre { + let _ = meta.set_genre(Some(genre)).await; + } + if let Some(track_number) = metadata.track_number { + let _ = meta.set_track_number(Some(track_number)).await; + } + if let Some(track_total) = metadata.track_total { + let _ = meta.set_track_total(Some(track_total)).await; + } + if let Some(disc_number) = metadata.disc_number { + let _ = meta.set_disc_number(Some(disc_number)).await; + } + if let Some(disc_total) = metadata.disc_total { + let _ = meta.set_disc_total(Some(disc_total)).await; + } + if let Some(channels) = metadata.channels { + let _ = meta.set_channels(Some(channels)).await; + } + if let Some(bitrate) = metadata.bitrate { + let _ = meta.set_bitrate(Some(bitrate)).await; + } + + // Libérer le lock explicitement avant les opérations de collection + drop(meta); // Mettre à jour la collection si les métadonnées en fournissent une - if collection.is_none() { - if let Some(auto_collection) = metadata.collection_key() { - cache - .db - .add(&pk, None, Some(&auto_collection)) - .map_err(|e| anyhow::anyhow!("Database error: {}", e))?; - cache.db.set_origin_url(&pk, url)?; - } + if let Some(auto_collection) = auto_collection { + cache + .db + .add(&pk, None, Some(&auto_collection)) + .map_err(|e| anyhow::anyhow!("Database error: {}", e))?; + cache.db.set_origin_url(&pk, url)?; } Ok(pk) } -/// Récupère les métadonnées audio d'un fichier en cache -/// -/// # Arguments -/// -/// * `cache` - Instance du cache -/// * `pk` - Clé primaire du fichier -/// -/// # Returns -/// -/// Les métadonnées audio désérialisées depuis le JSON stocké en DB -/// -/// # Exemple -/// -/// ```rust,no_run -/// use pmoaudiocache::cache; -/// -/// # async fn example(cache: &pmoaudiocache::cache::Cache, pk: &str) -> anyhow::Result<()> { -/// let metadata = cache::get_metadata(cache, pk)?; -/// println!("Title: {:?}", metadata.title); -/// println!("Artist: {:?}", metadata.artist); -/// # Ok(()) -/// # } -/// ``` -pub fn get_metadata(cache: &Cache, pk: &str) -> Result { - let metadata_json = cache - .db - .get_metadata_json(pk) - .map_err(|e| anyhow::anyhow!("Database error: {}", e))? - .ok_or_else(|| anyhow::anyhow!("No metadata found for pk: {}", pk))?; +/// Parse minimal FLAC STREAMINFO (first metadata block) to retrieve sample rate, +/// bits per sample, and total samples. +fn parse_flac_streaminfo(data: &[u8]) -> Option<(u32, u8, u64)> { + // Expect "fLaC" + 4-byte metadata block header + 34-byte STREAMINFO + if data.len() < 4 + 4 + 34 { + return None; + } + if &data[0..4] != b"fLaC" { + return None; + } - let metadata: crate::metadata::AudioMetadata = serde_json::from_str(&metadata_json) - .map_err(|e| anyhow::anyhow!("Metadata deserialization error: {}", e))?; + let block_type = data[4] & 0x7F; + let block_len = ((data[5] as usize) << 16) | ((data[6] as usize) << 8) | data[7] as usize; + if block_type != 0 || block_len < 34 || data.len() < 8 + block_len { + return None; + } - Ok(metadata) + let s = &data[8..8 + 34]; + + // sample_rate: 20 bits: bytes 10..12 + let sample_rate = ((s[10] as u32) << 12) | ((s[11] as u32) << 4) | ((s[12] as u32 & 0xF0) >> 4); + + // bits_per_sample: 5 bits spanning byte 12 (lsb) and byte 13 (msb) + let bps_raw = (((s[12] & 0x01) as u8) << 4) | ((s[13] & 0xF0) >> 4); + let bits_per_sample = bps_raw.saturating_add(1); + + // total_samples: 36 bits: lower 4 bits of byte 13 + bytes 14..17 + let total_samples = (((s[13] & 0x0F) as u64) << 32) + | ((s[14] as u64) << 24) + | ((s[15] as u64) << 16) + | ((s[16] as u64) << 8) + | (s[17] as u64); + + Some((sample_rate, bits_per_sample, total_samples)) } diff --git a/pmoaudiocache/src/lib.rs b/pmoaudiocache/src/lib.rs index 61dfa085..bb813f3b 100755 --- a/pmoaudiocache/src/lib.rs +++ b/pmoaudiocache/src/lib.rs @@ -28,12 +28,11 @@ //! None, //! ).await?; //! -//! // Lecture des métadonnées extraites -//! let metadata = cache::get_metadata(&cache, &pk)?; -//! println!( -//! "Titre: {}", -//! metadata.title.as_deref().unwrap_or("Inconnu") -//! ); +//! // Lecture des métadonnées extraites via TrackMetadata +//! use pmoaudiocache::metadata_ext::AudioTrackMetadataExt; +//! let track_meta = cache.track_metadata(&pk); +//! let title = track_meta.read().await.get_title().await?; +//! println!("Titre: {}", title.unwrap_or_else(|| "Inconnu".to_string())); //! //! // Accès au fichier FLAC converti //! let flac_path = cache.get(&pk).await?; @@ -82,6 +81,9 @@ pub mod metadata_ext; pub mod streaming; pub mod track_metadata; +#[cfg(feature = "pmoserver")] +pub mod api; + #[cfg(feature = "pmoserver")] pub mod openapi; @@ -89,9 +91,11 @@ pub mod openapi; pub mod config_ext; // Re-exports principaux -pub use cache::{add_with_metadata_extraction, get_metadata, new_cache, AudioConfig, Cache}; +pub use cache::{ + add_with_metadata_extraction, new_cache, new_cache_with_consolidation, AudioConfig, Cache, +}; pub use metadata::AudioMetadata; -pub use metadata_ext::{AudioMetadataExt, AudioTrackMetadataExt}; +pub use metadata_ext::{AudioMetadataExt, AudioTrackMetadataExt, TrackMetadataDidlExt}; pub use track_metadata::AudioCacheTrackMetadata; #[cfg(feature = "pmoconfig")] @@ -217,7 +221,19 @@ impl AudioCacheExt for pmoserver::Server { // API REST générique (pmocache) // Routes: GET/POST/DELETE /api/audio, etc. - let api_router = create_api_router(cache.clone()); + let mut api_router = create_api_router(cache.clone()); + + // Ajouter les endpoints audio spécifiques + // Route: GET /api/audio/{pk}/cover-url + api_router = api_router.merge( + axum::Router::new() + .route( + "/{pk}/cover-url", + axum::routing::get(crate::api::get_cover_url), + ) + .with_state(cache.clone()), + ); + let openapi = crate::ApiDoc::openapi(); self.add_openapi(api_router, openapi, "audio").await; diff --git a/pmoaudiocache/src/metadata.rs b/pmoaudiocache/src/metadata.rs index 51cea0d8..858bcfd0 100644 --- a/pmoaudiocache/src/metadata.rs +++ b/pmoaudiocache/src/metadata.rs @@ -224,7 +224,9 @@ impl AudioMetadata { /// ``` pub fn to_didl_resource(&self, url: String) -> pmodidl::Resource { pmodidl::Resource { - protocol_info: "http-get:*:audio/flac:*".to_string(), + // Aligne le protocolInfo sur ce que le renderer déclare dans Sink (audio/flac). + // PN explicite pour compatibilité DLNA, sinon fallback générique. + protocol_info: "http-get:*:audio/flac:DLNA.ORG_PN=FLAC".to_string(), bits_per_sample: None, sample_frequency: self.sample_rate.map(|sr| sr.to_string()), nr_audio_channels: self.channels.map(|ch| ch.to_string()), diff --git a/pmoaudiocache/src/metadata_ext.rs b/pmoaudiocache/src/metadata_ext.rs old mode 100755 new mode 100644 index cd920a6d..0eb59580 --- a/pmoaudiocache/src/metadata_ext.rs +++ b/pmoaudiocache/src/metadata_ext.rs @@ -1,43 +1,19 @@ -//! Extension trait pour accéder aux métadonnées audio de manière typée +//! Extension trait pour accéder aux métadonnées audio via `TrackMetadata` //! -//! Ce module utilise la macro `define_metadata_properties!` de pmocache -//! pour générer automatiquement des méthodes d'accès typées aux métadonnées audio. +//! Cette couche est désormais un mince wrapper qui délègue au nœud central +//! `AudioCacheTrackMetadata` (implémentation de `pmometadata::TrackMetadata`). +//! Elle ne touche plus directement la base de données ni ne s'appuie sur des +//! structures parallèles : toutes les lectures passent par `TrackMetadata`. use crate::{AudioCacheTrackMetadata, AudioConfig}; -use pmocache::define_metadata_properties; -use pmometadata::TrackMetadata; +use pmometadata::{MetadataError, TrackMetadata}; use std::sync::Arc; use tokio::sync::RwLock; -// Génération automatique du trait AudioMetadataExt avec toutes les propriétés audio -define_metadata_properties! { - AudioMetadataExt for pmocache::Cache { - // Métadonnées textuelles - title: String as string, - artist: String as string, - album: String as string, - album_artist: String as string, - genre: String as string, - composer: String as string, - comment: String as string, - - // Métadonnées numériques (année, numéros de piste) - year: i64 as i64, - track_number: i64 as i64, - disc_number: i64 as i64, - total_tracks: i64 as i64, - total_discs: i64 as i64, - - // Métadonnées techniques audio - duration_secs: i64 as i64, - sample_rate: i64 as i64, - bitrate: i64 as i64, - channels: i64 as i64, - bit_depth: i64 as i64, - } -} - /// Fournit un accès direct à une implémentation `TrackMetadata` basée sur le cache. +/// +/// Utilise `AudioCacheTrackMetadata` comme backend, ce qui garantit que toutes +/// les lectures/écritures passent par la DB `pmocache` sans recharger le FLAC. pub trait AudioTrackMetadataExt { fn track_metadata(&self, pk: impl Into) -> Arc>; } @@ -48,3 +24,126 @@ impl AudioTrackMetadataExt for Arc> { Arc::new(RwLock::new(metadata)) } } + +/// Accès « léger » aux principales métadonnées en s'appuyant sur `TrackMetadata`. +/// +/// Cette version remplace l'ancienne macro `define_metadata_properties!` qui +/// accédait directement aux clés de la DB. Les méthodes restent asynchrones et +/// retournent `Option` ; en cas d'erreur backend, elles lèvent `anyhow::Error`. +/// Utile dans les handlers HTTP ou les services qui n'ont besoin que de quelques +/// champs sans manipuler explicitement un `TrackMetadata`. +pub trait AudioMetadataExt { + async fn get_title(&self, pk: &str) -> anyhow::Result>; + async fn get_artist(&self, pk: &str) -> anyhow::Result>; + async fn get_album(&self, pk: &str) -> anyhow::Result>; + async fn get_duration_secs(&self, pk: &str) -> anyhow::Result>; +} + +impl AudioMetadataExt for Arc> { + async fn get_title(&self, pk: &str) -> anyhow::Result> { + let meta = self.track_metadata(pk); + let res = { + let guard = meta.read().await; + guard.get_title().await + }; + res.map_err(|e| map_err("title", pk, e)) + } + + async fn get_artist(&self, pk: &str) -> anyhow::Result> { + let meta = self.track_metadata(pk); + let res = { + let guard = meta.read().await; + guard.get_artist().await + }; + res.map_err(|e| map_err("artist", pk, e)) + } + + async fn get_album(&self, pk: &str) -> anyhow::Result> { + let meta = self.track_metadata(pk); + let res = { + let guard = meta.read().await; + guard.get_album().await + }; + res.map_err(|e| map_err("album", pk, e)) + } + + async fn get_duration_secs(&self, pk: &str) -> anyhow::Result> { + let meta = self.track_metadata(pk); + let res = { + let guard = meta.read().await; + guard.get_duration().await + }; + let duration = res.map_err(|e| map_err("duration", pk, e))?; + Ok(duration.map(|d| d.as_secs() as i64)) + } +} + +fn map_err(field: &str, pk: &str, err: MetadataError) -> anyhow::Error { + match err { + MetadataError::NotImplemented | MetadataError::ReadOnly => { + anyhow::anyhow!("metadata {field} for {pk} is not implemented") + } + MetadataError::Backend(msg) => { + anyhow::anyhow!("backend error on {field} for {pk}: {msg}") + } + } +} + +/// Extension pour convertir TrackMetadata en Resource DIDL-Lite (UPnP) +#[async_trait::async_trait] +pub trait TrackMetadataDidlExt { + /// Convertit les métadonnées en Resource DIDL-Lite + /// + /// # Arguments + /// + /// * `url` - URL de la ressource audio + /// + /// # Exemple + /// + /// ```rust,ignore + /// use pmoaudiocache::metadata_ext::{AudioTrackMetadataExt, TrackMetadataDidlExt}; + /// + /// let track_meta = cache.track_metadata(&pk); + /// let resource = track_meta.read().await.to_didl_resource("http://example.com/track.flac".to_string()).await; + /// ``` + async fn to_didl_resource(&self, url: String) -> pmodidl::Resource; +} + +#[async_trait::async_trait] +impl TrackMetadataDidlExt for dyn TrackMetadata { + async fn to_didl_resource(&self, url: String) -> pmodidl::Resource { + // Récupérer la durée et la formater pour DIDL-Lite (H:MM:SS) + let duration = self.get_duration().await.ok().flatten().map(|d| { + let secs = d.as_secs(); + let hours = secs / 3600; + let minutes = (secs % 3600) / 60; + let seconds = secs % 60; + format!("{}:{:02}:{:02}", hours, minutes, seconds) + }); + + pmodidl::Resource { + // Aligne sur Sink du renderer (audio/flac) avec PN explicite. + protocol_info: "http-get:*:audio/flac:DLNA.ORG_PN=FLAC".to_string(), + bits_per_sample: self + .get_bits_per_sample() + .await + .ok() + .flatten() + .map(|b| b.to_string()), + sample_frequency: self + .get_sample_rate() + .await + .ok() + .flatten() + .map(|sr| sr.to_string()), + nr_audio_channels: self + .get_channels() + .await + .ok() + .flatten() + .map(|ch| ch.to_string()), + duration, + url, + } + } +} diff --git a/pmoaudiocache/src/openapi.rs b/pmoaudiocache/src/openapi.rs index b941904f..aa138f27 100644 --- a/pmoaudiocache/src/openapi.rs +++ b/pmoaudiocache/src/openapi.rs @@ -15,6 +15,7 @@ use utoipa::OpenApi; pmocache::api::DeleteItemResponse, pmocache::api::ErrorResponse, pmocache::api::DownloadStatus, + crate::api::CoverUrlResponse, ) ), tags( @@ -56,6 +57,9 @@ Supprime une piste ### GET /api/audio/{pk}/status Récupère le statut du téléchargement et de la conversion +### GET /api/audio/{pk}/cover-url +Récupère l'URL de la cover avec fallback automatique (cover_pk → cover_url → image par défaut) + ### DELETE /api/audio Purge complètement le cache @@ -84,6 +88,8 @@ Les métadonnées suivantes sont extraites et stockées : - Numéro de piste/disque, total de pistes/disques - Durée, taux d'échantillonnage, bitrate - Nombre de canaux +- Cover : `cover_pk` (clé dans le cache de covers) et `cover_url` (URL externe) + - Fallback automatique vers une image SVG par défaut si aucune cover n'est disponible ## Collections diff --git a/pmoaudiocache/src/streaming.rs b/pmoaudiocache/src/streaming.rs index 86e9f40a..53d98779 100644 --- a/pmoaudiocache/src/streaming.rs +++ b/pmoaudiocache/src/streaming.rs @@ -9,9 +9,19 @@ use bytes::Bytes; use pmocache::download::TransformMetadata; use pmocache::StreamTransformer; use pmoflac::{transcode_to_flac_stream, AudioCodec, TranscodeOptions}; +use serde_json::json; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -/// Creates the transformer consumed by the audio cache. +/// Crée le `StreamTransformer` utilisé par le cache audio. +/// +/// - Accepte n'importe quel codec pris en charge par `pmoflac` (FLAC, MP3, OGG/Vorbis, +/// Opus, WAV, AIFF). +/// - Convertit en FLAC en streaming (passthrough si entrée FLAC) et écrit dans le +/// fichier fourni par `pmocache`. +/// - Renseigne `TransformMetadata` (codec source, mode, SR, BPS, canaux, total_samples) +/// pour que `pmocache` puisse les stocker en DB. +/// +/// À utiliser comme factory dans `Cache::with_transformer`. pub fn create_streaming_flac_transformer() -> StreamTransformer { Box::new(|input, mut file, context| { Box::pin(async move { @@ -32,13 +42,33 @@ pub fn create_streaming_flac_transformer() -> StreamTransformer { "transcode" }; - context - .set_metadata(TransformMetadata { - mode: Some(mode.to_string()), - input_codec: Some(codec_to_string(codec)), - details: None, - }) - .await; + let metadata = TransformMetadata { + mode: Some(mode.to_string()), + input_codec: Some(codec_to_string(codec)), + details: Some( + json!({ + "sample_rate": info.sample_rate, + "bits_per_sample": info.bits_per_sample, + "channels": info.channels, + "total_samples": info.total_samples, + }) + .to_string(), + ), + sample_rate: Some(info.sample_rate), + bits_per_sample: Some(info.bits_per_sample), + channels: Some(info.channels), + total_samples: info.total_samples, + }; + + tracing::debug!( + "Transformer setting metadata: sr={:?}, bps={:?}, ch={:?}, ts={:?}", + metadata.sample_rate, + metadata.bits_per_sample, + metadata.channels, + metadata.total_samples + ); + + context.set_metadata(metadata).await; let mut flac_stream = transcode.into_stream(); let mut buffer = vec![0u8; 64 * 1024]; diff --git a/pmoaudiocache/src/track_metadata.rs b/pmoaudiocache/src/track_metadata.rs index 83a04a94..5b55248e 100644 --- a/pmoaudiocache/src/track_metadata.rs +++ b/pmoaudiocache/src/track_metadata.rs @@ -8,12 +8,25 @@ fn map_db_err(err: rusqlite::Error) -> MetadataError { MetadataError::Backend(err.to_string()) } +/// Implémentation `TrackMetadata` adossée au cache audio. +/// +/// Cette couche lit/écrit directement dans la table `metadata` de `pmocache` +/// pour un `pk` donné. Elle se comporte comme une façade `TrackMetadata` +/// classique mais repose sur la DB du cache plutôt que sur un fichier taggé, +/// ce qui permet : +/// - d'exposer les métadonnées immédiatement après ingestion/transform ; +/// - de servir des lecteurs UPnP/DLNA sans relire le FLAC sur disque ; +/// - de persister les mises à jour d'un client (ex: renommer un titre). pub struct AudioCacheTrackMetadata { cache: Arc, pk: String, } impl AudioCacheTrackMetadata { + /// Construit un adaptateur `TrackMetadata` pour un `pk` du cache audio. + /// + /// Le type implémente ensuite toutes les méthodes du trait `pmometadata::TrackMetadata` + /// en stockant les données dans la base SQLite de `pmocache`. pub fn new(cache: Arc, pk: impl Into) -> Self { Self { cache, @@ -176,6 +189,68 @@ impl TrackMetadata for AudioCacheTrackMetadata { Ok(Some(())) } + async fn get_genre(&self) -> MetadataResult { + Ok(self.read_string("genre")?) + } + + async fn set_genre(&mut self, value: Option) -> MetadataResult<()> { + self.write_string("genre", value)?; + let _ = self.touch().await?; + Ok(Some(())) + } + + async fn get_track_number(&self) -> MetadataResult { + Ok(match self.read_number("track_number")? { + Some(n) => n.as_i64().and_then(|v| u32::try_from(v).ok()), + None => None, + }) + } + + async fn set_track_number(&mut self, value: Option) -> MetadataResult<()> { + self.write_number("track_number", value.map(|v| v as i64))?; + let _ = self.touch().await?; + Ok(Some(())) + } + + async fn get_track_total(&self) -> MetadataResult { + Ok(match self.read_number("track_total")? { + Some(n) => n.as_i64().and_then(|v| u32::try_from(v).ok()), + None => None, + }) + } + + async fn set_track_total(&mut self, value: Option) -> MetadataResult<()> { + self.write_number("track_total", value.map(|v| v as i64))?; + let _ = self.touch().await?; + Ok(Some(())) + } + + async fn get_disc_number(&self) -> MetadataResult { + Ok(match self.read_number("disc_number")? { + Some(n) => n.as_i64().and_then(|v| u32::try_from(v).ok()), + None => None, + }) + } + + async fn set_disc_number(&mut self, value: Option) -> MetadataResult<()> { + self.write_number("disc_number", value.map(|v| v as i64))?; + let _ = self.touch().await?; + Ok(Some(())) + } + + async fn get_disc_total(&self) -> MetadataResult { + Ok(match self.read_number("disc_total")? { + Some(n) => n.as_i64().and_then(|v| u32::try_from(v).ok()), + None => None, + }) + } + + async fn set_disc_total(&mut self, value: Option) -> MetadataResult<()> { + self.write_number("disc_total", value.map(|v| v as i64))?; + let _ = self.touch().await?; + Ok(Some(())) + } + async fn get_duration(&self) -> MetadataResult { Ok(self.read_duration()?) } @@ -186,6 +261,71 @@ impl TrackMetadata for AudioCacheTrackMetadata { Ok(Some(())) } + async fn get_sample_rate(&self) -> MetadataResult { + Ok(match self.read_number("sample_rate")? { + Some(n) => n.as_i64().and_then(|v| u32::try_from(v).ok()), + None => None, + }) + } + + async fn set_sample_rate(&mut self, value: Option) -> MetadataResult<()> { + self.write_number("sample_rate", value.map(|v| v as i64))?; + let _ = self.touch().await?; + Ok(Some(())) + } + + async fn get_total_samples(&self) -> MetadataResult { + Ok(match self.read_number("total_samples")? { + Some(n) => n.as_i64().and_then(|v| u64::try_from(v).ok()), + None => None, + }) + } + + async fn set_total_samples(&mut self, value: Option) -> MetadataResult<()> { + self.write_u64("total_samples", value)?; + let _ = self.touch().await?; + Ok(Some(())) + } + + async fn get_bits_per_sample(&self) -> MetadataResult { + Ok(match self.read_number("bits_per_sample")? { + Some(n) => n.as_i64().and_then(|v| u8::try_from(v).ok()), + None => None, + }) + } + + async fn set_bits_per_sample(&mut self, value: Option) -> MetadataResult<()> { + self.write_number("bits_per_sample", value.map(|v| v as i64))?; + let _ = self.touch().await?; + Ok(Some(())) + } + + async fn get_channels(&self) -> MetadataResult { + Ok(match self.read_number("channels")? { + Some(n) => n.as_i64().and_then(|v| u8::try_from(v).ok()), + None => None, + }) + } + + async fn set_channels(&mut self, value: Option) -> MetadataResult<()> { + self.write_number("channels", value.map(|v| v as i64))?; + let _ = self.touch().await?; + Ok(Some(())) + } + + async fn get_bitrate(&self) -> MetadataResult { + Ok(match self.read_number("bitrate")? { + Some(n) => n.as_i64().and_then(|v| u32::try_from(v).ok()), + None => None, + }) + } + + async fn set_bitrate(&mut self, value: Option) -> MetadataResult<()> { + self.write_number("bitrate", value.map(|v| v as i64))?; + let _ = self.touch().await?; + Ok(Some(())) + } + async fn get_track_id(&self) -> MetadataResult { Ok(self.read_string("track_id")?) } @@ -286,6 +426,9 @@ mod tests { meta.set_duration(Some(Duration::from_secs(90))) .await .unwrap(); + meta.set_sample_rate(Some(44100)).await.unwrap(); + meta.set_total_samples(Some(9_999_999)).await.unwrap(); + meta.set_bits_per_sample(Some(16)).await.unwrap(); meta.set_track_id(Some("trk".into())).await.unwrap(); meta.set_channel_id(Some("chn".into())).await.unwrap(); meta.set_event(Some("event".into())).await.unwrap(); @@ -296,27 +439,29 @@ mod tests { meta.set_cover_pk(Some("cover123".into())).await.unwrap(); } { - let meta = track.read().await; + let meta = track.read().await; - - assert_eq!(meta.get_title().await.unwrap(), Some("Title".into())); - assert_eq!(meta.get_artist().await.unwrap(), Some("Artist".into())); - assert_eq!(meta.get_album().await.unwrap(), Some("Album".into())); - assert_eq!(meta.get_year().await.unwrap(), Some(2024)); - assert_eq!( - meta.get_duration().await.unwrap(), - Some(Duration::from_secs(90)) - ); - assert_eq!(meta.get_track_id().await.unwrap(), Some("trk".into())); - assert_eq!(meta.get_channel_id().await.unwrap(), Some("chn".into())); - assert_eq!(meta.get_event().await.unwrap(), Some("event".into())); - assert_eq!(meta.get_rating().await.unwrap(), Some(4.5)); - assert_eq!( - meta.get_cover_url().await.unwrap(), - Some("http://cover".into()) - ); - assert_eq!(meta.get_cover_pk().await.unwrap(), Some("cover123".into())); - assert!(meta.get_updated_at().await.unwrap().is_some()); - } + assert_eq!(meta.get_title().await.unwrap(), Some("Title".into())); + assert_eq!(meta.get_artist().await.unwrap(), Some("Artist".into())); + assert_eq!(meta.get_album().await.unwrap(), Some("Album".into())); + assert_eq!(meta.get_year().await.unwrap(), Some(2024)); + assert_eq!( + meta.get_duration().await.unwrap(), + Some(Duration::from_secs(90)) + ); + assert_eq!(meta.get_sample_rate().await.unwrap(), Some(44100)); + assert_eq!(meta.get_total_samples().await.unwrap(), Some(9_999_999)); + assert_eq!(meta.get_bits_per_sample().await.unwrap(), Some(16)); + assert_eq!(meta.get_track_id().await.unwrap(), Some("trk".into())); + assert_eq!(meta.get_channel_id().await.unwrap(), Some("chn".into())); + assert_eq!(meta.get_event().await.unwrap(), Some("event".into())); + assert_eq!(meta.get_rating().await.unwrap(), Some(4.5)); + assert_eq!( + meta.get_cover_url().await.unwrap(), + Some("http://cover".into()) + ); + assert_eq!(meta.get_cover_pk().await.unwrap(), Some("cover123".into())); + assert!(meta.get_updated_at().await.unwrap().is_some()); + } } } diff --git a/pmocache/src/cache.rs b/pmocache/src/cache.rs index 4ba635da..76d56aae 100755 --- a/pmocache/src/cache.rs +++ b/pmocache/src/cache.rs @@ -12,11 +12,43 @@ use anyhow::{anyhow, bail, Result}; use serde_json::{Number, Value}; use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use tokio::io::{AsyncRead, AsyncReadExt}; use tokio::sync::RwLock; use tracing; +/// Informations transmises lors de la diffusion d'un élément du cache via HTTP. +/// +/// - Emis uniquement quand une réponse 2xx est renvoyée par les routes HTTP générées +/// (fichier complet, stream progressif ou variante générée). +/// - Inclut le qualifier utilisé pour la requête afin de distinguer `orig`, `stream`, etc. +/// - Peut être utilisé pour synchroniser des clients (ex: WebSocket) ou tracer les hits. +#[derive(Debug, Clone)] +pub struct CacheBroadcastEvent { + /// Identifiant unique du fichier servi. + pub pk: String, + /// Qualifier (paramètre de route) utilisé pour cette diffusion. + pub qualifier: String, + /// Nom logique du cache (`CacheConfig::cache_name`). + pub cache_name: &'static str, + /// Type du cache (`CacheConfig::cache_type`). + pub cache_type: &'static str, +} + +/// Handle retourné lors de l'abonnement à un évènement de diffusion. +/// +/// Conservez-le pour pouvoir vous désabonner explicitement via +/// [`Cache::unsubscribe_broadcast`]. Le couple `(pk, id)` identifie de manière +/// unique la callback enregistrée. +#[derive(Debug, Clone)] +pub struct CacheSubscription { + pub pk: String, + pub id: u64, +} + +type CacheServeCallback = Arc bool + Send + Sync>; + /// Taille minimale de prébuffering par défaut (512 KB = ~5 secondes de FLAC) pub const DEFAULT_PREBUFFER_SIZE: u64 = 512 * 1024; @@ -59,6 +91,10 @@ pub struct Cache { pub db: Arc, /// Map des downloads en cours (pk -> Download) downloads: Arc>>>, + /// Callback(s) à déclencher lorsqu'un élément est servi via HTTP (pk -> callbacks) + serve_subscribers: Arc>>>, + /// Générateur d'identifiants uniques pour les abonnements + subscriber_counter: AtomicU64, /// Factory pour créer des transformers (optionnel) transformer_factory: Option StreamTransformer + Send + Sync>>, /// Taille minimale de prébuffering en octets (0 = désactivé) @@ -70,7 +106,8 @@ pub struct Cache { impl Cache { /// Retourne le chemin du fichier marker de complétion fn get_completion_marker_path(&self, pk: &str) -> PathBuf { - self.get_file_path(pk).with_extension(format!("{}.complete", C::file_extension())) + self.get_file_path(pk) + .with_extension(format!("{}.complete", C::file_extension())) } /// Vérifie si un fichier est en cache et complet @@ -78,7 +115,7 @@ impl Cache { /// # Returns /// /// - `Ok(true)` si le fichier est en cache et complet (fichier .complete existe) - /// - `Ok(false)` si le fichier n'est pas en cache ou incomplet (et supprime les fichiers incomplets) + /// - `Ok(false)` si le fichier n'est pas en cache ou incomplet (et supprime les fichiers incomplets SI aucun download en cours) /// - `Err` en cas d'erreur async fn check_cached_and_complete(&self, pk: &str) -> Result { if self.db.get(pk, false).is_ok() { @@ -91,12 +128,34 @@ impl Cache { tracing::debug!("File with pk {} is complete (marker exists)", pk); return Ok(true); } else { + // Vérifier si un download est en cours avant de supprimer + let is_downloading = { + let downloads = self.downloads.read().await; + downloads.contains_key(pk) + }; + + if is_downloading { + tracing::debug!( + "File with pk {} has no completion marker but download is in progress, waiting", + pk + ); + return Ok(false); + } + tracing::warn!( - "File with pk {} in cache has no completion marker, will re-download/re-ingest", + "File with pk {} in cache has no completion marker and no download in progress, will re-download/re-ingest", pk ); - // Supprimer le fichier incomplet + // Supprimer le fichier incomplet ET l'entrée DB seulement si pas de download en cours let _ = std::fs::remove_file(&file_path); + let _ = std::fs::remove_file(&completion_marker); // Nettoyer aussi le marker s'il existe + if let Err(e) = self.db.delete(pk) { + tracing::warn!( + "Failed to delete DB entry for incomplete file {}: {}", + pk, + e + ); + } return Ok(false); } } @@ -118,10 +177,15 @@ impl Cache { }; if let Some(download) = download_handle { - tracing::debug!("Download already in progress for pk {}, waiting for prebuffering", pk); + tracing::debug!( + "Download already in progress for pk {}, waiting for prebuffering", + pk + ); if self.min_prebuffer_size > 0 { - download.wait_until_min_size(self.min_prebuffer_size).await + download + .wait_until_min_size(self.min_prebuffer_size) + .await .map_err(|e| anyhow!("Prebuffering failed: {}", e))?; tracing::debug!("Prebuffering complete for pk {}", pk); } @@ -135,12 +199,74 @@ impl Cache { /// Finalise l'ajout d'un fichier au cache /// /// Cette fonction helper gère le prébuffering et le nettoyage en background - async fn finalize_download(&self, pk: &str, download: Arc) -> Result { + async fn finalize_download( + &self, + pk: &str, + download: Arc, + collection: Option<&str>, + origin_url: Option<&str>, + ) -> Result { // Attendre le prébuffering (pour le cache progressif) if self.min_prebuffer_size > 0 { - download.wait_until_min_size(self.min_prebuffer_size).await + download + .wait_until_min_size(self.min_prebuffer_size) + .await .map_err(|e| anyhow!("Prebuffering failed: {}", e))?; - tracing::debug!("Prebuffering complete for pk {} ({} bytes)", pk, self.min_prebuffer_size); + tracing::debug!( + "Prebuffering complete for pk {} ({} bytes)", + pk, + self.min_prebuffer_size + ); + } + + // Ajouter à la DB une fois le prébuffer terminé + self.db.add(pk, None, collection)?; + if let Some(url) = origin_url { + self.db.set_origin_url(pk, url)?; + } + + // Sauvegarder les métadonnées techniques du transformer + if let Some(transform) = download.transform_metadata().await { + tracing::debug!( + "Cache: Got transform metadata for pk {}: sr={:?}, bps={:?}, ch={:?}, ts={:?}", + pk, + transform.sample_rate, + transform.bits_per_sample, + transform.channels, + transform.total_samples + ); + + if let Some(sr) = transform.sample_rate { + self.db + .set_a_metadata(pk, "sample_rate", serde_json::json!(sr))?; + } + if let Some(bps) = transform.bits_per_sample { + self.db + .set_a_metadata(pk, "bits_per_sample", serde_json::json!(bps))?; + } + if let Some(ch) = transform.channels { + self.db + .set_a_metadata(pk, "channels", serde_json::json!(ch))?; + } + if let Some(ts) = transform.total_samples { + self.db + .set_a_metadata(pk, "total_samples", serde_json::json!(ts))?; + + // Calculer la durée à partir de total_samples et sample_rate + if let Some(sr) = transform.sample_rate { + if sr > 0 { + let secs = (ts as f64 / sr as f64).round() as u64; + self.db + .set_a_metadata(pk, "duration_secs", serde_json::json!(secs))?; + } + } + } + } else { + tracing::debug!("Cache: No transform metadata available for pk {}", pk); + } + + if let Err(e) = self.enforce_limit().await { + tracing::warn!("Error enforcing cache limit: {}", e); } // Lancer une tâche de nettoyage et marquage de complétion en background @@ -155,7 +281,11 @@ impl Cache { // Créer le fichier marker de complétion si le téléchargement a réussi if result.is_ok() { if let Err(e) = std::fs::write(&completion_marker, "") { - tracing::warn!("Failed to create completion marker for pk {}: {}", pk_clone, e); + tracing::warn!( + "Failed to create completion marker for pk {}: {}", + pk_clone, + e + ); } else { tracing::debug!("Created completion marker for pk {}", pk_clone); } @@ -225,6 +355,8 @@ impl Cache { limit, db: Arc::new(db), downloads: Arc::new(RwLock::new(HashMap::new())), + serve_subscribers: Arc::new(RwLock::new(HashMap::new())), + subscriber_counter: AtomicU64::new(1), transformer_factory, min_prebuffer_size: DEFAULT_PREBUFFER_SIZE, _phantom: std::marker::PhantomData, @@ -259,6 +391,102 @@ impl Cache { self.min_prebuffer_size } + /// S'abonne aux diffusions HTTP pour un `pk` donné. + /// + /// La callback est appelée à chaque fois qu'un élément est servi avec succès via les routes + /// HTTP du cache. Si la callback retourne `false`, elle est automatiquement désinscrite ; + /// retourner `true` permet de rester abonné aux diffusions suivantes. + /// + /// Retourne un [`CacheSubscription`] à conserver pour se désabonner explicitement via + /// [`Cache::unsubscribe_broadcast`]. + /// + /// # Exemple + /// + /// ```rust,no_run + /// use pmocache::{Cache, CacheConfig, CacheSubscription}; + /// use std::sync::Arc; + /// + /// struct MyConfig; + /// impl CacheConfig for MyConfig { + /// fn file_extension() -> &'static str { "dat" } + /// } + /// + /// # async fn demo() -> anyhow::Result<()> { + /// let cache = Arc::new(Cache::::new("/tmp/cache", 100)?); + /// let token: CacheSubscription = cache + /// .subscribe_broadcast("abc123", |event| { + /// println!("{} served with param {}", event.pk, event.qualifier); + /// // Retourner true pour rester abonné + /// true + /// }) + /// .await; + /// + /// // ... plus tard, pour se désabonner explicitement : + /// cache.unsubscribe_broadcast(&token).await; + /// # Ok(()) + /// # } + /// ``` + pub async fn subscribe_broadcast( + &self, + pk: impl Into, + callback: F, + ) -> CacheSubscription + where + F: Fn(&CacheBroadcastEvent) -> bool + Send + Sync + 'static, + { + let pk = pk.into(); + let id = self.subscriber_counter.fetch_add(1, Ordering::Relaxed); + let mut subscribers = self.serve_subscribers.write().await; + subscribers + .entry(pk.clone()) + .or_default() + .push((id, Arc::new(callback))); + + CacheSubscription { pk, id } + } + + /// Désabonne une callback précédemment enregistrée via [`Cache::subscribe_broadcast`]. + /// + /// N'a aucun effet si le token est inconnu ou déjà désinscrit. + pub async fn unsubscribe_broadcast(&self, token: &CacheSubscription) { + let mut subscribers = self.serve_subscribers.write().await; + if let Some(list) = subscribers.get_mut(&token.pk) { + list.retain(|(id, _)| *id != token.id); + if list.is_empty() { + subscribers.remove(&token.pk); + } + } + } + + /// Notifie les abonnés qu'un élément du cache a été diffusé via HTTP. + /// + /// Interne au crate : les routes Axum appellent cette méthode lorsqu'une réponse 2xx est + /// renvoyée. Les callbacks qui retournent `false` sont retirées. + pub(crate) async fn notify_broadcast(&self, pk: &str, qualifier: &str) { + let mut subscribers = self.serve_subscribers.write().await; + if let Some(callbacks) = subscribers.get_mut(pk) { + let event = CacheBroadcastEvent { + pk: pk.to_string(), + qualifier: qualifier.to_string(), + cache_name: C::cache_name(), + cache_type: C::cache_type(), + }; + + let mut to_keep = Vec::new(); + for (id, callback) in callbacks.drain(..) { + if callback(&event) { + to_keep.push((id, callback)); + } + } + + if to_keep.is_empty() { + subscribers.remove(pk); + } else { + callbacks.extend(to_keep); + } + } + } + /// Télécharge un fichier depuis une URL et l'ajoute au cache /// /// Cette méthode utilise un système d'identifiants basé sur le contenu plutôt que sur l'URL. @@ -288,8 +516,28 @@ impl Cache { /// Deux URLs différentes pointant vers le même contenu auront le même pk, /// permettant une déduplication automatique. pub async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result { - // 1. Télécharger les 512 premiers octets pour calculer le pk - let header = crate::download::peek_header(url, 512) + // 0. Vérifier d'abord si cette URL est déjà en cache (optimisation réseau) + if let Ok(Some(existing_pk)) = self.db.get_pk_by_origin_url(url) { + // Vérifier que le fichier est toujours complet et valide + if self.check_cached_and_complete(&existing_pk).await? { + tracing::debug!( + "URL {} already in cache with pk {}, skipping download", + url, + existing_pk + ); + self.db.update_hit(&existing_pk)?; + return Ok(existing_pk); + } else { + tracing::debug!( + "URL {} found in DB with pk {} but file is incomplete, re-downloading", + url, + existing_pk + ); + } + } + + // 1. Télécharger les 2048 premiers octets pour calculer le pk + let header = crate::download::peek_header(url, 2048) .await .map_err(|e| anyhow!("Failed to peek header: {}", e))?; @@ -321,17 +569,9 @@ impl Cache { downloads.insert(pk.clone(), download.clone()); } - // Ajouter immédiatement à la DB - self.db.add(&pk, None, collection)?; - self.db.set_origin_url(&pk, url)?; - - // Appliquer la politique d'éviction LRU si nécessaire - if let Err(e) = self.enforce_limit().await { - tracing::warn!("Error enforcing cache limit: {}", e); - } - // Finaliser avec prébuffering et nettoyage - self.finalize_download(&pk, download).await + self.finalize_download(&pk, download, collection, Some(url)) + .await } /// Ajoute un fichier à partir d'un flux asynchrone. @@ -368,7 +608,8 @@ impl Cache { where R: AsyncRead + Send + Unpin + 'static, { - self.add_from_reader_with_pk(source_uri, reader, length, collection, None).await + self.add_from_reader_with_pk(source_uri, reader, length, collection, None) + .await } /// Ajoute un fichier à partir d'un flux avec un pk explicite optionnel. @@ -442,17 +683,9 @@ impl Cache { downloads.insert(pk.clone(), download.clone()); } - self.db.add(&pk, None, collection)?; - if let Some(uri) = source_uri { - self.db.set_origin_url(&pk, uri)?; - } - - if let Err(e) = self.enforce_limit().await { - tracing::warn!("Error enforcing cache limit: {}", e); - } - // Finaliser avec prébuffering et nettoyage - self.finalize_download(&pk, download).await + self.finalize_download(&pk, download, collection, source_uri) + .await } /// Ajoute un fichier local au cache diff --git a/pmocache/src/cache_trait.rs b/pmocache/src/cache_trait.rs index 0874414b..edd0f6d3 100644 --- a/pmocache/src/cache_trait.rs +++ b/pmocache/src/cache_trait.rs @@ -163,20 +163,29 @@ pub trait FileCache: Send + Sync { } if !file_path.exists() { - tracing::warn!("is_valid_pk({}): File not created after 1s despite DB entry existing", pk); + tracing::warn!( + "is_valid_pk({}): File not created after 1s despite DB entry existing", + pk + ); return false; } - tracing::debug!("is_valid_pk({}): File created after {}ms", pk, attempts * 10); + tracing::debug!( + "is_valid_pk({}): File created after {}ms", + pk, + attempts * 10 + ); } // Vérifier d'abord si le marker de completion existe - let completion_marker = file_path.with_extension( - format!("{}.complete", C::file_extension()) - ); + let completion_marker = + file_path.with_extension(format!("{}.complete", C::file_extension())); if completion_marker.exists() { - tracing::debug!("is_valid_pk({}): Completion marker found, file is complete", pk); + tracing::debug!( + "is_valid_pk({}): Completion marker found, file is complete", + pk + ); return true; } @@ -190,7 +199,11 @@ pub trait FileCache: Send + Sync { tracing::debug!("is_valid_pk({}): No marker but file is recent ({}s), download in progress", pk, age_secs); return true; } else { - tracing::debug!("is_valid_pk({}): No marker and file is old ({}s), incomplete download", pk, age_secs); + tracing::debug!( + "is_valid_pk({}): No marker and file is old ({}s), incomplete download", + pk, + age_secs + ); return false; } } @@ -198,7 +211,10 @@ pub trait FileCache: Send + Sync { } // Ne peut pas vérifier le statut - rejeter par sécurité - tracing::debug!("is_valid_pk({}): Could not check file status, rejecting", pk); + tracing::debug!( + "is_valid_pk({}): Could not check file status, rejecting", + pk + ); false } } diff --git a/pmocache/src/db.rs b/pmocache/src/db.rs index cf00bf77..21aa485e 100644 --- a/pmocache/src/db.rs +++ b/pmocache/src/db.rs @@ -81,12 +81,12 @@ impl<'a> std::ops::DerefMut for ConnGuard<'a> { impl DB { fn lock_conn(&self, ctx: &'static str) -> ConnGuard<'_> { - trace!("DB mutex → acquiring ({ctx})"); + // trace!("DB mutex → acquiring ({ctx})"); let start = Instant::now(); let guard = self.conn.lock().unwrap(); let waited = start.elapsed(); - trace!("DB mutex → acquired ({ctx}) in {:?}", waited); + // trace!("DB mutex → acquired ({ctx}) in {:?}", waited); if waited > std::time::Duration::from_millis(50) { warn!("DB mutex wait >50 ms ({}): {:?}", ctx, waited); } @@ -372,7 +372,25 @@ impl DB { } /// Enregistre l'URL d'origine liée à un élément du cache. + /// + /// Cette méthode détecte automatiquement les collisions de pk : + /// si le pk existe déjà avec une URL différente, un log d'erreur est émis. pub fn set_origin_url(&self, pk: &str, origin_url: &str) -> rusqlite::Result<()> { + // Vérifier si ce pk a déjà une URL d'origine différente (détection de collision) + if let Ok(Some(existing_url)) = self.get_origin_url(pk) { + if existing_url != origin_url { + tracing::error!( + "🚨 COLLISION DE PK DÉTECTÉE: pk='{}' existe déjà avec origin_url='{}' mais tentative d'enregistrement avec origin_url='{}'", + pk, + existing_url, + origin_url + ); + tracing::error!( + " Cela indique que deux fichiers différents ont généré le même pk. Considérez augmenter la taille du header pour le calcul du pk." + ); + } + } + self.set_a_metadata(pk, "origin_url", Value::String(origin_url.to_owned())) } @@ -389,6 +407,30 @@ impl DB { } } + /// Recherche un pk par son URL d'origine. + /// + /// Cette méthode permet de vérifier si un fichier avec une URL donnée + /// est déjà en cache avant de lancer un téléchargement. + /// + /// # Arguments + /// + /// * `origin_url` - L'URL d'origine à rechercher + /// + /// # Returns + /// + /// * `Ok(Some(pk))` - Le pk du fichier en cache avec cette URL + /// * `Ok(None)` - Aucun fichier avec cette URL n'est en cache + pub fn get_pk_by_origin_url(&self, origin_url: &str) -> rusqlite::Result> { + let conn = self.lock_conn("get_pk_by_origin_url"); + + conn.query_row( + "SELECT pk FROM metadata WHERE key = 'origin_url' AND value = ?", + [origin_url], + |row| row.get(0), + ) + .optional() + } + /// Récupère uniquement les métadonnées JSON d'une entrée /// /// # Arguments diff --git a/pmocache/src/download.rs b/pmocache/src/download.rs index aaeb12d6..2e73c60d 100644 --- a/pmocache/src/download.rs +++ b/pmocache/src/download.rs @@ -32,6 +32,11 @@ pub type TransformContextHandle = Arc; type ByteStream = Pin> + Send>>; /// Source générique (HTTP ou lecteur) exposée aux transformers. +/// +/// `CacheInput` masque l'origine des données pour les transformers (HTTP ou flux +/// applicatif). Il permet de consulter la taille attendue (`content_length`), +/// de récupérer l'intégralité du buffer (`bytes`) ou d'itérer en streaming +/// (`into_byte_stream`). pub struct CacheInput { inner: CacheInputInner, } @@ -50,6 +55,10 @@ enum CacheInputInner { } impl CacheInput { + /// Crée un `CacheInput` à partir d'une réponse HTTP (`reqwest::Response`). + /// + /// Conserve la longueur du contenu si elle est fournie par le serveur et + /// permet un accès ultérieur en streaming ou en mémoire. pub fn from_response(response: reqwest::Response) -> Self { let length = response.content_length(); Self { @@ -61,6 +70,10 @@ impl CacheInput { } } + /// Crée un `CacheInput` à partir d'un `AsyncRead` typé. + /// + /// La longueur peut être fournie si elle est connue, ce qui améliore la + /// mise à jour des métadonnées de progression. pub fn from_reader(reader: R, length: Option) -> Self where R: AsyncRead + Send + Unpin + 'static, @@ -68,6 +81,7 @@ impl CacheInput { Self::from_reader_box(Box::new(reader), length) } + /// Crée un `CacheInput` à partir d'un trait object `AsyncRead`. pub fn from_reader_box(reader: Box, length: Option) -> Self { Self { inner: CacheInputInner::Reader { @@ -78,6 +92,7 @@ impl CacheInput { } } + /// Retourne la taille du contenu si elle est connue (Content-Length ou buffer déjà lu). pub fn content_length(&self) -> Option { match &self.inner { CacheInputInner::Http { length, buffer, .. } => { @@ -127,6 +142,9 @@ impl CacheInput { } } + /// Retourne un flux d'octets (stream) consommable par les transformers. + /// + /// Si le contenu a déjà été lu en mémoire, le stream renverra ce buffer. pub fn into_byte_stream(self) -> ByteStream { match self.inner { CacheInputInner::Http { @@ -185,7 +203,12 @@ struct DownloadState { transform_metadata: Option, } -/// Objet représentant un téléchargement en cours +/// Objet représentant un téléchargement en cours. +/// +/// Expose la progression, les tailles attendues/transformées, l'état d'erreur +/// et les métadonnées de transformation éventuelles. Les méthodes sont sûres +/// côté concurrence et peuvent être utilisées depuis les routes HTTP pour +/// suivre l'état du cache progressif. #[derive(Debug)] pub struct Download { filename: PathBuf, @@ -208,10 +231,14 @@ impl Download { }) } + /// Chemin du fichier cible sur disque. pub fn filename(&self) -> &Path { &self.filename } + /// Attend que `transformed_size` atteigne au moins `min_size` (ou fin / erreur). + /// + /// Utile pour le prébuffering audio ou vidéo avant de démarrer un stream HTTP. pub async fn wait_until_min_size(&self, min_size: u64) -> Result<(), String> { loop { let state = self.state.read().await; @@ -226,6 +253,7 @@ impl Download { } } + /// Attend la fin complète du téléchargement ou renvoie l'erreur rencontrée. pub async fn wait_until_finished(&self) -> Result<(), String> { loop { let state = self.state.read().await; @@ -240,58 +268,81 @@ impl Download { } } + /// Ouvre le fichier associé pour lecture (bloquant standard). pub fn open(&self) -> io::Result { File::open(&self.filename) } + /// Dernière position lue (tracking pour lecture progressive). pub async fn pos(&self) -> u64 { let state = self.state.read().await; state.read_position } + /// Met à jour la position lue (utile pour les streamers progressifs). pub async fn set_pos(&self, pos: u64) { let mut state = self.state.write().await; state.read_position = pos; } + /// Taille attendue du flux source (Content-Length ou renseignée par l'appelant). pub async fn expected_size(&self) -> Option { let state = self.state.read().await; state.expected_size } + /// Nombre d'octets effectivement téléchargés (source). pub async fn current_size(&self) -> u64 { let state = self.state.read().await; state.current_size } + /// Nombre d'octets écrits après transformation (peut différer de `current_size`). pub async fn transformed_size(&self) -> u64 { let state = self.state.read().await; state.transformed_size } + /// Indique si le téléchargement est terminé (succès ou erreur). pub async fn finished(&self) -> bool { let state = self.state.read().await; state.finished } + /// Renvoie l'erreur rencontrée, le cas échéant. pub async fn error(&self) -> Option { let state = self.state.read().await; state.error.clone() } + /// Métadonnées renseignées par le transformer (codec, sample rate, etc.). pub async fn transform_metadata(&self) -> Option { let state = self.state.read().await; state.transform_metadata.clone() } } +/// Métadonnées techniques optionnelles remontées par un transformer. #[derive(Debug, Clone, Default)] pub struct TransformMetadata { + /// Mode ou preset utilisé (ex: "flac", "webp-80"). pub mode: Option, + /// Codec ou format en entrée. pub input_codec: Option, + /// Détails libres (ex: paramètres d'encodage). pub details: Option, + /// Fréquence d'échantillonnage en Hz. + pub sample_rate: Option, + /// Profondeur de bits par échantillon. + pub bits_per_sample: Option, + /// Nombre de canaux audio. + pub channels: Option, + /// Nombre total d'échantillons (si connu). + pub total_samples: Option, } +/// Contexte passé aux transformers pour signaler la progression et renseigner +/// des métadonnées de transformation. pub struct TransformContext { state: Arc>, progress_cb: Arc, @@ -302,17 +353,17 @@ impl TransformContext { Self { state, progress_cb } } - /// Reports progress (in bytes) to the download state. + /// Signale une progression (en octets transformés) au download. pub fn report_progress(&self, bytes: u64) { (self.progress_cb)(bytes); } - /// Returns the underlying progress callback (useful for piping into other APIs). + /// Retourne le callback de progression sous-jacent (utile pour le passer à d'autres APIs). pub fn progress_callback(&self) -> Arc { Arc::clone(&self.progress_cb) } - /// Stores metadata describing the transformation that occurred. + /// Stocke des métadonnées décrivant la transformation appliquée. pub async fn set_metadata(&self, metadata: TransformMetadata) { let mut state = self.state.write().await; state.transform_metadata = Some(metadata); @@ -325,6 +376,9 @@ pub fn download>(filename: P, url: &str) -> Arc { } /// Lance le téléchargement d'une URL avec transformation du stream. +/// +/// Le transformer reçoit le flux source, un handle de fichier déjà ouvert et un +/// [`TransformContext`] pour reporter la progression et les métadonnées. pub fn download_with_transformer>( filename: P, url: &str, @@ -333,7 +387,11 @@ pub fn download_with_transformer>( spawn_download(filename, DownloadSource::Url(url.to_string()), transformer) } -/// Ingère un flux (AsyncRead) dans le cache avec transformation optionnelle. +/// Ingère un flux (`AsyncRead`) dans le cache avec transformation optionnelle. +/// +/// Permet d'alimenter le cache depuis une source non-HTTP (ex: pipe interne, +/// fichier local, décodage amont) tout en conservant la même mécanique de +/// suivi de progression qu'un téléchargement classique. pub fn ingest_with_transformer( filename: P, reader: R, diff --git a/pmocache/src/lib.rs b/pmocache/src/lib.rs index 0c0843c4..7b02399c 100644 --- a/pmocache/src/lib.rs +++ b/pmocache/src/lib.rs @@ -129,7 +129,7 @@ pub mod openapi; #[cfg(feature = "pmoconfig")] pub mod config_ext; -pub use cache::{Cache, CacheConfig}; +pub use cache::{Cache, CacheBroadcastEvent, CacheConfig, CacheSubscription}; pub use cache_trait::{pk_from_content_header, FileCache}; pub use db::{CacheEntry, DB}; pub use download::{ diff --git a/pmocache/src/pmoserver_ext.rs b/pmocache/src/pmoserver_ext.rs index e6dab594..9d9fd5a6 100644 --- a/pmocache/src/pmoserver_ext.rs +++ b/pmocache/src/pmoserver_ext.rs @@ -124,13 +124,21 @@ async fn serve_file_with_streaming( param_generator: Option>, ) -> Response { let file_path = cache.get_file_path_with_qualifier(pk, param); + let qualifier = param.to_string(); // Si le fichier n'existe pas et qu'on a un générateur, l'utiliser if !file_path.exists() { if let Some(generator) = param_generator { if let Some(data) = generator(cache.clone(), pk.to_string(), param.to_string()).await { // Le générateur a créé les données, les servir directement - return (StatusCode::OK, [("content-type", content_type)], data).into_response(); + let response = + (StatusCode::OK, [("content-type", content_type)], data).into_response(); + + if response.status().is_success() { + cache.notify_broadcast(pk, &qualifier).await; + } + + return response; } } } @@ -145,12 +153,24 @@ async fn serve_file_with_streaming( // Le fichier est en cours de téléchargement if !download.finished().await { // Streaming progressif - return stream_file_progressive(file_path, download, content_type).await; + let response = stream_file_progressive(file_path, download, content_type).await; + + if response.status().is_success() { + cache.notify_broadcast(pk, &qualifier).await; + } + + return response; } } // Fichier terminé ou pas de download en cours, servir normalement - serve_complete_file(file_path, content_type).await + let response = serve_complete_file(file_path, content_type).await; + + if response.status().is_success() { + cache.notify_broadcast(pk, &qualifier).await; + } + + response } /// Stream un fichier en cours de téléchargement de manière progressive diff --git a/pmocache/tests/test_db.rs b/pmocache/tests/test_db.rs index 1066e7a2..3c814320 100644 --- a/pmocache/tests/test_db.rs +++ b/pmocache/tests/test_db.rs @@ -227,6 +227,54 @@ fn test_origin_url() { assert_eq!(retrieved_url, Some(url.to_string())); } +#[test] +fn test_pk_collision_detection() { + let (_temp_dir, db) = create_test_db(); + + let pk = "collision_pk_123"; + let url1 = "https://example.com/file1.jpg"; + let url2 = "https://example.com/file2.jpg"; + + // Ajouter le premier fichier avec le pk + db.add(pk, None, None).unwrap(); + db.set_origin_url(pk, url1).unwrap(); + + // Vérifier que l'URL est bien enregistrée + let retrieved_url = db.get_origin_url(pk).unwrap(); + assert_eq!(retrieved_url, Some(url1.to_string())); + + // Tenter d'enregistrer une URL différente pour le même pk + // Ceci devrait logger une erreur mais ne devrait pas échouer + let result = db.set_origin_url(pk, url2); + assert!(result.is_ok()); + + // L'URL devrait être écrasée par la nouvelle (comportement actuel) + let retrieved_url = db.get_origin_url(pk).unwrap(); + assert_eq!(retrieved_url, Some(url2.to_string())); +} + +#[test] +fn test_get_pk_by_origin_url() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk_456"; + let url = "https://example.com/cover.webp"; + + // Ajouter une entrée avec URL + db.add(pk, None, None).unwrap(); + db.set_origin_url(pk, url).unwrap(); + + // Rechercher le pk par URL + let found_pk = db.get_pk_by_origin_url(url).unwrap(); + assert_eq!(found_pk, Some(pk.to_string())); + + // Rechercher une URL qui n'existe pas + let not_found = db + .get_pk_by_origin_url("https://example.com/notfound.jpg") + .unwrap(); + assert_eq!(not_found, None); +} + #[test] fn test_get_from_id() { let (_temp_dir, db) = create_test_db(); @@ -295,14 +343,28 @@ fn test_metadata_types() { db.add(pk, None, None).unwrap(); // Tester les différents types de métadonnées - db.set_a_metadata(pk, "string_val", Value::String("test".to_string())).unwrap(); + db.set_a_metadata(pk, "string_val", Value::String("test".to_string())) + .unwrap(); db.set_a_metadata(pk, "number_val", json!(42)).unwrap(); - db.set_a_metadata(pk, "bool_val", Value::Bool(true)).unwrap(); + db.set_a_metadata(pk, "bool_val", Value::Bool(true)) + .unwrap(); db.set_a_metadata(pk, "null_val", Value::Null).unwrap(); // Vérifier les valeurs - assert_eq!(db.get_metadata_value(pk, "string_val").unwrap(), Some(Value::String("test".to_string()))); - assert_eq!(db.get_metadata_value(pk, "number_val").unwrap(), Some(json!(42))); - assert_eq!(db.get_metadata_value(pk, "bool_val").unwrap(), Some(Value::Bool(true))); - assert_eq!(db.get_metadata_value(pk, "null_val").unwrap(), Some(Value::Null)); + assert_eq!( + db.get_metadata_value(pk, "string_val").unwrap(), + Some(Value::String("test".to_string())) + ); + assert_eq!( + db.get_metadata_value(pk, "number_val").unwrap(), + Some(json!(42)) + ); + assert_eq!( + db.get_metadata_value(pk, "bool_val").unwrap(), + Some(Value::Bool(true)) + ); + assert_eq!( + db.get_metadata_value(pk, "null_val").unwrap(), + Some(Value::Null) + ); } diff --git a/pmocontrol/Cargo.toml b/pmocontrol/Cargo.toml new file mode 100644 index 00000000..8db3929e --- /dev/null +++ b/pmocontrol/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "pmocontrol" +version = "0.1.0" +edition = "2024" + +[dependencies] +pmoupnp = { path = "../pmoupnp" } +pmodidl = { path = "../pmodidl" } +quick-xml = "0.38.4" +thiserror = "2.0.17" +ureq = "3.1.4" +tracing = "0.1.41" +tracing-subscriber = "0.3" +tracing-log = "0.1" +anyhow = "1.0" +xmltree = "0.11.0" +crossbeam-channel = "0.5" +ratatui = { version = "0.26", default-features = false, features = ["crossterm"] } +crossterm = "0.27" + +# pmoserver extension support (optional) +pmoserver = { path = "../pmoserver", optional = true } +utoipa = { version = "5.4.0", optional = true } +axum = { version = "0.8.4", optional = true } +serde = { version = "1.0", features = ["derive"], optional = true } +serde_json = { version = "1.0", optional = true } +tokio = { version = "1", features = ["sync", "rt"], optional = true } +tokio-util = { version = "0.7", optional = true } +async-trait = { version = "0.1", optional = true } +tokio-stream = { version = "0.1", features = ["sync"], optional = true } +async-stream = { version = "0.3", optional = true } +chrono = { version = "0.4", features = ["serde"], optional = true } + +[dev-dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +percent-encoding = "2.3" + +[features] +default = [] +# Active l'API REST pmoserver +pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:serde", "dep:serde_json", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"] diff --git a/pmocontrol/examples/arylic_tcp_demo.rs b/pmocontrol/examples/arylic_tcp_demo.rs new file mode 100644 index 00000000..e46ff1d7 --- /dev/null +++ b/pmocontrol/examples/arylic_tcp_demo.rs @@ -0,0 +1,116 @@ +use std::io; +use std::thread; +use std::time::Duration; + +use pmocontrol::{ + ControlPoint, DeviceRegistryRead, MusicRenderer, PlaybackPosition, PlaybackPositionInfo, + PlaybackState, PlaybackStatus, TransportControl, VolumeControl, +}; + +fn main() -> io::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + println!("Starting PMOMusic Arylic TCP demo..."); + + let cp = ControlPoint::spawn(5)?; + println!("Waiting 5 seconds for SSDP discovery..."); + thread::sleep(Duration::from_secs(5)); + + let registry = cp.registry(); + let renderers = { + let reg = registry.read().unwrap(); + reg.list_renderers() + }; + + if renderers.is_empty() { + println!("No renderers discovered."); + return Ok(()); + } + + println!("Discovered renderers:"); + for (idx, info) in renderers.iter().enumerate() { + println!( + " [{}] {} | model={} | Arylic TCP={} | LinkPlay HTTP={}", + idx, + info.friendly_name, + info.model_name, + info.capabilities.has_arylic_tcp, + info.capabilities.has_linkplay_http + ); + } + + let arylic_renderers: Vec = renderers + .iter() + .filter(|info| info.capabilities.has_arylic_tcp) + .filter_map(|info| MusicRenderer::from_registry_info(info.clone(), ®istry)) + .collect(); + + if arylic_renderers.is_empty() { + println!("\nNo Arylic TCP-capable renderers detected."); + return Ok(()); + } + + println!("\nArylic TCP renderer status:"); + for renderer in arylic_renderers { + println!("- {} ({})", renderer.friendly_name(), renderer.id().0); + + match renderer.playback_state() { + Ok(state) => println!(" state: {}", format_playback_state(&state)), + Err(err) => println!(" state: error: {}", err), + } + + match renderer.playback_position() { + Ok(pos) => println!(" position: {}", format_position(&pos)), + Err(err) => println!(" position: error: {}", err), + } + + match renderer.volume() { + Ok(vol) => println!(" volume: {}", vol), + Err(err) => println!(" volume: error: {}", err), + } + + match renderer.mute() { + Ok(mute) => println!(" mute: {}", mute), + Err(err) => println!(" mute: error: {}", err), + } + + println!(" attempting pause/play test..."); + if let Err(err) = renderer.pause() { + println!(" pause error: {}", err); + } else { + thread::sleep(Duration::from_millis(500)); + println!(" pause OK"); + } + + if let Err(err) = renderer.play() { + println!(" play error: {}", err); + } else { + println!(" play OK"); + } + + println!(); + } + + Ok(()) +} + +fn format_playback_state(state: &PlaybackState) -> String { + match state { + PlaybackState::Stopped => "Stopped".to_string(), + PlaybackState::Playing => "Playing".to_string(), + PlaybackState::Paused => "Paused".to_string(), + PlaybackState::Transitioning => "Transitioning".to_string(), + PlaybackState::NoMedia => "NoMedia".to_string(), + PlaybackState::Unknown(raw) => format!("Unknown({})", raw), + } +} + +fn format_position(pos: &PlaybackPositionInfo) -> String { + let rel = pos.rel_time.as_deref().unwrap_or("-"); + let dur = pos.track_duration.as_deref().unwrap_or("-"); + let track = pos + .track + .map(|t| t.to_string()) + .unwrap_or_else(|| "-".to_string()); + + format!("track={} rel_time={} duration={}", track, rel, dur) +} diff --git a/pmocontrol/examples/discover.rs b/pmocontrol/examples/discover.rs new file mode 100644 index 00000000..3e4dd991 --- /dev/null +++ b/pmocontrol/examples/discover.rs @@ -0,0 +1,52 @@ +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use pmocontrol::RendererProtocol; +use pmocontrol::{ControlPoint, DeviceRegistryRead, MediaServerInfo, RendererInfo}; + +fn main() -> std::io::Result<()> { + // Un tout petit logging optionnel + tracing_subscriber::fmt::init(); + tracing::info!("Starting PMOMusic control point SSDP discovery example..."); + + // Lance le control point (timeout HTTP pour les descriptions UPnP) + let cp = ControlPoint::spawn(5)?; + + loop { + thread::sleep(Duration::from_secs(5)); + + // Accès thread-safe au DeviceRegistry + let reg = cp.registry(); + let reg = reg.read().expect("registry poisoned"); + + let renderers: Vec = reg.list_renderers(); + let servers: Vec = reg.list_servers(); + + println!("====================="); + println!("Renderers detected : {}", renderers.len()); + for r in &renderers { + let proto = match r.protocol { + RendererProtocol::UpnpAvOnly => "UPnP AV", + RendererProtocol::OpenHomeOnly => "OpenHome", + RendererProtocol::Hybrid => "Hybrid", + }; + + println!( + "- [{}] {} ({}) [{}] online={}", + r.id.0, r.friendly_name, r.model_name, proto, r.online + ); + } + + println!(); + println!("Media servers detected : {}", servers.len()); + for s in &servers { + println!( + "- [{}] {} ({}) online={}", + s.id.0, s.friendly_name, s.model_name, s.online + ); + } + + println!("====================="); + } +} diff --git a/pmocontrol/examples/dump_ssdp.rs b/pmocontrol/examples/dump_ssdp.rs new file mode 100644 index 00000000..461847d4 --- /dev/null +++ b/pmocontrol/examples/dump_ssdp.rs @@ -0,0 +1,60 @@ +use std::thread; +use std::time::Duration; + +use pmoupnp::ssdp::{SsdpClient, SsdpEvent}; + +fn main() -> std::io::Result<()> { + tracing_subscriber::fmt::init(); + tracing::info!("Starting raw SSDP dump helper..."); + + let client = SsdpClient::new()?; + + // Envoie quelques requêtes M-SEARCH ciblées pour accélérer les réponses. + let search_targets = [ + "ssdp:all", + "urn:schemas-upnp-org:device:MediaRenderer:1", + "urn:av-openhome-org:device:MediaRenderer:1", + "urn:schemas-upnp-org:device:MediaServer:1", + "urn:schemas-wiimu-com:service:PlayQueue:1", + ]; + for st in &search_targets { + if let Err(err) = client.send_msearch(st, 3) { + eprintln!("Failed to send M-SEARCH for {}: {}", st, err); + } + thread::sleep(Duration::from_millis(200)); + } + + println!("Listening for SSDP events. Press Ctrl+C to stop."); + + client.run_event_loop(|event| match event { + SsdpEvent::Alive { + usn, + nt, + location, + server, + max_age, + from, + } => { + println!( + "[ALIVE] from={} usn={} nt={} location={} server={} max_age={}", + from, usn, nt, location, server, max_age + ); + } + SsdpEvent::SearchResponse { + usn, + st, + location, + server, + max_age, + from, + } => { + println!( + "[SEARCH RESPONSE] from={} usn={} st={} location={} server={} max_age={}", + from, usn, st, location, server, max_age + ); + } + SsdpEvent::ByeBye { usn, nt, from } => { + println!("[BYEBYE] from={} usn={} nt={}", from, usn, nt); + } + }) +} diff --git a/pmocontrol/examples/event_demo.rs b/pmocontrol/examples/event_demo.rs new file mode 100644 index 00000000..471577a7 --- /dev/null +++ b/pmocontrol/examples/event_demo.rs @@ -0,0 +1,276 @@ +// examples/events_demo.rs +// +// Demo temps réel des RendererEvent émis par le runtime de ControlPoint : +// - SSDP discovery via `ControlPoint` +// - sélection d'un renderer (facultatif) +// - abonnement à `subscribe_events()` +// - affichage continu des événements avec horodatage HH:MM:SS +// +// Build et run (depuis la racine du crate pmocontrol) : +// cargo run --example events_demo -- # écoute tous les renderers +// cargo run --example events_demo -- 0 # filtre sur renderer index 0 +// cargo run --example events_demo -- 1 # filtre sur renderer index 1, etc. +// +// Ctrl-C pour quitter. + +use std::env; +use std::io; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use pmocontrol::model::TrackMetadata; +use pmocontrol::openhome_renderer::{format_seconds, map_openhome_state}; +use pmocontrol::{ + ControlPoint, DeviceRegistryRead, PlaybackPositionInfo, PlaybackState, RendererEvent, + RendererId, RendererInfo, +}; + +fn main() -> io::Result<()> { + // Logging simple (tracing_subscriber est déjà utilisé dans les autres exemples) + let _ = tracing_subscriber::fmt::try_init(); + println!("Starting PMOMusic renderer events demo..."); + + // 1. Lance le ControlPoint (timeout HTTP pour les descriptions UPnP) + let cp = ControlPoint::spawn(5)?; + + // 2. Laisse la découverte tourner un peu avant de lister les renderers + println!("Waiting 5 seconds for SSDP discovery..."); + thread::sleep(Duration::from_secs(5)); + + let registry = cp.registry(); + let renderers: Vec = { + let reg = registry.read().unwrap(); + reg.list_renderers() + }; + + if renderers.is_empty() { + println!("No renderers discovered. Make sure your devices are on and reachable."); + return Ok(()); + } + + println!("\nDiscovered renderers:"); + for (idx, info) in renderers.iter().enumerate() { + println!( + " [{}] {} | model={} | udn={} | location={} | online={}", + idx, info.friendly_name, info.model_name, info.udn, info.location, info.online + ); + print_openhome_summary(" ", info, &cp); + } + + // 3. Optionnel : sélection d'un renderer par index (filtrage des événements) + let args: Vec = env::args().collect(); + let selected_id: Option = if args.len() >= 2 { + match args[1].parse::() { + Ok(idx) if idx < renderers.len() => { + let info = &renderers[idx]; + println!( + "\nFiltering events on renderer [{}] {} (id={})", + idx, info.friendly_name, info.id.0 + ); + Some(info.id.clone()) + } + Ok(idx) => { + eprintln!( + "\nRenderer index {} is out of range (0..{}), listening to all renderers.", + idx, + renderers.len().saturating_sub(1) + ); + None + } + Err(e) => { + eprintln!( + "\nArgument '{}' is not a valid index (error: {}), listening to all renderers.", + args[1], e + ); + None + } + } + } else { + println!("\nNo renderer index provided, listening to events from all renderers."); + None + }; + + // 4. Abonnement aux événements du runtime + let rx = cp.subscribe_events(); + + println!("\nSubscribed to renderer events."); + println!("Press Ctrl-C to quit.\n"); + + // 5. Boucle bloquante sur les événements + loop { + match rx.recv() { + Ok(event) => { + if let Some(ref id) = selected_id { + // Filtre : on ignore les événements des autres renderers + if !event_matches_id(&event, id) { + continue; + } + } + + print_event(&event); + } + Err(err) => { + eprintln!("Event channel closed: {}. Exiting.", err); + break; + } + } + } + + Ok(()) +} + +/// Vérifie si un événement concerne un RendererId donné. +fn event_matches_id(event: &RendererEvent, id: &RendererId) -> bool { + match event { + RendererEvent::StateChanged { id: eid, .. } => eid == id, + RendererEvent::PositionChanged { id: eid, .. } => eid == id, + RendererEvent::VolumeChanged { id: eid, .. } => eid == id, + RendererEvent::MuteChanged { id: eid, .. } => eid == id, + RendererEvent::MetadataChanged { id: eid, .. } => eid == id, + RendererEvent::QueueUpdated { id: eid, .. } => eid == id, + RendererEvent::BindingChanged { id: eid, .. } => eid == id, + } +} + +/// Format HH:MM:SS basé sur l'heure système (UTC mod 24h). +fn now_hms() -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)); + let total = now.as_secs() % 86_400; + let h = total / 3600; + let m = (total % 3600) / 60; + let s = total % 60; + format!("{:02}:{:02}:{:02}", h, m, s) +} + +/// Affichage lisible d'un PlaybackState. +fn format_playback_state(state: &PlaybackState) -> String { + match state { + PlaybackState::Stopped => "Stopped".to_string(), + PlaybackState::Playing => "Playing".to_string(), + PlaybackState::Paused => "Paused".to_string(), + PlaybackState::Transitioning => "Transitioning".to_string(), + PlaybackState::NoMedia => "NoMedia".to_string(), + PlaybackState::Unknown(s) => format!("Unknown({})", s), + } +} + +/// Affichage lisible d'un PlaybackPositionInfo. +fn format_position(pos: &PlaybackPositionInfo) -> String { + let track = pos + .track + .map(|t| t.to_string()) + .unwrap_or_else(|| "-".to_string()); + let rel = pos.rel_time.as_deref().unwrap_or("-").to_string(); + let dur = pos.track_duration.as_deref().unwrap_or("-").to_string(); + + format!("track={} rel_time={} duration={}", track, rel, dur) +} + +fn print_openhome_summary(prefix: &str, info: &RendererInfo, cp: &ControlPoint) { + if !info.capabilities.has_oh_playlist + && !info.capabilities.has_oh_info + && !info.capabilities.has_oh_time + { + return; + } + + let registry = cp.registry(); + let reg = registry.read().unwrap(); + let playlist_client = reg.oh_playlist_client_for_renderer(&info.id); + let info_client = reg.oh_info_client_for_renderer(&info.id); + let time_client = reg.oh_time_client_for_renderer(&info.id); + drop(reg); + + if playlist_client.is_none() && info_client.is_none() && time_client.is_none() { + return; + } + + println!("{prefix}OpenHome:"); + + if let Some(client) = playlist_client { + match client.id_array() { + Ok(ids) => println!("{prefix} Playlist tracks : {}", ids.len()), + Err(err) => println!("{prefix} Playlist tracks : "), + } + } + + if let Some(client) = info_client { + match client.transport_state() { + Ok(state) => { + let logical = map_openhome_state(&state); + println!("{prefix} Transport state : {} ({:?})", state, logical); + } + Err(err) => println!("{prefix} Transport state : "), + } + } + + if let Some(client) = time_client { + match client.position() { + Ok(pos) => println!( + "{prefix} Position : {}/{} (tracks={})", + format_seconds(pos.elapsed_secs), + format_seconds(pos.duration_secs), + pos.track_count + ), + Err(err) => println!("{prefix} Position : "), + } + } +} + +/// Affiche un RendererEvent avec horodatage. +fn print_event(event: &RendererEvent) { + let ts = now_hms(); + match event { + RendererEvent::StateChanged { id, state } => { + println!( + "[{}] [{}] StateChanged: {}", + ts, + id.0, + format_playback_state(state) + ); + } + RendererEvent::PositionChanged { id, position } => { + println!( + "[{}] [{}] PositionChanged: {}", + ts, + id.0, + format_position(position) + ); + } + RendererEvent::VolumeChanged { id, volume } => { + println!("[{}] [{}] VolumeChanged: {}", ts, id.0, volume); + } + RendererEvent::MuteChanged { id, mute } => { + println!("[{}] [{}] MuteChanged: {}", ts, id.0, mute); + } + RendererEvent::MetadataChanged { id, metadata } => { + println!( + "[{}] [{}] MetadataChanged: {}", + ts, + id.0, + format_metadata(metadata) + ); + } + RendererEvent::QueueUpdated { id, queue_length } => { + println!( + "[{}] [{}] QueueUpdated: queue_length={}", + ts, id.0, queue_length + ); + } + } +} + +fn format_metadata(meta: &TrackMetadata) -> String { + let title = meta.title.as_deref().unwrap_or(""); + let artist = meta.artist.as_deref().unwrap_or(""); + let album = meta.album.as_deref().unwrap_or(""); + if !artist.is_empty() && !album.is_empty() { + format!("{} - {} ({})", artist, title, album) + } else if !artist.is_empty() { + format!("{} - {}", artist, title) + } else { + title.to_string() + } +} diff --git a/pmocontrol/examples/full_control_point_demo.rs b/pmocontrol/examples/full_control_point_demo.rs new file mode 100644 index 00000000..ed4182cb --- /dev/null +++ b/pmocontrol/examples/full_control_point_demo.rs @@ -0,0 +1,1445 @@ +//! Full interactive control point demo with Ratatui-powered UI. +//! +//! This version replaces the legacy println!-driven interface with a +//! Crossterm + Ratatui dashboard featuring menus, overlays and live updates. + +use std::collections::HashMap; +use std::io::{self, Stdout}; +use std::process; +use std::sync::Arc; +use std::sync::mpsc::{self, Sender}; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow}; +use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use pmocontrol::model::TrackMetadata; +use pmocontrol::{ + ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaResource, MediaServerEvent, + MediaServerInfo, MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, + PlaybackStatus, RendererEvent, RendererInfo, TransportControl, VolumeControl, +}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph}; + +const DEFAULT_TIMEOUT_SECS: u64 = 5; +const DEFAULT_DISCOVERY_SECS: u64 = 15; +const TICK_RATE: Duration = Duration::from_millis(200); + +#[derive(Clone)] +struct UiState { + renderer_name: String, + server_name: Option, + playback_state: Option, + position: Option, + volume: Option, + mute: Option, + metadata: Option, + last_status: Option, + current_track_uri: Option, +} + +impl UiState { + fn new(renderer_name: String) -> Self { + Self { + renderer_name, + server_name: None, + playback_state: None, + position: None, + volume: None, + mute: None, + metadata: None, + last_status: Some("Interface initialisée.".to_string()), + current_track_uri: None, + } + } + + fn placeholder() -> Self { + Self::new("".to_string()) + } + + fn set_status>(&mut self, status: S) { + self.last_status = Some(status.into()); + } +} + +fn main() -> Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + println!("=== Full Control Point Ratatui Demo ==="); + println!( + "Starting control point with timeout={}s", + DEFAULT_TIMEOUT_SECS + ); + + let control_point = + ControlPoint::spawn(DEFAULT_TIMEOUT_SECS).context("Failed to start control point")?; + + println!( + "Discovery running for {} seconds...", + DEFAULT_DISCOVERY_SECS + ); + std::thread::sleep(Duration::from_secs(DEFAULT_DISCOVERY_SECS)); + + let registry = control_point.registry(); + let renderers = { + let reg = registry.read().expect("registry poisoned"); + let list = reg.list_renderers(); + if list.is_empty() { + eprintln!("No renderers discovered. Exiting."); + process::exit(1); + } + list + }; + + let servers = { + let reg = registry.read().expect("registry poisoned"); + let list: Vec = reg + .list_servers() + .into_iter() + .filter(|s| s.has_content_directory && s.content_directory_control_url.is_some()) + .collect(); + if list.is_empty() { + eprintln!("No media servers with ContentDirectory discovered. Exiting."); + process::exit(1); + } + list + }; + + let control_point = Arc::new(control_point); + let app = App::new(control_point, renderers, servers); + if let Err(err) = run_app(app) { + eprintln!("Application exited with error: {err}"); + } + + println!("\nExiting. Goodbye!"); + Ok(()) +} + +struct App { + control_point: Arc, + renderers: Vec, + renderer_index: usize, + renderer_info: Option, + servers: Vec, + server_index: usize, + server_info: Option, + music_server: Option, + browser: Option, + mode: Mode, + ui_state: UiState, + queue_snapshot: Vec, + queue_current_index: Option, + show_queue_overlay: bool, + show_help_overlay: bool, + pending_binding_container: Option, + status_line: String, + known_tracks: std::collections::HashMap, +} + +enum Mode { + SelectRenderer, + SelectServer, + Browse, + BindingPrompt, + Control, +} + +struct BrowserState { + nav_state: NavigationState, + entries: Vec, + selected_index: usize, +} + +enum AppEvent { + Renderer(RendererEvent), + Media(MediaServerEvent), +} + +impl App { + fn new( + control_point: Arc, + renderers: Vec, + servers: Vec, + ) -> Self { + Self { + control_point, + renderers, + renderer_index: 0, + renderer_info: None, + servers, + server_index: 0, + server_info: None, + music_server: None, + browser: None, + mode: Mode::SelectRenderer, + ui_state: UiState::placeholder(), + queue_snapshot: Vec::new(), + queue_current_index: None, + show_queue_overlay: false, + show_help_overlay: false, + pending_binding_container: None, + status_line: "Sélectionne un renderer avec ↑/↓ et Entrée".to_string(), + known_tracks: HashMap::new(), + } + } + + fn renderer_id(&self) -> Option { + self.renderer_info.as_ref().map(|info| info.id.clone()) + } + + fn draw(&self, terminal: &mut ratatui::Frame<'_>) { + match self.mode { + Mode::SelectRenderer => self.draw_renderer_selection(terminal), + Mode::SelectServer => self.draw_server_selection(terminal), + Mode::Browse => self.draw_browser(terminal), + _ => self.draw_control_screen(terminal), + } + + if self.show_queue_overlay { + self.draw_queue_overlay(terminal); + } + + if self.show_help_overlay { + self.draw_help_overlay(terminal); + } + + if matches!(self.mode, Mode::BindingPrompt) { + self.draw_binding_prompt(terminal); + } + } + + fn draw_renderer_selection(&self, f: &mut ratatui::Frame<'_>) { + let area = f.size(); + let block = Block::default() + .borders(Borders::ALL) + .title("Sélection du renderer"); + let items: Vec = self + .renderers + .iter() + .map(|info| { + let text = format!("{} | {}", info.friendly_name, info.model_name); + ListItem::new(text) + }) + .collect(); + let list = List::new(items) + .block(block) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + let mut state = ListState::default(); + state.select(Some(self.renderer_index)); + f.render_stateful_widget(list, area, &mut state); + self.draw_status_line(f); + } + + fn draw_server_selection(&self, f: &mut ratatui::Frame<'_>) { + let area = f.size(); + let block = Block::default() + .borders(Borders::ALL) + .title("Sélection du serveur"); + let items: Vec = self + .servers + .iter() + .map(|info| { + let text = format!("{} | {}", info.friendly_name, info.model_name); + ListItem::new(text) + }) + .collect(); + let list = List::new(items) + .block(block) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + let mut state = ListState::default(); + state.select(Some(self.server_index)); + f.render_stateful_widget(list, area, &mut state); + self.draw_status_line(f); + } + + fn draw_browser(&self, f: &mut ratatui::Frame<'_>) { + let area = f.size(); + let Some(browser) = &self.browser else { + self.draw_status_line(f); + return; + }; + + let block = Block::default().borders(Borders::ALL).title(Span::styled( + format!( + "Navigation: {} (id: {})", + browser.nav_state.current_container_title, browser.nav_state.current_container_id + ), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )); + + let items: Vec = browser + .entries + .iter() + .map(|entry| { + let icon = if entry.is_container { "📁" } else { "♪" }; + let label = format!("{} {}", icon, entry.title); + ListItem::new(label) + }) + .collect(); + + let list = List::new(items) + .block(block) + .highlight_style( + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + let mut state = ListState::default(); + state.select(Some(browser.selected_index)); + f.render_stateful_widget(list, area, &mut state); + self.draw_status_line(f); + } + + fn draw_control_screen(&self, f: &mut ratatui::Frame<'_>) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Min(8), + Constraint::Length(3), + ]) + .split(f.size()); + + self.draw_header(f, chunks[0]); + self.draw_playback_panel(f, chunks[1]); + self.draw_help(f, chunks[2]); + + self.draw_status_line(f); + } + + fn draw_header(&self, f: &mut ratatui::Frame<'_>, area: Rect) { + let ui = &self.ui_state; + let renderer = &ui.renderer_name; + let server = ui.server_name.as_deref().unwrap_or(""); + let state = ui + .playback_state + .as_ref() + .map(|s| format!("{:?}", s)) + .unwrap_or_else(|| "Inconnu".to_string()); + let volume = ui + .volume + .map(|v| v.to_string()) + .unwrap_or_else(|| "--".to_string()); + let mute = match ui.mute { + Some(true) => "ON", + Some(false) => "OFF", + None => "??", + }; + + let text = vec![ + Line::from(vec![Span::styled( + format!("Renderer : {renderer}"), + Style::default().fg(Color::Yellow), + )]), + Line::from(vec![Span::raw(format!("Serveur : {server}"))]), + Line::from(vec![Span::raw(format!( + "État : {state} Volume {volume} | Mute {mute}" + ))]), + ]; + + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title("Statut")) + .alignment(Alignment::Left); + f.render_widget(paragraph, area); + } + + fn draw_playback_panel(&self, f: &mut ratatui::Frame<'_>, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(6), Constraint::Length(3)]) + .split(area); + + let meta_lines = render_metadata_block(self.ui_state.metadata.as_ref()); + let paragraph = Paragraph::new(meta_lines) + .block( + Block::default() + .borders(Borders::ALL) + .title("Lecture en cours"), + ) + .wrap(ratatui::widgets::Wrap { trim: true }); + f.render_widget(paragraph, chunks[0]); + + let gauge_area = chunks[1]; + let gauge = match self.ui_state.position.as_ref() { + Some(pos) => build_progress_gauge(pos), + None => Gauge::default() + .block(Block::default().borders(Borders::ALL).title("Progression")) + .label("en attente...") + .ratio(0.0), + }; + f.render_widget(gauge, gauge_area); + } + + fn draw_help(&self, f: &mut ratatui::Frame<'_>, area: Rect) { + let lines = vec![ + Line::from("Commandes: h=Aide détaillée | R=Renderer | S=Serveur"), + Line::from(" r=Play p=Pause s=Stop n=Next"), + Line::from(" +=Vol+ -=Vol- m=Mute i=Infos k=Queue b=Binding"), + Line::from(" q=Quit | ESC ferme les overlays"), + ]; + let paragraph = + Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("Raccourcis")); + f.render_widget(paragraph, area); + } + + fn draw_help_overlay(&self, f: &mut ratatui::Frame<'_>) { + let area = centered_rect(70, 60, f.size()); + let lines = vec![ + Line::from("Raccourcis disponibles:"), + Line::from(" R / S : re-sélection du renderer / serveur"), + Line::from(" n / p : navigation dans la file"), + Line::from(" +/-/m : volume et mute"), + Line::from(" k : afficher la queue actuelle"), + Line::from(" b : afficher le binding playlist"), + Line::from(" h : fermer cette aide"), + Line::from(" ESC : fermer les overlays"), + ]; + let block = Block::default() + .title("Aide détaillée (h pour fermer)") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)); + f.render_widget(Clear, area); + f.render_widget(Paragraph::new(lines).block(block), area); + } + + fn draw_queue_overlay(&self, f: &mut ratatui::Frame<'_>) { + let area = centered_rect(80, 70, f.size()); + let mut lines = Vec::new(); + lines.push(Line::from("Playlist actuelle (▶ = en cours):")); + if self.queue_snapshot.is_empty() { + lines.push(Line::from(" ")); + } else { + for (idx, item) in self.queue_snapshot.iter().enumerate() { + let title = item.title.as_deref().unwrap_or(""); + let artist = item.artist.as_deref().unwrap_or(""); + let prefix = match self.queue_current_index { + Some(current) if current == idx => "▶", + _ => " ", + }; + let line = if artist.is_empty() { + format!("{prefix} [{idx}] {title}") + } else { + format!("{prefix} [{idx}] {artist} - {title}") + }; + lines.push(Line::from(line)); + } + } + let block = Block::default() + .title("Playlist (fermer avec k ou Esc)") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)); + f.render_widget(Clear, area); + f.render_widget(Paragraph::new(lines).block(block), area); + } + + fn draw_binding_prompt(&self, f: &mut ratatui::Frame<'_>) { + let area = centered_rect(60, 40, f.size()); + let lines = vec![ + Line::from("Attacher la file au conteneur pour auto-refresh ?"), + Line::from("y = oui | n = non"), + ]; + let block = Block::default() + .title("Binding playlist") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)); + f.render_widget(Clear, area); + f.render_widget(Paragraph::new(lines).block(block), area); + } + + fn draw_status_line(&self, f: &mut ratatui::Frame<'_>) { + let area = Rect { + x: 0, + y: f.size().height.saturating_sub(1), + width: f.size().width, + height: 1, + }; + let status = self + .ui_state + .last_status + .clone() + .unwrap_or_else(|| self.status_line.clone()); + let paragraph = Paragraph::new(status).style(Style::default().fg(Color::Gray)); + f.render_widget(paragraph, area); + } + + fn handle_key(&mut self, key: KeyEvent) -> Result { + match self.mode { + Mode::SelectRenderer => self.handle_renderer_key(key), + Mode::SelectServer => self.handle_server_key(key), + Mode::Browse => self.handle_browse_key(key), + Mode::BindingPrompt => self.handle_binding_key(key), + Mode::Control => self.handle_control_key(key), + } + } + + fn handle_renderer_key(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Char('q') => return Ok(true), + KeyCode::Up => { + if self.renderer_index > 0 { + self.renderer_index -= 1; + } + } + KeyCode::Down => { + if self.renderer_index + 1 < self.renderers.len() { + self.renderer_index += 1; + } + } + KeyCode::Enter => { + let info = self.renderers[self.renderer_index].clone(); + if let Some(current) = &self.renderer_info { + if current.id != info.id { + let _ = self.stop_current_renderer_playback(); + } + } + self.ui_state = UiState::new(info.friendly_name.clone()); + self.renderer_info = Some(info); + self.server_info = None; + self.music_server = None; + self.browser = None; + self.queue_snapshot.clear(); + self.queue_current_index = None; + self.known_tracks.clear(); + self.pending_binding_container = None; + self.ui_state.current_track_uri = None; + self.ui_state.server_name = None; + self.show_queue_overlay = false; + self.show_help_overlay = false; + self.mode = Mode::SelectServer; + self.update_renderer_status_from_device(); + self.status_line = "Sélectionne un serveur avec ↑/↓ et Entrée".to_string(); + } + _ => {} + } + Ok(false) + } + + fn handle_server_key(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Char('q') => return Ok(true), + KeyCode::Up => { + if self.server_index > 0 { + self.server_index -= 1; + } + } + KeyCode::Down => { + if self.server_index + 1 < self.servers.len() { + self.server_index += 1; + } + } + KeyCode::Enter => { + let info = self.servers[self.server_index].clone(); + match MusicServer::from_info(&info, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) { + Ok(server) => { + let entries = server.browse_root()?; + let browser = BrowserState::new(entries); + self.music_server = Some(server); + self.server_info = Some(info.clone()); + self.browser = Some(browser); + self.mode = Mode::Browse; + self.ui_state.server_name = Some(info.friendly_name.clone()); + self.status_line = + "Navigue avec ↑/↓, Entrée pour ouvrir, s pour sélectionner".to_string(); + } + Err(err) => { + self.ui_state + .set_status(format!("MusicServer init failed: {err}")); + } + } + } + _ => {} + } + Ok(false) + } + + fn handle_browse_key(&mut self, key: KeyEvent) -> Result { + if key.code == KeyCode::Char('q') { + return Ok(true); + } + let Some(browser) = self.browser.as_mut() else { + return Ok(false); + }; + let Some(server) = self.music_server.as_mut() else { + return Ok(false); + }; + + match key.code { + KeyCode::Up => { + if browser.selected_index > 0 { + browser.selected_index -= 1; + } + } + KeyCode::Down => { + if browser.selected_index + 1 < browser.entries.len() { + browser.selected_index += 1; + } + } + KeyCode::Enter => { + if let Some(entry) = browser.current_entry() { + if entry.is_container { + match server.browse_children(&entry.id, 0, 100) { + Ok(children) => { + browser + .nav_state + .enter_container(entry.id.clone(), entry.title.clone()); + browser.entries = children; + browser.selected_index = 0; + } + Err(err) => { + self.ui_state + .set_status(format!("Impossible d'ouvrir: {err}")); + } + } + } + } + } + KeyCode::Char('b') => { + if browser.nav_state.go_back() { + let entries = if browser.nav_state.current_container_id == "0" { + server.browse_root()? + } else { + server.browse_children(&browser.nav_state.current_container_id, 0, 100)? + }; + browser.entries = entries; + browser.selected_index = 0; + } + } + KeyCode::Char('s') => { + self.enqueue_current_container()?; + } + _ => {} + } + Ok(false) + } + + fn handle_binding_key(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Char('y') => { + self.attach_binding(true)?; + } + KeyCode::Char('n') | KeyCode::Esc => { + self.attach_binding(false)?; + } + KeyCode::Char('q') => return Ok(true), + _ => {} + } + Ok(false) + } + + fn handle_control_key(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Char('q') => return Ok(true), + KeyCode::Char('R') => { + self.open_renderer_menu(); + } + KeyCode::Char('S') => { + self.open_server_menu(); + } + KeyCode::Char('h') => { + self.show_help_overlay = !self.show_help_overlay; + if self.show_help_overlay { + self.ui_state.set_status("Aide ouverte"); + } else { + self.ui_state.set_status("Aide fermée"); + } + } + KeyCode::Char('p') => { + self.pause_renderer()?; + } + KeyCode::Char('r') => { + self.resume_renderer()?; + } + KeyCode::Char('s') => { + self.stop_renderer()?; + } + KeyCode::Char('n') => { + self.play_next()?; + } + KeyCode::Char('+') | KeyCode::Char('=') => { + self.adjust_volume(5)?; + } + KeyCode::Char('-') => { + self.adjust_volume(-5)?; + } + KeyCode::Char('m') => { + self.toggle_mute()?; + } + KeyCode::Char('i') => { + self.show_renderer_info()?; + } + KeyCode::Char('k') => { + self.show_queue_overlay = !self.show_queue_overlay; + } + KeyCode::Char('b') => { + self.show_binding(); + } + KeyCode::Esc => { + self.show_queue_overlay = false; + self.show_help_overlay = false; + } + _ => {} + } + Ok(false) + } + + fn enqueue_current_container(&mut self) -> Result<()> { + let Some(browser) = self.browser.as_ref() else { + return Ok(()); + }; + let current_container_id = browser.nav_state.current_container_id.clone(); + let entries = browser.entries.clone(); + let Some(server) = self.music_server.as_ref() else { + return Ok(()); + }; + let renderer_id = self + .renderer_id() + .ok_or_else(|| anyhow!("Renderer not selected"))?; + + let items = collect_playable_items(server, &entries)?; + if items.is_empty() { + self.ui_state.set_status("Aucun média dans ce conteneur"); + return Ok(()); + } + + self.control_point.clear_queue(&renderer_id)?; + self.control_point.enqueue_items(&renderer_id, items)?; + let (full_queue, current_index) = self + .control_point + .get_full_queue_snapshot(&renderer_id) + .context("failed to snapshot queue after enqueue")?; + self.queue_snapshot = full_queue; + self.queue_current_index = current_index; + self.record_queue_metadata(); + self.pending_binding_container = Some(current_container_id); + self.status_line = format!("File prête ({})", self.queue_snapshot.len()); + self.ui_state.set_status(&self.status_line); + self.mode = Mode::BindingPrompt; + Ok(()) + } + + fn attach_binding(&mut self, attach: bool) -> Result<()> { + if attach { + if let (Some(server), Some(container), Some(renderer_id)) = ( + &self.server_info, + self.pending_binding_container.clone(), + self.renderer_id(), + ) { + self.control_point + .attach_queue_to_playlist(&renderer_id, server.id.clone(), container.clone()) + .context("Failed to attach queue to playlist")?; + self.ui_state + .set_status(format!("File liée à '{}'", container)); + } + } else { + self.ui_state.set_status("File locale uniquement"); + } + + self.pending_binding_container = None; + self.mode = Mode::Control; + self.start_playback()?; + self.update_renderer_status_from_device(); + Ok(()) + } + + fn start_playback(&mut self) -> Result<()> { + let renderer_id = self + .renderer_id() + .ok_or_else(|| anyhow!("Renderer not selected"))?; + let next_item = self.peek_next_queue_item(&renderer_id); + self.control_point.play_next_from_queue(&renderer_id)?; + if let Some(item) = next_item { + self.apply_item_as_current(&item); + } else { + self.ui_state.set_status("File vide"); + } + self.refresh_queue_snapshot(&renderer_id); + Ok(()) + } + + fn pause_renderer(&mut self) -> Result<()> { + let renderer = self.get_renderer()?; + renderer.pause()?; + self.ui_state.set_status("Lecture en pause"); + Ok(()) + } + + fn resume_renderer(&mut self) -> Result<()> { + let renderer = self.get_renderer()?; + renderer.play()?; + self.ui_state.set_status("Lecture reprise"); + Ok(()) + } + + fn stop_renderer(&mut self) -> Result<()> { + let renderer = self.get_renderer()?; + renderer.stop()?; + self.ui_state.set_status("Lecture arrêtée"); + Ok(()) + } + + fn play_next(&mut self) -> Result<()> { + let renderer_id = self + .renderer_id() + .ok_or_else(|| anyhow!("Renderer not selected"))?; + let next_item = self.peek_next_queue_item(&renderer_id); + self.control_point.play_next_from_queue(&renderer_id)?; + if let Some(item) = next_item { + self.apply_item_as_current(&item); + } else { + self.ui_state.set_status("Piste suivante (file vide)"); + } + self.refresh_queue_snapshot(&renderer_id); + Ok(()) + } + + fn adjust_volume(&mut self, delta: i32) -> Result<()> { + let renderer = self.get_renderer()?; + let current = renderer.volume()?; + let new_volume = (current as i32 + delta).clamp(0, 100) as u16; + renderer.set_volume(new_volume)?; + self.ui_state.set_status(format!("Volume → {new_volume}")); + self.ui_state.volume = Some(new_volume); + Ok(()) + } + + fn toggle_mute(&mut self) -> Result<()> { + let renderer = self.get_renderer()?; + let current = renderer.mute()?; + renderer.set_mute(!current)?; + self.ui_state.mute = Some(!current); + self.ui_state.set_status(if !current { + "Mute activé" + } else { + "Mute désactivé" + }); + Ok(()) + } + + fn show_renderer_info(&mut self) -> Result<()> { + let renderer = self.get_renderer()?; + let info = renderer.info(); + self.ui_state.set_status(format!( + "Renderer: {} ({})", + info.friendly_name, info.model_name + )); + Ok(()) + } + + fn show_binding(&mut self) { + if let Some(renderer_id) = self.renderer_id() { + if let Some((server_id, container_id, has_seen_update)) = self + .control_point + .current_queue_playlist_binding(&renderer_id) + { + self.ui_state.set_status(format!( + "Binding: {} -> {} (maj vue: {})", + server_id.0, container_id, has_seen_update + )); + } else { + self.ui_state.set_status("Pas de binding actif"); + } + } + } + + fn stop_current_renderer_playback(&self) -> Result<()> { + if let Some(renderer_id) = self.renderer_id() { + if let Some(renderer) = self.control_point.music_renderer_by_id(&renderer_id) { + renderer.stop()?; + } + } + Ok(()) + } + + fn update_renderer_status_from_device(&mut self) { + if let Ok(renderer) = self.get_renderer() { + if let Ok(volume) = renderer.volume() { + self.ui_state.volume = Some(volume); + } + if let Ok(mute) = renderer.mute() { + self.ui_state.mute = Some(mute); + } + if let Ok(state) = renderer.playback_state() { + self.ui_state.playback_state = Some(state); + } + if let Ok(position) = renderer.playback_position() { + self.ui_state.position = Some(position); + } + } + } + + fn open_renderer_menu(&mut self) { + self.mode = Mode::SelectRenderer; + self.status_line = "Sélectionne un renderer avec ↑/↓ et Entrée".to_string(); + self.ui_state.set_status("Menu renderer ouvert"); + self.show_queue_overlay = false; + self.show_help_overlay = false; + } + + fn open_server_menu(&mut self) { + if self.renderer_info.is_some() { + self.mode = Mode::SelectServer; + self.status_line = "Sélectionne un serveur avec ↑/↓ et Entrée".to_string(); + self.ui_state.set_status("Menu serveur ouvert"); + self.show_queue_overlay = false; + self.show_help_overlay = false; + } else { + self.ui_state.set_status("Sélectionne d'abord un renderer"); + } + } + + fn record_queue_metadata(&mut self) { + for item in &self.queue_snapshot { + if let Some(meta) = playback_metadata_from_item(item) { + self.known_tracks.insert(item.uri.clone(), meta); + } + } + } + + fn apply_item_as_current(&mut self, item: &PlaybackItem) { + if let Some(meta) = playback_metadata_from_item(item) { + self.known_tracks.insert(item.uri.clone(), meta.clone()); + self.ui_state.metadata = Some(meta.clone()); + self.ui_state.set_status(format_track_status(&meta)); + } else { + let label = item + .title + .as_deref() + .map(|t| t.to_string()) + .unwrap_or_else(|| item.uri.clone()); + self.ui_state.metadata = None; + self.ui_state.set_status(format!("Lecture: {label}")); + } + self.ui_state.current_track_uri = Some(item.uri.clone()); + } + + fn update_metadata_from_uri(&mut self, uri: &str) { + self.ui_state.current_track_uri = Some(uri.to_string()); + if let Some(meta) = self.known_tracks.get(uri).cloned() { + self.ui_state.metadata = Some(meta.clone()); + self.ui_state.set_status(format_track_status(&meta)); + } else { + self.ui_state.metadata = None; + self.ui_state.set_status(format!("Lecture: {uri}")); + } + } + + fn refresh_queue_snapshot(&mut self, renderer_id: &pmocontrol::model::RendererId) { + if let Ok((queue, current_index)) = self.control_point.get_full_queue_snapshot(renderer_id) + { + self.queue_snapshot = queue; + self.queue_current_index = current_index; + self.record_queue_metadata(); + if let Some(idx) = self.queue_current_index { + if let Some(item) = self.queue_snapshot.get(idx).cloned() { + let needs_update = + self.ui_state.current_track_uri.as_deref() != Some(item.uri.as_str()); + if needs_update { + self.apply_item_as_current(&item); + } + } + } + } + } + + fn peek_next_queue_item( + &self, + renderer_id: &pmocontrol::model::RendererId, + ) -> Option { + self.control_point + .get_queue_snapshot(renderer_id) + .ok() + .and_then(|queue| queue.into_iter().next()) + } + + fn get_renderer(&self) -> Result { + let renderer_id = self + .renderer_id() + .ok_or_else(|| anyhow!("Renderer not selected"))?; + self.control_point + .music_renderer_by_id(&renderer_id) + .ok_or_else(|| anyhow!("Renderer not found")) + } + + fn handle_app_event(&mut self, event: AppEvent) { + match event { + AppEvent::Renderer(ev) => self.handle_renderer_event(ev), + AppEvent::Media(ev) => self.handle_media_event(ev), + } + } + + fn handle_renderer_event(&mut self, event: RendererEvent) { + let Some(renderer_id) = self.renderer_id() else { + return; + }; + match event { + RendererEvent::StateChanged { id, state } => { + if id == renderer_id { + self.ui_state.playback_state = Some(state.clone()); + self.ui_state.set_status(format!("État: {:?}", state)); + } + } + RendererEvent::PositionChanged { id, position } => { + if id == renderer_id { + let track_changed = + position.track_uri.as_ref() != self.ui_state.current_track_uri.as_ref(); + self.ui_state.position = Some(position.clone()); + if track_changed { + if let Some(uri) = position.track_uri.as_deref() { + self.update_metadata_from_uri(uri); + self.refresh_queue_snapshot(&renderer_id); + } else { + self.ui_state.current_track_uri = None; + } + } + } + } + RendererEvent::VolumeChanged { id, volume } => { + if id == renderer_id { + self.ui_state.volume = Some(volume); + self.ui_state.set_status(format!("Volume → {volume}")); + } + } + RendererEvent::MuteChanged { id, mute } => { + if id == renderer_id { + self.ui_state.mute = Some(mute); + self.ui_state.set_status(if mute { + "Mute activé" + } else { + "Mute désactivé" + }); + } + } + RendererEvent::MetadataChanged { id, metadata } => { + if id == renderer_id { + self.ui_state.metadata = Some(metadata.clone()); + self.ui_state.set_status(format_track_status(&metadata)); + if let Some(uri) = self.ui_state.current_track_uri.clone() { + self.known_tracks.insert(uri, metadata); + } + } + } + RendererEvent::QueueUpdated { id, queue_length } => { + if id == renderer_id { + self.refresh_queue_snapshot(&renderer_id); + self.ui_state + .set_status(format!("File mise à jour ({queue_length})")); + } + } + } + } + + fn handle_media_event(&mut self, event: MediaServerEvent) { + match event { + MediaServerEvent::GlobalUpdated { + server_id, + system_update_id, + } => { + self.ui_state.set_status(format!( + "MAJ serveur {} (SystemUpdateID={})", + server_id.0, + system_update_id.unwrap_or(0) + )); + } + MediaServerEvent::ContainersUpdated { + server_id, + container_ids, + } => { + let mut status = format!( + "Conteneurs mis à jour sur {}: {:?}", + server_id.0, container_ids + ); + if let Some(renderer_id) = self.renderer_id() { + if let Some((bound_server, bound_container, _)) = self + .control_point + .current_queue_playlist_binding(&renderer_id) + { + if bound_server == server_id && container_ids.contains(&bound_container) { + status + .push_str(&format!(" | Playlist '{}' rafraîchie", bound_container)); + self.refresh_queue_snapshot(&renderer_id); + } + } + } + self.ui_state.set_status(status); + } + } + } +} + +impl BrowserState { + fn new(entries: Vec) -> Self { + Self { + nav_state: NavigationState::new("0".to_string(), "Root".to_string()), + entries, + selected_index: 0, + } + } + + fn current_entry(&self) -> Option<&MediaEntry> { + self.entries.get(self.selected_index) + } +} + +fn run_app(mut app: App) -> Result<()> { + let mut terminal = setup_terminal()?; + let (event_tx, event_rx) = mpsc::channel(); + let mut renderer_events_started = false; + let mut last_tick = Instant::now(); + + loop { + terminal.draw(|f| app.draw(f))?; + + while let Ok(event) = event_rx.try_recv() { + app.handle_app_event(event); + } + + if !renderer_events_started { + if app.renderer_id().is_some() { + start_event_threads(Arc::clone(&app.control_point), event_tx.clone()); + renderer_events_started = true; + } + } + + let timeout = TICK_RATE + .checked_sub(last_tick.elapsed()) + .unwrap_or_else(|| Duration::from_secs(0)); + + if event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + if app.handle_key(key)? { + let _ = app.stop_current_renderer_playback(); + break; + } + } + } + + if last_tick.elapsed() >= TICK_RATE { + last_tick = Instant::now(); + } + } + + restore_terminal(&mut terminal)?; + Ok(()) +} + +fn setup_terminal() -> Result>> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + Ok(terminal) +} + +fn restore_terminal(terminal: &mut Terminal>) -> Result<()> { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + Ok(()) +} + +fn start_event_threads(control_point: Arc, tx: Sender) { + let renderer_cp = Arc::clone(&control_point); + let renderer_tx = tx.clone(); + thread::spawn(move || { + let event_rx = renderer_cp.subscribe_events(); + for event in event_rx { + if renderer_tx.send(AppEvent::Renderer(event)).is_err() { + break; + } + } + }); + + thread::spawn(move || { + let media_rx = control_point.subscribe_media_server_events(); + for event in media_rx { + if tx.send(AppEvent::Media(event)).is_err() { + break; + } + } + }); +} + +/// Navigation state for ContentDirectory browsing. +struct NavigationState { + path_stack: Vec<(String, String)>, + current_container_id: String, + current_container_title: String, +} + +impl NavigationState { + fn new(root_id: String, root_title: String) -> Self { + Self { + path_stack: Vec::new(), + current_container_id: root_id, + current_container_title: root_title, + } + } + + fn enter_container(&mut self, container_id: String, container_title: String) { + self.path_stack.push(( + self.current_container_id.clone(), + self.current_container_title.clone(), + )); + self.current_container_id = container_id; + self.current_container_title = container_title; + } + + fn go_back(&mut self) -> bool { + if let Some((parent_id, parent_title)) = self.path_stack.pop() { + self.current_container_id = parent_id; + self.current_container_title = parent_title; + true + } else { + false + } + } +} + +/// Collect playable items from MediaEntry list (including nested containers). +fn collect_playable_items( + server: &MusicServer, + entries: &[MediaEntry], +) -> Result> { + let mut items = Vec::new(); + for entry in entries { + if entry.is_container { + match server.browse_children(&entry.id, 0, 100) { + Ok(children) => { + items.extend(collect_playable_items(server, &children)?); + } + Err(err) => { + eprintln!( + "Warning: failed to browse container '{}': {err}", + entry.title + ); + } + } + } else if let Some(item) = playback_item_from_entry(server, entry) { + items.push(item); + } + } + Ok(items) +} + +/// Convert MediaEntry to PlaybackItem. +fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option { + let resource = entry.resources.iter().find(|res| is_audio_resource(res))?; + let mut item = PlaybackItem::new(resource.uri.clone()); + item.title = Some(entry.title.clone()); + item.server_id = Some(server.id().clone()); + item.object_id = Some(entry.id.clone()); + item.artist = entry.artist.clone(); + item.album = entry.album.clone(); + item.genre = entry.genre.clone(); + item.album_art_uri = entry.album_art_uri.clone(); + item.date = entry.date.clone(); + item.track_number = entry.track_number.clone(); + item.creator = entry.creator.clone(); + Some(item) +} + +fn playback_metadata_from_item(item: &PlaybackItem) -> Option { + let metadata = TrackMetadata { + title: item.title.clone(), + artist: item.artist.clone(), + album: item.album.clone(), + genre: item.genre.clone(), + album_art_uri: item.album_art_uri.clone(), + date: item.date.clone(), + track_number: item.track_number.clone(), + creator: item.creator.clone(), + }; + + if metadata.title.is_none() + && metadata.artist.is_none() + && metadata.album.is_none() + && metadata.genre.is_none() + && metadata.album_art_uri.is_none() + && metadata.date.is_none() + && metadata.track_number.is_none() + && metadata.creator.is_none() + { + return None; + } + + Some(metadata) +} + +/// Check if MediaResource is audio. +fn is_audio_resource(res: &MediaResource) -> bool { + let lower = res.protocol_info.to_ascii_lowercase(); + if lower.contains("audio/") { + return true; + } + lower + .split(':') + .nth(2) + .map(|mime| mime.starts_with("audio/")) + .unwrap_or(false) +} + +fn render_metadata_block(metadata: Option<&TrackMetadata>) -> Vec> { + let mut lines = Vec::new(); + if let Some(meta) = metadata { + let title = meta + .title + .clone() + .unwrap_or_else(|| "".to_string()); + lines.push(Line::from(format!("Titre : {title}"))); + if let Some(artist) = meta.artist.as_deref() { + lines.push(Line::from(format!("Artiste: {artist}"))); + } + if let Some(album) = meta.album.as_deref() { + lines.push(Line::from(format!("Album : {album}"))); + } + if let Some(genre) = meta.genre.as_deref() { + lines.push(Line::from(format!("Genre : {genre}"))); + } + if let Some(date) = meta.date.as_deref() { + lines.push(Line::from(format!("Date : {}", format_date(date)))); + } + if let Some(track) = meta.track_number.as_deref() { + lines.push(Line::from(format!("Piste : {track}"))); + } + if let Some(art) = meta.album_art_uri.as_deref() { + lines.push(Line::from(format!("Cover : {art}"))); + } + } else { + lines.push(Line::from("(En attente des métadonnées...)")); + } + lines +} + +fn format_date(raw: &str) -> String { + let parts: Vec<&str> = raw.split('-').collect(); + if parts.len() == 3 && parts[1] == "01" && parts[2] == "01" { + return parts[0].to_string(); + } + raw.to_string() +} + +fn build_progress_gauge(position: &PlaybackPositionInfo) -> Gauge<'static> { + let rel_secs = position.rel_time.as_deref().and_then(parse_time_to_seconds); + let dur_secs = position + .track_duration + .as_deref() + .and_then(parse_time_to_seconds); + + let ratio = match (rel_secs, dur_secs) { + (Some(rel), Some(dur)) if dur > 0 => rel as f64 / dur as f64, + _ => 0.0, + }; + let ratio = ratio.clamp(0.0, 1.0); + + let rel_label = position + .rel_time + .clone() + .unwrap_or_else(|| "--:--".to_string()); + let dur_label = position + .track_duration + .clone() + .unwrap_or_else(|| "--:--".to_string()); + + let label = format!("{} / {}", rel_label, dur_label); + Gauge::default() + .block(Block::default().borders(Borders::ALL).title("Progression")) + .gauge_style(Style::default().fg(Color::Magenta)) + .ratio(ratio) + .label(label) +} + +fn format_track_status(meta: &TrackMetadata) -> String { + let title = meta.title.as_deref().unwrap_or(""); + let artist = meta.artist.as_deref().unwrap_or(""); + if artist.is_empty() { + format!("Lecture: {title}") + } else { + format!("Lecture: {artist} - {title}") + } +} + +fn parse_time_to_seconds(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed == "-" { + return None; + } + if let Ok(seconds) = trimmed.parse::() { + return Some(seconds); + } + + let parts: Vec<&str> = trimmed.split(':').collect(); + match parts.len() { + 3 => { + let hours = parts[0].parse::().ok()?; + let minutes = parts[1].parse::().ok()?; + let seconds = parse_seconds(parts[2])?; + Some(hours * 3600 + minutes * 60 + seconds) + } + 2 => { + let minutes = parts[0].parse::().ok()?; + let seconds = parse_seconds(parts[1])?; + Some(minutes * 60 + seconds) + } + _ => None, + } +} + +fn parse_seconds(fragment: &str) -> Option { + fragment + .split('.') + .next() + .and_then(|s| s.parse::().ok()) +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ] + .as_ref(), + ) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ] + .as_ref(), + ) + .split(popup_layout[1])[1] +} diff --git a/pmocontrol/examples/linkplay_demo.rs b/pmocontrol/examples/linkplay_demo.rs new file mode 100644 index 00000000..ec76a24c --- /dev/null +++ b/pmocontrol/examples/linkplay_demo.rs @@ -0,0 +1,98 @@ +use std::io; +use std::thread; +use std::time::Duration; + +use pmocontrol::{ + ControlPoint, DeviceRegistryRead, MusicRenderer, PlaybackPosition, PlaybackPositionInfo, + PlaybackState, PlaybackStatus, VolumeControl, +}; + +fn main() -> io::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + println!("Starting PMOMusic LinkPlay demo..."); + + let cp = ControlPoint::spawn(5)?; + println!("Waiting 5 seconds for SSDP discovery..."); + thread::sleep(Duration::from_secs(5)); + + let registry = cp.registry(); + let renderers = { + let reg = registry.read().unwrap(); + reg.list_renderers() + }; + + if renderers.is_empty() { + println!("No renderers discovered."); + return Ok(()); + } + + println!("Discovered renderers:"); + for (idx, info) in renderers.iter().enumerate() { + println!( + " [{}] {} | model={} | LinkPlay HTTP={}", + idx, info.friendly_name, info.model_name, info.capabilities.has_linkplay_http + ); + } + + let linkplay_renderers: Vec = renderers + .iter() + .filter(|info| info.capabilities.has_linkplay_http) + .filter_map(|info| MusicRenderer::from_registry_info(info.clone(), ®istry)) + .collect(); + + if linkplay_renderers.is_empty() { + println!("\nNo LinkPlay-capable renderers detected."); + return Ok(()); + } + + println!("\nLinkPlay renderer status:"); + for renderer in linkplay_renderers { + println!("- {} ({})", renderer.friendly_name(), renderer.id().0); + + match renderer.playback_state() { + Ok(state) => println!(" state: {}", format_playback_state(&state)), + Err(err) => println!(" state: error: {}", err), + } + + match renderer.playback_position() { + Ok(pos) => println!(" position: {}", format_position(&pos)), + Err(err) => println!(" position: error: {}", err), + } + + match renderer.volume() { + Ok(vol) => println!(" volume: {}", vol), + Err(err) => println!(" volume: error: {}", err), + } + + match renderer.mute() { + Ok(mute) => println!(" mute: {}", mute), + Err(err) => println!(" mute: error: {}", err), + } + + println!(); + } + + Ok(()) +} + +fn format_playback_state(state: &PlaybackState) -> String { + match state { + PlaybackState::Stopped => "Stopped".to_string(), + PlaybackState::Playing => "Playing".to_string(), + PlaybackState::Paused => "Paused".to_string(), + PlaybackState::Transitioning => "Transitioning".to_string(), + PlaybackState::NoMedia => "NoMedia".to_string(), + PlaybackState::Unknown(raw) => format!("Unknown({})", raw), + } +} + +fn format_position(pos: &PlaybackPositionInfo) -> String { + let rel = pos.rel_time.as_deref().unwrap_or("-"); + let dur = pos.track_duration.as_deref().unwrap_or("-"); + let track = pos + .track + .map(|t| t.to_string()) + .unwrap_or_else(|| "-".to_string()); + + format!("track={} rel_time={} duration={}", track, rel, dur) +} diff --git a/pmocontrol/examples/live_pmomusic_demo.rs b/pmocontrol/examples/live_pmomusic_demo.rs new file mode 100644 index 00000000..7829d908 --- /dev/null +++ b/pmocontrol/examples/live_pmomusic_demo.rs @@ -0,0 +1,604 @@ +//! Live PMOMusic demo: binds a renderer queue to a dynamic "Live Playlist" container +//! and monitors ContentDirectory updates over an extended period (~30 minutes). + +use std::collections::{HashSet, VecDeque}; +use std::env; +use std::process; +use std::thread; +use std::time::Duration; + +use anyhow::{Context, Result}; +use pmocontrol::{ + ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaResource, MediaServerEvent, + MediaServerInfo, MusicRenderer, MusicServer, PlaybackItem, PlaybackPosition, + PlaybackPositionInfo, RendererInfo, RendererProtocol, +}; + +const DEFAULT_TIMEOUT_SECS: u64 = 5; +const DEFAULT_DISCOVERY_SECS: u64 = 5; +const DEFAULT_MAX_INITIAL_TRACKS: usize = 15; +const MONITOR_DURATION_SECS: u64 = 1800; // ~30 minutes +const MONITOR_POLL_SECS: u64 = 5; +const MAX_BROWSE_DEPTH: usize = 4; +const MAX_CONTAINERS_TO_EXPLORE: usize = 100; +const FALLBACK_MIN_TRACKS: usize = 5; + +fn main() -> Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + let config = CliConfig::parse_from_env().unwrap_or_else(|err| { + eprintln!("Error parsing arguments: {err}"); + print_usage_and_exit(); + }); + + println!( + "Starting live_pmomusic_demo with timeout={}s discovery={}s max_initial_tracks={}", + config.timeout_secs, config.discovery_secs, config.max_initial_tracks + ); + + // ControlPoint::spawn starts the HttpXmlDescriptionProvider + DiscoveryManager combo. + let control_point = + ControlPoint::spawn(config.timeout_secs).context("Failed to start control point")?; + + println!( + "Discovery running for {} seconds before selecting devices...", + config.discovery_secs + ); + thread::sleep(Duration::from_secs(config.discovery_secs)); + + let registry = control_point.registry(); + let (renderer, server_info) = { + let reg = registry.read().expect("registry poisoned"); + let renderer_candidates: Vec = reg + .list_renderers() + .into_iter() + .filter(|info| !is_pmomusic_renderer(info)) + .collect(); + let renderer = pick_renderer(renderer_candidates) + .unwrap_or_else(|| no_renderer_and_exit("No suitable renderer found after discovery.")); + let server = pick_pmomusic_server(reg.list_servers()) + .unwrap_or_else(|| no_server_and_exit("No PMOMusic media server found.")); + (renderer, server) + }; + + println!( + "Selected renderer \"{}\" (protocol={:?}, id={})", + renderer.friendly_name, renderer.protocol, renderer.id.0 + ); + println!( + "Selected media server \"{}\" at {} (id={})", + server_info.friendly_name, server_info.location, server_info.id.0 + ); + + let renderer_instance = MusicRenderer::from_registry_info(renderer.clone(), ®istry) + .expect("Selected renderer is not usable by MusicRenderer façade"); + let supports_set_next = renderer_instance + .as_upnp() + .map(|upnp| upnp.supports_set_next()) + .unwrap_or(false); + println!( + "Renderer \"{}\": AVTransport present = {}, SetNextAVTransportURI supported = {}", + renderer.friendly_name, renderer.capabilities.has_avtransport, supports_set_next + ); + + let timeout = Duration::from_secs(config.timeout_secs); + let server = + MusicServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?; + + println!("Searching for a Live Playlist container in ContentDirectory..."); + let live_playlist_container = find_live_playlist_container(&server) + .context("Failed to search for Live Playlist container")?; + + let live_playlist_container = match live_playlist_container { + Some(container) => container, + None => { + println!( + "No Live Playlist container found on server \"{}\". Exiting.", + server_info.friendly_name + ); + process::exit(1); + } + }; + + println!( + "✓ Found Live Playlist: '{}' (id: {}, class: {})", + live_playlist_container.title, live_playlist_container.id, live_playlist_container.class + ); + + // Build initial queue from the live playlist container + let playback_items = collect_playable_items_from_container( + &server, + &live_playlist_container.id, + config.max_initial_tracks, + ) + .context("Failed to collect playable items from Live Playlist container")?; + + if playback_items.is_empty() { + println!( + "Live Playlist container '{}' contains no playable tracks.", + live_playlist_container.title + ); + process::exit(1); + } + + println!( + "Discovered {} playable items from Live Playlist; enqueuing…", + playback_items.len() + ); + + let renderer_id = renderer.id.clone(); + control_point + .clear_queue(&renderer_id) + .context("Failed to clear playback queue")?; + control_point + .enqueue_items(&renderer_id, playback_items) + .context("Failed to enqueue playback items")?; + + // Attach queue to live playlist container + control_point + .attach_queue_to_playlist( + &renderer_id, + server_info.id.clone(), + live_playlist_container.id.clone(), + ) + .context("Failed to attach queue to live playlist container")?; + println!( + "✓ Queue attached to Live Playlist container '{}' (id: {}) on server '{}'", + live_playlist_container.title, live_playlist_container.id, server_info.friendly_name + ); + + let snapshot = control_point + .get_queue_snapshot(&renderer_id) + .context("Failed to snapshot queue after enqueue")?; + print_queue_snapshot(&snapshot); + let mut planned_queue: VecDeque = snapshot.clone().into(); + + control_point + .play_next_from_queue(&renderer_id) + .context("Failed to start playback from queue")?; + let mut current_track = planned_queue.pop_front(); + let remaining = control_point + .get_queue_snapshot(&renderer_id) + .context("Failed to snapshot queue after play_next_from_queue")?; + planned_queue = remaining.clone().into(); + println!( + "Playback started on \"{}\"; {} tracks remaining in queue.", + renderer.friendly_name, + remaining.len() + ); + + println!( + "Monitoring Live Playlist queue for {} seconds (poll every {}s)…", + MONITOR_DURATION_SECS, MONITOR_POLL_SECS + ); + println!("This will observe ContentDirectory updates and auto-advance behavior."); + + // Subscribe to media server events to observe playlist updates + let media_event_rx = control_point.subscribe_media_server_events(); + + let poll_count = MONITOR_DURATION_SECS / MONITOR_POLL_SECS; + for tick in 0..poll_count { + thread::sleep(Duration::from_secs(MONITOR_POLL_SECS)); + + // Drain any MediaServerEvent that arrived since last poll + loop { + match media_event_rx.try_recv() { + Ok(MediaServerEvent::GlobalUpdated { + server_id, + system_update_id, + }) => { + println!( + " 📢 MediaServer {} global update (SystemUpdateID={:?})", + server_id.0, system_update_id + ); + } + Ok(MediaServerEvent::ContainersUpdated { + server_id, + container_ids, + }) => { + println!( + " 📢 MediaServer {} containers updated: {:?}", + server_id.0, container_ids + ); + + // Check if our bound container was updated + if let Some((bound_server, bound_container, _)) = + control_point.current_queue_playlist_binding(&renderer_id) + { + if bound_server == server_id && container_ids.contains(&bound_container) { + println!( + " 🔄 Bound Live Playlist container '{}' was updated, queue refresh triggered automatically", + bound_container + ); + // Take a fresh snapshot to observe changes + if let Ok(fresh_snapshot) = + control_point.get_queue_snapshot(&renderer_id) + { + println!( + " → Queue length after refresh: {} items", + fresh_snapshot.len() + ); + if !fresh_snapshot.is_empty() { + println!( + " → First item: {}", + fresh_snapshot[0].title.as_deref().unwrap_or("") + ); + } + } + } + } + } + Err(_) => break, // No more events, continue with normal monitoring + } + } + + let snapshot = control_point + .get_queue_snapshot(&renderer_id) + .context("Queue snapshot failed during monitoring loop")?; + let new_plan: VecDeque = snapshot.clone().into(); + + // Detect queue changes (auto-advance) + if planned_queue.len() > new_plan.len() { + let removed = planned_queue.len() - new_plan.len(); + for _ in 0..removed { + current_track = planned_queue.pop_front(); + } + } + planned_queue = new_plan; + + let playback_info = control_point + .music_renderer_by_id(&renderer_id) + .and_then(|renderer| renderer.playback_position().ok()); + + let title = current_track_title(current_track.as_ref()); + if let Some(info) = playback_info { + println!( + "[tick {tick}] Queue length = {} | now playing: {} [{}]", + snapshot.len(), + title, + format_playback_position(&info) + ); + } else { + println!( + "[tick {tick}] Queue length = {} | now playing: {} [position unavailable]", + snapshot.len(), + title + ); + } + } + + println!( + "Monitoring finished ({}s elapsed), exiting.", + MONITOR_DURATION_SECS + ); + Ok(()) +} + +#[derive(Debug)] +struct CliConfig { + timeout_secs: u64, + discovery_secs: u64, + max_initial_tracks: usize, +} + +impl CliConfig { + fn parse_from_env() -> Result { + let mut timeout_secs = DEFAULT_TIMEOUT_SECS; + let mut discovery_secs = DEFAULT_DISCOVERY_SECS; + let mut max_initial_tracks = DEFAULT_MAX_INITIAL_TRACKS; + + let mut args = env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--timeout-secs" => { + let value = args + .next() + .ok_or_else(|| "--timeout-secs requires a value".to_string())?; + timeout_secs = value.parse().map_err(|err| { + format!("Invalid value for --timeout-secs ({value}): {err}") + })?; + } + "--discovery-secs" => { + let value = args + .next() + .ok_or_else(|| "--discovery-secs requires a value".to_string())?; + discovery_secs = value.parse().map_err(|err| { + format!("Invalid value for --discovery-secs ({value}): {err}") + })?; + } + "--max-initial-tracks" => { + let value = args + .next() + .ok_or_else(|| "--max-initial-tracks requires a value".to_string())?; + max_initial_tracks = value.parse().map_err(|err| { + format!("Invalid value for --max-initial-tracks ({value}): {err}") + })?; + } + "--help" | "-h" => { + print_usage_and_exit(); + } + unknown => { + return Err(format!("Unknown argument: {unknown}")); + } + } + } + + Ok(Self { + timeout_secs, + discovery_secs, + max_initial_tracks, + }) + } +} + +fn pick_renderer(renderers: Vec) -> Option { + let mut candidates: Vec = renderers; + + if candidates.is_empty() { + return None; + } + + println!("Renderer candidates:"); + for (idx, info) in candidates.iter().enumerate() { + println!( + " [{}] {} | model={} | location={} | online={}", + idx, info.friendly_name, info.model_name, info.location, info.online + ); + } + + let selected = candidates.remove(0); + println!( + "Automatically selecting renderer index 0: {}", + selected.friendly_name + ); + Some(selected) +} + +fn pick_pmomusic_server(servers: Vec) -> Option { + let mut candidates: Vec = servers + .into_iter() + .filter(|info| info.has_content_directory) + .filter(|info| info.content_directory_control_url.is_some()) + .collect(); + + if candidates.is_empty() { + return None; + } + + // Prioritize PMOMusic servers + if let Some(idx) = candidates.iter().position(is_pmomusic_server) { + let server = candidates.remove(idx); + println!( + "✓ Selected PMOMusic server \"{}\" (model: {}, manufacturer: {}).", + server.friendly_name, server.model_name, server.manufacturer + ); + return Some(server); + } + + // Fallback to first ContentDirectory server if no PMOMusic found + println!("No PMOMusic server discovered; falling back to first ContentDirectory server."); + Some(candidates.remove(0)) +} + +fn is_pmomusic_server(info: &MediaServerInfo) -> bool { + let name = info.friendly_name.to_ascii_lowercase(); + let model = info.model_name.to_ascii_lowercase(); + let manufacturer = info.manufacturer.to_ascii_lowercase(); + name.contains("pmomusic") || model.contains("pmomusic") || manufacturer.contains("pmomusic") +} + +fn is_pmomusic_renderer(info: &RendererInfo) -> bool { + info.friendly_name + .to_ascii_lowercase() + .contains("pmomusic audio renderer") +} + +/// Search for a container whose title contains "Live Playlist" using BFS. +fn find_live_playlist_container(server: &MusicServer) -> Result> { + let root_entries = server + .browse_root() + .context("Failed to browse ContentDirectory root")?; + + println!( + "Root returned {} entries, starting BFS search for Live Playlist...", + root_entries.len() + ); + + // BFS queue: (entry, depth) + let mut queue: VecDeque<(MediaEntry, usize)> = VecDeque::new(); + let mut visited: HashSet = HashSet::new(); + let mut containers_explored = 0; + + // Initialize with root entries + for entry in root_entries { + if entry.is_container { + queue.push_back((entry, 0)); + } + } + + while let Some((container, depth)) = queue.pop_front() { + // Check exploration limits + if depth > MAX_BROWSE_DEPTH { + continue; + } + if containers_explored >= MAX_CONTAINERS_TO_EXPLORE { + println!( + "Reached max containers to explore ({}), stopping search.", + MAX_CONTAINERS_TO_EXPLORE + ); + break; + } + + // Skip already visited + if visited.contains(&container.id) { + continue; + } + visited.insert(container.id.clone()); + containers_explored += 1; + + let title_lower = container.title.to_ascii_lowercase(); + + // Check if this container is a Live Playlist + if title_lower.contains("live playlist") { + println!( + "✓ Found Live Playlist at depth {}: '{}' (id: {})", + depth, container.title, container.id + ); + return Ok(Some(container)); + } + + // Browse children and add containers to queue + match server.browse_children(&container.id, 0, 100) { + Ok(children) => { + for child in children { + if child.is_container && !visited.contains(&child.id) { + queue.push_back((child, depth + 1)); + } + } + } + Err(err) => { + tracing::warn!( + container_id = container.id.as_str(), + error = %err, + "Failed to browse container during Live Playlist search" + ); + } + } + } + + println!( + "No Live Playlist container found after exploring {} containers.", + containers_explored + ); + Ok(None) +} + +/// Collect playable items from a specific container. +/// Retries with fewer items if the initial browse times out. +fn collect_playable_items_from_container( + server: &MusicServer, + container_id: &str, + max_tracks: usize, +) -> Result> { + println!( + "Attempting to browse Live Playlist container (requesting {} items)...", + max_tracks + ); + + // First attempt with requested count + let children = match server.browse_children(container_id, 0, max_tracks as u32) { + Ok(children) => children, + Err(err) => { + let err_str = err.to_string().to_lowercase(); + if err_str.contains("timeout") && max_tracks > FALLBACK_MIN_TRACKS { + println!( + "Browse timed out, retrying with fewer items ({})...", + FALLBACK_MIN_TRACKS + ); + // Fallback: try with minimal number of items + server + .browse_children(container_id, 0, FALLBACK_MIN_TRACKS as u32) + .context( + "Failed to browse Live Playlist container even with minimal item count", + )? + } else { + return Err(err).context("Failed to browse Live Playlist container children"); + } + } + }; + + println!( + "Browse returned {} entries from Live Playlist", + children.len() + ); + + let mut items = Vec::new(); + for entry in &children { + if !entry.is_container { + if let Some(item) = playback_item_from_entry(server, entry) { + items.push(item); + if items.len() >= max_tracks { + break; + } + } + } + } + + println!( + "Extracted {} playable items from Live Playlist", + items.len() + ); + Ok(items) +} + +fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option { + // Skip live streams (we're looking for regular tracks in a live playlist) + if entry.title.to_ascii_lowercase().contains("live stream") { + return None; + } + let resource = entry.resources.iter().find(|res| is_audio_resource(res))?; + let mut item = PlaybackItem::new(resource.uri.clone()); + item.title = Some(entry.title.clone()); + item.server_id = Some(server.id().clone()); + item.object_id = Some(entry.id.clone()); + Some(item) +} + +fn is_audio_resource(res: &MediaResource) -> bool { + let lower = res.protocol_info.to_ascii_lowercase(); + if lower.contains("audio/") { + return true; + } + lower + .split(':') + .nth(2) + .map(|mime| mime.starts_with("audio/")) + .unwrap_or(false) +} + +fn print_queue_snapshot(items: &[PlaybackItem]) { + println!("Current queue snapshot ({} items):", items.len()); + for (idx, item) in items.iter().take(10).enumerate() { + let label = item.title.as_deref().unwrap_or_else(|| item.uri.as_str()); + println!(" [{}] {}", idx, label); + } + if items.len() > 10 { + println!(" ... and {} more items", items.len() - 10); + } + if items.is_empty() { + println!(" "); + } +} + +fn current_track_title(item: Option<&PlaybackItem>) -> String { + match item { + Some(track) => track + .title + .as_deref() + .unwrap_or_else(|| track.uri.as_str()) + .to_string(), + None => "".to_string(), + } +} + +fn format_playback_position(info: &PlaybackPositionInfo) -> String { + let rel = info.rel_time.as_deref().unwrap_or("-"); + let dur = info.track_duration.as_deref().unwrap_or("-"); + format!("{rel} / {dur}") +} + +fn no_renderer_and_exit(message: &str) -> ! { + println!("{message}"); + process::exit(1); +} + +fn no_server_and_exit(message: &str) -> ! { + println!("{message}"); + process::exit(1); +} + +fn print_usage_and_exit() -> ! { + println!( + "Usage: cargo run -p pmocontrol --example live_pmomusic_demo -- [--timeout-secs N] [--discovery-secs N] [--max-initial-tracks N]" + ); + process::exit(1); +} diff --git a/pmocontrol/examples/media_server_events_demo.rs b/pmocontrol/examples/media_server_events_demo.rs new file mode 100644 index 00000000..7840e373 --- /dev/null +++ b/pmocontrol/examples/media_server_events_demo.rs @@ -0,0 +1,115 @@ +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 = 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, + 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(|| "".into()) + ); + } + MediaServerEvent::ContainersUpdated { + server_id, + container_ids, + } => { + let label = describe_server(cp, cache, server_id); + println!( + "[{}] Containers updated: {}", + label, + if container_ids.is_empty() { + "".into() + } else { + container_ids.join(", ") + } + ); + } + } +} + +fn describe_server( + cp: &ControlPoint, + cache: &mut HashMap, + 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) +} diff --git a/pmocontrol/examples/pmo_remote_control.rs b/pmocontrol/examples/pmo_remote_control.rs new file mode 100644 index 00000000..45a12577 --- /dev/null +++ b/pmocontrol/examples/pmo_remote_control.rs @@ -0,0 +1,1847 @@ +//! Remote REST-based control point demo using Ratatui. +//! +//! This example mirrors the UX of `full_control_point_demo.rs` but drives a +//! remote PMOMusic server exclusively through the `/api/control` REST API. + +use std::env; +use std::fs::{File, OpenOptions}; +use std::io::{self, Stdout, Write}; +use std::process; +use std::sync::mpsc::TryRecvError; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow, bail}; +use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph}; +use serde::de::{DeserializeOwned, Deserializer}; +use serde::{Deserialize, Serialize}; +use tracing::info; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::fmt::writer::BoxMakeWriter; +use ureq::http; +use ureq::{Agent, Body}; + +const DEFAULT_BASE_URL: &str = "http://localhost:8080/api/control"; +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15); +const TICK_RATE: Duration = Duration::from_millis(200); +const ROOT_CONTAINERS: &[&str] = &["0", "0$"]; + +fn main() -> Result<()> { + // Install panic handler to restore terminal even on panic + std::panic::set_hook(Box::new(|panic_info| { + // Force terminal restoration + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); + eprintln!("\n\n❌ Application panicked: {:?}", panic_info); + eprintln!("Terminal has been restored. You can now close this window safely."); + })); + + init_tracing(); + let options = resolve_options()?; + info!( + base_url = %options.base_url, + timeout_ms = options.timeout.as_millis(), + "Démarrage du client REST" + ); + println!("PMO Remote Control demo"); + println!("Using Control API at {}", options.base_url); + + let client = RestClient::new(&options.base_url, options.timeout)?; + let renderers = client + .list_renderers() + .context("Impossible de récupérer la liste des renderers")?; + if renderers.is_empty() { + eprintln!( + "Aucun renderer disponible via {}. Lancement annulé.", + options.base_url + ); + return Ok(()); + } + + let app = App::new(client, renderers); + if let Err(err) = run_app(app) { + eprintln!("Application fermée avec erreur: {err}"); + } + + println!("\nAu revoir !"); + Ok(()) +} + +struct AppOptions { + base_url: String, + timeout: Duration, +} + +fn resolve_options() -> Result { + let mut args = env::args().skip(1); + let mut cli_base: Option = None; + let mut cli_timeout: Option = None; + while let Some(arg) = args.next() { + match arg.as_str() { + "--base-url" => { + let value = args + .next() + .ok_or_else(|| anyhow!("--base-url requiert une valeur"))?; + cli_base = Some(value); + } + "--timeout-ms" => { + let value = args + .next() + .ok_or_else(|| anyhow!("--timeout-ms requiert une valeur"))?; + let millis: u64 = value + .parse() + .with_context(|| format!("Valeur invalide pour --timeout-ms: {value}"))?; + cli_timeout = Some(millis); + } + "--help" | "-h" => { + print_usage(); + process::exit(0); + } + other => bail!("Argument inconnu: {other}. Utilise --help pour l'aide."), + } + } + let base = cli_base + .or_else(|| env::var("PMO_REMOTE_BASE_URL").ok()) + .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()); + let timeout_ms = cli_timeout + .or_else(|| { + env::var("PMO_REMOTE_TIMEOUT_MS") + .ok() + .and_then(|v| v.parse().ok()) + }) + .unwrap_or_else(|| DEFAULT_TIMEOUT.as_millis() as u64); + let timeout = Duration::from_millis(timeout_ms.max(1)); + Ok(AppOptions { + base_url: base, + timeout, + }) +} + +fn print_usage() { + println!( + "Usage: cargo run -p pmocontrol --example pmo_remote_control [-- --base-url --timeout-ms ]" + ); + println!("Variables d'environnement:"); + println!( + " PMO_REMOTE_BASE_URL Override la base de l'API REST (par défaut {DEFAULT_BASE_URL})" + ); + println!( + " PMO_REMOTE_TIMEOUT_MS Timeout HTTP global en millisecondes (par défaut {})", + DEFAULT_TIMEOUT.as_millis() + ); + println!( + " PMO_REMOTE_LOG_FILE Écrit les logs tracing dans ce fichier (append) au lieu de stderr" + ); + println!( + " RUST_LOG Active le filtrage tracing/log (ex: pmocontrol=debug,ureq=debug)" + ); +} + +fn init_tracing() { + let _ = tracing_log::LogTracer::init(); + let writer = log_writer(); + let env_filter = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new("info")) + .unwrap_or_else(|_| EnvFilter::new("info")); + let _ = tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_writer(writer) + .try_init(); +} + +fn log_writer() -> BoxMakeWriter { + if let Ok(path) = env::var("PMO_REMOTE_LOG_FILE") { + match OpenOptions::new().create(true).append(true).open(&path) { + Ok(file) => { + let shared = SharedLogWriter::new(file); + let writer = BoxMakeWriter::new(move || shared.clone()); + return writer; + } + Err(err) => { + eprintln!( + "Impossible d'ouvrir {path} pour les logs tracing: {err}. Retour à stderr" + ); + } + } + } + BoxMakeWriter::new(io::stderr) +} + +#[derive(Clone)] +struct SharedLogWriter { + inner: Arc>, +} + +impl SharedLogWriter { + fn new(file: File) -> Self { + Self { + inner: Arc::new(Mutex::new(file)), + } + } +} + +impl Write for SharedLogWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let mut guard = self + .inner + .lock() + .map_err(|err| io::Error::new(io::ErrorKind::Other, err.to_string()))?; + guard.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + let mut guard = self + .inner + .lock() + .map_err(|err| io::Error::new(io::ErrorKind::Other, err.to_string()))?; + guard.flush() + } +} + +struct App { + client: RestClient, + renderers: Vec, + renderer_index: usize, + selected_renderer: Option, + servers: Vec, + server_index: usize, + selected_server: Option, + browser: Option, + mode: Mode, + ui_state: UiState, + queue_snapshot: Vec, + queue_current_index: Option, + binding_info: Option, + show_queue_overlay: bool, + show_help_overlay: bool, + pending_binding: Option, + status_line: String, + binding_worker: Option>, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { + SelectRenderer, + SelectServer, + Browse, + BindingPrompt, + Control, +} + +#[derive(Clone)] +struct UiState { + renderer_name: String, + server_name: Option, + transport_state: Option, + progress: Option, + volume: Option, + mute: Option, + metadata: Option, + last_status: Option, +} + +#[derive(Clone)] +struct PlaybackProgress { + position_ms: Option, + duration_ms: Option, +} + +#[derive(Clone)] +struct TrackMetadata { + title: Option, + artist: Option, + album: Option, + album_art_uri: Option, +} + +struct PendingBinding { + container_id: String, + container_title: String, +} + +enum BindingWorkerMessage { + Success { container_title: String }, + Failure { error: String }, +} + +struct BrowserState { + server_id: String, + nav_state: NavigationState, + entries: Vec, + selected_index: usize, +} + +struct NavigationState { + path_stack: Vec<(String, String)>, + current_container_id: String, + current_container_title: String, +} + +impl App { + fn new(client: RestClient, renderers: Vec) -> Self { + Self { + client, + renderers, + renderer_index: 0, + selected_renderer: None, + servers: Vec::new(), + server_index: 0, + selected_server: None, + browser: None, + mode: Mode::SelectRenderer, + ui_state: UiState::placeholder(), + queue_snapshot: Vec::new(), + queue_current_index: None, + binding_info: None, + show_queue_overlay: false, + show_help_overlay: false, + pending_binding: None, + status_line: "Sélectionne un renderer avec ↑/↓ puis Entrée".to_string(), + binding_worker: None, + } + } + + fn draw(&self, f: &mut ratatui::Frame<'_>) { + match self.mode { + Mode::SelectRenderer => self.draw_renderer_selection(f), + Mode::SelectServer => self.draw_server_selection(f), + Mode::Browse => self.draw_browser(f), + _ => self.draw_control_screen(f), + }; + + if self.show_queue_overlay { + self.draw_queue_overlay(f); + } + if self.show_help_overlay { + self.draw_help_overlay(f); + } + if matches!(self.mode, Mode::BindingPrompt) { + self.draw_binding_prompt(f); + } + self.draw_status_line(f); + } + + fn draw_renderer_selection(&self, f: &mut ratatui::Frame<'_>) { + let area = f.size(); + let block = Block::default() + .borders(Borders::ALL) + .title("Sélection du renderer"); + let items: Vec = self + .renderers + .iter() + .map(|info| { + let status = if info.online { + "[en ligne]" + } else { + "[hors ligne]" + }; + let text = format!("{status} {} | {}", info.friendly_name, info.model_name); + ListItem::new(text) + }) + .collect(); + let list = List::new(items) + .block(block) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + let mut state = ListState::default(); + if !self.renderers.is_empty() { + state.select(Some( + self.renderer_index + .min(self.renderers.len().saturating_sub(1)), + )); + } + f.render_stateful_widget(list, area, &mut state); + } + + fn draw_server_selection(&self, f: &mut ratatui::Frame<'_>) { + let area = f.size(); + let block = Block::default() + .borders(Borders::ALL) + .title("Sélection du serveur"); + let items: Vec = self + .servers + .iter() + .map(|info| { + let status = if info.online { + "[en ligne]" + } else { + "[hors ligne]" + }; + let text = format!("{status} {} | {}", info.friendly_name, info.model_name); + ListItem::new(text) + }) + .collect(); + let list = List::new(items) + .block(block) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + let mut state = ListState::default(); + if !self.servers.is_empty() { + state.select(Some( + self.server_index.min(self.servers.len().saturating_sub(1)), + )); + } + f.render_stateful_widget(list, area, &mut state); + } + + fn draw_browser(&self, f: &mut ratatui::Frame<'_>) { + let area = f.size(); + let Some(browser) = &self.browser else { + return; + }; + + let block = Block::default().borders(Borders::ALL).title(Span::styled( + format!( + "Navigation: {} (id: {})", + browser.nav_state.current_container_title, browser.nav_state.current_container_id + ), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )); + + let items: Vec = browser + .entries + .iter() + .map(|entry| { + let icon = if entry.is_container { "📁" } else { "♪" }; + let text = format!("{icon} {}", entry.title); + ListItem::new(text) + }) + .collect(); + + let list = List::new(items) + .block(block) + .highlight_style( + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + let mut state = ListState::default(); + if !browser.entries.is_empty() { + state.select(Some( + browser + .selected_index + .min(browser.entries.len().saturating_sub(1)), + )); + } + f.render_stateful_widget(list, area, &mut state); + } + + fn draw_control_screen(&self, f: &mut ratatui::Frame<'_>) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Min(8), + Constraint::Length(3), + ]) + .split(f.size()); + + self.draw_header(f, chunks[0]); + self.draw_playback_panel(f, chunks[1]); + self.draw_help_strip(f, chunks[2]); + } + + fn draw_header(&self, f: &mut ratatui::Frame<'_>, area: Rect) { + let ui = &self.ui_state; + let renderer = &ui.renderer_name; + let server = ui.server_name.as_deref().unwrap_or(""); + let state = ui + .transport_state + .as_deref() + .unwrap_or("État inconnu") + .to_string(); + let volume = ui + .volume + .map(|v| v.to_string()) + .unwrap_or_else(|| "--".to_string()); + let mute = match ui.mute { + Some(true) => "ON", + Some(false) => "OFF", + None => "??", + }; + + let binding = self + .binding_info + .as_ref() + .map(|info| format!("{} / {}", info.server_id, info.container_id)) + .unwrap_or_else(|| "".to_string()); + + let text = vec![ + Line::from(vec![Span::styled( + format!("Renderer : {renderer}"), + Style::default().fg(Color::Yellow), + )]), + Line::from(vec![Span::raw(format!("Serveur : {server}"))]), + Line::from(vec![Span::raw(format!( + "État : {state} Volume {volume} | Mute {mute}" + ))]), + Line::from(vec![Span::raw(format!("Playlist : {binding}"))]), + ]; + + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title("Statut")) + .alignment(Alignment::Left); + f.render_widget(paragraph, area); + } + + fn draw_playback_panel(&self, f: &mut ratatui::Frame<'_>, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(6), Constraint::Length(3)]) + .split(area); + + let meta_lines = render_metadata_block(self.ui_state.metadata.as_ref()); + let paragraph = Paragraph::new(meta_lines) + .block( + Block::default() + .borders(Borders::ALL) + .title("Lecture en cours"), + ) + .wrap(ratatui::widgets::Wrap { trim: true }); + f.render_widget(paragraph, chunks[0]); + + let gauge = match self.ui_state.progress.as_ref() { + Some(progress) => build_progress_gauge(progress), + None => Gauge::default() + .block(Block::default().borders(Borders::ALL).title("Progression")) + .label("en attente...") + .ratio(0.0), + }; + f.render_widget(gauge, chunks[1]); + } + + fn draw_help_strip(&self, f: &mut ratatui::Frame<'_>, area: Rect) { + let lines = vec![ + Line::from("Commandes: h=Aide | R=Renderer | S=Serveur | B=Browse"), + Line::from(" Espace=Play/Pause s=Stop n=Next m=Mute +/-=Volume k=Queue"), + Line::from(" b=Binding dans le browser | q=Quit | ESC ferme les overlays"), + ]; + let paragraph = + Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("Raccourcis")); + f.render_widget(paragraph, area); + } + + fn draw_help_overlay(&self, f: &mut ratatui::Frame<'_>) { + let area = centered_rect(70, 60, f.size()); + let lines = vec![ + Line::from("Raccourcis disponibles:"), + Line::from(" R / S : re-sélectionner renderer / serveur"), + Line::from(" B : revenir au navigateur depuis l'écran principal"), + Line::from(" ↑/↓ ou +/- : ajuster le volume (via REST)"), + Line::from(" k / h : toggle queue ou aide"), + Line::from( + " Browser : Entrée=ouvrir, ←/Backspace ou r=retour, s ou b=sélectionner", + ), + Line::from(" Binding prompt : y=confirmer, n=annuler"), + Line::from(" ESC : fermer overlay courant"), + ]; + let block = Block::default() + .title("Aide détaillée (h pour fermer)") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)); + f.render_widget(Clear, area); + f.render_widget(Paragraph::new(lines).block(block), area); + } + + fn draw_queue_overlay(&self, f: &mut ratatui::Frame<'_>) { + let area = centered_rect(80, 70, f.size()); + let mut lines = Vec::new(); + lines.push(Line::from("Playlist actuelle (▶ = en cours):")); + if self.queue_snapshot.is_empty() { + lines.push(Line::from(" ")); + } else { + for (idx, item) in self.queue_snapshot.iter().enumerate() { + let title = item.title.as_deref().unwrap_or(""); + let artist = item.artist.as_deref().unwrap_or(""); + let prefix = match self.queue_current_index { + Some(current) if current == idx => "▶", + _ => " ", + }; + let line = if artist.is_empty() { + format!("{prefix} [{idx}] {title}") + } else { + format!("{prefix} [{idx}] {artist} - {title}") + }; + lines.push(Line::from(line)); + } + } + let block = Block::default() + .title("Playlist (fermer avec k ou Esc)") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)); + f.render_widget(Clear, area); + f.render_widget(Paragraph::new(lines).block(block), area); + } + + fn draw_binding_prompt(&self, f: &mut ratatui::Frame<'_>) { + let area = centered_rect(60, 40, f.size()); + let Some(binding) = &self.pending_binding else { + return; + }; + let renderer = self.ui_state.renderer_name.clone(); + let server = self + .selected_server + .as_ref() + .map(|s| s.friendly_name.clone()) + .unwrap_or_else(|| "".to_string()); + let lines = vec![ + Line::from("Attacher ce conteneur comme playlist distante ?"), + Line::from(format!("Renderer: {renderer}")), + Line::from(format!("Serveur : {server}")), + Line::from(format!( + "Container: {} ({})", + binding.container_title, binding.container_id + )), + Line::from("y = oui | n = non"), + ]; + let block = Block::default() + .title("Binding playlist") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)); + f.render_widget(Clear, area); + f.render_widget(Paragraph::new(lines).block(block), area); + } + + fn draw_status_line(&self, f: &mut ratatui::Frame<'_>) { + let area = Rect { + x: 0, + y: f.size().height.saturating_sub(1), + width: f.size().width, + height: 1, + }; + let status = self + .ui_state + .last_status + .clone() + .unwrap_or_else(|| self.status_line.clone()); + let paragraph = Paragraph::new(status).style(Style::default().fg(Color::Gray)); + f.render_widget(paragraph, area); + } + + fn handle_key(&mut self, key: KeyEvent) -> Result { + match self.mode { + Mode::SelectRenderer => self.handle_renderer_key(key), + Mode::SelectServer => self.handle_server_key(key), + Mode::Browse => self.handle_browse_key(key), + Mode::BindingPrompt => self.handle_binding_key(key), + Mode::Control => self.handle_control_key(key), + } + } + + fn handle_renderer_key(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Char('q') => return Ok(true), + KeyCode::Esc => return Ok(true), + KeyCode::Up => { + if self.renderer_index > 0 { + self.renderer_index -= 1; + } + } + KeyCode::Down => { + if self.renderer_index + 1 < self.renderers.len() { + self.renderer_index += 1; + } + } + KeyCode::Enter => { + let info = self.renderers[self.renderer_index].clone(); + let previous_renderer_id = self + .selected_renderer + .as_ref() + .map(|renderer| renderer.id.clone()); + let mut stop_error: Option = None; + if let Some(prev_id) = previous_renderer_id { + if prev_id != info.id { + if let Err(err) = self.client.stop(&prev_id) { + stop_error = Some(format!( + "Renderer sélectionné mais arrêt de l'ancien impossible: {err}" + )); + } + } + } + self.selected_renderer = Some(info.clone()); + self.ui_state = UiState::new(info.friendly_name.clone()); + self.queue_snapshot.clear(); + self.queue_current_index = None; + self.binding_info = None; + self.selected_server = None; + self.browser = None; + self.show_help_overlay = false; + self.show_queue_overlay = false; + self.pending_binding = None; + self.mode = Mode::SelectServer; + self.status_line = "Sélectionne un serveur avec ↑/↓ puis Entrée".to_string(); + self.load_servers(); + self.refresh_renderer_state(); + self.refresh_queue(); + self.refresh_binding_info(); + if let Some(message) = stop_error { + self.ui_state.set_status(message); + } else { + self.ui_state.set_status("Renderer sélectionné"); + } + } + _ => {} + } + Ok(false) + } + + fn handle_server_key(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Char('q') => return Ok(true), + KeyCode::Esc => { + self.mode = Mode::SelectRenderer; + self.status_line = "Sélectionne un renderer avec ↑/↓ puis Entrée".to_string(); + } + KeyCode::Up => { + if self.server_index > 0 { + self.server_index -= 1; + } + } + KeyCode::Down => { + if self.server_index + 1 < self.servers.len() { + self.server_index += 1; + } + } + KeyCode::Enter => { + if self.servers.is_empty() { + self.ui_state + .set_status("Aucun serveur disponible. Vérifie le backend."); + return Ok(false); + } + let info = self.servers[self.server_index].clone(); + self.selected_server = Some(info.clone()); + self.ui_state.server_name = Some(info.friendly_name.clone()); + match self.load_browser_for_server(&info) { + Ok(_) => { + self.mode = Mode::Browse; + self.status_line = + "Navigue avec ↑/↓, Entrée pour ouvrir, ←/Backspace ou r pour remonter, s pour sélectionner" + .to_string(); + self.ui_state.set_status("Serveur sélectionné"); + } + Err(err) => { + self.ui_state + .set_status(format!("Navigation impossible: {err}")); + } + } + } + _ => {} + } + Ok(false) + } + + fn handle_browse_key(&mut self, key: KeyEvent) -> Result { + if key.code == KeyCode::Char('q') { + return Ok(true); + } + if self.browser.is_none() { + return Ok(false); + } + + match key.code { + KeyCode::Up => { + if let Some(browser) = self.browser.as_mut() { + if browser.selected_index > 0 { + browser.selected_index -= 1; + } + } + } + KeyCode::Down => { + if let Some(browser) = self.browser.as_mut() { + if browser.selected_index + 1 < browser.entries.len() { + browser.selected_index += 1; + } + } + } + KeyCode::Enter => { + let entry = self + .browser + .as_ref() + .and_then(|b| b.current_entry().cloned()); + if let Some(entry) = entry { + if entry.is_container { + self.enter_container(entry)?; + } else { + self.ui_state + .set_status("Sélectionne un dossier pour le binding."); + } + } + } + KeyCode::Char('s') | KeyCode::Char('b') => { + let entry = self + .browser + .as_ref() + .and_then(|b| b.current_entry().cloned()); + if let Some(entry) = entry { + if entry.is_container { + self.pending_binding = Some(PendingBinding { + container_id: entry.id, + container_title: entry.title, + }); + self.mode = Mode::BindingPrompt; + self.ui_state.set_status("Confirme le binding (y/n)"); + } else { + self.ui_state + .set_status("Impossible de binder un item individuel."); + } + } + } + KeyCode::Left | KeyCode::Backspace => { + self.navigate_browser_up(); + } + KeyCode::Char('r') | KeyCode::Char('R') => { + self.navigate_browser_up(); + } + KeyCode::Char('h') => { + self.show_help_overlay = !self.show_help_overlay; + } + KeyCode::Esc => { + self.mode = Mode::SelectServer; + self.status_line = "Sélectionne un serveur avec ↑/↓ puis Entrée".to_string(); + self.show_help_overlay = false; + self.show_queue_overlay = false; + } + _ => {} + } + Ok(false) + } + + fn handle_binding_key(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Char('y') => { + self.show_help_overlay = false; + self.show_queue_overlay = false; + self.attach_binding(true)?; + } + KeyCode::Char('n') | KeyCode::Esc => { + self.show_help_overlay = false; + self.show_queue_overlay = false; + self.attach_binding(false)?; + } + KeyCode::Char('q') => return Ok(true), + _ => {} + } + Ok(false) + } + + fn handle_control_key(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Char('q') => return Ok(true), + KeyCode::Char('R') => { + self.open_renderer_menu(); + } + KeyCode::Char('S') => { + self.open_server_menu(); + } + KeyCode::Char('B') => { + if self.browser.is_some() { + self.mode = Mode::Browse; + self.status_line = + "Navigue avec ↑/↓, Entrée pour ouvrir, ←/Backspace ou r pour remonter, s pour sélectionner" + .to_string(); + } else { + self.ui_state + .set_status("Pas de navigateur actif. Reprends la sélection serveur."); + } + } + KeyCode::Char('h') => { + self.show_help_overlay = !self.show_help_overlay; + } + KeyCode::Char('k') => { + self.show_queue_overlay = !self.show_queue_overlay; + } + KeyCode::Esc => { + self.show_queue_overlay = false; + self.show_help_overlay = false; + } + KeyCode::Char(' ') => { + self.toggle_play_pause()?; + } + KeyCode::Char('p') => { + self.pause_renderer()?; + } + KeyCode::Char('s') => { + self.stop_renderer()?; + } + KeyCode::Char('n') => { + self.play_next()?; + } + KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Up => { + self.volume_up()?; + } + KeyCode::Char('-') | KeyCode::Down => { + self.volume_down()?; + } + KeyCode::Char('m') => { + self.toggle_mute()?; + } + _ => {} + } + Ok(false) + } + + fn load_servers(&mut self) { + match self.client.list_servers() { + Ok(list) => { + self.servers = list; + self.server_index = 0; + if self.servers.is_empty() { + self.ui_state + .set_status("Aucun serveur disponible. Vérifie PMOMusic."); + } + } + Err(err) => { + self.ui_state + .set_status(format!("Erreur REST serveurs: {err}")); + } + } + } + + fn load_browser_for_server(&mut self, info: &MediaServerSummaryClient) -> Result<()> { + let (root_id, entries) = self.fetch_root_entries(&info.id)?; + self.browser = Some(BrowserState::new( + info.id.clone(), + root_id, + entries, + info.friendly_name.clone(), + )); + Ok(()) + } + + fn fetch_root_entries(&self, server_id: &str) -> Result<(String, Vec)> { + let mut last_err: Option = None; + for &candidate in ROOT_CONTAINERS { + match self.client.browse_container(server_id, candidate) { + Ok(resp) => return Ok((resp.container_id, resp.entries)), + Err(err) => last_err = Some(err), + } + } + Err(last_err.unwrap_or_else(|| { + anyhow!("Impossible de parcourir la racine pour le serveur {server_id}") + })) + } + + fn enter_container(&mut self, entry: ContainerEntryClient) -> Result<()> { + let Some(browser) = self.browser.as_mut() else { + return Ok(()); + }; + let container_id = entry.id.clone(); + let container_title = entry.title.clone(); + match self + .client + .browse_container(&browser.server_id, &container_id) + { + Ok(resp) => { + browser + .nav_state + .enter_container(container_id, container_title); + browser.entries = resp.entries; + browser.selected_index = 0; + } + Err(err) => { + self.ui_state + .set_status(format!("Impossible d'ouvrir: {err}")); + } + } + Ok(()) + } + + fn navigate_browser_up(&mut self) { + let (server_id, container_id) = { + let Some(browser) = self.browser.as_mut() else { + return; + }; + if browser.nav_state.go_back() { + ( + browser.server_id.clone(), + browser.nav_state.current_container_id.clone(), + ) + } else { + self.ui_state.set_status("Déjà à la racine."); + return; + } + }; + match self.client.browse_container(&server_id, &container_id) { + Ok(resp) => { + if let Some(browser) = self.browser.as_mut() { + browser.entries = resp.entries; + browser.selected_index = 0; + } + } + Err(err) => { + self.ui_state + .set_status(format!("Retour impossible: {err}")); + } + } + } + + fn attach_binding(&mut self, attach: bool) -> Result<()> { + if attach { + if self.binding_worker.is_some() { + self.ui_state + .set_status("Binding déjà en cours. Patiente quelques secondes..."); + return Ok(()); + } + let Some(renderer) = self.selected_renderer.as_ref() else { + self.ui_state.set_status("Choisis un renderer en premier."); + return Ok(()); + }; + let Some(server) = self.selected_server.as_ref() else { + self.ui_state.set_status("Choisis un serveur en premier."); + return Ok(()); + }; + let Some(binding) = self.pending_binding.take() else { + return Ok(()); + }; + let client = self.client.clone(); + let renderer_id = renderer.id.clone(); + let server_id = server.id.clone(); + let container_id = binding.container_id.clone(); + let container_title = binding.container_title.clone(); + let (tx, rx) = mpsc::channel(); + self.binding_worker = Some(rx); + self.mode = Mode::Browse; + self.status_line = + "Binding en cours... patiente pendant la préparation de la playlist".to_string(); + self.show_help_overlay = false; + self.show_queue_overlay = false; + self.ui_state + .set_status(format!("Association en cours: {}", container_title)); + thread::spawn(move || { + let outcome = (|| -> Result<()> { + client.attach_playlist(&renderer_id, &server_id, &container_id)?; + // Préparer la lecture en sélectionnant le premier item + client.next(&renderer_id)?; + Ok(()) + })(); + let message = match outcome { + Ok(_) => BindingWorkerMessage::Success { container_title }, + Err(err) => BindingWorkerMessage::Failure { + error: err.to_string(), + }, + }; + let _ = tx.send(message); + }); + } else { + self.pending_binding = None; + self.mode = Mode::Browse; + self.status_line = + "Navigue avec ↑/↓, Entrée pour ouvrir, ←/Backspace ou r pour remonter, s pour sélectionner".to_string(); + self.ui_state.set_status("Binding annulé"); + } + Ok(()) + } + + fn toggle_play_pause(&mut self) -> Result<()> { + let Some(renderer) = self.selected_renderer.as_ref() else { + return Ok(()); + }; + let current = self + .ui_state + .transport_state + .as_deref() + .unwrap_or("") + .to_string(); + if current.eq_ignore_ascii_case("PLAYING") { + self.client.pause(&renderer.id)?; + self.ui_state.set_status("Pause envoyée"); + } else { + self.client.play(&renderer.id)?; + self.ui_state.set_status("Lecture envoyée"); + } + Ok(()) + } + + fn pause_renderer(&mut self) -> Result<()> { + if let Some(renderer) = self.selected_renderer.as_ref() { + self.client.pause(&renderer.id)?; + self.ui_state.set_status("Pause envoyée"); + } + Ok(()) + } + + fn stop_renderer(&mut self) -> Result<()> { + if let Some(renderer) = self.selected_renderer.as_ref() { + self.client.stop(&renderer.id)?; + self.ui_state.set_status("Stop envoyé"); + } + Ok(()) + } + + fn play_next(&mut self) -> Result<()> { + if let Some(renderer) = self.selected_renderer.as_ref() { + self.client.next(&renderer.id)?; + self.ui_state.set_status("Piste suivante demandée"); + } + Ok(()) + } + + fn volume_up(&mut self) -> Result<()> { + if let Some(renderer) = self.selected_renderer.as_ref() { + self.client.volume_up(&renderer.id)?; + self.ui_state.set_status("Volume +"); + } + Ok(()) + } + + fn volume_down(&mut self) -> Result<()> { + if let Some(renderer) = self.selected_renderer.as_ref() { + self.client.volume_down(&renderer.id)?; + self.ui_state.set_status("Volume -"); + } + Ok(()) + } + + fn toggle_mute(&mut self) -> Result<()> { + if let Some(renderer) = self.selected_renderer.as_ref() { + self.client.toggle_mute(&renderer.id)?; + self.ui_state.set_status("Mute togglé"); + } + Ok(()) + } + + fn open_renderer_menu(&mut self) { + match self.client.list_renderers() { + Ok(list) => { + self.renderers = list; + self.renderer_index = 0; + self.mode = Mode::SelectRenderer; + self.status_line = "Sélectionne un renderer avec ↑/↓ puis Entrée".to_string(); + self.show_help_overlay = false; + self.show_queue_overlay = false; + } + Err(err) => { + self.ui_state + .set_status(format!("Impossible de rafraîchir les renderers: {err}")); + } + } + } + + fn open_server_menu(&mut self) { + if self.selected_renderer.is_none() { + self.ui_state.set_status("Sélectionne d'abord un renderer."); + return; + } + self.load_servers(); + self.mode = Mode::SelectServer; + self.status_line = "Sélectionne un serveur avec ↑/↓ puis Entrée".to_string(); + self.show_help_overlay = false; + self.show_queue_overlay = false; + } + + fn refresh_renderer_state(&mut self) { + let Some(renderer) = self.selected_renderer.as_ref() else { + return; + }; + match self.client.get_renderer_state(&renderer.id) { + Ok(state) => { + self.ui_state.transport_state = Some(state.transport_state.clone()); + self.ui_state.volume = state.volume; + self.ui_state.mute = state.mute; + self.ui_state.progress = Some(PlaybackProgress { + position_ms: state.position_ms, + duration_ms: state.duration_ms, + }); + if let Some(info) = state.attached_playlist { + self.binding_info = Some(info); + } + } + Err(err) => { + self.ui_state + .set_status(format!("Erreur REST renderer: {err}")); + } + } + } + + fn refresh_queue(&mut self) { + let Some(renderer) = self.selected_renderer.as_ref() else { + return; + }; + let current_signature = self.capture_current_queue_signature(); + match self.client.get_renderer_queue(&renderer.id) { + Ok(snapshot) => { + let next_items = snapshot.items; + let mut next_index = snapshot.current_index; + if !Self::is_valid_queue_index(next_index, &next_items) { + next_index = current_signature + .and_then(|sig| Self::find_queue_index_by_signature(&next_items, &sig)); + } + self.queue_snapshot = next_items; + self.queue_current_index = next_index; + self.update_current_track_metadata(); + } + Err(err) => { + self.ui_state + .set_status(format!("Erreur REST queue: {err}")); + } + } + } + + fn capture_current_queue_signature(&self) -> Option { + let idx = self.queue_current_index?; + let item = self.queue_snapshot.get(idx)?; + Some(QueueItemSignature::from_item(item)) + } + + fn is_valid_queue_index(index: Option, items: &[QueueItemClient]) -> bool { + match index { + Some(idx) => idx < items.len(), + None => false, + } + } + + fn find_queue_index_by_signature( + items: &[QueueItemClient], + signature: &QueueItemSignature, + ) -> Option { + items.iter().enumerate().find_map(|(idx, item)| { + if signature.matches(item) { + Some(idx) + } else { + None + } + }) + } + + fn refresh_binding_info(&mut self) { + let Some(renderer) = self.selected_renderer.as_ref() else { + return; + }; + match self.client.get_renderer_binding(&renderer.id) { + Ok(binding) => { + self.binding_info = binding; + } + Err(err) => { + self.ui_state + .set_status(format!("Erreur REST binding: {err}")); + } + } + } + + fn update_current_track_metadata(&mut self) { + if let Some(idx) = self.queue_current_index { + if let Some(item) = self.queue_snapshot.get(idx) { + self.ui_state.metadata = Some(TrackMetadata { + title: item.title.clone(), + artist: item.artist.clone(), + album: item.album.clone(), + album_art_uri: item.album_art_uri.clone(), + }); + return; + } + } + self.ui_state.metadata = None; + } + + fn on_tick(&mut self) { + self.poll_binding_worker(); + if self.mode != Mode::Control { + return; + } + self.refresh_renderer_state(); + self.refresh_queue(); + self.refresh_binding_info(); + } + + fn poll_binding_worker(&mut self) { + let Some(receiver) = self.binding_worker.as_ref() else { + return; + }; + match receiver.try_recv() { + Ok(BindingWorkerMessage::Success { container_title }) => { + self.binding_worker = None; + self.finish_binding_success(container_title); + } + Ok(BindingWorkerMessage::Failure { error }) => { + self.binding_worker = None; + self.finish_binding_failure(error); + } + Err(TryRecvError::Empty) => {} + Err(TryRecvError::Disconnected) => { + self.binding_worker = None; + self.finish_binding_failure("Worker binding interrompu (canal fermé)".to_string()); + } + } + } + + fn finish_binding_success(&mut self, container_title: String) { + self.mode = Mode::Control; + self.status_line = "Espace=Play/Pause, n=Next, +/- volume, k=Queue, B=Browse".to_string(); + self.show_help_overlay = false; + self.show_queue_overlay = false; + self.ui_state + .set_status(format!("Lecture lancée depuis {container_title}")); + self.refresh_binding_info(); + self.refresh_queue(); + self.refresh_renderer_state(); + } + + fn finish_binding_failure(&mut self, error: String) { + self.mode = Mode::Browse; + self.status_line = + "Navigue avec ↑/↓, Entrée pour ouvrir, ←/Backspace ou r pour remonter, s pour sélectionner".to_string(); + self.show_help_overlay = false; + self.show_queue_overlay = false; + self.ui_state + .set_status(format!("Binding impossible: {error}")); + } +} + +impl UiState { + fn new(renderer_name: String) -> Self { + Self { + renderer_name, + server_name: None, + transport_state: None, + progress: None, + volume: None, + mute: None, + metadata: None, + last_status: Some("Interface initialisée.".to_string()), + } + } + + fn placeholder() -> Self { + Self::new("".to_string()) + } + + fn set_status>(&mut self, status: S) { + self.last_status = Some(status.into()); + } +} + +impl BrowserState { + fn new( + server_id: String, + root_container_id: String, + entries: Vec, + friendly_name: String, + ) -> Self { + Self { + server_id, + nav_state: NavigationState::new(root_container_id, friendly_name), + entries, + selected_index: 0, + } + } + + fn current_entry(&self) -> Option<&ContainerEntryClient> { + self.entries.get(self.selected_index) + } +} + +impl NavigationState { + fn new(root_id: String, root_title: String) -> Self { + Self { + path_stack: Vec::new(), + current_container_id: root_id, + current_container_title: root_title, + } + } + + fn enter_container(&mut self, container_id: String, container_title: String) { + self.path_stack.push(( + self.current_container_id.clone(), + self.current_container_title.clone(), + )); + self.current_container_id = container_id; + self.current_container_title = container_title; + } + + fn go_back(&mut self) -> bool { + if let Some((parent_id, parent_title)) = self.path_stack.pop() { + self.current_container_id = parent_id; + self.current_container_title = parent_title; + true + } else { + false + } + } +} + +fn run_app(mut app: App) -> Result<()> { + let terminal = setup_terminal()?; + let mut guard = TerminalGuard { terminal }; + let mut last_tick = Instant::now(); + + let result = (|| -> Result<()> { + loop { + guard.terminal.draw(|f| app.draw(f))?; + + let timeout = TICK_RATE + .checked_sub(last_tick.elapsed()) + .unwrap_or_else(|| Duration::from_secs(0)); + + if event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + if app.handle_key(key)? { + break; + } + } + } + + if last_tick.elapsed() >= TICK_RATE { + app.on_tick(); + last_tick = Instant::now(); + } + } + Ok(()) + })(); + + // Restauration garantie via Drop de TerminalGuard + // On tente un stop avec timeout court + if let Some(renderer) = app.selected_renderer.as_ref() { + // Utiliser un timeout très court pour ne pas bloquer le shutdown + let _ = app.client.stop(&renderer.id); + } + + result +} + +fn setup_terminal() -> Result>> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + Ok(terminal) +} + +fn render_metadata_block(metadata: Option<&TrackMetadata>) -> Vec> { + let mut lines = Vec::new(); + if let Some(meta) = metadata { + let title = meta + .title + .clone() + .unwrap_or_else(|| "".to_string()); + lines.push(Line::from(format!("Titre : {title}"))); + if let Some(artist) = meta.artist.as_deref() { + lines.push(Line::from(format!("Artiste: {artist}"))); + } + if let Some(album) = meta.album.as_deref() { + lines.push(Line::from(format!("Album : {album}"))); + } + if let Some(art) = meta.album_art_uri.as_deref() { + lines.push(Line::from(format!("Cover : {art}"))); + } + } else { + lines.push(Line::from("(En attente des métadonnées...)")); + } + lines +} + +fn build_progress_gauge(progress: &PlaybackProgress) -> Gauge<'static> { + let ratio = match (progress.position_ms, progress.duration_ms) { + (Some(pos), Some(dur)) if dur > 0 => (pos as f64 / dur as f64).clamp(0.0, 1.0), + _ => 0.0, + }; + let label = format!( + "{} / {}", + progress + .position_ms + .and_then(format_time_ms) + .unwrap_or_else(|| "--:--".to_string()), + progress + .duration_ms + .and_then(format_time_ms) + .unwrap_or_else(|| "--:--".to_string()) + ); + Gauge::default() + .block(Block::default().borders(Borders::ALL).title("Progression")) + .gauge_style(Style::default().fg(Color::Magenta)) + .ratio(ratio) + .label(label) +} + +fn format_time_ms(ms: u64) -> Option { + let total_seconds = ms / 1000; + let hours = total_seconds / 3600; + let minutes = (total_seconds % 3600) / 60; + let seconds = total_seconds % 60; + if hours > 0 { + Some(format!("{hours:02}:{minutes:02}:{seconds:02}")) + } else { + Some(format!("{minutes:02}:{seconds:02}")) + } +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ] + .as_ref(), + ) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ] + .as_ref(), + ) + .split(popup_layout[1])[1] +} + +/// RAII guard pour garantir la restauration du terminal même en cas d'erreur ou de panic +struct TerminalGuard { + terminal: Terminal>, +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + // Force la restauration du terminal, même si les appels échouent + let _ = disable_raw_mode(); + let _ = execute!( + self.terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ); + let _ = self.terminal.show_cursor(); + } +} + +// ============================================================================ +// REST DTOs +// ============================================================================ + +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize)] +struct RendererSummaryClient { + id: String, + friendly_name: String, + model_name: String, + protocol: String, + online: bool, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize)] +struct RendererStateClient { + id: String, + friendly_name: String, + transport_state: String, + position_ms: Option, + duration_ms: Option, + volume: Option, + mute: Option, + queue_len: usize, + attached_playlist: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize)] +struct AttachedPlaylistInfoClient { + server_id: String, + container_id: String, + has_seen_update: bool, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize)] +struct QueueItemClient { + index: usize, + uri: String, + title: Option, + artist: Option, + album: Option, + server_id: Option, + object_id: Option, + album_art_uri: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize)] +struct QueueSnapshotClient { + renderer_id: String, + #[serde(default, deserialize_with = "deserialize_nullable_vec")] + items: Vec, + current_index: Option, +} + +#[derive(Debug, Clone)] +struct QueueItemSignature { + server_id: Option, + object_id: Option, + uri: String, +} + +impl QueueItemSignature { + fn from_item(item: &QueueItemClient) -> Self { + Self { + server_id: item.server_id.clone(), + object_id: item.object_id.clone(), + uri: item.uri.clone(), + } + } + + fn matches(&self, other: &QueueItemClient) -> bool { + if let (Some(sig_obj), Some(other_obj)) = (&self.object_id, &other.object_id) { + if sig_obj == other_obj { + if let (Some(sig_server), Some(other_server)) = (&self.server_id, &other.server_id) + { + return sig_server == other_server; + } + return true; + } + } + self.uri == other.uri + } +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize)] +struct MediaServerSummaryClient { + id: String, + friendly_name: String, + model_name: String, + online: bool, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize)] +struct ContainerEntryClient { + id: String, + title: String, + class: String, + is_container: bool, + child_count: Option, + artist: Option, + album: Option, + album_art_uri: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize)] +struct BrowseResponseClient { + container_id: String, + #[serde(default, deserialize_with = "deserialize_nullable_vec")] + entries: Vec, +} + +#[allow(dead_code)] +#[derive(Debug, Serialize)] +struct VolumeSetRequest { + volume: u8, +} + +#[derive(Debug, Serialize)] +struct AttachPlaylistRequest<'a> { + server_id: &'a str, + container_id: &'a str, +} + +fn deserialize_nullable_vec<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + let opt = Option::>::deserialize(deserializer)?; + Ok(opt.unwrap_or_default()) +} + +// ============================================================================ +// REST CLIENT +// ============================================================================ + +struct RestClient { + base_url: String, + agent: Agent, +} + +impl Clone for RestClient { + fn clone(&self) -> Self { + Self { + base_url: self.base_url.clone(), + agent: self.agent.clone(), + } + } +} + +impl RestClient { + fn new(base_url: &str, timeout: Duration) -> Result { + let mut builder = Agent::config_builder(); + builder = builder.timeout_global(Some(timeout)); + builder = builder.http_status_as_error(false); + let config = builder.build(); + let agent: Agent = config.into(); + Ok(Self { + base_url: base_url.trim_end_matches('/').to_string(), + agent, + }) + } + + fn list_renderers(&self) -> Result> { + self.get_json(&["renderers"]) + } + + fn get_renderer_state(&self, id: &str) -> Result { + self.get_json(&["renderers", id]) + } + + fn get_renderer_queue(&self, id: &str) -> Result { + self.get_json(&["renderers", id, "queue"]) + } + + fn get_renderer_binding(&self, id: &str) -> Result> { + self.get_json(&["renderers", id, "binding"]) + } + + fn play(&self, id: &str) -> Result<()> { + self.post_empty(&["renderers", id, "play"]) + } + + fn pause(&self, id: &str) -> Result<()> { + self.post_empty(&["renderers", id, "pause"]) + } + + fn stop(&self, id: &str) -> Result<()> { + self.post_empty(&["renderers", id, "stop"]) + } + + fn next(&self, id: &str) -> Result<()> { + self.post_empty(&["renderers", id, "next"]) + } + + #[allow(dead_code)] + fn set_volume(&self, id: &str, volume: u8) -> Result<()> { + let payload = VolumeSetRequest { volume }; + self.post_json(&["renderers", id, "volume", "set"], &payload) + } + + fn volume_up(&self, id: &str) -> Result<()> { + self.post_empty(&["renderers", id, "volume", "up"]) + } + + fn volume_down(&self, id: &str) -> Result<()> { + self.post_empty(&["renderers", id, "volume", "down"]) + } + + fn toggle_mute(&self, id: &str) -> Result<()> { + self.post_empty(&["renderers", id, "mute", "toggle"]) + } + + fn attach_playlist(&self, id: &str, server_id: &str, container_id: &str) -> Result<()> { + let payload = AttachPlaylistRequest { + server_id, + container_id, + }; + self.post_json(&["renderers", id, "binding", "attach"], &payload) + } + + #[allow(dead_code)] + fn detach_playlist(&self, id: &str) -> Result<()> { + self.post_empty(&["renderers", id, "binding", "detach"]) + } + + fn list_servers(&self) -> Result> { + self.get_json(&["servers"]) + } + + fn browse_container( + &self, + server_id: &str, + container_id: &str, + ) -> Result { + self.get_json(&["servers", server_id, "containers", container_id]) + } + + fn get_json(&self, segments: &[&str]) -> Result + where + T: DeserializeOwned, + { + let url = self.build_url_segments(segments); + let response = self.agent.get(&url).call(); + let mut response = Self::handle_response(response)?; + let text = response + .body_mut() + .read_to_string() + .with_context(|| format!("Échec de lecture JSON depuis {url}"))?; + let value = serde_json::from_str(&text) + .with_context(|| format!("Échec de parsing JSON depuis {url}"))?; + Ok(value) + } + + fn post_empty(&self, segments: &[&str]) -> Result<()> { + let url = self.build_url_segments(segments); + let response = self.agent.post(&url).send_empty(); + Self::handle_response(response)?; + Ok(()) + } + + fn post_json(&self, segments: &[&str], payload: &T) -> Result<()> + where + T: Serialize, + { + let url = self.build_url_segments(segments); + let body = serde_json::to_vec(payload)?; + let response = self + .agent + .post(&url) + .header("content-type", "application/json") + .send(body); + Self::handle_response(response)?; + Ok(()) + } + + fn build_url_segments(&self, segments: &[&str]) -> String { + let mut url = self.base_url.clone(); + for segment in segments { + url.push('/'); + url.push_str(&utf8_percent_encode(segment, NON_ALPHANUMERIC).to_string()); + } + url + } + + fn handle_response( + response: Result, ureq::Error>, + ) -> Result> { + match response { + Ok(resp) => { + if resp.status().is_success() { + Ok(resp) + } else { + let mut resp = resp; + let status = resp.status(); + let body = resp + .body_mut() + .read_to_string() + .unwrap_or_else(|_| "".into()); + Err(anyhow!("HTTP {}: {}", status, body)) + } + } + Err(err) => Err(anyhow!(err)), + } + } +} diff --git a/pmocontrol/examples/pmomusic_integration_example.rs b/pmocontrol/examples/pmomusic_integration_example.rs new file mode 100644 index 00000000..abcf96c0 --- /dev/null +++ b/pmocontrol/examples/pmomusic_integration_example.rs @@ -0,0 +1,81 @@ +//! Exemple d'intégration du Control Point dans PMOMusic +//! +//! Cet exemple montre comment enregistrer le Control Point dans une application +//! PMOMusic complète, en suivant le même pattern que les autres composants. + +#[cfg(not(feature = "pmoserver"))] +fn main() { + eprintln!("This example requires the 'pmoserver' feature. Re-run with `--features pmoserver`."); +} + +#[cfg(feature = "pmoserver")] +use pmocontrol::ControlPointExt; +#[cfg(feature = "pmoserver")] +use pmoserver::Server; +#[cfg(feature = "pmoserver")] +use tracing::info; + +#[cfg(feature = "pmoserver")] +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialiser le logging + tracing_subscriber::fmt::init(); + + // ========== PHASE 1 : Infrastructure ========== + + let server = Server::create_upnp_server().await?; + + // ========== PHASE 2 : Enregistrement des composants ========== + + // Enregistrer les devices UPnP, sources musicales, etc. + // (code existant de PMOMusic...) + + // ========== Enregistrer le Control Point ========== + // + // Cette ligne unique : + // 1. Lance le runtime SSDP et la découverte des devices + // 2. Démarre le polling des renderers (état, position, volume, etc.) + // 3. S'abonne aux événements UPnP des serveurs de médias + // 4. Enregistre toutes les routes REST (/api/control/*) + // 5. Enregistre tous les endpoints SSE (/api/control/events/*) + // 6. Génère la documentation OpenAPI + + info!("🎛️ Registering Control Point..."); + let control_point = server + .write() + .await + .register_control_point(5) // timeout de 5 secondes pour les requêtes HTTP + .await?; + + // Le Control Point est maintenant actif ! + // On peut l'utiliser directement si besoin + info!("✅ Control Point ready!"); + + // Exemple : lister les renderers découverts (optionnel) + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + let renderers = control_point.list_music_renderers(); + info!("📻 Discovered {} renderer(s)", renderers.len()); + for renderer in renderers { + info!(" - {} ({})", renderer.friendly_name, renderer.id.0); + } + + // ========== PHASE 3 : Démarrage du serveur ========== + + info!("🌐 Starting HTTP server..."); + server.write().await.start().await; + + info!("✅ PMOMusic is ready!"); + info!("📡 Control Point API available at:"); + info!(" - GET /api/control/renderers"); + info!(" - GET /api/control/servers"); + info!(" - GET /api/control/events (SSE)"); + info!(" - GET /api/control/events/renderers (SSE)"); + info!(" - GET /api/control/events/servers (SSE)"); + info!(" - Docs: /swagger-ui/control"); + info!(""); + info!("Press Ctrl+C to stop..."); + + server.write().await.wait().await; + + Ok(()) +} diff --git a/pmocontrol/examples/queue_pmomusic_demo.rs b/pmocontrol/examples/queue_pmomusic_demo.rs new file mode 100644 index 00000000..a61740f5 --- /dev/null +++ b/pmocontrol/examples/queue_pmomusic_demo.rs @@ -0,0 +1,534 @@ +//! End-to-end queue demo that prefers the PMOMusic media server and exercises +//! the ControlPoint playback queue API. + +use std::collections::VecDeque; +use std::env; +use std::process; +use std::thread; +use std::time::Duration; + +use anyhow::{Context, Result}; +use pmocontrol::{ + ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaResource, MediaServerEvent, + MediaServerInfo, MusicRenderer, MusicServer, PlaybackItem, PlaybackPosition, + PlaybackPositionInfo, RendererInfo, RendererProtocol, +}; + +const DEFAULT_TIMEOUT_SECS: u64 = 5; +const DEFAULT_DISCOVERY_SECS: u64 = 5; +const DEFAULT_MAX_TRACKS: usize = 3; +const MONITOR_DURATION_SECS: u64 = 600; +const MONITOR_POLL_SECS: u64 = 5; +const MAX_BROWSE_DEPTH: usize = 2; + +fn main() -> Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + let config = CliConfig::parse_from_env().unwrap_or_else(|err| { + eprintln!("Error parsing arguments: {err}"); + print_usage_and_exit(); + }); + + if config.max_tracks == 0 { + eprintln!("--max-tracks must be >= 1"); + process::exit(1); + } + + println!( + "Starting queue_pmomusic_demo with timeout={}s discovery={}s max_tracks={}", + config.timeout_secs, config.discovery_secs, config.max_tracks + ); + + // ControlPoint::spawn starts the HttpXmlDescriptionProvider + DiscoveryManager combo. + let control_point = + ControlPoint::spawn(config.timeout_secs).context("Failed to start control point")?; + + println!( + "Discovery running for {} seconds before selecting devices...", + config.discovery_secs + ); + thread::sleep(Duration::from_secs(config.discovery_secs)); + + let registry = control_point.registry(); + let (renderer, server_info) = { + let reg = registry.read().expect("registry poisoned"); + let renderer_candidates: Vec = reg + .list_renderers() + .into_iter() + .filter(|info| !is_pmomusic_renderer(info)) + .collect(); + let renderer = pick_renderer(renderer_candidates) + .unwrap_or_else(|| no_renderer_and_exit("No suitable renderer found after discovery.")); + let server = pick_media_server(reg.list_servers()) + .unwrap_or_else(|| no_server_and_exit("No media server with ContentDirectory.")); + (renderer, server) + }; + + println!( + "Selected renderer \"{}\" (protocol={:?}, id={})", + renderer.friendly_name, renderer.protocol, renderer.id.0 + ); + println!( + "Selected media server \"{}\" at {} (id={})", + server_info.friendly_name, server_info.location, server_info.id.0 + ); + + let renderer_instance = MusicRenderer::from_registry_info(renderer.clone(), ®istry) + .expect("Selected renderer is not usable by MusicRenderer façade"); + let supports_set_next = renderer_instance + .as_upnp() + .map(|upnp| upnp.supports_set_next()) + .unwrap_or(false); + println!( + "Renderer \"{}\": AVTransport present = {}, SetNextAVTransportURI supported = {}", + renderer.friendly_name, renderer.capabilities.has_avtransport, supports_set_next + ); + + let timeout = Duration::from_secs(config.timeout_secs); + let server = + MusicServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?; + + let root_entries = server + .browse_root() + .context("Failed to browse ContentDirectory root")?; + println!("Root returned {} entries", root_entries.len()); + + // Try to find a playlist container first + let (playback_items, bound_container_id) = + collect_playable_items_with_binding(&server, &root_entries, config.max_tracks) + .context("Failed to derive playable items from ContentDirectory root/children")?; + + if playback_items.is_empty() { + println!("No playable tracks were found on the selected server."); + process::exit(1); + } + + println!( + "Discovered {} playable items; enqueuing…", + playback_items.len() + ); + + if let Some(ref container_id) = bound_container_id { + println!( + "Found playlist container '{}' to bind queue to", + container_id + ); + } else { + println!("No playlist container found; queue will not be bound to server"); + } + + let mut planned_queue: VecDeque; + let renderer_id = renderer.id.clone(); + control_point + .clear_queue(&renderer_id) + .context("Failed to clear playback queue")?; + control_point + .enqueue_items(&renderer_id, playback_items) + .context("Failed to enqueue playback items")?; + + // Attach queue to playlist container if we found one + if let Some(container_id) = bound_container_id { + control_point + .attach_queue_to_playlist(&renderer_id, server_info.id.clone(), container_id.clone()) + .context("Failed to attach queue to playlist container")?; + println!( + "✓ Queue attached to playlist container {} on server {}", + container_id, server_info.friendly_name + ); + } + + let snapshot = control_point + .get_queue_snapshot(&renderer_id) + .context("Failed to snapshot queue after enqueue")?; + print_queue_snapshot(&snapshot); + planned_queue = snapshot.clone().into(); + + control_point + .play_next_from_queue(&renderer_id) + .context("Failed to start playback from queue")?; + let mut current_track = planned_queue.pop_front(); + let remaining = control_point + .get_queue_snapshot(&renderer_id) + .context("Failed to snapshot queue after play_next_from_queue")?; + planned_queue = remaining.clone().into(); + println!( + "Playback started on \"{}\"; {} tracks remaining in queue.", + renderer.friendly_name, + remaining.len() + ); + + println!( + "Monitoring queue auto-advance for {} seconds (poll every {}s)…", + MONITOR_DURATION_SECS, MONITOR_POLL_SECS + ); + + // Subscribe to media server events to observe playlist updates + let media_event_rx = control_point.subscribe_media_server_events(); + + let poll_count = MONITOR_DURATION_SECS / MONITOR_POLL_SECS; + for tick in 0..poll_count { + thread::sleep(Duration::from_secs(MONITOR_POLL_SECS)); + + // Drain any MediaServerEvent that arrived since last poll + loop { + match media_event_rx.try_recv() { + Ok(MediaServerEvent::GlobalUpdated { + server_id, + system_update_id, + }) => { + println!( + " 📢 MediaServer {} global update (SystemUpdateID={:?})", + server_id.0, system_update_id + ); + } + Ok(MediaServerEvent::ContainersUpdated { + server_id, + container_ids, + }) => { + println!( + " 📢 MediaServer {} containers updated: {:?}", + server_id.0, container_ids + ); + + // Check if our bound container was updated + if let Some((bound_server, bound_container, _)) = + control_point.current_queue_playlist_binding(&renderer_id) + { + if bound_server == server_id && container_ids.contains(&bound_container) { + println!( + " 🔄 Bound playlist container '{}' was updated, queue will refresh automatically", + bound_container + ); + } + } + } + Err(_) => break, // No more events, continue with normal monitoring + } + } + + let snapshot = control_point + .get_queue_snapshot(&renderer_id) + .context("Queue snapshot failed during monitoring loop")?; + let new_plan: VecDeque = snapshot.clone().into(); + if planned_queue.len() > new_plan.len() { + let removed = planned_queue.len() - new_plan.len(); + for _ in 0..removed { + current_track = planned_queue.pop_front(); + } + } + planned_queue = new_plan; + + let playback_info = control_point + .music_renderer_by_id(&renderer_id) + .and_then(|renderer| renderer.playback_position().ok()); + + let title = current_track_title(current_track.as_ref()); + if let Some(info) = playback_info { + println!( + "[tick {tick}] Queue length = {} | now playing: {} [{}]", + snapshot.len(), + title, + format_playback_position(&info) + ); + } else { + println!( + "[tick {tick}] Queue length = {} | now playing: {} [position unavailable]", + snapshot.len(), + title + ); + } + } + + println!("Monitoring finished, exiting."); + Ok(()) +} + +#[derive(Debug)] +struct CliConfig { + timeout_secs: u64, + discovery_secs: u64, + max_tracks: usize, +} + +impl CliConfig { + fn parse_from_env() -> Result { + let mut timeout_secs = DEFAULT_TIMEOUT_SECS; + let mut discovery_secs = DEFAULT_DISCOVERY_SECS; + let mut max_tracks = DEFAULT_MAX_TRACKS; + + let mut args = env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--timeout-secs" => { + let value = args + .next() + .ok_or_else(|| "--timeout-secs requires a value".to_string())?; + timeout_secs = value.parse().map_err(|err| { + format!("Invalid value for --timeout-secs ({value}): {err}") + })?; + } + "--discovery-secs" => { + let value = args + .next() + .ok_or_else(|| "--discovery-secs requires a value".to_string())?; + discovery_secs = value.parse().map_err(|err| { + format!("Invalid value for --discovery-secs ({value}): {err}") + })?; + } + "--max-tracks" => { + let value = args + .next() + .ok_or_else(|| "--max-tracks requires a value".to_string())?; + max_tracks = value.parse().map_err(|err| { + format!("Invalid value for --max-tracks ({value}): {err}") + })?; + } + "--help" | "-h" => { + print_usage_and_exit(); + } + unknown => { + return Err(format!("Unknown argument: {unknown}")); + } + } + } + + Ok(Self { + timeout_secs, + discovery_secs, + max_tracks, + }) + } +} + +fn pick_renderer(renderers: Vec) -> Option { + let mut candidates: Vec = renderers; + + if candidates.is_empty() { + return None; + } + + println!("Renderer candidates:"); + for (idx, info) in candidates.iter().enumerate() { + println!( + " [{}] {} | model={} | location={} | online={}", + idx, info.friendly_name, info.model_name, info.location, info.online + ); + } + + let selected = candidates.remove(0); + println!( + "Automatically selecting renderer index 0: {}", + selected.friendly_name + ); + Some(selected) +} + +fn pick_media_server(servers: Vec) -> Option { + let mut candidates: Vec = servers + .into_iter() + .filter(|info| info.has_content_directory) + .filter(|info| info.content_directory_control_url.is_some()) + .collect(); + + if candidates.is_empty() { + return None; + } + + if let Some(idx) = candidates.iter().position(is_pmomusic_server) { + let server = candidates.remove(idx); + println!( + "Preferring PMOMusic server \"{}\" (server header: {}).", + server.friendly_name, server.server_header + ); + Some(server) + } else { + println!("No PMOMusic server discovered; falling back to first ContentDirectory server."); + Some(candidates.remove(0)) + } +} + +fn is_pmomusic_server(info: &MediaServerInfo) -> bool { + let name = info.friendly_name.to_ascii_lowercase(); + let header = info.server_header.to_ascii_lowercase(); + name.contains("pmomusic") || header.contains("pmomusic") +} + +fn is_pmomusic_renderer(info: &RendererInfo) -> bool { + info.friendly_name + .to_ascii_lowercase() + .contains("pmomusic audio renderer") +} + +fn collect_playable_items_with_binding( + server: &MusicServer, + entries: &[MediaEntry], + max_tracks: usize, +) -> Result<(Vec, Option)> { + // First, try to find a playlist container + let playlist_container = entries.iter().find(|entry| { + entry.is_container + && entry + .class + .to_ascii_lowercase() + .contains("object.container.playlistcontainer") + }); + + if let Some(playlist) = playlist_container { + println!( + "Found playlist container: '{}' (id: {}, class: {})", + playlist.title, playlist.id, playlist.class + ); + + // Browse the playlist container + match server.browse_children(&playlist.id, 0, max_tracks as u32) { + Ok(children) => { + let mut items = Vec::new(); + for entry in &children { + if let Some(item) = playback_item_from_entry(server, entry) { + items.push(item); + if items.len() >= max_tracks { + break; + } + } + } + + if !items.is_empty() { + return Ok((items, Some(playlist.id.clone()))); + } + + println!( + "Playlist container '{}' is empty, falling back to general browse", + playlist.title + ); + } + Err(err) => { + println!( + "Failed to browse playlist container '{}': {}, falling back", + playlist.title, err + ); + } + } + } else { + println!("No playlist container found in root entries, using fallback"); + } + + // Fallback: collect from any container/item + let mut items = Vec::new(); + for entry in entries { + gather_items_from_entry(server, entry, max_tracks, 0, &mut items)?; + if items.len() >= max_tracks { + break; + } + } + Ok((items, None)) +} + +fn gather_items_from_entry( + server: &MusicServer, + entry: &MediaEntry, + max_tracks: usize, + depth: usize, + out: &mut Vec, +) -> Result<()> { + if out.len() >= max_tracks { + return Ok(()); + } + + if entry.is_container { + if depth >= MAX_BROWSE_DEPTH { + return Ok(()); + } + + match server.browse_children(&entry.id, 0, 50) { + Ok(children) => { + for child in children { + gather_items_from_entry(server, &child, max_tracks, depth + 1, out)?; + if out.len() >= max_tracks { + break; + } + } + } + Err(err) => { + tracing::warn!( + container_id = entry.id.as_str(), + error = %err, + "Failed to browse child container" + ); + } + } + return Ok(()); + } + + if let Some(item) = playback_item_from_entry(server, entry) { + out.push(item); + } + Ok(()) +} + +fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option { + if entry.title.to_ascii_lowercase().contains("live stream") { + return None; + } + let resource = entry.resources.iter().find(|res| is_audio_resource(res))?; + let mut item = PlaybackItem::new(resource.uri.clone()); + item.title = Some(entry.title.clone()); + item.server_id = Some(server.id().clone()); + item.object_id = Some(entry.id.clone()); + Some(item) +} + +fn is_audio_resource(res: &MediaResource) -> bool { + let lower = res.protocol_info.to_ascii_lowercase(); + if lower.contains("audio/") { + return true; + } + lower + .split(':') + .nth(2) + .map(|mime| mime.starts_with("audio/")) + .unwrap_or(false) +} + +fn print_queue_snapshot(items: &[PlaybackItem]) { + println!("Current queue snapshot ({} items):", items.len()); + for (idx, item) in items.iter().enumerate() { + let label = item.title.as_deref().unwrap_or_else(|| item.uri.as_str()); + println!(" [{}] {} -> {}", idx, label, item.uri); + } + if items.is_empty() { + println!(" "); + } +} + +fn current_track_title(item: Option<&PlaybackItem>) -> String { + match item { + Some(track) => track + .title + .as_deref() + .unwrap_or_else(|| track.uri.as_str()) + .to_string(), + None => "".to_string(), + } +} + +fn format_playback_position(info: &PlaybackPositionInfo) -> String { + let rel = info.rel_time.as_deref().unwrap_or("-"); + let dur = info.track_duration.as_deref().unwrap_or("-"); + format!("{rel} / {dur}") +} + +fn no_renderer_and_exit(message: &str) -> ! { + println!("{message}"); + process::exit(1); +} + +fn no_server_and_exit(message: &str) -> ! { + println!("{message}"); + process::exit(1); +} + +fn print_usage_and_exit() -> ! { + println!( + "Usage: cargo run -p pmocontrol --example queue_pmomusic_demo -- [--timeout-secs N] [--discovery-secs N] [--max-tracks N]" + ); + process::exit(1); +} diff --git a/pmocontrol/examples/renderer_demo.rs b/pmocontrol/examples/renderer_demo.rs new file mode 100644 index 00000000..06da20b4 --- /dev/null +++ b/pmocontrol/examples/renderer_demo.rs @@ -0,0 +1,442 @@ +// examples/music_renderer_demo.rs +// +// End-to-end demo using the `MusicRenderer` façade: +// - SSDP discovery via `ControlPoint` +// - selection of a renderer +// - generic `TransportControl` + `VolumeControl` + `PlaybackStatus` + `PlaybackPosition` +// - optional UPnP-specific inspection (TransportInfo, ConnectionManager) +// +// Build and run (from pmocontrol crate root): +// cargo run --example music_renderer_demo -- [index] [uri] +// +// index: optional 0-based renderer index (default: 0) +// uri : optional URI to play (default: Radio Paradise FLAC) + +use anyhow::Result; +use pmocontrol::PlaybackPosition; +use pmocontrol::model::RendererInfo; +use pmocontrol::openhome_client::{OhInfoClient, OhPlaylistClient, OhTimeClient}; +use pmocontrol::{ + ControlPoint, MusicRenderer, PlaybackState, PlaybackStatus, RendererCapabilities, + RendererProtocol, TransportControl, VolumeControl, +}; +use std::env; +use std::thread; +use std::time::Duration; + +// Default URI if none is provided on the CLI. +const DEFAULT_TEST_URI: &str = "https://audio-fb.radioparadise.com/chan/1/x/1117/4/g/1117-3.flac"; + +// Extra wait after play_uri() so slow renderers (e.g. Arylic H50) have time +// to prefetch and actually start playback. +const AFTER_PLAY_WAIT_SECS: u64 = 15; + +fn main() -> Result<()> { + // 1. Start control point and let discovery run a bit + let cp = ControlPoint::spawn(5)?; + thread::sleep(Duration::from_secs(5)); + + // 2. Snapshot of logical music renderers + let mut renderers: Vec = cp.list_music_renderers(); + + // Filter out the in-dev PMOMusic renderer (if present) + renderers.retain(|r| { + let name = r.friendly_name().to_ascii_lowercase(); + !name.contains("pmomusic audio renderer") + }); + + if renderers.is_empty() { + println!("No valid music renderer discovered."); + return Ok(()); + } + + // 3. CLI args: [index] [uri] + let args: Vec = env::args().skip(1).collect(); + + let selected_index: usize = if !args.is_empty() { + args[0].parse().unwrap_or(0) + } else { + 0 + }; + + let maybe_uri: Option = if args.len() >= 2 { + Some(args[1].clone()) + } else { + None + }; + + // Bounds check + if selected_index >= renderers.len() { + println!( + "Renderer index {} out of range ({} available).", + selected_index, + renderers.len() + ); + return Ok(()); + } + + // 4. List renderers with basic info + println!("Discovered MusicRenderers:"); + for (idx, r) in renderers.iter().enumerate() { + let info = r.info(); + println!( + " [{}] {} | model={} | udn={} | location={}", + idx, info.friendly_name, info.model_name, info.udn, info.location + ); + print_backend(" ", r); + print_capabilities(" ", &info.capabilities, &info.protocol); + print_openhome_details(" ", info); + } + + // 5. Select renderer + let renderer = &renderers[selected_index]; + let info = renderer.info(); + + println!("\nSelected renderer (index {}):", selected_index); + println!(" Name : {}", info.friendly_name); + println!(" Model : {}", info.model_name); + println!(" Manufacturer: {}", info.manufacturer); + println!(" UDN : {}", info.udn); + println!(" Location : {}", info.location); + println!(" Protocol : {:?}", info.protocol); + print_backend(" ", renderer); + print_capabilities(" ", &info.capabilities, &info.protocol); + print_openhome_details(" ", info); + + if let Some(upnp) = renderer.as_upnp() { + println!( + " [UPnP] AVTransport control URL : {}", + upnp.info + .avtransport_control_url + .as_deref() + .unwrap_or("") + ); + println!( + " [UPnP] AVTransport service type: {}", + upnp.info + .avtransport_service_type + .as_deref() + .unwrap_or("") + ); + println!( + " [UPnP] RenderingControl control URL : {}", + upnp.info + .rendering_control_control_url + .as_deref() + .unwrap_or("") + ); + println!( + " [UPnP] RenderingControl service type: {}", + upnp.info + .rendering_control_service_type + .as_deref() + .unwrap_or("") + ); + println!( + " [UPnP] ConnectionManager control URL : {}", + upnp.info + .connection_manager_control_url + .as_deref() + .unwrap_or("") + ); + println!( + " [UPnP] ConnectionManager service type: {}", + upnp.info + .connection_manager_service_type + .as_deref() + .unwrap_or("") + ); + } + + // 6. Initial state dump (generic + UPnP-specific) + dump_renderer_state(renderer, "Initial state")?; + + // 7. Play URI via logical façade + let uri = maybe_uri.unwrap_or_else(|| DEFAULT_TEST_URI.to_string()); + let meta = ""; // or full DIDL-Lite + + println!("\nCalling play_uri on music renderer..."); + if let Err(e) = renderer.play_uri(&uri, meta) { + println!(" play_uri failed: {e}"); + } else { + println!(" play_uri: OK"); + } + + println!( + "Waiting {}s to let the renderer prefetch and start playback...", + AFTER_PLAY_WAIT_SECS + ); + thread::sleep(Duration::from_secs(AFTER_PLAY_WAIT_SECS)); + dump_renderer_state(renderer, "After play_uri")?; + + // 8. Short progress polling using the PlaybackPosition façade + progress_monitor(renderer, "Progress while playing", 8, 3); + + // 9. Seek (if supported) + println!("\nCalling seek_rel_time(\"00:01:00\")..."); + if let Err(e) = renderer.seek_rel_time("00:01:00") { + println!(" seek_rel_time failed: {e}"); + } else { + println!(" seek_rel_time: OK"); + thread::sleep(Duration::from_secs(2)); + dump_renderer_state(renderer, "After seek_rel_time")?; + + // 9b. Second progress loop after seek: verify RelTime advances + progress_monitor(renderer, "Progress after seek", 8, 3); + } + + // 10. Volume dance (if supported) + println!("\nProbing volume control via music façade..."); + if let Err(e) = volume_demo(renderer) { + println!(" Volume control not fully usable: {e}"); + } + + // 11. Pause then Stop + println!("\nCalling pause() via music façade..."); + if let Err(e) = renderer.pause() { + println!(" pause failed: {e}"); + } else { + println!(" pause: OK"); + thread::sleep(Duration::from_secs(2)); + dump_renderer_state(renderer, "After pause")?; + } + + println!("\nCalling stop() via music façade..."); + if let Err(e) = renderer.stop() { + println!(" stop failed: {e}"); + } else { + println!(" stop: OK"); + dump_renderer_state(renderer, "After stop")?; + } + + println!("\nDone."); + Ok(()) +} + +fn print_capabilities(prefix: &str, caps: &RendererCapabilities, proto: &RendererProtocol) { + println!("{prefix}Capabilities:"); + println!("{prefix} Protocol : {:?}", proto); + println!("{prefix} AVTransport : {}", caps.has_avtransport); + println!("{prefix} RendControl : {}", caps.has_rendering_control); + println!("{prefix} ConnManager : {}", caps.has_connection_manager); + println!("{prefix} LinkPlay HTTP : {}", caps.has_linkplay_http); + println!("{prefix} Arylic TCP : {}", caps.has_arylic_tcp); + println!("{prefix} OH Playlist : {}", caps.has_oh_playlist); + println!("{prefix} OH Volume : {}", caps.has_oh_volume); + println!("{prefix} OH Info : {}", caps.has_oh_info); + println!("{prefix} OH Time : {}", caps.has_oh_time); + println!("{prefix} OH Radio : {}", caps.has_oh_radio); +} + +fn print_openhome_details(prefix: &str, info: &RendererInfo) { + if !info.capabilities.has_oh_playlist + && !info.capabilities.has_oh_info + && !info.capabilities.has_oh_time + { + return; + } + + let playlist_client = build_playlist_client(info); + let info_client = build_info_client(info); + let time_client = build_time_client(info); + + if playlist_client.is_none() && info_client.is_none() && time_client.is_none() { + return; + } + + println!("{prefix}OpenHome:"); + + if let Some(client) = playlist_client { + match client.id_array() { + Ok(ids) => println!("{prefix} Playlist tracks : {}", ids.len()), + Err(err) => println!("{prefix} Playlist tracks : "), + } + } + + if let Some(client) = info_client { + match client.transport_state() { + Ok(state) => { + let logical = map_openhome_state(&state); + println!("{prefix} Transport state : {} ({:?})", state, logical); + } + Err(err) => println!("{prefix} Transport state : "), + } + } + + if let Some(client) = time_client { + match client.position() { + Ok(pos) => println!( + "{prefix} Position : {}/{} (tracks={})", + format_seconds(pos.elapsed_secs), + format_seconds(pos.duration_secs), + pos.track_count + ), + Err(err) => println!("{prefix} Position : "), + } + } +} + +fn print_backend(prefix: &str, renderer: &MusicRenderer) { + let backend = match renderer { + MusicRenderer::Upnp(_) => "UpnpRenderer (UPnP AV / DLNA)", + MusicRenderer::LinkPlay(_) => "LinkPlayRenderer (LinkPlay HTTP)", + MusicRenderer::ArylicTcp(_) => "ArylicTcpRenderer (ARylic TCP Protocol)", + MusicRenderer::HybridUpnpArylic { .. } => { + "Hybrid UpnpArylicRenderer (UPnP AV / DLNA + ARylic TCP Protocol)" + } + MusicRenderer::OpenHome(_) => "OpenHomeRenderer (native OpenHome stack)", + }; + println!("{prefix}Backend : {backend}"); +} + +fn dump_renderer_state(renderer: &MusicRenderer, label: &str) -> Result<()> { + println!("\n[{label}]"); + + if let Ok(state) = renderer.playback_state() { + println!(" Playback state (music): {:?}", state); + } else { + println!(" Playback state (music): "); + } + + // Generic volume façade + match renderer.volume() { + Ok(v) => println!(" Volume (music) : {}", v), + Err(e) => println!(" Volume not available: {e}"), + } + + match renderer.mute() { + Ok(m) => println!(" Mute : {}", m), + Err(e) => println!(" Mute state unknown : {e}"), + } + + if let Ok(pos) = renderer.playback_position() { + println!(" Position info:"); + println!(" Track : {:?}", pos.track); + println!(" Duration : {:?}", pos.track_duration); + println!(" RelTime : {:?}", pos.rel_time); + println!(" AbsTime : {:?}", pos.abs_time); + } else { + println!(" Position info: "); + } + + // Optional UPnP-specific TransportInfo + if let Some(upnp) = renderer.as_upnp() { + if upnp.has_avtransport() { + match upnp.avtransport() { + Ok(avt) => match avt.get_transport_info(0) { + Ok(info) => { + println!(" [UPnP] TransportInfo:"); + println!(" State : {}", info.current_transport_state); + println!(" Status : {}", info.current_transport_status); + println!(" Speed : {}", info.current_speed); + } + Err(e) => { + println!(" [UPnP] TransportInfo unavailable: {e}"); + } + }, + Err(e) => println!(" [UPnP] No AVTransport client: {e}"), + } + } else { + println!(" [UPnP] AVTransport not present on this renderer."); + } + } + + Ok(()) +} + +fn progress_monitor(renderer: &MusicRenderer, label: &str, iterations: usize, interval_secs: u64) { + println!( + "\n[{label}] polling playback state/position {} times (every {} s)...", + iterations, interval_secs + ); + + for i in 0..iterations { + if let Ok(state) = renderer.playback_state() { + print!(" Sample {:02}: state={:?}", i + 1, state); + } else { + print!(" Sample {:02}: state=", i + 1); + } + + if let Ok(pos) = renderer.playback_position() { + println!( + " | track={:?}, rel={:?}, dur={:?}", + pos.track, pos.rel_time, pos.track_duration + ); + } else { + println!(" | position="); + } + + thread::sleep(Duration::from_secs(interval_secs)); + } +} + +fn volume_demo(renderer: &MusicRenderer) -> Result<()> { + // Try to get current volume + let original = renderer.volume()?; + println!(" Current music volume : {}", original); + + // Try mute toggle + let muted = renderer.mute()?; + println!(" Current mute state : {}", muted); + + println!(" Setting mute = true..."); + renderer.set_mute(true)?; + thread::sleep(Duration::from_secs(1)); + println!(" Mute now: {}", renderer.mute()?); + + println!(" Restoring mute = {}", muted); + renderer.set_mute(muted)?; + thread::sleep(Duration::from_millis(500)); + + // Small volume bump if possible + let new_volume = original.saturating_add(10).min(u16::MAX); + println!(" Bumping volume to : {}", new_volume); + renderer.set_volume(new_volume)?; + println!(" Volume after bump : {}", renderer.volume()?); + thread::sleep(Duration::from_secs(5)); + + println!(" Restoring original volume: {}", original); + println!(" Volume after reset : {}", renderer.volume()?); + renderer.set_volume(original)?; + thread::sleep(Duration::from_secs(5)); + + Ok(()) +} + +fn build_playlist_client(info: &RendererInfo) -> Option { + let control_url = info.oh_playlist_control_url.as_ref()?; + let service_type = info.oh_playlist_service_type.as_ref()?; + Some(OhPlaylistClient::new( + control_url.clone(), + service_type.clone(), + )) +} + +fn build_info_client(info: &RendererInfo) -> Option { + let control_url = info.oh_info_control_url.as_ref()?; + let service_type = info.oh_info_service_type.as_ref()?; + Some(OhInfoClient::new(control_url.clone(), service_type.clone())) +} + +fn build_time_client(info: &RendererInfo) -> Option { + let control_url = info.oh_time_control_url.as_ref()?; + let service_type = info.oh_time_service_type.as_ref()?; + Some(OhTimeClient::new(control_url.clone(), service_type.clone())) +} + +fn map_openhome_state(raw: &str) -> PlaybackState { + match raw.trim().to_ascii_uppercase().as_str() { + "PLAYING" => PlaybackState::Playing, + "PAUSED" | "PAUSED_PLAYBACK" => PlaybackState::Paused, + "STOPPED" => PlaybackState::Stopped, + "BUFFERING" | "TRANSITIONING" => PlaybackState::Transitioning, + other => PlaybackState::Unknown(other.to_string()), + } +} + +fn format_seconds(seconds: u32) -> String { + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let secs = seconds % 60; + format!("{hours:02}:{minutes:02}:{secs:02}") +} diff --git a/pmocontrol/examples/test_avtransport.rs b/pmocontrol/examples/test_avtransport.rs new file mode 100644 index 00000000..57dfcdb5 --- /dev/null +++ b/pmocontrol/examples/test_avtransport.rs @@ -0,0 +1,154 @@ +use anyhow::{Result, anyhow}; +use pmocontrol::{ControlPoint, DeviceRegistryRead, RendererInfo}; +use std::env; +use std::io::{self, Write}; +use std::thread; +use std::time::Duration; + +fn main() -> Result<()> { + // Default values + let default_uri = + "https://audio-fb.radioparadise.com/chan/1/x/1117/4/g/1117-3.flac".to_string(); + let mut uri = default_uri.clone(); + let mut renderer_index: usize = 0; + + // Args: + // - si args[1] est un entier : index, args[2] éventuel = uri + // - sinon : args[1] = uri, args[2] éventuel = index + let args: Vec = env::args().collect(); + if args.len() >= 2 { + let first = &args[1]; + if let Ok(idx) = first.parse::() { + // cas: avtransport_demo 1 [URI] + renderer_index = idx; + if args.len() >= 3 { + uri = args[2].clone(); + } + } else { + // cas: avtransport_demo URI [INDEX] + uri = first.clone(); + if args.len() >= 3 { + if let Ok(idx) = args[2].parse::() { + renderer_index = idx; + } + } + } + } + + println!("Using URI: {}", uri); + println!("Requested renderer index: {}", renderer_index); + + // 1. Start control point and let discovery run a bit + let cp = ControlPoint::spawn(5)?; + thread::sleep(Duration::from_secs(5)); + + // 2. Get registry snapshot + let registry = cp.registry(); + let reg = registry.read().unwrap(); + let all_renderers = reg.list_renderers(); + + // Filter out the in-dev PMOMusic renderer + let renderers: Vec = all_renderers + .into_iter() + .filter(|r| { + !r.friendly_name + .to_ascii_lowercase() + .contains("pmomusic audio renderer") + }) + .collect(); + + if renderers.is_empty() { + println!("No valid UPnP MediaRenderer discovered."); + return Ok(()); + } + + println!("Discovered MediaRenderers:"); + for (idx, r) in renderers.iter().enumerate() { + println!( + " [{}] {} | model={} | udn={} | location={}", + idx, r.friendly_name, r.model_name, r.udn, r.location, + ); + } + + if renderer_index >= renderers.len() { + return Err(anyhow!( + "Renderer index {} out of range (0..={})", + renderer_index, + renderers.len().saturating_sub(1) + )); + } + + // 3. Selection by index + let renderer: &RendererInfo = &renderers[renderer_index]; + + println!("\nSelected renderer (index {}):", renderer_index); + println!(" Name : {}", renderer.friendly_name); + println!(" Model : {}", renderer.model_name); + println!(" Manufacturer: {}", renderer.manufacturer); + println!(" UDN : {}", renderer.udn); + println!(" Location : {}", renderer.location); + + // 4. Get AVTransport client + let avtransport = reg + .avtransport_client_for_renderer(&renderer.id) + .expect("Selected renderer has no AVTransport service"); + + println!(" AVTransport control URL : {}", avtransport.control_url); + println!(" AVTransport service type: {}", avtransport.service_type); + + // We are done with the registry lock + drop(reg); + + // 5. Configure the URI + let meta = ""; // or a full DIDL-Lite string + + println!("\nCalling SetAVTransportURI..."); + avtransport.set_av_transport_uri(&uri, meta)?; + println!(" SetAVTransportURI: OK"); + + // Helper closure to dump current TransportInfo + let dump_info = |label: &str| -> Result<()> { + let info = avtransport.get_transport_info(0)?; + println!("\n[{}]", label); + println!(" State : {}", info.current_transport_state); + println!(" Status : {}", info.current_transport_status); + println!(" Speed : {}", info.current_speed); + Ok(()) + }; + + dump_info("After SetAVTransportURI")?; + + // 6. Play + println!("\nCalling Play (Speed=\"1\")..."); + avtransport.play(0, "1")?; + println!(" Play: OK"); + thread::sleep(Duration::from_secs(20)); + dump_info("After Play")?; + + // 7. Optional: wait before Pause/Stop + print!("\nPress ENTER to Pause..."); + io::stdout().flush().ok(); + let _ = io::stdin().read_line(&mut String::new()); + + // 8. Pause + println!("\nCalling Pause..."); + if let Err(e) = avtransport.pause(0) { + println!(" Pause failed: {e}"); + } else { + println!(" Pause: OK"); + thread::sleep(Duration::from_secs(2)); + dump_info("After Pause")?; + } + + // 9. Stop + println!("\nCalling Stop..."); + if let Err(e) = avtransport.stop(0) { + println!(" Stop failed: {e}"); + } else { + println!(" Stop: OK"); + dump_info("After Stop")?; + } + + println!("\nDone."); + Ok(()) +} diff --git a/pmocontrol/src/arylic_tcp.rs b/pmocontrol/src/arylic_tcp.rs new file mode 100644 index 00000000..caf2351b --- /dev/null +++ b/pmocontrol/src/arylic_tcp.rs @@ -0,0 +1,659 @@ +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{Shutdown, TcpStream, ToSocketAddrs}; +use std::sync::{Mutex, OnceLock}; +use std::thread; +use std::time::Duration; + +use anyhow::anyhow; +use anyhow::{Context, Result}; +use tracing::{debug, warn}; + +use crate::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, + VolumeControl, +}; +use crate::linkplay::{extract_linkplay_host, parse_flat_json}; +use crate::model::{RendererId, RendererInfo}; +use std::time::Instant; + +// Garde global pour respecter le délai de 200ms entre commandes +static LAST_COMMAND_TIME: OnceLock> = OnceLock::new(); + +fn last_command_time() -> &'static Mutex { + LAST_COMMAND_TIME.get_or_init(|| Mutex::new(Instant::now())) +} + +const ARYLIC_TCP_PORT: u16 = 8899; +const PACKET_HEADER: [u8; 4] = [0x18, 0x96, 0x18, 0x20]; +const RESERVED_BYTES: [u8; 8] = [0; 8]; +const MAX_RESPONSE_ATTEMPTS: usize = 8; +const DEFAULT_TIMEOUT_SECS: u64 = 3; + +static DETECTION_CACHE: OnceLock>> = OnceLock::new(); + +/// Mode d’attente de réponse pour une commande TCP Arylic. +enum ResponseMode<'a> { + /// On n’attend aucune réponse (fire-and-forget). + None, + /// On attend une réponse, mais si la lecture échoue immédiatement, on traite comme succès. + Optional(&'a [&'a str]), + /// On attend une réponse, et l’absence de réponse est une erreur. + Required(&'a [&'a str]), +} + +fn detection_cache() -> &'static Mutex> { + DETECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Probe whether the renderer at the given location exposes the Arylic TCP API. +pub(crate) fn detect_arylic_tcp(location: &str, timeout: Duration) -> bool { + let Some(host) = extract_linkplay_host(location) else { + return false; + }; + + if let Ok(cache) = detection_cache().lock() { + if let Some(result) = cache.get(&host) { + return *result; + } + } + + let detected = match try_detect_tcp(&host, timeout) { + Ok(_) => true, + Err(err) => { + debug!( + "Arylic TCP detection failed for {} (host={}): {}", + location, host, err + ); + false + } + }; + + if let Ok(mut cache) = detection_cache().lock() { + cache.insert(host, detected); + } + + detected +} + +fn try_detect_tcp(host: &str, timeout: Duration) -> Result<()> { + let payload = send_command_required( + host, + ARYLIC_TCP_PORT, + timeout, + "MCU+INF+GET", + &["AXX+INF+", "AXX+DEV+"], + )?; + + if payload.starts_with("AXX+INF+") || payload.starts_with("AXX+DEV+") { + Ok(()) + } else { + Err(anyhow!( + "Unexpected INF response from {}: {}", + host, + payload + )) + } +} + +/// Backend speaking the Arylic TCP control protocol (port 8899). +#[derive(Clone, Debug)] +pub struct ArylicTcpRenderer { + pub info: RendererInfo, + host: String, + port: u16, + timeout: Duration, +} + +impl ArylicTcpRenderer { + pub fn from_renderer_info(info: RendererInfo) -> Result { + let host = extract_linkplay_host(&info.location) + .ok_or_else(|| anyhow!("Renderer {} has no valid LOCATION host", info.udn))?; + + Ok(Self { + info, + host, + port: ARYLIC_TCP_PORT, + timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS), + }) + } + + pub fn id(&self) -> &RendererId { + &self.info.id + } + + pub fn friendly_name(&self) -> &str { + &self.info.friendly_name + } + + fn send_required(&self, cmd: &str, expected: &[&str]) -> Result { + send_command_required(&self.host, self.port, self.timeout, cmd, expected) + } + + fn send_optional(&self, cmd: &str, expected: &[&str]) -> Result> { + send_command_optional(&self.host, self.port, self.timeout, cmd, expected) + } + + fn send_no_response(&self, cmd: &str) -> Result<()> { + send_command_no_response(&self.host, self.port, self.timeout, cmd) + } + + fn fetch_playback_info(&self) -> Result { + let payload = self.send_required("MCU+PINFGET", &["AXX+PLY+INF"])?; + match parse_playback_info(&payload) { + Ok(info) => Ok(info), + Err(err) => { + debug!( + "Failed to parse Arylic playback info for {}: {}", + self.host, err + ); + Err(err) + } + } + } + + fn format_volume_command(value: u16) -> String { + format!("MCU+VOL+{:03}", value.min(100)) + } + + fn parse_volume_payload(payload: &str) -> Result { + let data = payload + .strip_prefix("AXX+VOL+") + .ok_or_else(|| anyhow!("Unexpected volume response: {}", payload))?; + let value: u16 = data + .trim() + .parse() + .with_context(|| format!("Invalid volume value: {}", data))?; + Ok(value.min(100)) + } + + fn parse_mute_payload(payload: &str) -> Result { + let data = payload + .strip_prefix("AXX+MUT+") + .ok_or_else(|| anyhow!("Unexpected mute response: {}", payload))?; + match data.trim() { + "000" | "0" => Ok(false), + "001" | "1" => Ok(true), + other => Err(anyhow!("Invalid mute value: {}", other)), + } + } +} + +impl TransportControl for ArylicTcpRenderer { + fn play_uri(&self, _uri: &str, _meta: &str) -> Result<()> { + Err(anyhow!( + "Arylic TCP backend does not support direct URL loading. Use UPnP AVTransport SetAVTransportURI instead." + )) + } + + fn play(&self) -> Result<()> { + self.send_no_response("MCU+PLY-PLA") + } + + fn pause(&self) -> Result<()> { + let _ = self.send_optional("MCU+PLY-PUS", &["AXX+PLY+"])?; + Ok(()) + } + + fn stop(&self) -> Result<()> { + self.send_no_response("MCU+PLY-STP") + } + + fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { + let _ = parse_hhmmss(hhmmss)?; + Err(anyhow!( + "Arylic TCP seek_rel_time is not implemented yet for this device." + )) + } +} + +impl VolumeControl for ArylicTcpRenderer { + fn volume(&self) -> Result { + if let Ok(info) = self.fetch_playback_info() { + if let Some(vol) = info.volume { + return Ok(vol); + } + debug!( + "Arylic playback info for {} missing volume, falling back to VOL GET", + self.host + ); + } + let payload = self.send_required("MCU+VOL+GET", &["AXX+VOL+"])?; + Self::parse_volume_payload(&payload) + } + + fn set_volume(&self, v: u16) -> Result<()> { + let command = Self::format_volume_command(v); + let _ = self.send_optional(&command, &["AXX+VOL+"])?; + Ok(()) + } + + fn mute(&self) -> Result { + if let Ok(info) = self.fetch_playback_info() { + if let Some(mute) = info.mute { + return Ok(mute); + } + debug!( + "Arylic playback info for {} missing mute, falling back to MUT GET", + self.host + ); + } + let payload = self.send_required("MCU+MUT+GET", &["AXX+MUT+"])?; + Self::parse_mute_payload(&payload) + } + + fn set_mute(&self, m: bool) -> Result<()> { + let command = if m { "MCU+MUT+001" } else { "MCU+MUT+000" }; + let payload = self.send_required(command, &["AXX+MUT+"])?; + let _ = Self::parse_mute_payload(&payload)?; + Ok(()) + } +} + +impl PlaybackStatus for ArylicTcpRenderer { + fn playback_state(&self) -> Result { + let info = self.fetch_playback_info()?; + Ok(info.playback_state()) + } +} + +impl PlaybackPosition for ArylicTcpRenderer { + fn playback_position(&self) -> Result { + let info = self.fetch_playback_info()?; + Ok(info.position_info()) + } +} + +#[derive(Debug)] +struct ArylicPlaybackInfo { + status_raw: String, + curpos_ms: u64, + totlen_ms: u64, + volume: Option, + mute: Option, + playlist_size: Option, + track_index: Option, +} + +impl ArylicPlaybackInfo { + fn playback_state(&self) -> PlaybackState { + match self.status_raw.as_str() { + "play" => PlaybackState::Playing, + "pause" => PlaybackState::Paused, + "stop" => PlaybackState::Stopped, + other => PlaybackState::Unknown(other.to_string()), + } + } + + fn position_info(&self) -> PlaybackPositionInfo { + let track = match (self.track_index, self.playlist_size) { + (Some(idx), Some(count)) if count > 0 => Some(idx.min(count)), + (Some(idx), _) => Some(idx), + _ => None, + }; + + PlaybackPositionInfo { + track, + rel_time: Some(format_hms(self.curpos_ms / 1000)), + abs_time: None, + track_duration: if self.totlen_ms > 0 { + Some(format_hms(self.totlen_ms / 1000)) + } else { + None + }, + track_metadata: None, + track_uri: None, + } + } +} + +fn parse_playback_info(payload: &str) -> Result { + let json_blob = payload + .strip_prefix("AXX+PLY+INF") + .ok_or_else(|| anyhow!("Unexpected playback info prefix: {}", payload))?; + + let json_blob = json_blob.trim_end_matches('&').trim(); + let map = parse_flat_json(json_blob)?; + + let status_raw = map + .get("status") + .cloned() + .ok_or_else(|| anyhow!("Playback info missing `status` field"))?; + + let curpos_ms = parse_u64_field(&map, "curpos")?; + let totlen_ms = parse_u64_field(&map, "totlen")?; + + let volume = match map.get("vol") { + Some(raw) => match raw.parse::() { + Ok(value) => Some(value.min(100)), + Err(err) => { + debug!("Invalid Arylic `vol` value {}: {}", raw, err); + None + } + }, + None => None, + }; + + let mute = match map.get("mute") { + Some(value) if value == "1" => Some(true), + Some(value) if value == "0" => Some(false), + Some(other) => { + debug!("Invalid Arylic `mute` value {}", other); + None + } + None => None, + }; + + let playlist_size = map + .get("plicount") + .and_then(|raw| match raw.parse::() { + Ok(count) if count > 0 => Some(count), + Ok(_) => None, + Err(err) => { + debug!("Invalid Arylic `plicount` value {}: {}", raw, err); + None + } + }); + + let track_index = map.get("plicurr").and_then(|raw| match raw.parse::() { + Ok(idx) if idx > 0 => Some(idx), + Ok(_) => None, + Err(err) => { + debug!("Invalid Arylic `plicurr` value {}: {}", raw, err); + None + } + }); + + Ok(ArylicPlaybackInfo { + status_raw, + curpos_ms, + totlen_ms, + volume, + mute, + playlist_size, + track_index, + }) +} + +fn parse_u64_field(map: &HashMap, key: &str) -> Result { + let raw = map + .get(key) + .ok_or_else(|| anyhow!("Playback info missing `{}` field", key))?; + raw.parse::() + .with_context(|| format!("Invalid `{}` value: {}", key, raw)) +} + +fn connect(host: &str, port: u16, timeout: Duration) -> Result { + if let Ok(mut last_time) = last_command_time().lock() { + let elapsed = last_time.elapsed(); + if elapsed < Duration::from_millis(200) { + let wait = Duration::from_millis(200) - elapsed; + debug!( + "Waiting {:?} before sending command to respect 200ms interval", + wait + ); + thread::sleep(wait); + } + *last_time = Instant::now(); + } + + let address = if host.contains(':') { + format!("[{}]:{}", host, port) + } else { + format!("{host}:{port}") + }; + + let mut last_err = None; + for addr in address + .to_socket_addrs() + .with_context(|| format!("Failed to resolve {}:{}", host, port))? + { + match TcpStream::connect_timeout(&addr, timeout) { + Ok(stream) => { + stream + .set_read_timeout(Some(timeout)) + .and_then(|_| stream.set_write_timeout(Some(timeout))) + .with_context(|| format!("Failed to set socket timeouts for {}", address))?; + return Ok(stream); + } + Err(err) => { + last_err = Some((addr, err)); + } + } + } + + match last_err { + Some((addr, err)) => Err(anyhow!( + "Failed to connect to {} via {}: {}", + host, + addr, + err + )), + None => Err(anyhow!("No socket addresses resolved for {}", address)), + } +} + +fn encode_packet(payload: &str) -> Vec { + let bytes = payload.as_bytes(); + let len = bytes.len() as u32; + let checksum = bytes.iter().fold(0u32, |acc, b| acc + (*b as u32)); + + let mut out = Vec::with_capacity(4 + 4 + 4 + 8 + bytes.len()); + out.extend_from_slice(&PACKET_HEADER); + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&checksum.to_le_bytes()); + out.extend_from_slice(&RESERVED_BYTES); + out.extend_from_slice(bytes); + out +} + +fn read_packet(stream: &mut TcpStream) -> Result { + let mut header = [0u8; 4]; + stream.read_exact(&mut header)?; + if header != PACKET_HEADER { + return Err(anyhow!("Invalid Arylic packet header: {:x?}", header)); + } + + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf)?; + let len = u32::from_le_bytes(len_buf) as usize; + + let mut checksum_buf = [0u8; 4]; + stream.read_exact(&mut checksum_buf)?; + let expected_checksum = u32::from_le_bytes(checksum_buf); + + let mut reserved = [0u8; 8]; + stream.read_exact(&mut reserved)?; + + let mut payload = vec![0u8; len]; + stream.read_exact(&mut payload)?; + + let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32)); + if actual_checksum != expected_checksum { + warn!( + "Arylic payload checksum mismatch: expected={} actual={}", + expected_checksum, actual_checksum + ); + } + + Ok(String::from_utf8(payload)?) +} + +fn format_hms(secs: u64) -> String { + let h = secs / 3600; + let m = (secs % 3600) / 60; + let s = secs % 60; + format!("{:02}:{:02}:{:02}", h, m, s) +} + +fn parse_hhmmss(value: &str) -> Result { + let parts: Vec<_> = value.split(':').collect(); + if parts.len() != 3 { + return Err(anyhow!( + "Invalid time format `{}`. Expected HH:MM:SS.", + value + )); + } + + let hours: u64 = parts[0] + .parse() + .with_context(|| format!("Invalid hour component in {}", value))?; + let minutes: u64 = parts[1] + .parse() + .with_context(|| format!("Invalid minute component in {}", value))?; + let seconds: u64 = parts[2] + .parse() + .with_context(|| format!("Invalid second component in {}", value))?; + + if minutes > 59 || seconds > 59 { + return Err(anyhow!( + "Invalid HH:MM:SS value `{}`. Minutes and seconds must be < 60.", + value + )); + } + + Ok(hours * 3600 + minutes * 60 + seconds) +} + +fn send_command_with_mode( + host: &str, + port: u16, + timeout: Duration, + payload: &str, + mode: ResponseMode<'_>, +) -> Result> { + let mut stream = connect(host, port, timeout)?; + let packet = encode_packet(payload); + + stream.write_all(&packet).with_context(|| { + format!( + "Failed to write Arylic TCP packet for {}: {}", + host, payload + ) + })?; + stream.flush().with_context(|| { + format!( + "Failed to flush Arylic TCP stream for {} (command {})", + host, payload + ) + })?; + + match mode { + ResponseMode::None => { + debug!( + "Arylic TCP fire-and-forget command sent to {}: {}", + host, payload + ); + let _ = stream.shutdown(Shutdown::Write); + Ok(None) + } + ResponseMode::Required(expected) => { + read_expected_response(&mut stream, host, payload, expected).map(Some) + } + ResponseMode::Optional(expected) => { + for _ in 0..MAX_RESPONSE_ATTEMPTS { + match read_packet(&mut stream) { + Ok(response) => { + if expected.iter().any(|p| response.starts_with(p)) { + return Ok(Some(response)); + } + debug!( + "Ignoring unsolicited Arylic payload from {}: {}", + host, response + ); + } + Err(err) => { + debug!( + "No full response for Arylic TCP command {} on {}: {}. Treating as success and relying on PINFGET.", + payload, host, err + ); + return Ok(None); + } + } + } + + Err(anyhow!( + "No expected response for optional command {} on {}", + payload, + host + )) + } + } +} + +fn read_expected_response( + stream: &mut TcpStream, + host: &str, + payload: &str, + expected: &[&str], +) -> Result { + for _ in 0..MAX_RESPONSE_ATTEMPTS { + let response = match read_packet(stream) { + Ok(resp) => resp, + Err(err) => { + return Err(anyhow!( + "Failed to read Arylic TCP response for {} (command {}): {}", + host, + payload, + err + )); + } + }; + if expected.iter().any(|prefix| response.starts_with(prefix)) { + return Ok(response); + } + + debug!( + "Ignoring unsolicited Arylic payload from {}: {}", + host, response + ); + } + + Err(anyhow!( + "No expected response for command {} on {}", + payload, + host + )) +} + +fn send_command_required( + host: &str, + port: u16, + timeout: Duration, + payload: &str, + expected: &[&str], +) -> Result { + match send_command_with_mode( + host, + port, + timeout, + payload, + ResponseMode::Required(expected), + )? { + Some(s) => Ok(s), + None => Err(anyhow!( + "Arylic TCP: no response payload for required command {}", + payload + )), + } +} + +fn send_command_optional( + host: &str, + port: u16, + timeout: Duration, + payload: &str, + expected: &[&str], +) -> Result> { + send_command_with_mode( + host, + port, + timeout, + payload, + ResponseMode::Optional(expected), + ) +} + +fn send_command_no_response(host: &str, port: u16, timeout: Duration, payload: &str) -> Result<()> { + send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ()) +} diff --git a/pmocontrol/src/avtransport_client.rs b/pmocontrol/src/avtransport_client.rs new file mode 100644 index 00000000..89e8f546 --- /dev/null +++ b/pmocontrol/src/avtransport_client.rs @@ -0,0 +1,458 @@ +// pmocontrol/src/avtransport_client.rs + +use std::time::Duration; + +use crate::soap_client::{SoapCallResult, invoke_upnp_action, invoke_upnp_action_with_timeout}; +use anyhow::{Result, anyhow}; +use pmoupnp::soap::SoapEnvelope; +use xmltree::{Element, XMLNode}; + +const AVTRANSPORT_ACTION_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone)] +pub struct AvTransportClient { + pub control_url: String, + pub service_type: String, +} + +#[derive(Debug, Clone)] +pub struct TransportInfo { + pub current_transport_state: String, + pub current_transport_status: String, + pub current_speed: String, +} + +impl AvTransportClient { + pub fn new(control_url: String, service_type: String) -> Self { + Self { + control_url, + service_type, + } + } + + /// AVTransport:1 — GetTransportInfo + pub fn get_transport_info(&self, instance_id: u32) -> Result { + let instance_id_str = instance_id.to_string(); + let args = [("InstanceID", instance_id_str.as_str())]; + + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "GetTransportInfo", + &args, + )?; + + if !call_result.status.is_success() { + return Err(anyhow!( + "GetTransportInfo failed with HTTP status {}", + call_result.status + )); + } + + let envelope = call_result + .envelope + .as_ref() + .ok_or_else(|| anyhow!("Missing SOAP envelope in GetTransportInfo response"))?; + + parse_transport_info(envelope) + } + + /// AVTransport:1 — SetAVTransportURI + /// + /// Pour l’instant on force `InstanceID = 0`, ce qui couvre la majorité + /// des MediaRenderers UPnP AV (un seul instance de transport). + /// + /// - `uri` : CurrentURI + /// - `meta` : CurrentURIMetaData (DIDL-Lite ou chaîne vide) + pub fn set_av_transport_uri(&self, uri: &str, meta: &str) -> Result<()> { + let args = [ + ("InstanceID", "0"), + ("CurrentURI", uri), + ("CurrentURIMetaData", meta), + ]; + + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "SetAVTransportURI", + &args, + )?; + + handle_action_response("SetAVTransportURI", &call_result) + } + + /// AVTransport:1 — Play + pub fn play(&self, instance_id: u32, speed: &str) -> Result<()> { + let instance_id_str = instance_id.to_string(); + let args = [("InstanceID", instance_id_str.as_str()), ("Speed", speed)]; + + let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Play", &args)?; + + handle_action_response("Play", &call_result) + } + + /// AVTransport:1 — Pause + pub fn pause(&self, instance_id: u32) -> Result<()> { + let instance_id_str = instance_id.to_string(); + let args = [("InstanceID", instance_id_str.as_str())]; + + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "Pause", &args)?; + + handle_action_response("Pause", &call_result) + } + + /// AVTransport:1 — Stop + pub fn stop(&self, instance_id: u32) -> Result<()> { + let instance_id_str = instance_id.to_string(); + let args = [("InstanceID", instance_id_str.as_str())]; + + let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Stop", &args)?; + + handle_action_response("Stop", &call_result) + } + + /// AVTransport:1 — Seek + pub fn seek(&self, instance_id: u32, unit: &str, target: &str) -> Result<()> { + let instance_id_str = instance_id.to_string(); + let args = [ + ("InstanceID", instance_id_str.as_str()), + ("Unit", unit), + ("Target", target), + ]; + + let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Seek", &args)?; + + handle_action_response("Seek", &call_result) + } + + /// Optional AVTransport:1 action SetNextAVTransportURI. + /// + /// This should configure the *next* track to be played after the current one. + /// Many renderers do NOT implement this action; in that case the method + /// returns an error derived from the UPnP error code. + pub fn set_next_av_transport_uri(&self, next_uri: &str, next_meta: &str) -> Result<()> { + let args = [ + ("InstanceID", "0"), + ("NextURI", next_uri), + ("NextURIMetaData", next_meta), + ]; + + let call_result = invoke_upnp_action_with_timeout( + &self.control_url, + &self.service_type, + "SetNextAVTransportURI", + &args, + Some(AVTRANSPORT_ACTION_TIMEOUT), + )?; + + if let Err(err) = handle_action_response("SetNextAVTransportURI", &call_result) { + if let Some(env) = &call_result.envelope { + if let Some(upnp_error) = parse_upnp_error(env) { + if is_set_next_not_supported_error(&upnp_error) { + return Err(anyhow!( + "Renderer does not support AVTransport.SetNextAVTransportURI (UPnP error {}: {})", + upnp_error.error_code, + upnp_error.error_description + )); + } + } + } + + return Err(err); + } + + Ok(()) + } +} + +fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> { + if !call_result.status.is_success() { + if let Some(env) = &call_result.envelope { + if let Some(upnp_error) = parse_upnp_error(env) { + return Err(anyhow!( + "{action} failed with UPnP error {}: {} (HTTP status {})", + upnp_error.error_code, + upnp_error.error_description, + call_result.status + )); + } + } + + return Err(anyhow!( + "{action} failed with HTTP status {} and body: {}", + call_result.status, + call_result.raw_body + )); + } + + if let Some(env) = &call_result.envelope { + if let Some(upnp_error) = parse_upnp_error(env) { + return Err(anyhow!( + "{action} returned UPnP error {}: {} (HTTP status {})", + upnp_error.error_code, + upnp_error.error_description, + call_result.status + )); + } + } + + Ok(()) +} + +fn parse_transport_info(envelope: &SoapEnvelope) -> Result { + let response = find_child_with_suffix(&envelope.body.content, "GetTransportInfoResponse") + .ok_or_else(|| anyhow!("Missing GetTransportInfoResponse element in SOAP body"))?; + + let current_transport_state = extract_child_text(response, "CurrentTransportState")?; + let current_transport_status = extract_child_text(response, "CurrentTransportStatus")?; + let current_speed = extract_child_text(response, "CurrentSpeed")?; + + Ok(TransportInfo { + current_transport_state, + current_transport_status, + current_speed, + }) +} + +/// Représente une erreur UPnP extraite d’un SOAP Fault. +#[derive(Debug, Clone)] +struct UpnpError { + pub error_code: u32, + pub error_description: String, +} + +/// Parse un éventuel SOAP Fault contenant un UPnPError. +/// +/// Schéma typique (SOAP 1.1) : +/// +/// ```xml +/// +/// +/// ... +/// ... +/// +/// +/// 401 +/// Invalid Action +/// +/// +/// +/// +/// ``` +fn parse_upnp_error(envelope: &SoapEnvelope) -> Option { + let fault = find_child_with_suffix(&envelope.body.content, "Fault")?; + let detail = find_child_with_suffix(fault, "detail")?; + let upnp_error = find_child_with_suffix(detail, "UPnPError")?; + + // errorCode (obligatoire dans la spec) + let error_code_elem = upnp_error.children.iter().find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem), + _ => None, + })?; + + let binding = error_code_elem.get_text()?; + let error_code_text = binding.trim(); + let error_code = error_code_text.parse::().ok()?; + + // errorDescription (optionnel, mais utile) + let error_description = upnp_error + .children + .iter() + .find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => { + elem.get_text().map(|t| t.trim().to_string()) + } + _ => None, + }) + .unwrap_or_else(|| String::from("")); + + Some(UpnpError { + error_code, + error_description, + }) +} + +fn is_set_next_not_supported_error(err: &UpnpError) -> bool { + if err.error_code == 401 { + return true; + } + + let desc = err.error_description.to_ascii_lowercase(); + desc.contains("invalid action") || desc.contains("not implemented") +} + +fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> { + parent.children.iter().find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem), + _ => None, + }) +} + +fn extract_child_text(parent: &Element, suffix: &str) -> Result { + let child = find_child_with_suffix(parent, suffix) + .ok_or_else(|| anyhow!("Missing {suffix} element in GetTransportInfoResponse"))?; + + let text = child + .get_text() + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .ok_or_else(|| anyhow!("{suffix} element missing text in GetTransportInfoResponse"))?; + + Ok(text) +} + +#[cfg(test)] +mod tests { + use super::*; + use pmoupnp::soap::{SoapBody, SoapEnvelope}; + + fn text_element(name: &str, text: &str) -> Element { + let mut elem = Element::new(name); + elem.children.push(XMLNode::Text(text.to_string())); + elem + } + + #[test] + fn parse_transport_info_extracts_fields() { + let mut response = Element::new("u:GetTransportInfoResponse"); + response.children.push(XMLNode::Element(text_element( + "CurrentTransportState", + "STOPPED", + ))); + response.children.push(XMLNode::Element(text_element( + "CurrentTransportStatus", + "OK", + ))); + response + .children + .push(XMLNode::Element(text_element("CurrentSpeed", "1"))); + + let mut body = Element::new("s:Body"); + body.children.push(XMLNode::Element(response)); + + let envelope = SoapEnvelope { + header: None, + body: SoapBody { content: body }, + }; + + let info = parse_transport_info(&envelope).unwrap(); + assert_eq!(info.current_transport_state, "STOPPED"); + assert_eq!(info.current_transport_status, "OK"); + assert_eq!(info.current_speed, "1"); + } +} + +#[cfg(test)] +mod upnp_error_tests { + use super::*; + use pmoupnp::soap::{SoapBody, SoapEnvelope}; + + fn text_element(name: &str, text: &str) -> Element { + let mut elem = Element::new(name); + elem.children.push(XMLNode::Text(text.to_string())); + elem + } + + #[test] + fn parse_upnp_error_extracts_error_code_and_description() { + let error_code = text_element("errorCode", "401"); + let error_description = text_element("errorDescription", "Invalid Action"); + + let mut upnp_error = Element::new("UPnPError"); + upnp_error.children.push(XMLNode::Element(error_code)); + upnp_error + .children + .push(XMLNode::Element(error_description)); + + let mut detail = Element::new("detail"); + detail.children.push(XMLNode::Element(upnp_error)); + + let mut fault = Element::new("s:Fault"); + fault.children.push(XMLNode::Element(detail)); + + let mut body = Element::new("s:Body"); + body.children.push(XMLNode::Element(fault)); + + let envelope = SoapEnvelope { + header: None, + body: SoapBody { content: body }, + }; + + let err = parse_upnp_error(&envelope).expect("Expected UPnPError"); + assert_eq!(err.error_code, 401); + assert_eq!(err.error_description, "Invalid Action"); + } +} + +#[derive(Debug, Clone)] +pub struct PositionInfo { + pub track: u32, + pub track_duration: Option, // HH:MM:SS or None + pub rel_time: Option, // HH:MM:SS or None + pub abs_time: Option, // HH:MM:SS or None + pub track_metadata: Option, // DIDL-Lite XML or None + pub track_uri: Option, // Current track URI +} + +impl AvTransportClient { + /// AVTransport:1 — GetPositionInfo + pub fn get_position_info(&self, instance_id: u32) -> Result { + let instance_id_str = instance_id.to_string(); + let args = [("InstanceID", instance_id_str.as_str())]; + + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "GetPositionInfo", + &args, + )?; + + if !call_result.status.is_success() { + return Err(anyhow!( + "GetPositionInfo failed with HTTP status {}", + call_result.status + )); + } + + let envelope = call_result + .envelope + .as_ref() + .ok_or_else(|| anyhow!("Missing SOAP envelope in GetPositionInfo response"))?; + + parse_position_info(envelope) + } +} + +fn parse_position_info(envelope: &SoapEnvelope) -> Result { + let response = find_child_with_suffix(&envelope.body.content, "GetPositionInfoResponse") + .ok_or_else(|| anyhow!("Missing GetPositionInfoResponse element"))?; + + // Helpers allow missing text (AVTransport allows empty durations) + fn opt(parent: &Element, name: &str) -> Option { + find_child_with_suffix(parent, name) + .and_then(|e| e.get_text()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } + + let track = opt(response, "Track") + .unwrap_or_else(|| "0".into()) + .parse::() + .unwrap_or(0); + + let track_duration = opt(response, "TrackDuration"); + let rel_time = opt(response, "RelTime"); + let abs_time = opt(response, "AbsTime"); + let track_metadata = opt(response, "TrackMetaData"); + let track_uri = opt(response, "TrackURI"); + + Ok(PositionInfo { + track, + track_duration, + rel_time, + abs_time, + track_metadata, + track_uri, + }) +} diff --git a/pmocontrol/src/capabilities.rs b/pmocontrol/src/capabilities.rs new file mode 100644 index 00000000..f974ad5e --- /dev/null +++ b/pmocontrol/src/capabilities.rs @@ -0,0 +1,99 @@ +// pmocontrol/src/capabilities.rs +use anyhow::Result; + +/// Logical playback position across backends. +/// +/// Times peuvent être soit en secondes, soit en "HH:MM:SS" selon ce que +/// tu préfères pour la façade; ici je reste en String pour garder la +/// même granularité que UPnP sans parser. +#[derive(Clone, Debug)] +pub struct PlaybackPositionInfo { + pub track: Option, + pub rel_time: Option, // position courante + pub abs_time: Option, // si pertinent + pub track_duration: Option, // durée totale + pub track_metadata: Option, // DIDL-Lite XML from GetPositionInfo + pub track_uri: Option, // Current track URI +} +pub trait PlaybackPosition { + fn playback_position(&self) -> Result; +} + +/// High-level playback state across backends. +#[derive(Clone, Debug)] +pub enum PlaybackState { + Stopped, + Playing, + Paused, + Transitioning, + NoMedia, + /// Backend-specific or unknown state string. + Unknown(String), +} + +impl PlaybackState { + /// Map a raw UPnP AVTransport CurrentTransportState string + /// to a logical PlaybackState. + pub fn from_upnp_state(raw: &str) -> Self { + let s = raw.trim().to_ascii_uppercase(); + match s.as_str() { + "STOPPED" => PlaybackState::Stopped, + "PLAYING" => PlaybackState::Playing, + "PAUSED_PLAYBACK" => PlaybackState::Paused, + // States from the AVTransport spec that we normalize: + "PAUSED_RECORDING" => PlaybackState::Paused, + "RECORDING" => PlaybackState::Playing, + // Common vendor-specific states: + "TRANSITIONING" => PlaybackState::Transitioning, + "BUFFERING" | "PREPARING" => PlaybackState::Transitioning, + "NO_MEDIA_PRESENT" => PlaybackState::NoMedia, + _ => PlaybackState::Unknown(raw.to_string()), + } + } +} + +/// Generic abstraction for playback status (transport state). +/// +/// For UPnP AV, this is backed by AVTransport::GetTransportInfo. +/// For OpenHome, a future implementation will adapt from OH Info/Time. +pub trait PlaybackStatus { + fn playback_state(&self) -> Result; +} + +/// Abstraction générique des capacités de transport (lecture / pause / stop / seek) +/// indépendamment du protocole sous-jacent (UPnP AV, OpenHome, ...). +pub trait TransportControl { + /// Set la ressource à lire (URI + métadonnées) et/ou commence la lecture. + /// + /// Selon l'implémentation, cette méthode peut soit : + /// - faire un "Set...URI" + "Play" (cas UPnP AV), + /// - ou configurer la file de lecture (cas OpenHome, etc.). + fn play_uri(&self, uri: &str, meta: &str) -> Result<()>; + + /// Démarre ou reprend la lecture. + fn play(&self) -> Result<()>; + + /// Met la lecture en pause. + fn pause(&self) -> Result<()>; + + /// Arrête la lecture. + fn stop(&self) -> Result<()>; + + /// Seek à un temps relatif (HH:MM:SS) si supporté. + fn seek_rel_time(&self, hhmmss: &str) -> Result<()>; +} + +/// Abstraction générique des capacités de contrôle de volume / mute. +pub trait VolumeControl { + /// Retourne le volume logique courant (échelle dépendante du renderer). + fn volume(&self) -> Result; + + /// Définit le volume logique (échelle dépendante du renderer). + fn set_volume(&self, v: u16) -> Result<()>; + + /// Indique si le renderer est muet (mute activé). + fn mute(&self) -> Result; + + /// Active ou désactive le mute. + fn set_mute(&self, m: bool) -> Result<()>; +} diff --git a/pmocontrol/src/connection_manager_client.rs b/pmocontrol/src/connection_manager_client.rs new file mode 100644 index 00000000..ae468928 --- /dev/null +++ b/pmocontrol/src/connection_manager_client.rs @@ -0,0 +1,294 @@ +use anyhow::{Result, anyhow}; + +use crate::soap_client::{SoapCallResult, invoke_upnp_action}; +use pmoupnp::soap::SoapEnvelope; +use xmltree::{Element, XMLNode}; + +#[derive(Debug, Clone)] +pub struct ConnectionManagerClient { + pub control_url: String, + pub service_type: String, +} + +#[derive(Debug, Clone)] +pub struct ProtocolInfo { + /// Liste brute des protocolInfo "source" (séparés par virgule dans UPnP) + pub source: Vec, + /// Liste brute des protocolInfo "sink" + pub sink: Vec, +} + +#[derive(Debug, Clone)] +pub struct ConnectionInfo { + pub rcs_id: i32, + pub av_transport_id: i32, + pub protocol_info: String, + pub peer_connection_manager: String, + pub peer_connection_id: i32, + pub direction: String, + pub status: String, +} + +impl ConnectionManagerClient { + pub fn new(control_url: String, service_type: String) -> Self { + Self { + control_url, + service_type, + } + } + + /// GetProtocolInfo + pub fn get_protocol_info(&self) -> Result { + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "GetProtocolInfo", + &[], + )?; + + ensure_success("GetProtocolInfo", &call_result)?; + + let envelope = call_result + .envelope + .as_ref() + .ok_or_else(|| anyhow!("Missing SOAP envelope in GetProtocolInfo response"))?; + + if let Some(err) = parse_upnp_error(envelope) { + return Err(anyhow!( + "GetProtocolInfo returned UPnP error {}: {} (HTTP status {})", + err.error_code, + err.error_description, + call_result.status + )); + } + + let response = find_child_with_suffix(&envelope.body.content, "GetProtocolInfoResponse") + .ok_or_else(|| anyhow!("Missing GetProtocolInfoResponse element in SOAP body"))?; + + let source_text = extract_child_text_allow_empty(response, "Source")?; + let sink_text = extract_child_text_allow_empty(response, "Sink")?; + + Ok(ProtocolInfo { + source: split_list(&source_text), + sink: split_list(&sink_text), + }) + } + + /// GetCurrentConnectionIDs + pub fn get_current_connection_ids(&self) -> Result> { + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "GetCurrentConnectionIDs", + &[], + )?; + + ensure_success("GetCurrentConnectionIDs", &call_result)?; + + let envelope = call_result + .envelope + .as_ref() + .ok_or_else(|| anyhow!("Missing SOAP envelope in GetCurrentConnectionIDs response"))?; + + if let Some(err) = parse_upnp_error(envelope) { + return Err(anyhow!( + "GetCurrentConnectionIDs returned UPnP error {}: {} (HTTP status {})", + err.error_code, + err.error_description, + call_result.status + )); + } + + let response = + find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionIDsResponse") + .ok_or_else(|| { + anyhow!("Missing GetCurrentConnectionIDsResponse element in SOAP body") + })?; + + let ids_text = extract_child_text_allow_empty(response, "ConnectionIDs")?; + let trimmed = ids_text.trim(); + + if trimmed.is_empty() || trimmed == "0" { + return Ok(Vec::new()); + } + + let mut ids = Vec::new(); + for part in trimmed.split(',') { + let value = part.trim(); + if value.is_empty() { + continue; + } + let parsed = value + .parse::() + .map_err(|_| anyhow!("Invalid ConnectionID value: {}", value))?; + ids.push(parsed); + } + + Ok(ids) + } + + /// GetCurrentConnectionInfo + pub fn get_current_connection_info(&self, connection_id: i32) -> Result { + let connection_id_str = connection_id.to_string(); + let args = [("ConnectionID", connection_id_str.as_str())]; + + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "GetCurrentConnectionInfo", + &args, + )?; + + ensure_success("GetCurrentConnectionInfo", &call_result)?; + + let envelope = call_result + .envelope + .as_ref() + .ok_or_else(|| anyhow!("Missing SOAP envelope in GetCurrentConnectionInfo response"))?; + + if let Some(err) = parse_upnp_error(envelope) { + return Err(anyhow!( + "GetCurrentConnectionInfo returned UPnP error {}: {} (HTTP status {})", + err.error_code, + err.error_description, + call_result.status + )); + } + + let response = + find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionInfoResponse") + .ok_or_else(|| { + anyhow!("Missing GetCurrentConnectionInfoResponse element in SOAP body") + })?; + + let rcs_id = extract_child_text(response, "RcsID")? + .parse::() + .map_err(|_| anyhow!("Invalid RcsID value in response"))?; + + let av_transport_id = extract_child_text(response, "AVTransportID")? + .parse::() + .map_err(|_| anyhow!("Invalid AVTransportID value in response"))?; + + let protocol_info = extract_child_text_allow_empty(response, "ProtocolInfo")?; + let peer_connection_manager = + extract_child_text_allow_empty(response, "PeerConnectionManager")?; + + let peer_connection_id = extract_child_text(response, "PeerConnectionID")? + .parse::() + .map_err(|_| anyhow!("Invalid PeerConnectionID value in response"))?; + + let direction = extract_child_text(response, "Direction")?; + let status = extract_child_text(response, "Status")?; + + Ok(ConnectionInfo { + rcs_id, + av_transport_id, + protocol_info, + peer_connection_manager, + peer_connection_id, + direction, + status, + }) + } +} + +fn split_list(value: &str) -> Vec { + value + .split(',') + .filter_map(|part| { + let trimmed = part.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) + .collect() +} + +fn ensure_success(action: &str, call_result: &SoapCallResult) -> Result<()> { + if call_result.status.is_success() { + return Ok(()); + } + + if let Some(env) = &call_result.envelope { + if let Some(err) = parse_upnp_error(env) { + return Err(anyhow!( + "{action} failed with UPnP error {}: {} (HTTP status {})", + err.error_code, + err.error_description, + call_result.status + )); + } + } + + Err(anyhow!( + "{action} failed with HTTP status {} and body: {}", + call_result.status, + call_result.raw_body + )) +} + +#[derive(Debug, Clone)] +struct UpnpError { + pub error_code: u32, + pub error_description: String, +} + +fn parse_upnp_error(envelope: &SoapEnvelope) -> Option { + let fault = find_child_with_suffix(&envelope.body.content, "Fault")?; + let detail = find_child_with_suffix(fault, "detail")?; + let upnp_error = find_child_with_suffix(detail, "UPnPError")?; + + let error_code_elem = upnp_error.children.iter().find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem), + _ => None, + })?; + + let binding = error_code_elem.get_text()?; + let error_code_text = binding.trim(); + let error_code = error_code_text.parse::().ok()?; + + let error_description = upnp_error + .children + .iter() + .find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => { + elem.get_text().map(|t| t.trim().to_string()) + } + _ => None, + }) + .unwrap_or_else(|| String::from("")); + + Some(UpnpError { + error_code, + error_description, + }) +} + +fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> { + parent.children.iter().find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem), + _ => None, + }) +} + +fn extract_child_text(parent: &Element, suffix: &str) -> Result { + let text = extract_child_text_allow_empty(parent, suffix)?; + if text.is_empty() { + return Err(anyhow!("{suffix} element missing text in response")); + } + Ok(text) +} + +fn extract_child_text_allow_empty(parent: &Element, suffix: &str) -> Result { + let child = find_child_with_suffix(parent, suffix) + .ok_or_else(|| anyhow!("Missing {suffix} element in response"))?; + + let text = child + .get_text() + .map(|t| t.trim().to_string()) + .unwrap_or_default(); + + Ok(text) +} diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs new file mode 100644 index 00000000..98408b30 --- /dev/null +++ b/pmocontrol/src/control_point.rs @@ -0,0 +1,3056 @@ +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, Mutex, RwLock}; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Context, anyhow}; +use crossbeam_channel::{Receiver, Sender, unbounded}; +use pmoupnp::ssdp::SsdpClient; +use thiserror::Error; +use tracing::{debug, error, info, warn}; +use ureq::{Agent, http}; +use xmltree::{Element, XMLNode}; + +use crate::MusicRenderer; +use crate::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, + VolumeControl, +}; +use crate::discovery::DiscoveryManager; +use crate::events::{MediaServerEventBus, RendererEventBus}; +use crate::media_server::{ + MediaBrowser, MediaEntry, MediaResource, MediaServerInfo, MusicServer, ServerId, +}; +use crate::media_server_events::spawn_media_server_event_runtime; +use crate::model::TrackMetadata; +use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererInfo}; +#[cfg(feature = "pmoserver")] +use crate::openapi::{ + CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView, + RendererStateView, +}; +use crate::openhome_client::parse_track_metadata_from_didl; +use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; +use crate::openhome_renderer::{format_seconds, map_openhome_state}; +use crate::playback_queue::{PlaybackItem, PlaybackQueue}; +use crate::provider::HttpXmlDescriptionProvider; +use crate::registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate}; +use crate::upnp_renderer::UpnpRenderer; + +/// Optional attachment between a renderer playback queue and a server-side +/// DIDL-Lite playlist container. +/// +/// When a queue is bound to a playlist, the control point will automatically +/// refresh it whenever the server notifies us of changes to that container. +/// User-driven mutations (clear, enqueue, etc.) break the binding automatically. +#[derive(Clone, Debug)] +pub struct PlaylistBinding { + /// MediaServer that owns the playlist container. + pub server_id: ServerId, + /// DIDL-Lite object id of the playlist container. + pub container_id: String, + /// True once at least one ContainerUpdateIDs notification has been seen. + pub(crate) has_seen_update: bool, + /// Flag used internally to signal that the queue should be refreshed + /// from the server container. + pub(crate) pending_refresh: bool, + /// Whether the next refresh should auto-start playback if the renderer is idle. + pub(crate) auto_play_on_refresh: bool, +} + +#[derive(Debug, Error)] +pub enum OpenHomeAccessError { + #[error("Renderer {0} not found")] + RendererNotFound(String), + #[error("Renderer {0} has no OpenHome playlist service")] + PlaylistNotSupported(String), +} + +/// Control point minimal : +/// - lance un SsdpClient dans un thread, +/// - passe les SsdpEvent au DiscoveryManager, +/// - applique les DeviceUpdate dans le DeviceRegistry. +/// +/// Le runtime est **l'unique source de vérité** pour l'état des renderers : +/// les clients doivent toujours consommer des snapshots consolidés côté serveur +/// et n'utiliser les événements SSE que comme signaux de rafraîchissement. +pub struct ControlPoint { + registry: Arc>, + event_bus: RendererEventBus, + media_event_bus: MediaServerEventBus, + runtime: Arc, + /// Optional attachment between a renderer playback queue and a + /// server-side DIDL-Lite playlist container. + /// + /// Key : RendererId + /// Value : PlaylistBinding + playlist_bindings: Arc>>, +} + +impl ControlPoint { + /// Crée un ControlPoint et lance le thread de découverte SSDP. + /// + /// `timeout_secs` : timeout HTTP pour la récupération des descriptions UPnP. + pub fn spawn(timeout_secs: u64) -> io::Result { + 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()); + let playlist_bindings = Arc::new(Mutex::new(HashMap::new())); + + // SsdpClient + let client = SsdpClient::new()?; // pmoupnp::ssdp::SsdpClient + + // Arc utilisé dans le thread + let registry_for_thread = Arc::clone(®istry); + + // Thread de découverte + thread::spawn(move || { + // Provider HTTP+XML et DiscoveryManager VIVENT dans le thread + let provider = HttpXmlDescriptionProvider::new(timeout_secs); + let mut discovery = DiscoveryManager::new(provider); + + // ACTIVE DISCOVERY : envoyer quelques M-SEARCH au démarrage + // pour forcer les devices à répondre rapidement. + let search_targets = [ + "ssdp:all", + "urn:schemas-upnp-org:device:MediaRenderer:1", + "urn:av-openhome-org:device:MediaRenderer:1", + "urn:schemas-upnp-org:device:MediaServer:1", + "urn:schemas-wiimu-com:service:PlayQueue:1", // <-- AJOUTER + ]; + + for st in &search_targets { + if let Err(e) = client.send_msearch(st, 3) { + eprintln!("Failed to send M-SEARCH for {}: {}", st, e); + } + std::thread::sleep(Duration::from_millis(200)); + } + + // La closure passée à run_event_loop capture discovery par mutable borrow + // => FnMut, ce que SsdpClient::run_event_loop accepte. + client.run_event_loop(move |event| { + let updates: Vec = discovery.handle_ssdp_event(event); + + if updates.is_empty() { + return; + } + + if let Ok(mut reg) = registry_for_thread.write() { + for update in updates { + reg.apply_update(update); + } + } + }); + }); + + let runtime_cp = ControlPoint { + registry: Arc::clone(®istry), + event_bus: event_bus.clone(), + media_event_bus: media_event_bus.clone(), + runtime: Arc::clone(&runtime), + playlist_bindings: Arc::clone(&playlist_bindings), + }; + + thread::spawn(move || { + let mut tick: u32 = 0; + loop { + let infos = { + let reg = runtime_cp.registry.read().unwrap(); + reg.list_renderers() + }; + let renderers = infos + .into_iter() + .filter_map(|info| { + MusicRenderer::from_registry_info(info, &runtime_cp.registry) + }) + .collect::>(); + + for renderer in renderers { + let info = renderer.info(); + + if !info.online { + continue; + } + + let backend = if info.capabilities.has_oh_playlist { + PlaylistBackend::OpenHome + } else { + PlaylistBackend::PMOQueue + }; + let previous_backend = runtime_cp.runtime.playlist_backend(&info.id); + if previous_backend != backend { + runtime_cp.runtime.set_playlist_backend(&info.id, backend); + if matches!(backend, PlaylistBackend::OpenHome) { + if let Err(err) = sync_openhome_playlist( + &runtime_cp.registry, + &runtime_cp.runtime, + &runtime_cp.event_bus, + &info.id, + ) { + debug!( + renderer = info.id.0.as_str(), + error = %err, + "Initial OpenHome playlist sync failed" + ); + } + } + } + + let renderer_id = info.id.clone(); + let prev_snapshot = runtime_cp.runtime.snapshot_for(&renderer_id); + let mut new_snapshot = prev_snapshot.clone(); + let prev_position = prev_snapshot.position.clone(); + + // Poll position every tick (1s) for smooth UI progress + if let Ok(position) = renderer.playback_position() { + let has_changed = match prev_snapshot.position.as_ref() { + Some(prev) => !playback_position_equal(prev, &position), + None => true, + }; + + if has_changed { + runtime_cp.emit_renderer_event(RendererEvent::PositionChanged { + id: renderer_id.clone(), + position: position.clone(), + }); + } + + // Extract and emit metadata changes + match extract_track_metadata(&position) { + Some(metadata) => { + let metadata_changed = match prev_snapshot.last_metadata.as_ref() { + Some(prev) => prev != &metadata, + None => true, + }; + + if metadata_changed { + debug!( + renderer = renderer_id.0.as_str(), + title = metadata.title.as_deref(), + artist = metadata.artist.as_deref(), + "Emitting metadata changed event" + ); + runtime_cp.emit_renderer_event( + RendererEvent::MetadataChanged { + id: renderer_id.clone(), + metadata: metadata.clone(), + }, + ); + new_snapshot.last_metadata = Some(metadata); + } + } + None => { + debug!( + renderer = renderer_id.0.as_str(), + has_track_metadata = position.track_metadata.is_some(), + "No metadata extracted from position info" + ); + } + } + + new_snapshot.position = Some(position); + } + + // Poll state every tick to ensure responsive playback control + if let Ok(raw_state) = renderer.playback_state() { + let logical_state = compute_logical_playback_state( + &raw_state, + prev_position.as_ref(), + new_snapshot.position.as_ref(), + ); + + let has_changed = match prev_snapshot.state.as_ref() { + Some(prev) => !playback_state_equal(prev, &logical_state), + None => true, + }; + + // Emit event only for non-transient states to reduce noise + // and avoid overwhelming the renderer during track changes + if has_changed && !matches!(logical_state, PlaybackState::Transitioning) { + runtime_cp.emit_renderer_event(RendererEvent::StateChanged { + id: renderer_id.clone(), + state: logical_state.clone(), + }); + } + + new_snapshot.state = Some(logical_state); + } + + // Poll volume and mute less frequently (every 3 seconds) + // to reduce SOAP overhead without impacting UI responsiveness + if tick % 3 == 0 { + if let Ok(volume) = renderer.volume() { + if prev_snapshot.last_volume != Some(volume) { + runtime_cp.emit_renderer_event(RendererEvent::VolumeChanged { + id: renderer_id.clone(), + volume, + }); + } + + new_snapshot.last_volume = Some(volume); + } + + if let Ok(mute) = renderer.mute() { + if prev_snapshot.last_mute != Some(mute) { + runtime_cp.emit_renderer_event(RendererEvent::MuteChanged { + id: renderer_id.clone(), + mute, + }); + } + + new_snapshot.last_mute = Some(mute); + } + } + + runtime_cp + .runtime + .update_snapshot(&renderer_id, new_snapshot); + } + + tick = tick.wrapping_add(1); + // Keep 1 second polling for smooth position updates + thread::sleep(Duration::from_secs(1)); + } + }); + + spawn_media_server_event_runtime( + Arc::clone(®istry), + media_event_bus.clone(), + timeout_secs, + )?; + + let (oh_event_tx, oh_event_rx) = unbounded::(); + let event_forwarder_cp = ControlPoint { + registry: Arc::clone(®istry), + event_bus: event_bus.clone(), + media_event_bus: media_event_bus.clone(), + runtime: Arc::clone(&runtime), + playlist_bindings: Arc::clone(&playlist_bindings), + }; + + thread::Builder::new() + .name("cp-openhome-event-forwarder".into()) + .spawn(move || { + while let Ok(event) = oh_event_rx.recv() { + event_forwarder_cp.emit_renderer_event(event); + } + })?; + + spawn_openhome_event_runtime( + Arc::clone(®istry), + Arc::clone(&runtime), + event_bus.clone(), + oh_event_tx, + )?; + + // Worker thread to process MediaServerEvent and trigger queue refreshes + // for renderers bound to updated playlist containers + let registry_for_media_worker = Arc::clone(®istry); + let runtime_for_media_worker = Arc::clone(&runtime); + let bindings_for_media_worker = Arc::clone(&playlist_bindings); + let event_bus_for_media_worker = event_bus.clone(); + let media_rx = media_event_bus.subscribe(); + + thread::Builder::new() + .name("cp-media-server-event-worker".into()) + .spawn(move || { + loop { + let event = match media_rx.recv() { + Ok(e) => e, + Err(_) => { + warn!("MediaServerEvent channel closed, worker exiting"); + break; + } + }; + + match event { + MediaServerEvent::GlobalUpdated { + server_id, + system_update_id, + } => { + info!( + server = server_id.0.as_str(), + system_update_id = system_update_id, + "MediaServer global update" + ); + } + MediaServerEvent::ContainersUpdated { + server_id, + container_ids, + } => { + let renderers_to_refresh: Vec = { + let mut bindings = bindings_for_media_worker.lock().unwrap(); + let mut to_refresh = Vec::new(); + + for (renderer_id, binding) in bindings.iter_mut() { + if binding.server_id == server_id + && container_ids.contains(&binding.container_id) + { + binding.pending_refresh = true; + binding.has_seen_update = true; + to_refresh.push(renderer_id.clone()); + } + } + + to_refresh + }; + + for renderer_id in renderers_to_refresh { + debug!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + "Triggering queue refresh for bound playlist" + ); + + if let Err(err) = refresh_attached_queue_for( + ®istry_for_media_worker, + &runtime_for_media_worker, + &bindings_for_media_worker, + &renderer_id, + &event_bus_for_media_worker, + None, + ) { + warn!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + error = %err, + "Failed to refresh queue from playlist container" + ); + } + } + } + } + } + })?; + + // Periodic refresh worker for bound playlists + // Every 60 seconds, trigger a refresh for all renderers with active bindings + let registry_for_periodic = Arc::clone(®istry); + let runtime_for_periodic = Arc::clone(&runtime); + let bindings_for_periodic = Arc::clone(&playlist_bindings); + let event_bus_for_periodic = event_bus.clone(); + + thread::Builder::new() + .name("cp-playlist-periodic-refresh".into()) + .spawn(move || { + loop { + // Sleep for 60 seconds between refresh cycles + thread::sleep(Duration::from_secs(60)); + + // Collect all renderers with active bindings and mark them for refresh + let renderers_to_refresh: Vec = { + let mut bindings = bindings_for_periodic.lock().unwrap(); + let mut to_refresh = Vec::new(); + + for (renderer_id, binding) in bindings.iter_mut() { + binding.pending_refresh = true; + to_refresh.push(renderer_id.clone()); + } + + to_refresh + }; + + // Trigger refresh for each bound renderer (outside of lock) + for renderer_id in renderers_to_refresh { + debug!( + renderer = renderer_id.0.as_str(), + "Periodic refresh triggered for bound playlist" + ); + + if let Err(err) = refresh_attached_queue_for( + ®istry_for_periodic, + &runtime_for_periodic, + &bindings_for_periodic, + &renderer_id, + &event_bus_for_periodic, + None, + ) { + warn!( + renderer = renderer_id.0.as_str(), + error = %err, + "Periodic refresh failed for bound playlist" + ); + } + } + } + })?; + + Ok(Self { + registry, + event_bus, + media_event_bus, + runtime, + playlist_bindings, + }) + } + + /// Accès au DeviceRegistry partagé. + pub fn registry(&self) -> Arc> { + Arc::clone(&self.registry) + } + + /// Snapshot list of renderers currently known by the registry. + pub fn list_upnp_renderers(&self) -> Vec { + let infos = { + let reg = self.registry.read().unwrap(); + reg.list_renderers() + }; + + infos + .into_iter() + .map(|info| UpnpRenderer::from_registry(info, &self.registry)) + .collect() + } + + /// Return the first renderer in the registry, if any. + pub fn default_upnp_renderer(&self) -> Option { + let info = { + let reg = self.registry.read().unwrap(); + reg.list_renderers().into_iter().next() + }?; + + Some(UpnpRenderer::from_registry(info, &self.registry)) + } + + /// Lookup a renderer by id. + pub fn upnp_renderer_by_id(&self, id: &RendererId) -> Option { + let info = { + let reg = self.registry.read().unwrap(); + reg.get_renderer(id) + }?; + + Some(UpnpRenderer::from_registry(info, &self.registry)) + } + + /// Snapshot list of music renderers (protocol-agnostic view). + pub fn list_music_renderers(&self) -> Vec { + let infos = { + let reg = self.registry.read().unwrap(); + reg.list_renderers() + }; + + infos + .into_iter() + .filter_map(|info| MusicRenderer::from_registry_info(info, &self.registry)) + .collect() + } + + /// Return the first music renderer in the registry, if any. + pub fn default_music_renderer(&self) -> Option { + let infos = { + let reg = self.registry.read().unwrap(); + reg.list_renderers() + }; + + infos + .into_iter() + .find_map(|info| MusicRenderer::from_registry_info(info, &self.registry)) + } + + /// Lookup a music renderer by id. + pub fn music_renderer_by_id(&self, id: &RendererId) -> Option { + let info = { + let reg = self.registry.read().unwrap(); + reg.get_renderer(id) + }?; + + MusicRenderer::from_registry_info(info, &self.registry) + } + + /// Snapshot list of media servers currently known by the registry. + pub fn list_media_servers(&self) -> Vec { + let reg = self.registry.read().unwrap(); + reg.list_servers() + } + + /// Lookup a media server by id. + pub fn media_server(&self, id: &ServerId) -> Option { + let reg = self.registry.read().unwrap(); + reg.get_server(id) + } + + pub fn clear_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + if !self.runtime.has_entry(renderer_id) { + let err = Self::runtime_entry_missing(renderer_id); + warn!( + renderer = renderer_id.0.as_str(), + "Cannot clear queue: renderer not registered in runtime" + ); + return Err(err); + } + + // User-driven mutation: detach any playlist binding + self.detach_playlist_binding(renderer_id, "clear_queue"); + + if self.runtime.uses_openhome_playlist(renderer_id) { + let renderer = self.openhome_renderer(renderer_id)?; + renderer.openhome_playlist_clear()?; + self.sync_openhome_playlist_for(renderer_id)?; + debug!( + renderer = renderer_id.0.as_str(), + "Cleared OpenHome playlist" + ); + return Ok(()); + } + + let removed = self + .runtime + .with_queue_mut(renderer_id, |queue| { + let removed = queue.upcoming_len(); + queue.clear(); + removed + }) + .ok_or_else(|| Self::runtime_entry_missing(renderer_id))?; + + debug!( + renderer = renderer_id.0.as_str(), + items_removed = removed, + queue_len = 0, + "Cleared playback queue" + ); + + // Emit QueueUpdated event + self.emit_renderer_event(RendererEvent::QueueUpdated { + id: renderer_id.clone(), + queue_length: 0, + }); + + Ok(()) + } + + pub fn enqueue_items( + &self, + renderer_id: &RendererId, + items: Vec, + ) -> anyhow::Result<()> { + if !self.runtime.has_entry(renderer_id) { + let err = Self::runtime_entry_missing(renderer_id); + warn!( + renderer = renderer_id.0.as_str(), + "Cannot enqueue items: renderer not registered in runtime" + ); + return Err(err); + } + + // User-driven mutation: detach any playlist binding + self.detach_playlist_binding(renderer_id, "enqueue_items"); + + if self.runtime.uses_openhome_playlist(renderer_id) { + self.enqueue_items_openhome(renderer_id, items)?; + return Ok(()); + } + + let item_count = items.len(); + let new_len = self + .runtime + .with_queue_mut(renderer_id, |queue| { + queue.enqueue_many(items); + queue.upcoming_len() + }) + .ok_or_else(|| Self::runtime_entry_missing(renderer_id))?; + + debug!( + renderer = renderer_id.0.as_str(), + added = item_count, + queue_len = new_len, + "Enqueued playback items" + ); + + // Emit QueueUpdated event + self.emit_renderer_event(RendererEvent::QueueUpdated { + id: renderer_id.clone(), + queue_length: new_len, + }); + + Ok(()) + } + + pub fn get_queue_snapshot( + &self, + renderer_id: &RendererId, + ) -> anyhow::Result> { + if !self.runtime.has_entry(renderer_id) { + let err = Self::runtime_entry_missing(renderer_id); + warn!( + renderer = renderer_id.0.as_str(), + "Cannot snapshot queue: renderer not registered in runtime" + ); + return Err(err); + } + + self.runtime + .queue_snapshot(renderer_id) + .ok_or_else(|| Self::runtime_entry_missing(renderer_id)) + } + + pub fn get_full_queue_snapshot( + &self, + renderer_id: &RendererId, + ) -> anyhow::Result<(Vec, Option)> { + if !self.runtime.has_entry(renderer_id) { + // Renderer not yet initialized in runtime (just discovered via SSDP) + // This is normal and will be fixed on first polling cycle + debug!( + renderer = renderer_id.0.as_str(), + "Renderer not yet initialized in runtime, returning empty queue" + ); + return Err(Self::runtime_entry_missing(renderer_id)); + } + + self.runtime + .queue_full_snapshot(renderer_id) + .ok_or_else(|| Self::runtime_entry_missing(renderer_id)) + } + + /// Retourne les métadonnées courantes depuis le snapshot en mémoire + pub fn get_current_track_metadata(&self, renderer_id: &RendererId) -> Option { + self.runtime.current_track_metadata(renderer_id) + } + + /// Force a resynchronization of the OpenHome playlist cache for a renderer. + /// + /// This is used by external APIs after mutating the native playlist so that + /// the local queue mirrors the renderer state. + pub fn refresh_openhome_playlist(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + self.sync_openhome_playlist_for(renderer_id) + } + + pub fn get_openhome_playlist_snapshot( + &self, + renderer_id: &RendererId, + ) -> anyhow::Result { + let renderer = self.openhome_renderer(renderer_id)?; + renderer.openhome_playlist_snapshot() + } + + pub fn get_openhome_playlist_len(&self, renderer_id: &RendererId) -> anyhow::Result { + let renderer = self.openhome_renderer(renderer_id)?; + renderer.openhome_playlist_len() + } + + /// Build a fully consistent snapshot for UI consumers (state + queue + binding). + #[cfg(feature = "pmoserver")] + pub fn renderer_full_snapshot( + &self, + renderer_id: &RendererId, + ) -> anyhow::Result { + let renderer = self + .music_renderer_by_id(renderer_id) + .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + let info = renderer.info(); + + let (runtime_snapshot, queue_items, current_index) = + self.runtime.renderer_snapshot_bundle(renderer_id); + let playback_source = self.runtime.playback_source(renderer_id); + let queue_len = queue_items.len(); + + let mut queue_current_index = current_index; + if queue_current_index.is_none() { + if let Some(position) = runtime_snapshot.position.as_ref() { + if let Some(uri) = position.track_uri.as_ref() { + if let Some(idx) = queue_items.iter().position(|item| item.uri == *uri) { + queue_current_index = Some(idx); + } + } else if let Some(track_no) = position.track { + let zero_based = track_no.saturating_sub(1) as usize; + if zero_based < queue_items.len() { + queue_current_index = Some(zero_based); + } + } + } + } + + if queue_current_index.is_none() + && matches!(playback_source, PlaybackSource::FromQueue) + && runtime_snapshot + .state + .as_ref() + .map(|state| matches!(state, PlaybackState::Playing | PlaybackState::Paused)) + .unwrap_or(false) + && !queue_items.is_empty() + { + queue_current_index = Some(0); + } + + let queue_view_items: Vec = queue_items + .iter() + .enumerate() + .map(|(index, item)| QueueItem { + index, + uri: item.uri.clone(), + title: item.title.clone(), + artist: item.artist.clone(), + album: item.album.clone(), + album_art_uri: item.album_art_uri.clone(), + server_id: item.server_id.as_ref().map(|s| s.0.clone()), + object_id: item.object_id.clone(), + }) + .collect(); + + let queue_view = QueueSnapshotView { + renderer_id: renderer_id.0.clone(), + items: queue_view_items, + current_index: queue_current_index, + }; + + let binding = self.current_queue_playlist_binding(renderer_id).map( + |(server_id, container_id, has_seen_update)| RendererBindingView { + server_id: server_id.0, + container_id, + has_seen_update, + }, + ); + + let (position_ms, duration_ms) = + convert_runtime_position(runtime_snapshot.position.as_ref()); + let queue_current_metadata = queue_current_index + .and_then(|idx| queue_items.get(idx)) + .map(current_track_from_playback_item); + + let current_track = runtime_snapshot + .last_metadata + .as_ref() + .map(|meta| CurrentTrackMetadata { + title: meta.title.clone(), + artist: meta.artist.clone(), + album: meta.album.clone(), + album_art_uri: meta.album_art_uri.clone(), + }) + .or(queue_current_metadata); + + let state_view = RendererStateView { + id: renderer_id.0.clone(), + friendly_name: info.friendly_name.clone(), + transport_state: runtime_snapshot + .state + .as_ref() + .map(playback_state_label) + .unwrap_or_else(|| "UNKNOWN".to_string()), + position_ms, + duration_ms, + volume: runtime_snapshot + .last_volume + .and_then(|value| u8::try_from(value).ok()), + mute: runtime_snapshot.last_mute, + queue_len, + attached_playlist: binding.clone(), + current_track, + }; + + Ok(FullRendererSnapshot { + state: state_view, + queue: queue_view, + binding, + }) + } + + pub fn clear_openhome_playlist(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + let renderer = self.openhome_renderer(renderer_id)?; + renderer.openhome_playlist_clear()?; + self.sync_openhome_playlist_for(renderer_id) + } + + pub fn add_openhome_track( + &self, + renderer_id: &RendererId, + uri: &str, + metadata: &str, + after_id: Option, + play: bool, + ) -> anyhow::Result<()> { + let renderer = self.openhome_renderer(renderer_id)?; + renderer.openhome_playlist_add_track(uri, metadata, after_id, play)?; + self.sync_openhome_playlist_for(renderer_id) + } + + pub fn play_openhome_track_id( + &self, + renderer_id: &RendererId, + track_id: u32, + ) -> anyhow::Result<()> { + let renderer = self.openhome_renderer(renderer_id)?; + renderer.openhome_playlist_play_id(track_id)?; + self.runtime + .set_playback_source(renderer_id, PlaybackSource::FromQueue); + self.sync_openhome_playlist_for(renderer_id) + } + + /// Play the current item from the queue without advancing the index. + /// + /// This is useful after a Stop operation to resume playback from the current + /// position rather than skipping to the next track. + pub fn play_current_from_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + if !self.runtime.has_entry(renderer_id) { + let err = Self::runtime_entry_missing(renderer_id); + warn!( + renderer = renderer_id.0.as_str(), + "Cannot play current: renderer not registered in runtime" + ); + return Err(err); + } + + if self.runtime.uses_openhome_playlist(renderer_id) { + return self.play_current_openhome(renderer_id); + } + + let Some((item, remaining)) = self.runtime.peek_current(renderer_id) else { + debug!( + renderer = renderer_id.0.as_str(), + "play_current_from_queue: queue is empty or no current item" + ); + self.runtime + .set_playback_source(renderer_id, PlaybackSource::None); + return Ok(()); + }; + + debug!( + renderer = renderer_id.0.as_str(), + queue_len = remaining + 1, + uri = item.uri.as_str(), + "Playing current playback item from queue" + ); + + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + warn!( + renderer = renderer_id.0.as_str(), + "Renderer disappeared before queue playback could start" + ); + anyhow!("Renderer {} not found", renderer_id.0) + })?; + + let playback = (|| -> anyhow::Result<()> { + let didl_metadata = item.to_didl_metadata(); + renderer.play_uri(&item.uri, &didl_metadata)?; + Ok(()) + })(); + + match playback { + Ok(()) => { + info!( + renderer = renderer_id.0.as_str(), + uri = item.uri.as_str(), + "Queue playback started (current item)" + ); + + // Sauvegarder les métadonnées dans le snapshot pour que current_track soit disponible + // même si le renderer UPnP ne retourne pas de métadonnées dans GetPositionInfo + let metadata = TrackMetadata { + title: item.title.clone(), + artist: item.artist.clone(), + album: item.album.clone(), + genre: item.genre.clone(), + album_art_uri: item.album_art_uri.clone(), + date: item.date.clone(), + track_number: item.track_number.clone(), + creator: item.creator.clone(), + }; + self.runtime.update_snapshot_with(renderer_id, |snapshot| { + snapshot.last_metadata = Some(metadata); + }); + + self.runtime + .set_playback_source(renderer_id, PlaybackSource::FromQueue); + Ok(()) + } + Err(e) => { + error!( + renderer = renderer_id.0.as_str(), + error = %e, + "Failed to play current item from queue" + ); + self.runtime + .set_playback_source(renderer_id, PlaybackSource::None); + Err(e) + } + } + } + + pub fn play_next_from_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + if !self.runtime.has_entry(renderer_id) { + let err = Self::runtime_entry_missing(renderer_id); + warn!( + renderer = renderer_id.0.as_str(), + "Cannot advance queue: renderer not registered in runtime" + ); + return Err(err); + } + + if self.runtime.uses_openhome_playlist(renderer_id) { + self.play_next_openhome(renderer_id)?; + return Ok(()); + } + + let Some((item, remaining_after)) = self.runtime.dequeue_next(renderer_id) else { + debug!( + renderer = renderer_id.0.as_str(), + "play_next_from_queue: queue is empty" + ); + self.runtime + .set_playback_source(renderer_id, PlaybackSource::None); + return Ok(()); + }; + + let queue_before = remaining_after + 1; + debug!( + renderer = renderer_id.0.as_str(), + queue_before, + queue_after = remaining_after, + uri = item.uri.as_str(), + "Dequeued next playback item" + ); + + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + warn!( + renderer = renderer_id.0.as_str(), + "Renderer disappeared before queue playback could start" + ); + anyhow!("Renderer {} not found", renderer_id.0) + })?; + + let playback = (|| -> anyhow::Result<()> { + let didl_metadata = item.to_didl_metadata(); + renderer.play_uri(&item.uri, &didl_metadata)?; + Ok(()) + })(); + + if let Err(err) = playback { + error!( + renderer = renderer_id.0.as_str(), + error = %err, + "Failed to start playback for queued item" + ); + if self + .runtime + .with_queue_mut(renderer_id, |queue| queue.enqueue_front(item)) + .is_none() + { + warn!( + renderer = renderer_id.0.as_str(), + "Failed to requeue item after playback error" + ); + } + self.runtime + .set_playback_source(renderer_id, PlaybackSource::None); + return Err(err); + } + + // Sauvegarder les métadonnées dans le snapshot pour que current_track soit disponible + // même si le renderer UPnP ne retourne pas de métadonnées dans GetPositionInfo + let metadata = TrackMetadata { + title: item.title.clone(), + artist: item.artist.clone(), + album: item.album.clone(), + genre: item.genre.clone(), + album_art_uri: item.album_art_uri.clone(), + date: item.date.clone(), + track_number: item.track_number.clone(), + creator: item.creator.clone(), + }; + self.runtime.update_snapshot_with(renderer_id, |snapshot| { + snapshot.last_metadata = Some(metadata); + }); + + self.runtime + .set_playback_source(renderer_id, PlaybackSource::FromQueue); + debug!( + renderer = renderer_id.0.as_str(), + queue_len = remaining_after, + "Started playback from queue" + ); + + if let Some(snapshot) = self.runtime.queue_snapshot(renderer_id) { + if let Some(next_item) = snapshot.first() { + if let Some(upnp) = renderer.as_upnp() { + let known_supported = upnp.supports_set_next(); + if known_supported || upnp.has_avtransport() { + let next_didl_metadata = next_item.to_didl_metadata(); + match upnp.set_next_uri(&next_item.uri, &next_didl_metadata) { + Ok(_) => debug!( + renderer = renderer_id.0.as_str(), + "Prefetched next track via SetNextAVTransportURI" + ), + Err(err) => debug!( + renderer = renderer_id.0.as_str(), + error = %err, + "SetNextAVTransportURI failed for next queue item; continuing without prefetch" + ), + } + } + } + } + } + + // Emit QueueUpdated event + self.emit_renderer_event(RendererEvent::QueueUpdated { + id: renderer_id.clone(), + queue_length: remaining_after, + }); + + Ok(()) + } + + fn start_queue_playback_if_idle(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + let snapshot = self.runtime.snapshot_for(renderer_id); + let renderer_playing = matches!(snapshot.state, Some(PlaybackState::Playing)); + if renderer_playing || self.runtime.is_playing_from_queue(renderer_id) { + return Ok(()); + } + + let has_items = self + .runtime + .queue_snapshot(renderer_id) + .map(|items| !items.is_empty()) + .unwrap_or(false); + if !has_items { + debug!( + renderer = renderer_id.0.as_str(), + "start_queue_playback_if_idle: queue is empty" + ); + return Ok(()); + } + + self.play_next_from_queue(renderer_id) + } + + /// Stop playback in response to user action (e.g., Stop button in UI). + /// + /// This method marks the stop as user-requested to prevent automatic + /// advancement to the next track in the queue when the STOPPED event + /// is received from the renderer. + pub fn user_stop(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + // Mark that user requested stop before actually stopping + self.runtime.mark_user_stop_requested(renderer_id); + + // Get renderer and call stop + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + warn!( + renderer = renderer_id.0.as_str(), + "Cannot stop: renderer not found in registry" + ); + anyhow!("Renderer {} not found", renderer_id.0) + })?; + + debug!(renderer = renderer_id.0.as_str(), "User-requested stop"); + + renderer.stop() + } + + /// Subscribe to renderer events emitted by the control point runtime. + /// + /// Each subscriber receives all future events independently. + pub fn subscribe_events(&self) -> Receiver { + 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 { + self.media_event_bus.subscribe() + } + + /// Attach a renderer's playback queue to a server-side playlist container. + /// + /// When attached, the queue will be automatically refreshed from the + /// container whenever the server notifies us of changes via ContentDirectory + /// events. The binding is broken if the user explicitly mutates the queue + /// through methods like `clear_queue` or `enqueue_items`. + /// Attach a renderer's queue to a playlist container. + /// + /// The queue will be automatically refreshed when the playlist changes on the server. + pub fn attach_queue_to_playlist( + &self, + renderer_id: &RendererId, + server_id: ServerId, + container_id: String, + ) -> anyhow::Result<()> { + self.attach_queue_to_playlist_with_options(renderer_id, server_id, container_id, false) + } + + /// Attach a renderer queue to a playlist with explicit `auto_play` behaviour. + pub fn attach_queue_to_playlist_with_options( + &self, + renderer_id: &RendererId, + server_id: ServerId, + container_id: String, + auto_play: bool, + ) -> anyhow::Result<()> { + self.attach_queue_to_playlist_internal(renderer_id, &server_id, &container_id, auto_play) + } + + /// Internal implementation shared by every attach wrapper. + fn attach_queue_to_playlist_internal( + &self, + renderer_id: &RendererId, + server_id: &ServerId, + container_id: &str, + auto_play: bool, + ) -> anyhow::Result<()> { + let binding = PlaylistBinding { + server_id: server_id.clone(), + container_id: container_id.to_string(), + has_seen_update: false, + pending_refresh: true, + auto_play_on_refresh: auto_play, + }; + + { + let mut bindings = self.playlist_bindings.lock().unwrap(); + bindings.insert(renderer_id.clone(), binding.clone()); + info!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + container = container_id, + auto_play, + "Queue attached to playlist container" + ); + } + + self.emit_renderer_event(RendererEvent::BindingChanged { + id: renderer_id.clone(), + binding: Some(binding), + }); + + let mut auto_start_cb = |rid: &RendererId| self.start_queue_playback_if_idle(rid); + let callback: Option<&mut dyn FnMut(&RendererId) -> anyhow::Result<()>> = if auto_play { + Some(&mut auto_start_cb) + } else { + None + }; + + refresh_attached_queue_for( + &self.registry, + &self.runtime, + &self.playlist_bindings, + renderer_id, + &self.event_bus, + callback, + ) + } + + /// Detach a renderer's queue from its associated playlist container. + /// + /// After calling this, the queue will no longer be automatically refreshed + /// from the server. If no binding existed, this is a no-op. + pub fn detach_queue_playlist(&self, renderer_id: &RendererId) { + self.detach_playlist_binding(renderer_id, "api_detach"); + } + + /// Query the current playlist binding for a renderer's queue, if any. + /// + /// Returns `(server_id, container_id, has_seen_update)` if the queue is + /// bound to a server playlist container, or `None` otherwise. + pub fn current_queue_playlist_binding( + &self, + renderer_id: &RendererId, + ) -> Option<(ServerId, String, bool)> { + let bindings = self.playlist_bindings.lock().unwrap(); + bindings.get(renderer_id).map(|binding| { + ( + binding.server_id.clone(), + binding.container_id.clone(), + binding.has_seen_update, + ) + }) + } + + /// Internal helper to detach any playlist binding and notify observers. + /// + /// Invariant: every user-driven queue mutation **must** call this method so + /// that bindings never become out of sync with the local queue snapshot. + fn detach_playlist_binding(&self, renderer_id: &RendererId, reason: &str) { + let removed = { + let mut bindings = self.playlist_bindings.lock().unwrap(); + bindings.remove(renderer_id) + }; + + if let Some(binding) = removed { + info!( + renderer = renderer_id.0.as_str(), + server = binding.server_id.0.as_str(), + container = binding.container_id.as_str(), + reason = reason, + "Playlist binding detached" + ); + self.emit_renderer_event(RendererEvent::BindingChanged { + id: renderer_id.clone(), + binding: None, + }); + } else { + debug!( + renderer = renderer_id.0.as_str(), + reason = reason, + "detach_playlist_binding: no binding to remove" + ); + } + } + + pub(crate) fn emit_renderer_event(&self, event: RendererEvent) { + self.handle_renderer_event(&event); + self.event_bus.broadcast(event); + } + + fn handle_renderer_event(&self, event: &RendererEvent) { + if let RendererEvent::StateChanged { id, state } = event { + match state { + PlaybackState::Stopped => { + // Check if user requested stop (via Stop button in UI) + if self.runtime.check_and_clear_user_stop_requested(id) { + debug!( + renderer = id.0.as_str(), + "Renderer stopped by user request; not auto-advancing" + ); + self.runtime.set_playback_source(id, PlaybackSource::None); + } else if self.runtime.is_playing_from_queue(id) { + debug!( + renderer = id.0.as_str(), + "Renderer stopped after queue-driven playback; advancing" + ); + if let Err(err) = self.play_next_from_queue(id) { + error!( + renderer = id.0.as_str(), + error = %err, + "Auto-advance failed; clearing queue playback state" + ); + self.runtime.set_playback_source(id, PlaybackSource::None); + } + } else { + self.runtime.set_playback_source(id, PlaybackSource::None); + } + } + PlaybackState::Playing => { + self.runtime.mark_external_if_idle(id); + } + _ => {} + } + } + } + + fn runtime_entry_missing(renderer_id: &RendererId) -> anyhow::Error { + anyhow!( + "Renderer {} not registered in control point runtime", + renderer_id.0 + ) + } + + fn openhome_renderer(&self, renderer_id: &RendererId) -> anyhow::Result { + let renderer = self + .music_renderer_by_id(renderer_id) + .ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))?; + if !renderer.info().capabilities.has_oh_playlist { + return Err(OpenHomeAccessError::PlaylistNotSupported(renderer_id.0.clone()).into()); + } + Ok(renderer) + } + + fn sync_openhome_playlist_for(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + sync_openhome_playlist(&self.registry, &self.runtime, &self.event_bus, renderer_id) + } + + fn enqueue_items_openhome( + &self, + renderer_id: &RendererId, + items: Vec, + ) -> anyhow::Result<()> { + if items.is_empty() { + return Ok(()); + } + + let renderer = self.openhome_renderer(renderer_id)?; + + // Get the last track ID from the OpenHome native playlist + // This ensures we append to the end of the actual playlist, not just our local queue + let mut after_id = renderer + .openhome_playlist_ids() + .ok() + .and_then(|ids| ids.last().copied()); + + for item in items.iter() { + let metadata = item.to_didl_metadata(); + after_id = + Some(renderer.openhome_playlist_add_track(&item.uri, &metadata, after_id, false)?); + } + + self.sync_openhome_playlist_for(renderer_id)?; + Ok(()) + } + + fn play_current_openhome(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + let renderer = self.openhome_renderer(renderer_id)?; + + // Pour les renderers OpenHome, vérifier d'abord si la playlist native a des pistes + // Cela couvre le cas où l'utilisateur a ajouté des morceaux directement via l'interface OpenHome + let native_playlist_len = renderer.openhome_playlist_len().unwrap_or(0); + + if native_playlist_len > 0 { + // La playlist native a des pistes, on peut simplement appeler play() + // Cela reprendra la lecture à partir du morceau actuel (ou du premier si rien n'est en cours) + renderer.play()?; + self.runtime + .set_playback_source(renderer_id, PlaybackSource::FromQueue); + self.sync_openhome_playlist_for(renderer_id)?; + info!( + renderer = renderer_id.0.as_str(), + playlist_len = native_playlist_len, + "Started OpenHome native playlist playback" + ); + return Ok(()); + } + + // Fallback: utiliser la PlaybackQueue locale si la playlist native est vide + // (ce cas se produit quand on a enqueue des items via le control point) + let Some((item, _)) = self.runtime.peek_current(renderer_id) else { + debug!( + renderer = renderer_id.0.as_str(), + "OpenHome playlist is empty or no current item (both native and queue)" + ); + self.runtime + .set_playback_source(renderer_id, PlaybackSource::None); + return Ok(()); + }; + + let track_id = openhome_track_id_from_item(&item) + .ok_or_else(|| anyhow!("Current OpenHome item has no track id"))?; + renderer.openhome_playlist_play_id(track_id)?; + self.runtime + .set_playback_source(renderer_id, PlaybackSource::FromQueue); + self.sync_openhome_playlist_for(renderer_id)?; + info!( + renderer = renderer_id.0.as_str(), + track_id, "Started OpenHome playlist playback (current item from queue)" + ); + Ok(()) + } + + fn play_next_openhome(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + let renderer = self.openhome_renderer(renderer_id)?; + let (queue, current_index) = self + .runtime + .queue_full_snapshot(renderer_id) + .ok_or_else(|| Self::runtime_entry_missing(renderer_id))?; + + let next_item = match current_index { + Some(idx) => queue.get(idx + 1), + None => queue.first(), + }; + + let Some(item) = next_item else { + debug!( + renderer = renderer_id.0.as_str(), + "No OpenHome track available to advance to" + ); + self.runtime + .set_playback_source(renderer_id, PlaybackSource::None); + return Ok(()); + }; + + let track_id = openhome_track_id_from_item(item) + .ok_or_else(|| anyhow!("Next OpenHome item has no track id"))?; + renderer.openhome_playlist_play_id(track_id)?; + self.runtime + .set_playback_source(renderer_id, PlaybackSource::FromQueue); + self.sync_openhome_playlist_for(renderer_id)?; + info!( + renderer = renderer_id.0.as_str(), + track_id, "Advanced OpenHome playlist to next track" + ); + Ok(()) + } +} + +#[cfg(feature = "pmoserver")] +fn convert_runtime_position(position: Option<&PlaybackPositionInfo>) -> (Option, Option) { + match position { + Some(info) => ( + parse_hms_to_ms(info.rel_time.as_deref()), + parse_hms_to_ms(info.track_duration.as_deref()), + ), + None => (None, None), + } +} + +#[cfg(feature = "pmoserver")] +fn playback_state_label(state: &PlaybackState) -> String { + match state { + PlaybackState::Stopped => "STOPPED".to_string(), + PlaybackState::Playing => "PLAYING".to_string(), + PlaybackState::Paused => "PAUSED".to_string(), + PlaybackState::Transitioning => "TRANSITIONING".to_string(), + PlaybackState::NoMedia => "NO_MEDIA".to_string(), + PlaybackState::Unknown(custom) => custom.clone(), + } +} + +#[cfg(feature = "pmoserver")] +fn parse_hms_to_ms(hms: Option<&str>) -> Option { + let value = hms?; + let parts: Vec<&str> = value.split(':').collect(); + if parts.len() != 3 { + return None; + } + + let hours: u64 = parts[0].parse().ok()?; + let minutes: u64 = parts[1].parse().ok()?; + let seconds: u64 = parts[2].parse().ok()?; + + Some((hours * 3600 + minutes * 60 + seconds) * 1000) +} + +#[derive(Clone, Default)] +struct RendererRuntimeSnapshot { + state: Option, + position: Option, + last_volume: Option, + last_mute: Option, + last_metadata: Option, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum PlaybackSource { + #[default] + None, + FromQueue, + External, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PlaylistBackend { + PMOQueue, + OpenHome, +} + +struct RendererRuntimeEntry { + snapshot: RendererRuntimeSnapshot, + queue: PlaybackQueue, + playback_source: PlaybackSource, + user_stop_requested: bool, + playlist_backend: PlaylistBackend, +} + +impl Default for RendererRuntimeEntry { + fn default() -> Self { + Self { + snapshot: RendererRuntimeSnapshot::default(), + queue: PlaybackQueue::default(), + playback_source: PlaybackSource::None, + user_stop_requested: false, + playlist_backend: PlaylistBackend::PMOQueue, + } + } +} + +struct RuntimeState { + entries: Mutex>, +} + +impl RuntimeState { + fn new() -> Self { + Self { + entries: Mutex::new(HashMap::new()), + } + } + + fn snapshot_for(&self, id: &RendererId) -> RendererRuntimeSnapshot { + let entries = self.entries.lock().unwrap(); + entries + .get(id) + .map(|entry| entry.snapshot.clone()) + .unwrap_or_default() + } + + fn update_snapshot(&self, id: &RendererId, snapshot: RendererRuntimeSnapshot) { + self.with_entry(id, |entry| { + entry.snapshot = snapshot; + }); + } + + fn update_snapshot_with(&self, id: &RendererId, f: F) + where + F: FnOnce(&mut RendererRuntimeSnapshot), + { + self.with_entry(id, |entry| { + f(&mut entry.snapshot); + }); + } + + fn has_entry(&self, id: &RendererId) -> bool { + let entries = self.entries.lock().unwrap(); + entries.contains_key(id) + } + + fn with_queue_mut(&self, id: &RendererId, f: F) -> Option + where + F: FnOnce(&mut PlaybackQueue) -> R, + { + let mut entries = self.entries.lock().unwrap(); + entries.get_mut(id).map(|entry| f(&mut entry.queue)) + } + + fn queue_snapshot(&self, id: &RendererId) -> Option> { + let entries = self.entries.lock().unwrap(); + entries.get(id).map(|entry| entry.queue.snapshot()) + } + + fn queue_full_snapshot(&self, id: &RendererId) -> Option<(Vec, Option)> { + let entries = self.entries.lock().unwrap(); + entries.get(id).map(|entry| entry.queue.full_snapshot()) + } + + fn current_track_metadata(&self, id: &RendererId) -> Option { + let entries = self.entries.lock().unwrap(); + entries + .get(id) + .and_then(|entry| entry.snapshot.last_metadata.clone()) + } + + #[cfg(feature = "pmoserver")] + fn renderer_snapshot_bundle( + &self, + id: &RendererId, + ) -> (RendererRuntimeSnapshot, Vec, Option) { + let entries = self.entries.lock().unwrap(); + if let Some(entry) = entries.get(id) { + let (items, current_index) = entry.queue.full_snapshot(); + (entry.snapshot.clone(), items, current_index) + } else { + (RendererRuntimeSnapshot::default(), Vec::new(), None) + } + } + + fn dequeue_next(&self, id: &RendererId) -> Option<(PlaybackItem, usize)> { + let mut entries = self.entries.lock().unwrap(); + let entry = entries.get_mut(id)?; + let item = entry.queue.dequeue()?; + let remaining = entry.queue.upcoming_len(); + Some((item, remaining)) + } + + fn peek_current(&self, id: &RendererId) -> Option<(PlaybackItem, usize)> { + let entries = self.entries.lock().unwrap(); + let entry = entries.get(id)?; + let item = entry.queue.peek()?.clone(); + let remaining = entry.queue.upcoming_len(); + Some((item, remaining)) + } + + fn set_playback_source(&self, id: &RendererId, source: PlaybackSource) { + let mut entries = self.entries.lock().unwrap(); + if let Some(entry) = entries.get_mut(id) { + entry.playback_source = source; + } + } + + fn playback_source(&self, id: &RendererId) -> PlaybackSource { + let entries = self.entries.lock().unwrap(); + entries + .get(id) + .map(|entry| entry.playback_source) + .unwrap_or(PlaybackSource::None) + } + + fn is_playing_from_queue(&self, id: &RendererId) -> bool { + matches!(self.playback_source(id), PlaybackSource::FromQueue) + } + + fn mark_external_if_idle(&self, id: &RendererId) { + let mut entries = self.entries.lock().unwrap(); + if let Some(entry) = entries.get_mut(id) { + if matches!(entry.playback_source, PlaybackSource::None) { + entry.playback_source = PlaybackSource::External; + } + } + } + + fn with_entry(&self, id: &RendererId, f: F) -> R + where + F: FnOnce(&mut RendererRuntimeEntry) -> R, + { + let mut entries = self.entries.lock().unwrap(); + let entry = entries + .entry(id.clone()) + .or_insert_with(RendererRuntimeEntry::default); + f(entry) + } + + fn set_playlist_backend(&self, id: &RendererId, backend: PlaylistBackend) { + self.with_entry(id, |entry| { + entry.playlist_backend = backend; + }); + } + + fn playlist_backend(&self, id: &RendererId) -> PlaylistBackend { + let entries = self.entries.lock().unwrap(); + entries + .get(id) + .map(|entry| entry.playlist_backend) + .unwrap_or(PlaylistBackend::PMOQueue) + } + + fn uses_openhome_playlist(&self, id: &RendererId) -> bool { + matches!(self.playlist_backend(id), PlaylistBackend::OpenHome) + } + + fn mark_user_stop_requested(&self, id: &RendererId) { + let mut entries = self.entries.lock().unwrap(); + if let Some(entry) = entries.get_mut(id) { + entry.user_stop_requested = true; + } + } + + fn check_and_clear_user_stop_requested(&self, id: &RendererId) -> bool { + let mut entries = self.entries.lock().unwrap(); + if let Some(entry) = entries.get_mut(id) { + let was_requested = entry.user_stop_requested; + entry.user_stop_requested = false; + was_requested + } else { + false + } + } +} + +/// Internal helper to refresh a renderer's playback queue from its bound +/// playlist container. +/// +/// This function is called automatically when a ContentDirectory event indicates +/// that the bound container has been updated. It attempts to preserve the +/// currently playing item when possible. +fn refresh_attached_queue_for( + registry: &Arc>, + runtime: &Arc, + bindings: &Arc>>, + renderer_id: &RendererId, + event_bus: &RendererEventBus, + mut after_refresh: Option<&mut dyn FnMut(&RendererId) -> anyhow::Result<()>>, +) -> anyhow::Result<()> { + // Step 1: Check binding and mark refresh as in-progress + let (server_id, container_id, auto_play) = { + let mut bindings_lock = bindings.lock().unwrap(); + let binding = match bindings_lock.get_mut(renderer_id) { + Some(b) => b, + None => { + debug!( + renderer = renderer_id.0.as_str(), + "refresh_attached_queue_for: no binding present" + ); + return Ok(()); + } + }; + + if !binding.pending_refresh { + debug!( + renderer = renderer_id.0.as_str(), + "refresh_attached_queue_for: pending_refresh is false, nothing to do" + ); + return Ok(()); + } + + // Mark as processed + binding.pending_refresh = false; + let auto_play = binding.auto_play_on_refresh; + binding.auto_play_on_refresh = false; + ( + binding.server_id.clone(), + binding.container_id.clone(), + auto_play, + ) + }; + + // Step 2: Fetch MediaServerInfo from registry + let server_info = { + let reg = registry.read().unwrap(); + reg.get_server(&server_id) + }; + + let server_info = match server_info { + Some(info) => info, + None => { + warn!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + "refresh_attached_queue_for: server not found in registry" + ); + return Ok(()); + } + }; + + if !server_info.online { + debug!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + "refresh_attached_queue_for: server offline, skipping refresh" + ); + return Ok(()); + } + + if !server_info.has_content_directory { + debug!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + "refresh_attached_queue_for: server has no ContentDirectory" + ); + return Ok(()); + } + + // Step 3: Create MusicServer and browse container + let music_server = MusicServer::from_info(&server_info, Duration::from_secs(5))?; + + let entries = match music_server.browse_children(&container_id, 0, 64) { + Ok(e) => e, + Err(err) => { + warn!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + container = container_id.as_str(), + error = %err, + "Failed to browse playlist container for refresh" + ); + return Err(err); + } + }; + + // Step 4: Convert MediaEntry to PlaybackItem + let new_items: Vec = entries + .iter() + .filter_map(|entry| playback_item_from_entry(&music_server, entry)) + .collect(); + + if new_items.is_empty() { + debug!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + container = container_id.as_str(), + "Refreshed playlist is empty, clearing queue" + ); + runtime.with_queue_mut(renderer_id, |queue| queue.clear()); + + // Emit QueueUpdated event + event_bus.broadcast(RendererEvent::QueueUpdated { + id: renderer_id.clone(), + queue_length: 0, + }); + + return Ok(()); + } + + // Step 5: Intelligent refresh: try to keep current item if it's still in the new list + // Get the full queue snapshot to access the item currently being played + let (full_queue, current_idx) = runtime + .queue_full_snapshot(renderer_id) + .unwrap_or((vec![], None)); + + // Get the item currently being played (at current_index), not the next one in queue + let current_item = current_idx.and_then(|idx| full_queue.get(idx).cloned()); + + let item_found_at = current_item.as_ref().and_then(|current| { + new_items.iter().position(|new_item| { + // Match by object_id if both have it + if let (Some(current_obj), Some(new_obj)) = (¤t.object_id, &new_item.object_id) { + return current_obj == new_obj; + } + // Fallback: match by URI + current.uri == new_item.uri + }) + }); + + let final_queue_len = runtime + .with_queue_mut(renderer_id, |queue| { + queue.clear(); + + if let Some(idx) = item_found_at { + // Current item found: load the ENTIRE new playlist and position at that item + // This preserves items before the current track (as "already played") + for item in new_items.iter() { + queue.enqueue(item.clone()); + } + queue.set_current_index(Some(idx)); + info!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + container = container_id.as_str(), + total_items = new_items.len(), + current_index = idx, + upcoming = new_items.len().saturating_sub(idx + 1), + current_preserved = true, + "Refreshed queue from playlist container" + ); + new_items.len() + } else if let Some(ref current) = current_item { + // Current item NOT found: insert it at the beginning, then add new items + // This preserves the currently playing track and prevents it from being lost + queue.enqueue(current.clone()); + for item in new_items.iter() { + queue.enqueue(item.clone()); + } + queue.set_current_index(Some(0)); + info!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + container = container_id.as_str(), + total_items = new_items.len() + 1, + current_index = 0, + upcoming = new_items.len(), + current_preserved = true, + current_reinserted = true, + "Refreshed queue from playlist container (current item reinserted at start)" + ); + new_items.len() + 1 + } else { + // No current item: replace with full new list + for item in new_items.iter() { + queue.enqueue(item.clone()); + } + queue.set_current_index(None); + info!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + container = container_id.as_str(), + total_items = new_items.len(), + current_preserved = false, + "Refreshed queue from playlist container (no current item)" + ); + new_items.len() + } + }) + .unwrap_or(0); + + // Emit QueueUpdated event + event_bus.broadcast(RendererEvent::QueueUpdated { + id: renderer_id.clone(), + queue_length: final_queue_len, + }); + + if auto_play { + if let Some(callback) = after_refresh.as_deref_mut() { + if let Err(err) = callback(renderer_id) { + warn!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + container = container_id.as_str(), + error = %err, + "Failed to auto-start playback after playlist refresh" + ); + } + } + } + + Ok(()) +} + +/// Helper to detect if a MediaResource is audio content. +fn is_audio_resource(res: &MediaResource) -> bool { + let lower = res.protocol_info.to_ascii_lowercase(); + if lower.contains("audio/") { + return true; + } + // Check MIME type in protocolInfo (format: protocol:network:contentFormat:additionalInfo) + lower + .split(':') + .nth(2) + .map(|mime| mime.starts_with("audio/")) + .unwrap_or(false) +} + +/// Helper to convert a MediaEntry to a PlaybackItem. +fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option { + // Ignore containers + if entry.is_container { + return None; + } + + // Skip "live stream" entries (heuristic from example) + if entry.title.to_ascii_lowercase().contains("live stream") { + return None; + } + + // Find an audio resource + let resource = entry.resources.iter().find(|res| is_audio_resource(res))?; + + let mut item = PlaybackItem::new(resource.uri.clone()); + item.title = Some(entry.title.clone()); + item.server_id = Some(server.id().clone()); + item.object_id = Some(entry.id.clone()); + item.artist = entry.artist.clone(); + item.album = entry.album.clone(); + item.genre = entry.genre.clone(); + item.album_art_uri = entry.album_art_uri.clone(); + item.date = entry.date.clone(); + item.track_number = entry.track_number.clone(); + item.creator = entry.creator.clone(); + item.protocol_info = Some(resource.protocol_info.clone()); + + Some(item) +} + +const OPENHOME_TRACK_PREFIX: &str = "openhome:"; + +fn playback_item_from_openhome_track(track: &OpenHomePlaylistTrack) -> PlaybackItem { + let mut item = PlaybackItem::new(track.uri.clone()); + item.object_id = Some(format!("{}{}", OPENHOME_TRACK_PREFIX, track.id)); + item.title = track.title.clone(); + item.artist = track.artist.clone(); + item.album = track.album.clone(); + item.album_art_uri = track.album_art_uri.clone(); + item +} + +fn openhome_track_id_from_item(item: &PlaybackItem) -> Option { + let object_id = item.object_id.as_ref()?; + let raw = object_id.strip_prefix(OPENHOME_TRACK_PREFIX)?; + raw.parse::().ok() +} + +fn sync_openhome_playlist( + registry: &Arc>, + runtime: &Arc, + event_bus: &RendererEventBus, + renderer_id: &RendererId, +) -> anyhow::Result<()> { + let renderer = { + let info = { + let reg = registry.read().unwrap(); + reg.get_renderer(renderer_id) + .ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))? + }; + MusicRenderer::from_registry_info(info, registry) + .and_then(|r| match r { + MusicRenderer::OpenHome(_) => Some(r), + _ => None, + }) + .ok_or_else(|| OpenHomeAccessError::PlaylistNotSupported(renderer_id.0.clone()))? + }; + + let snapshot = renderer.openhome_playlist_snapshot()?; + let playback_items: Vec = snapshot + .tracks + .iter() + .map(playback_item_from_openhome_track) + .collect(); + + let current_id = snapshot.current_id; + + let current_index = current_id.and_then(|id| { + playback_items + .iter() + .position(|item| openhome_track_id_from_item(item) == Some(id)) + }); + + let queue_len = runtime + .with_queue_mut(renderer_id, |queue| { + queue.clear(); + queue.enqueue_many(playback_items.iter().cloned()); + queue.set_current_index(current_index); + queue.len() + }) + .unwrap_or(0); + + event_bus.broadcast(RendererEvent::QueueUpdated { + id: renderer_id.clone(), + queue_length: queue_len, + }); + + Ok(()) +} + +const OPENHOME_SUBSCRIPTION_TIMEOUT_SECS: u64 = 300; +const OPENHOME_RENEWAL_MARGIN_SECS: u64 = 60; + +fn spawn_openhome_event_runtime( + registry: Arc>, + runtime: Arc, + event_bus: RendererEventBus, + event_tx: Sender, +) -> io::Result<()> { + let listener = TcpListener::bind("0.0.0.0:0")?; + let listener_addr = listener + .local_addr() + .map_err(|err| io::Error::new(io::ErrorKind::Other, err))?; + + info!("OpenHome event listener bound on {}", listener_addr); + + let (notify_tx, notify_rx) = unbounded::(); + thread::Builder::new() + .name("openhome-event-http".into()) + .spawn(move || run_openhome_http_listener(listener, notify_tx))?; + + let worker = OpenHomeEventRuntime::new( + registry, + runtime, + event_bus, + notify_rx, + event_tx, + listener_addr.port(), + ); + + thread::Builder::new() + .name("openhome-event-worker".into()) + .spawn(move || worker.run()) + .map(|_| ()) +} + +struct OpenHomeEventRuntime { + registry: Arc>, + runtime: Arc, + event_bus: RendererEventBus, + notify_rx: Receiver, + event_tx: Sender, + listener_port: u16, + http_timeout: Duration, + subscriptions: HashMap, + path_index: HashMap, +} + +impl OpenHomeEventRuntime { + fn new( + registry: Arc>, + runtime: Arc, + event_bus: RendererEventBus, + notify_rx: Receiver, + event_tx: Sender, + listener_port: u16, + ) -> Self { + Self { + registry, + runtime, + event_bus, + notify_rx, + event_tx, + listener_port, + http_timeout: Duration::from_secs(5), + subscriptions: HashMap::new(), + path_index: HashMap::new(), + } + } + + fn run(mut self) { + loop { + self.drain_notifications(); + self.refresh_renderers(); + 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_renderers(&mut self) { + let renderer_infos = { + let reg = self.registry.read().unwrap(); + reg.list_renderers() + }; + + let mut active: HashSet = HashSet::new(); + + for info in renderer_infos { + if !info.online { + continue; + } + + if let Some(url) = info.oh_playlist_event_sub_url.clone() { + let key = OpenHomeSubscriptionKey::new(&info.id, OpenHomeServiceKind::Playlist); + active.insert(key.clone()); + self.ensure_subscription(key, info.clone(), url); + } + + if let Some(url) = info.oh_info_event_sub_url.clone() { + let key = OpenHomeSubscriptionKey::new(&info.id, OpenHomeServiceKind::Info); + active.insert(key.clone()); + self.ensure_subscription(key, info.clone(), url); + } + + if let Some(url) = info.oh_time_event_sub_url.clone() { + let key = OpenHomeSubscriptionKey::new(&info.id, OpenHomeServiceKind::Time); + active.insert(key.clone()); + self.ensure_subscription(key, info.clone(), url); + } + } + + let stale: Vec = self + .subscriptions + .keys() + .filter(|key| !active.contains(*key)) + .cloned() + .collect(); + + for key in stale { + if let Some(mut entry) = self.subscriptions.remove(&key) { + self.path_index.remove(&entry.callback_path); + if let Err(err) = Self::unsubscribe_entry(self.http_timeout, &mut entry) { + warn!( + renderer = entry.renderer.friendly_name.as_str(), + service = entry.service.as_str(), + error = %err, + "Failed to unsubscribe from OpenHome events" + ); + } + } + } + } + + fn ensure_subscription( + &mut self, + key: OpenHomeSubscriptionKey, + info: RendererInfo, + event_url: String, + ) { + let entry = self.subscriptions.entry(key.clone()).or_insert_with(|| { + OpenHomeSubscriptionState::new(info.clone(), key.service, event_url.clone()) + }); + + entry.update(info, event_url); + self.path_index + .insert(entry.callback_path.clone(), key.clone()); + + if entry.sid.is_none() && entry.should_retry() { + if let Err(err) = Self::subscribe_entry(self.listener_port, self.http_timeout, entry) { + warn!( + renderer = entry.renderer.friendly_name.as_str(), + service = entry.service.as_str(), + error = %err, + "OpenHome SUBSCRIBE failed" + ); + entry.defer_retry(); + } + } + } + + fn renew_expiring(&mut self) { + let now = Instant::now(); + let mut to_renew = Vec::new(); + for (key, entry) in self.subscriptions.iter() { + if let Some(exp) = entry.expires_at { + if exp <= now + Duration::from_secs(OPENHOME_RENEWAL_MARGIN_SECS) { + to_renew.push(key.clone()); + } + } + } + + for key in to_renew { + if let Some(entry) = self.subscriptions.get_mut(&key) { + if let Err(err) = Self::renew_entry(self.http_timeout, entry) { + warn!( + renderer = entry.renderer.friendly_name.as_str(), + service = entry.service.as_str(), + error = %err, + "Failed to renew OpenHome subscription" + ); + entry.reset_subscription(); + } + } + } + } + + fn handle_notification(&mut self, notify: OpenHomeIncomingNotify) { + let Some(key) = self.path_index.get(¬ify.path).cloned() else { + debug!("Dropping OpenHome notify for unknown path {}", notify.path); + return; + }; + + let Some(entry) = self.subscriptions.get(&key) else { + return; + }; + + if let (Some(expected), Some(received)) = (&entry.sid, ¬ify.sid) { + if !expected.eq_ignore_ascii_case(received) { + debug!( + renderer = entry.renderer.friendly_name.as_str(), + service = entry.service.as_str(), + expected_sid = expected.as_str(), + received_sid = received.as_str(), + "Ignoring OpenHome notify with mismatched SID" + ); + return; + } + } + + let properties = + parse_openhome_propertyset(&entry.renderer.id, &entry.service, ¬ify.body); + if properties.is_empty() { + return; + } + + match entry.service { + OpenHomeServiceKind::Playlist => { + if properties + .iter() + .any(|(name, _)| is_id_array_property(name)) + { + if self.runtime.uses_openhome_playlist(&entry.renderer.id) { + if let Err(err) = sync_openhome_playlist( + &self.registry, + &self.runtime, + &self.event_bus, + &entry.renderer.id, + ) { + warn!( + renderer = entry.renderer.friendly_name.as_str(), + error = %err, + "Failed to sync OpenHome playlist after IdArray event" + ); + } + } + } + } + OpenHomeServiceKind::Info => { + self.handle_info_properties(&entry.renderer.id, properties); + } + OpenHomeServiceKind::Time => { + self.handle_time_properties(&entry.renderer.id, properties); + } + } + } + + fn handle_info_properties(&self, renderer_id: &RendererId, properties: Vec<(String, String)>) { + let mut metadata_xml: Option = None; + let mut transport_state: Option = None; + let mut track_id: Option = None; + let mut track_uri: Option = None; + + for (name, value) in properties { + match name.as_str() { + "Metadata" | "TrackMetadata" => { + if !value.trim().is_empty() { + metadata_xml = Some(value); + } + } + "TransportState" => { + transport_state = Some(value); + } + "Id" | "TrackId" => { + if let Ok(id) = value.trim().parse::() { + track_id = Some(id); + } + } + "Uri" | "TrackUri" => { + if !value.trim().is_empty() { + track_uri = Some(value); + } + } + _ => {} + } + } + + if let Some(xml) = metadata_xml { + if let Some(metadata) = parse_track_metadata_from_didl(&xml) { + self.runtime.update_snapshot_with(renderer_id, |snapshot| { + snapshot.last_metadata = Some(metadata.clone()); + let mut position = snapshot + .position + .clone() + .unwrap_or_else(|| empty_playback_position()); + position.track_metadata = Some(xml.clone()); + snapshot.position = Some(position); + }); + let _ = self.event_tx.send(RendererEvent::MetadataChanged { + id: renderer_id.clone(), + metadata, + }); + } + } + + if track_id.is_some() || track_uri.is_some() { + self.runtime.update_snapshot_with(renderer_id, |snapshot| { + let mut position = snapshot + .position + .clone() + .unwrap_or_else(|| empty_playback_position()); + if let Some(id) = track_id { + position.track = Some(id); + } + if let Some(uri) = track_uri.clone() { + position.track_uri = Some(uri); + } + snapshot.position = Some(position); + }); + } + + if let Some(state_str) = transport_state { + let playback_state = map_openhome_state(&state_str); + let _ = self.event_tx.send(RendererEvent::StateChanged { + id: renderer_id.clone(), + state: playback_state, + }); + } + } + + fn handle_time_properties(&self, renderer_id: &RendererId, properties: Vec<(String, String)>) { + let mut duration: Option = None; + let mut seconds: Option = None; + + for (name, value) in properties { + match name.as_str() { + "Duration" => { + duration = value.trim().parse::().ok(); + } + "Seconds" => { + seconds = value.trim().parse::().ok(); + } + _ => {} + } + } + + if duration.is_none() && seconds.is_none() { + return; + } + + let position = self.runtime.snapshot_for(renderer_id).position; + let mut new_position = position.unwrap_or_else(|| empty_playback_position()); + if let Some(d) = duration { + new_position.track_duration = Some(format_seconds(d)); + } + if let Some(s) = seconds { + new_position.rel_time = Some(format_seconds(s)); + } + + self.runtime.update_snapshot_with(renderer_id, |snapshot| { + snapshot.position = Some(new_position.clone()); + }); + + let _ = self.event_tx.send(RendererEvent::PositionChanged { + id: renderer_id.clone(), + position: new_position, + }); + } + + fn subscribe_entry( + listener_port: u16, + http_timeout: Duration, + entry: &mut OpenHomeSubscriptionState, + ) -> anyhow::Result<()> { + let event_url = entry.event_sub_url.clone(); + 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!( + renderer = entry.renderer.friendly_name.as_str(), + service = entry.service.as_str(), + callback = callback_url.as_str(), + "Subscribing to OpenHome events" + ); + + let host_header = format!("{}:{}", remote_host, remote_port); + let timeout_header = format!("Second-{}", OPENHOME_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(OPENHOME_SUBSCRIPTION_TIMEOUT_SECS)); + + entry.sid = Some(sid); + entry.expires_at = Some(Instant::now() + timeout); + entry.retry_after = Instant::now() + Duration::from_secs(5); + + info!( + renderer = entry.renderer.friendly_name.as_str(), + service = entry.service.as_str(), + "Subscribed to OpenHome events (timeout {}s)", + timeout.as_secs() + ); + + Ok(()) + } + + fn renew_entry( + http_timeout: Duration, + entry: &mut OpenHomeSubscriptionState, + ) -> anyhow::Result<()> { + let sid = entry.sid.clone().context("Cannot renew without SID")?; + let request = http::Request::builder() + .method("SUBSCRIBE") + .uri(&entry.event_sub_url) + .header("SID", sid) + .header( + "TIMEOUT", + format!("Second-{}", OPENHOME_SUBSCRIPTION_TIMEOUT_SECS), + ) + .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(OPENHOME_SUBSCRIPTION_TIMEOUT_SECS)); + entry.expires_at = Some(Instant::now() + timeout); + info!( + renderer = entry.renderer.friendly_name.as_str(), + service = entry.service.as_str(), + "Renewed OpenHome subscription (timeout {}s)", + timeout.as_secs() + ); + Ok(()) + } + + fn unsubscribe_entry( + http_timeout: Duration, + entry: &mut OpenHomeSubscriptionState, + ) -> anyhow::Result<()> { + let sid = match entry.sid.take() { + Some(sid) => sid, + None => return Ok(()), + }; + + let request = http::Request::builder() + .method("UNSUBSCRIBE") + .uri(&entry.event_sub_url) + .header("SID", sid) + .body(()) + .map_err(anyhow::Error::new)?; + let response = build_agent(http_timeout).run(request)?; + if !response.status().is_success() { + warn!( + renderer = entry.renderer.friendly_name.as_str(), + service = entry.service.as_str(), + status = response.status().as_u16(), + "UNSUBSCRIBE returned non-success status" + ); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct OpenHomeSubscriptionKey { + renderer_id: RendererId, + service: OpenHomeServiceKind, +} + +impl OpenHomeSubscriptionKey { + fn new(renderer_id: &RendererId, service: OpenHomeServiceKind) -> Self { + Self { + renderer_id: renderer_id.clone(), + service, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum OpenHomeServiceKind { + Playlist, + Info, + Time, +} + +impl OpenHomeServiceKind { + fn as_str(&self) -> &'static str { + match self { + OpenHomeServiceKind::Playlist => "playlist", + OpenHomeServiceKind::Info => "info", + OpenHomeServiceKind::Time => "time", + } + } +} + +struct OpenHomeSubscriptionState { + renderer: RendererInfo, + service: OpenHomeServiceKind, + event_sub_url: String, + callback_path: String, + sid: Option, + expires_at: Option, + retry_after: Instant, +} + +impl OpenHomeSubscriptionState { + fn new(renderer: RendererInfo, service: OpenHomeServiceKind, event_sub_url: String) -> Self { + Self { + callback_path: build_openhome_callback_path(&renderer.id, service), + renderer, + service, + event_sub_url, + sid: None, + expires_at: None, + retry_after: Instant::now(), + } + } + + fn update(&mut self, renderer: RendererInfo, event_url: String) { + if self.renderer.location != renderer.location || self.event_sub_url != event_url { + self.event_sub_url = event_url; + self.sid = None; + self.expires_at = None; + self.retry_after = Instant::now(); + } + self.renderer = renderer; + } + + 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); + } +} + +struct OpenHomeIncomingNotify { + path: String, + sid: Option, + body: Vec, +} + +fn run_openhome_http_listener(listener: TcpListener, notify_tx: Sender) { + 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 OpenHome notify connection: {}", + err + ); + } + + match read_openhome_http_request(&mut stream) { + Ok(request) => { + if request.method != "NOTIFY" { + let _ = write_openhome_http_response( + &mut stream, + 405, + "Method Not Allowed", + ); + continue; + } + + let notify = OpenHomeIncomingNotify { + path: request.path, + sid: request.headers.get("sid").cloned(), + body: request.body, + }; + + if notify_tx.send(notify).is_err() { + warn!("Dropping OpenHome notify because worker channel is closed"); + } + let _ = write_openhome_http_response(&mut stream, 200, "OK"); + } + Err(err) => { + warn!("Failed to parse OpenHome notify request: {}", err); + let _ = write_openhome_http_response(&mut stream, 400, "Bad Request"); + } + } + } + Err(err) => { + warn!("Incoming OpenHome notify connection failed: {}", err); + } + } + } +} + +struct OpenHomeHttpRequest { + method: String, + path: String, + headers: HashMap, + body: Vec, +} + +fn read_openhome_http_request(stream: &mut TcpStream) -> io::Result { + 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(); + + 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(OpenHomeHttpRequest { + method, + path, + headers, + body, + }) +} + +fn write_openhome_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()) +} + +fn build_openhome_callback_path(id: &RendererId, service: OpenHomeServiceKind) -> 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); + service.hash(&mut hasher); + let suffix = hasher.finish(); + format!("/openhome-events/{}/{:x}", service.as_str(), suffix) +} + +fn parse_openhome_propertyset( + renderer_id: &RendererId, + service: &OpenHomeServiceKind, + body: &[u8], +) -> Vec<(String, String)> { + let mut properties = Vec::new(); + let reader = std::io::Cursor::new(body); + let Ok(root) = Element::parse(reader) else { + warn!( + renderer = renderer_id.0.as_str(), + service = service.as_str(), + "Failed to parse OpenHome notify payload" + ); + return properties; + }; + + 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 let Some(text) = child.get_text() { + properties.push((child.name.clone(), text.into_owned())); + } + } + } + + properties +} + +fn is_id_array_property(name: &str) -> bool { + name.trim().to_ascii_lowercase().ends_with("idarray") +} + +fn empty_playback_position() -> PlaybackPositionInfo { + PlaybackPositionInfo { + track: None, + rel_time: None, + abs_time: None, + track_duration: None, + track_metadata: None, + track_uri: None, + } +} + +fn parse_timeout(raw: Option<&str>) -> Option { + let value = raw?; + let lower = value.trim().to_ascii_lowercase(); + if lower == "second-infinite" { + return Some(Duration::from_secs(OPENHOME_SUBSCRIPTION_TIMEOUT_SECS)); + } + if let Some(idx) = lower.find("second-") { + let number = &lower[idx + 7..]; + if let Ok(seconds) = number.parse::() { + 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 { + 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() +} + +/// Parse "HH:MM:SS" style time strings to seconds. +/// +/// Returns None for empty or sentinel values such as "NOT_IMPLEMENTED" or "-:--:--". +fn parse_hms_to_secs(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + + // Common sentinel values for "no information" in UPnP implementations. + if s == "NOT_IMPLEMENTED" || s == "-:--:--" { + return None; + } + + let parts: Vec<_> = s.split(':').collect(); + if parts.len() != 3 { + return None; + } + + let hours: u64 = parts[0].parse().ok()?; + let minutes: u64 = parts[1].parse().ok()?; + let seconds: u64 = parts[2].parse().ok()?; + + Some(hours * 3600 + minutes * 60 + seconds) +} + +fn parse_optional_hms_to_secs(value: &Option) -> Option { + value.as_ref().and_then(|s| parse_hms_to_secs(s)) +} + +/// Compute a logical playback state by combining the raw AVTransport state +/// with previous and current position information. +/// +/// This is designed to compensate for buggy LinkPlay/Arylic devices that +/// report: +/// - STOPPED while the time actually advances, +/// - NO_MEDIA_PRESENT while track duration is known. +fn compute_logical_playback_state( + raw: &PlaybackState, + prev_position: Option<&PlaybackPositionInfo>, + current_position: Option<&PlaybackPositionInfo>, +) -> PlaybackState { + use PlaybackState::*; + + // Rule 1: Arylic / LinkPlay sometimes report STOPPED while the stream is + // actually playing. If we detect that the relative time advances between + // two polls, we treat this as Playing. + if let Stopped = raw { + if let (Some(prev), Some(curr)) = (prev_position, current_position) { + if let (Some(prev_rel), Some(curr_rel)) = ( + parse_optional_hms_to_secs(&prev.rel_time), + parse_optional_hms_to_secs(&curr.rel_time), + ) { + if curr_rel > prev_rel { + let delta = curr_rel - prev_rel; + // Our poll loop runs every 1s; accept small jitter in the delta. + if delta <= 5 { + return Playing; + } + } + } + } + } + + // Rule 2: Some devices report NO_MEDIA_PRESENT while exposing a non-zero + // track duration. In practice this behaves like a stopped transport with + // a loaded track. + if let NoMedia = raw { + let duration_secs = current_position + .and_then(|p| parse_optional_hms_to_secs(&p.track_duration)) + .or_else(|| prev_position.and_then(|p| parse_optional_hms_to_secs(&p.track_duration))); + + if matches!(duration_secs, Some(d) if d > 0) { + return Stopped; + } + } + + // Fallback: keep the raw (already normalized) state. + raw.clone() +} + +fn playback_state_equal(a: &PlaybackState, b: &PlaybackState) -> bool { + match (a, b) { + (PlaybackState::Unknown(lhs), PlaybackState::Unknown(rhs)) => lhs == rhs, + _ => std::mem::discriminant(a) == std::mem::discriminant(b), + } +} + +fn playback_position_equal(a: &PlaybackPositionInfo, b: &PlaybackPositionInfo) -> bool { + a.track == b.track + && a.rel_time == b.rel_time + && a.abs_time == b.abs_time + && a.track_duration == b.track_duration + && a.track_metadata == b.track_metadata + && a.track_uri == b.track_uri +} + +#[cfg(feature = "pmoserver")] +fn current_track_from_playback_item(item: &PlaybackItem) -> CurrentTrackMetadata { + CurrentTrackMetadata { + title: item.title.clone(), + artist: item.artist.clone(), + album: item.album.clone(), + album_art_uri: item.album_art_uri.clone(), + } +} + +/// Extract TrackMetadata from DIDL-Lite XML in PlaybackPositionInfo. +fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option { + let didl_xml = match position.track_metadata.as_ref() { + Some(xml) => xml, + None => { + debug!("Position info has no track_metadata (DIDL-Lite XML)"); + return None; + } + }; + + // Parse DIDL-Lite XML + let didl = match pmodidl::parse_metadata::(didl_xml) { + Ok(parsed) => parsed.data, + Err(err) => { + debug!(error = %err, "Failed to parse DIDL-Lite metadata from GetPositionInfo"); + return None; + } + }; + + // Extract first item metadata + let item = match didl.items.first() { + Some(item) => item, + None => { + debug!("DIDL-Lite has no items"); + return None; + } + }; + + debug!( + title = item.title.as_str(), + has_album_art = item.album_art.is_some(), + album_art_uri = item.album_art.as_deref(), + "Extracted metadata from position info" + ); + + Some(TrackMetadata { + title: Some(item.title.clone()), + artist: item.artist.clone(), + album: item.album.clone(), + genre: item.genre.clone(), + album_art_uri: item.album_art.clone(), + date: item.date.clone(), + track_number: item.original_track_number.clone(), + creator: item.creator.clone(), + }) +} diff --git a/pmocontrol/src/discovery.rs b/pmocontrol/src/discovery.rs new file mode 100644 index 00000000..013d7198 --- /dev/null +++ b/pmocontrol/src/discovery.rs @@ -0,0 +1,226 @@ +use std::collections::{HashMap, HashSet}; +use std::time::SystemTime; + +use pmoupnp::ssdp::SsdpEvent; + +use crate::media_server::MediaServerInfo; +use crate::model::RendererInfo; +use crate::registry::DeviceUpdate; + +/// État connu pour un endpoint UPnP identifié par son UDN. +#[derive(Clone, Debug)] +pub struct DiscoveredEndpoint { + /// UDN normalisé (ex: "uuid:xxxx", en minuscules). + pub udn: String, + /// Dernière URL de description device (LOCATION SSDP). + pub location: String, + /// Dernier header SERVER vu sur cet endpoint. + pub server_header: String, + /// Dernier max-age indiqué (TTL SSDP). + pub max_age: u32, + /// Date de dernière vue (Now lors du dernier Alive ou SearchResponse). + pub last_seen: SystemTime, + /// Indique si on a vu ce endpoint comme MediaRenderer. + pub seen_as_renderer: bool, + /// Indique si on a vu ce endpoint comme MediaServer. + pub seen_as_server: bool, + /// ST/NT vus (pour debug/diagnostic si utile). + pub types_seen: HashSet, +} + +impl DiscoveredEndpoint { + pub fn new(udn: String, location: String, server_header: String, max_age: u32) -> Self { + Self { + udn, + location, + server_header, + max_age, + last_seen: SystemTime::now(), + seen_as_renderer: false, + seen_as_server: false, + types_seen: HashSet::new(), + } + } + + pub fn touch(&mut self, location: String, server_header: String, max_age: u32) { + self.location = location; + self.server_header = server_header; + self.max_age = max_age; + self.last_seen = SystemTime::now(); + } +} + +/// Fournit les descriptions haut niveau à partir d’un endpoint découvert. +/// L’implémentation pourra, plus tard, faire un HTTP GET sur `location` +/// et parser la description pour remplir RendererInfo / MediaServerInfo. +pub trait DeviceDescriptionProvider: Send + Sync { + /// Construit un RendererInfo pour cet endpoint, ou None s’il + /// ne correspond pas à un renderer audio intéressant. + fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option; + + /// Construit un MediaServerInfo pour cet endpoint, ou None s’il + /// ne correspond pas à un media server (ou pas intéressant). + fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option; +} + +/// Gestionnaire des événements SSDP -> DeviceUpdate. +pub struct DiscoveryManager

+where + P: DeviceDescriptionProvider, +{ + endpoints: HashMap, + provider: P, +} + +impl

DiscoveryManager

+where + P: DeviceDescriptionProvider, +{ + pub fn new(provider: P) -> Self { + Self { + endpoints: HashMap::new(), + provider, + } + } + + pub fn handle_ssdp_event(&mut self, event: SsdpEvent) -> Vec { + let mut updates = Vec::new(); + + match event { + SsdpEvent::Alive { + usn, + nt, + location, + server, + max_age, + .. + } => { + if let Some(udn) = extract_udn_from_usn(&usn) { + self.handle_alive(udn, nt, location, server, max_age, &mut updates); + } + } + SsdpEvent::SearchResponse { + usn, + st, + location, + server, + max_age, + .. + } => { + if let Some(udn) = extract_udn_from_usn(&usn) { + self.handle_search_response(udn, st, location, server, max_age, &mut updates); + } + } + SsdpEvent::ByeBye { usn, nt, .. } => { + if let Some(udn) = extract_udn_from_usn(&usn) { + self.handle_byebye(udn, nt, &mut updates); + } + } + } + + updates + } + + fn handle_alive( + &mut self, + udn: String, + nt: String, + location: String, + server_header: String, + max_age: u32, + updates: &mut Vec, + ) { + self.update_endpoint(udn, nt, location, server_header, max_age, updates); + } + + fn handle_search_response( + &mut self, + udn: String, + st: String, + location: String, + server_header: String, + max_age: u32, + updates: &mut Vec, + ) { + self.update_endpoint(udn, st, location, server_header, max_age, updates); + } + + fn handle_byebye(&mut self, udn: String, _nt: String, updates: &mut Vec) { + if let Some(endpoint) = self.endpoints.get(&udn) { + if endpoint.seen_as_renderer { + updates.push(DeviceUpdate::RendererOfflineByUdn(udn.clone())); + } + if endpoint.seen_as_server { + updates.push(DeviceUpdate::ServerOfflineByUdn(udn)); + } + } + } + + fn update_endpoint( + &mut self, + udn: String, + device_type: String, + location: String, + server_header: String, + max_age: u32, + updates: &mut Vec, + ) { + tracing::debug!( + "SSDP update: udn={} type={} location={} max_age={}", + udn, + device_type, + location, + max_age + ); + + let endpoint = self.endpoints.entry(udn.clone()).or_insert_with({ + let udn = udn.clone(); + let location = location.clone(); + let server_header = server_header.clone(); + move || DiscoveredEndpoint::new(udn, location, server_header, max_age) + }); + + endpoint.touch(location, server_header, max_age); + endpoint.types_seen.insert(device_type); + + if !endpoint.seen_as_renderer { + if let Some(info) = self.provider.build_renderer_info(endpoint) { + tracing::debug!( + "Renderer classified: udn={} friendly_name={} model={}", + info.udn, + info.friendly_name, + info.model_name + ); + endpoint.seen_as_renderer = true; + updates.push(DeviceUpdate::RendererOnline(info)); + } + } + + if !endpoint.seen_as_server { + if let Some(info) = self.provider.build_server_info(endpoint) { + tracing::debug!( + "Server classified: udn={} friendly_name={} model={}", + info.udn, + info.friendly_name, + info.model_name + ); + endpoint.seen_as_server = true; + updates.push(DeviceUpdate::ServerOnline(info)); + } + } + } +} + +fn extract_udn_from_usn(usn: &str) -> Option { + let lower = usn.trim().to_ascii_lowercase(); + if let Some(idx) = lower.find("uuid:") { + let sub = &lower[idx..]; + if let Some(end) = sub.find("::") { + Some(sub[..end].to_string()) + } else { + Some(sub.to_string()) + } + } else { + None + } +} diff --git a/pmocontrol/src/events.rs b/pmocontrol/src/events.rs new file mode 100644 index 00000000..e85fbfc7 --- /dev/null +++ b/pmocontrol/src/events.rs @@ -0,0 +1,60 @@ +use std::sync::{Arc, Mutex}; + +use crossbeam_channel::{Receiver, Sender, unbounded}; + +use crate::model::{MediaServerEvent, RendererEvent}; + +#[derive(Clone, Default)] +pub(crate) struct RendererEventBus { + subscribers: Arc>>>, +} + +impl RendererEventBus { + pub(crate) fn new() -> Self { + Self { + subscribers: Arc::new(Mutex::new(Vec::new())), + } + } + + pub(crate) fn subscribe(&self) -> Receiver { + let (tx, rx) = unbounded::(); + { + let mut subscribers = self.subscribers.lock().unwrap(); + subscribers.push(tx); + } + rx + } + + #[allow(dead_code)] + pub(crate) fn broadcast(&self, event: RendererEvent) { + let mut subscribers = self.subscribers.lock().unwrap(); + subscribers.retain(|tx| tx.send(event.clone()).is_ok()); + } +} + +#[derive(Clone, Default)] +pub struct MediaServerEventBus { + subscribers: Arc>>>, +} + +impl MediaServerEventBus { + pub fn new() -> Self { + Self { + subscribers: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn subscribe(&self) -> Receiver { + let (tx, rx) = unbounded::(); + { + 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()); + } +} diff --git a/pmocontrol/src/lib.rs b/pmocontrol/src/lib.rs new file mode 100644 index 00000000..3715d8cb --- /dev/null +++ b/pmocontrol/src/lib.rs @@ -0,0 +1,63 @@ +mod events; +mod media_server_events; + +pub mod arylic_tcp; +pub mod avtransport_client; +pub mod capabilities; +pub mod connection_manager_client; +pub mod control_point; +pub mod discovery; +pub mod linkplay; +pub mod media_server; +pub mod model; +pub mod music_renderer; +pub mod openhome_client; +pub mod openhome_playlist; +pub mod openhome_renderer; +pub mod playback_queue; +pub mod provider; +pub mod registry; +pub mod rendering_control_client; +pub mod soap_client; +pub mod upnp_renderer; + +// pmoserver extension (optional) +#[cfg(feature = "pmoserver")] +pub mod openapi; +#[cfg(feature = "pmoserver")] +pub mod pmoserver_ext; +#[cfg(feature = "pmoserver")] +pub mod sse; + +#[cfg(feature = "pmoserver")] +pub use pmoserver_ext::ControlPointExt; + +pub use arylic_tcp::ArylicTcpRenderer; +pub use avtransport_client::{AvTransportClient, PositionInfo, TransportInfo}; +pub use capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, + VolumeControl, +}; +pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo}; +pub use control_point::{ControlPoint, PlaylistBinding}; +pub use linkplay::LinkPlayRenderer; +pub use media_server::{ + MediaBrowser, MediaEntry, MediaResource, MediaServerInfo, MusicServer, ServerId, + UpnpMediaServer, +}; +pub use music_renderer::MusicRenderer; +pub use openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; +pub use openhome_renderer::OpenHomeRenderer; +pub use playback_queue::{PlaybackItem, PlaybackQueue}; +pub use rendering_control_client::RenderingControlClient; +pub use upnp_renderer::UpnpRenderer; + +pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager}; +pub use model::{ + MediaServerEvent, RendererCapabilities, RendererEvent, RendererId, RendererInfo, + RendererProtocol, +}; +pub use provider::HttpXmlDescriptionProvider; +pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate}; + +pub use soap_client::invoke_upnp_action; diff --git a/pmocontrol/src/linkplay.rs b/pmocontrol/src/linkplay.rs new file mode 100644 index 00000000..01959c18 --- /dev/null +++ b/pmocontrol/src/linkplay.rs @@ -0,0 +1,476 @@ +use std::char; +use std::collections::HashMap; +use std::fmt; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use tracing::debug; +use ureq::Agent; + +use crate::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, + VolumeControl, +}; +use crate::model::{RendererId, RendererInfo}; + +const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3; +const STATUS_COMMAND: &str = "getPlayerStatus"; + +/// Renderer backend for devices exposing the LinkPlay HTTP API. +#[derive(Clone)] +pub struct LinkPlayRenderer { + pub info: RendererInfo, + host: String, + timeout: Duration, +} + +impl fmt::Debug for LinkPlayRenderer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LinkPlayRenderer") + .field("id", &self.info.id) + .field("friendly_name", &self.info.friendly_name) + .field("host", &self.host) + .finish() + } +} + +impl LinkPlayRenderer { + /// Build a LinkPlay backend from a registry snapshot. + pub fn from_renderer_info(info: RendererInfo) -> Result { + let host = extract_linkplay_host(&info.location) + .ok_or_else(|| anyhow!("Renderer {} has no valid LOCATION host", info.udn))?; + + Ok(Self { + info, + host, + timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS), + }) + } + + fn agent(&self) -> Agent { + build_agent(self.timeout) + } + + pub fn id(&self) -> &RendererId { + &self.info.id + } + + pub fn friendly_name(&self) -> &str { + &self.info.friendly_name + } + + fn send_player_command(&self, command: &str) -> Result<()> { + let url = format!( + "http://{}/httpapi.asp?command=setPlayerCmd:{}", + self.host, command + ); + self.agent() + .get(&url) + .call() + .with_context(|| format!("LinkPlay command {} failed for {}", command, self.host))?; + Ok(()) + } + + fn fetch_status(&self) -> Result { + fetch_status_for_host(&self.host, self.timeout) + } +} + +impl TransportControl for LinkPlayRenderer { + fn play_uri(&self, uri: &str, _meta: &str) -> Result<()> { + let encoded = percent_encode(uri); + self.send_player_command(&format!("play:{}", encoded)) + } + + fn play(&self) -> Result<()> { + self.send_player_command("resume") + } + + fn pause(&self) -> Result<()> { + self.send_player_command("pause") + } + + fn stop(&self) -> Result<()> { + self.send_player_command("stop") + } + + fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { + let secs = parse_hhmmss_to_secs(hhmmss) + .ok_or_else(|| anyhow!("Invalid seek position format: {}", hhmmss))?; + self.send_player_command(&format!("seek:{}", secs)) + } +} + +impl VolumeControl for LinkPlayRenderer { + fn volume(&self) -> Result { + Ok(self.fetch_status()?.volume) + } + + fn set_volume(&self, v: u16) -> Result<()> { + let value = v.min(100); + self.send_player_command(&format!("vol:{}", value)) + } + + fn mute(&self) -> Result { + Ok(self.fetch_status()?.mute) + } + + fn set_mute(&self, m: bool) -> Result<()> { + self.send_player_command(if m { "mute:1" } else { "mute:0" }) + } +} + +impl PlaybackStatus for LinkPlayRenderer { + fn playback_state(&self) -> Result { + Ok(self.fetch_status()?.playback_state()) + } +} + +impl PlaybackPosition for LinkPlayRenderer { + fn playback_position(&self) -> Result { + Ok(self.fetch_status()?.position_info()) + } +} + +/// Detect whether a renderer exposes the LinkPlay HTTP API. +pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool { + let Some(host) = extract_linkplay_host(location) else { + return false; + }; + + match fetch_status_for_host(&host, timeout) { + Ok(_) => true, + Err(err) => { + debug!( + "LinkPlay detection failed for {} (host={}): {}", + location, host, err + ); + false + } + } +} + +/// Extract the IP/host component from a LOCATION URL. +pub fn extract_linkplay_host(location: &str) -> Option { + let (_, rest) = location.split_once("://")?; + let authority = rest.split('/').next().unwrap_or(rest); + let without_auth = authority.split('@').last().unwrap_or(authority); + + if without_auth.starts_with('[') { + let end = without_auth.find(']')?; + let host = &without_auth[1..end]; + if host.is_empty() { + None + } else { + Some(host.to_string()) + } + } else { + let host = without_auth.split(':').next().unwrap_or(""); + if host.is_empty() { + None + } else { + Some(host.to_string()) + } + } +} + +fn fetch_status_for_host(host: &str, timeout: Duration) -> Result { + let url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND); + let mut response = build_agent(timeout) + .get(&url) + .call() + .with_context(|| format!("HTTP request failed for LinkPlay status on {}", host))?; + + let body = response + .body_mut() + .read_to_string() + .context("Failed to read LinkPlay status body")?; + + parse_linkplay_status(&body) +} + +fn build_agent(timeout: Duration) -> Agent { + Agent::config_builder() + .timeout_global(Some(timeout)) + .build() + .into() +} + +#[derive(Clone, Debug)] +struct LinkPlayStatus { + state_raw: String, + curpos_ms: u64, + totlen_ms: u64, + track_index: Option, + volume: u16, + mute: bool, +} + +impl LinkPlayStatus { + fn playback_state(&self) -> PlaybackState { + match self.state_raw.as_str() { + "play" => PlaybackState::Playing, + "pause" => PlaybackState::Paused, + "stop" => PlaybackState::Stopped, + "load" => PlaybackState::Transitioning, + other => PlaybackState::Unknown(other.to_string()), + } + } + + fn position_info(&self) -> PlaybackPositionInfo { + PlaybackPositionInfo { + track: self.track_index, + rel_time: Some(format_hms(self.curpos_ms / 1000)), + abs_time: None, + track_duration: if self.totlen_ms > 0 { + Some(format_hms(self.totlen_ms / 1000)) + } else { + None + }, + track_metadata: None, + track_uri: None, + } + } +} + +fn parse_linkplay_status(body: &str) -> Result { + let mut map = parse_flat_json(body)?; + + let state_raw = map + .remove("status") + .ok_or_else(|| anyhow!("LinkPlay status missing `status` field"))?; + + let curpos_ms = parse_u64_field(&map, "curpos")?; + let totlen_ms = parse_u64_field(&map, "totlen")?; + let volume = parse_u16_field(&map, "vol")?; + let mute = match map.get("mute").map(|s| s.as_str()) { + Some("1") => true, + Some("0") => false, + Some(other) => { + return Err(anyhow!("Invalid LinkPlay mute value: {}", other)); + } + None => return Err(anyhow!("LinkPlay status missing `mute` field")), + }; + + let track_index = map + .get("plicurr") + .and_then(|s| s.parse::().ok()) + .filter(|idx| *idx > 0); + + Ok(LinkPlayStatus { + state_raw, + curpos_ms, + totlen_ms, + track_index, + volume, + mute, + }) +} + +fn parse_u64_field(map: &HashMap, key: &str) -> Result { + let raw = map + .get(key) + .ok_or_else(|| anyhow!("LinkPlay status missing `{}` field", key))?; + raw.parse::() + .with_context(|| format!("Invalid `{}` value: {}", key, raw)) +} + +fn parse_u16_field(map: &HashMap, key: &str) -> Result { + let raw = map + .get(key) + .ok_or_else(|| anyhow!("LinkPlay status missing `{}` field", key))?; + let value = raw + .parse::() + .with_context(|| format!("Invalid `{}` value: {}", key, raw))?; + Ok(value.min(100)) +} + +pub(crate) fn parse_flat_json(input: &str) -> Result> { + let mut chars = input.chars().peekable(); + skip_ws(&mut chars); + if chars.next() != Some('{') { + return Err(anyhow!("LinkPlay status is not a JSON object")); + } + + let mut map = HashMap::new(); + loop { + skip_ws(&mut chars); + match chars.peek() { + Some('}') => { + chars.next(); + break; + } + Some(_) => {} + None => return Err(anyhow!("Unexpected end of JSON object")), + } + + let key = parse_json_string(&mut chars)?; + skip_ws(&mut chars); + expect_char(&mut chars, ':')?; + skip_ws(&mut chars); + let value = parse_json_value(&mut chars)?; + map.insert(key, value); + skip_ws(&mut chars); + + match chars.peek() { + Some(',') => { + chars.next(); + continue; + } + Some('}') => { + chars.next(); + break; + } + Some(other) => { + return Err(anyhow!( + "Unexpected character '{}' while parsing JSON", + other + )); + } + None => return Err(anyhow!("Unexpected end of JSON while parsing fields")), + } + } + + Ok(map) +} + +fn parse_json_value(chars: &mut std::iter::Peekable>) -> Result { + match chars.peek() { + Some('"') => parse_json_string(chars), + Some(ch) if ch.is_ascii_digit() || *ch == '-' => parse_json_number(chars), + Some('t') => { + expect_literal(chars, "true")?; + Ok("true".to_string()) + } + Some('f') => { + expect_literal(chars, "false")?; + Ok("false".to_string()) + } + _ => Err(anyhow!("Unsupported JSON value in LinkPlay status")), + } +} + +fn parse_json_string(chars: &mut std::iter::Peekable>) -> Result { + if chars.next() != Some('"') { + return Err(anyhow!("Expected string")); + } + + let mut out = String::new(); + while let Some(ch) = chars.next() { + match ch { + '"' => return Ok(out), + '\\' => { + let escaped = chars.next().ok_or_else(|| anyhow!("Invalid escape"))?; + match escaped { + '"' => out.push('"'), + '\\' => out.push('\\'), + '/' => out.push('/'), + 'b' => out.push('\u{0008}'), + 'f' => out.push('\u{000C}'), + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 't' => out.push('\t'), + 'u' => { + let mut hex = String::with_capacity(4); + for _ in 0..4 { + let h = chars.next().ok_or_else(|| anyhow!("Invalid \\u escape"))?; + hex.push(h); + } + let code = u16::from_str_radix(&hex, 16) + .with_context(|| format!("Invalid unicode escape: {}", hex))?; + if let Some(c) = char::from_u32(code as u32) { + out.push(c); + } else { + return Err(anyhow!("Invalid unicode code point: {}", code)); + } + } + other => return Err(anyhow!("Unsupported escape: {}", other)), + } + } + other => out.push(other), + } + } + + Err(anyhow!("Unterminated JSON string")) +} + +fn parse_json_number(chars: &mut std::iter::Peekable>) -> Result { + let mut out = String::new(); + + if matches!(chars.peek(), Some('-')) { + out.push('-'); + chars.next(); + } + + while let Some(ch) = chars.peek() { + if ch.is_ascii_digit() || *ch == '.' { + out.push(*ch); + chars.next(); + } else { + break; + } + } + + if out.is_empty() || out == "-" { + return Err(anyhow!("Invalid number")); + } + + Ok(out) +} + +fn expect_literal( + chars: &mut std::iter::Peekable>, + literal: &str, +) -> Result<()> { + for expected in literal.chars() { + match chars.next() { + Some(ch) if ch == expected => {} + _ => return Err(anyhow!("Invalid literal while parsing JSON")), + } + } + Ok(()) +} + +fn expect_char(chars: &mut std::iter::Peekable>, expected: char) -> Result<()> { + match chars.next() { + Some(ch) if ch == expected => Ok(()), + _ => Err(anyhow!("Missing '{}' while parsing JSON", expected)), + } +} + +fn skip_ws(chars: &mut std::iter::Peekable>) { + while matches!(chars.peek(), Some(ch) if ch.is_whitespace()) { + chars.next(); + } +} + +fn percent_encode(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for b in input.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(b as char); + } + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} + +fn parse_hhmmss_to_secs(s: &str) -> Option { + let parts: Vec<_> = s.split(':').collect(); + if parts.len() != 3 { + return None; + } + let h: u64 = parts[0].parse().ok()?; + let m: u64 = parts[1].parse().ok()?; + let sec: u64 = parts[2].parse().ok()?; + Some(h * 3600 + m * 60 + sec) +} + +fn format_hms(secs: u64) -> String { + let h = secs / 3600; + let m = (secs % 3600) / 60; + let s = secs % 60; + format!("{:02}:{:02}:{:02}", h, m, s) +} diff --git a/pmocontrol/src/media_server.rs b/pmocontrol/src/media_server.rs new file mode 100644 index 00000000..edb6c63d --- /dev/null +++ b/pmocontrol/src/media_server.rs @@ -0,0 +1,456 @@ +use std::time::{Duration, SystemTime}; + +use anyhow::{Result, anyhow}; +use pmodidl::{self, DIDLLite}; +use pmoupnp::soap::SoapEnvelope; +use pmoupnp::soap::error_codes; +use xmltree::{Element, XMLNode}; + +use crate::soap_client::{SoapCallResult, invoke_upnp_action_with_timeout}; + +/// Unique identifier for a media server registered by the control point. +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct ServerId(pub String); + +/// Snapshot of a media server discovered through UPnP SSDP. +#[derive(Clone, Debug)] +pub struct MediaServerInfo { + pub id: ServerId, + pub udn: String, + pub friendly_name: String, + pub model_name: String, + pub manufacturer: String, + pub location: String, + pub server_header: String, + pub online: bool, + pub last_seen: SystemTime, + pub max_age: u32, + pub has_content_directory: bool, + pub content_directory_service_type: Option, + pub content_directory_control_url: Option, +} + +/// Simplified view over a DIDL-Lite resource entry. +#[derive(Clone, Debug)] +pub struct MediaResource { + pub uri: String, + pub protocol_info: String, + pub duration: Option, +} + +/// Representation of either a container or an item returned by ContentDirectory. +#[derive(Clone, Debug)] +pub struct MediaEntry { + pub id: String, + pub parent_id: String, + pub title: String, + pub is_container: bool, + pub class: String, + pub resources: Vec, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub album_art_uri: Option, + pub date: Option, + pub track_number: Option, + pub creator: Option, +} + +/// Backend-agnostic media browsing contract. +pub trait MediaBrowser { + fn browse_root(&self) -> Result>; + fn browse_children(&self, object_id: &str, start: u32, count: u32) -> Result>; + fn browse_object(&self, object_id: &str) -> Result; + fn search( + &self, + container_id: &str, + query: &str, + start: u32, + count: u32, + ) -> Result>; +} + +/// Façade over every supported media server backend. +#[derive(Clone, Debug)] +pub enum MusicServer { + Upnp(UpnpMediaServer), +} + +impl MusicServer { + pub fn from_info(info: &MediaServerInfo, timeout: Duration) -> Result { + Ok(MusicServer::Upnp(UpnpMediaServer::new( + info.clone(), + timeout, + ))) + } + + pub fn id(&self) -> &ServerId { + match self { + MusicServer::Upnp(upnp) => upnp.id(), + } + } + + pub fn info(&self) -> &MediaServerInfo { + match self { + MusicServer::Upnp(upnp) => upnp.info(), + } + } +} + +impl MediaBrowser for MusicServer { + fn browse_root(&self) -> Result> { + match self { + MusicServer::Upnp(upnp) => upnp.browse_root(), + } + } + + fn browse_children(&self, object_id: &str, start: u32, count: u32) -> Result> { + match self { + MusicServer::Upnp(upnp) => upnp.browse_children(object_id, start, count), + } + } + + fn browse_object(&self, object_id: &str) -> Result { + match self { + MusicServer::Upnp(upnp) => upnp.browse_object(object_id), + } + } + + fn search( + &self, + container_id: &str, + query: &str, + start: u32, + count: u32, + ) -> Result> { + match self { + MusicServer::Upnp(upnp) => upnp.search(container_id, query, start, count), + } + } +} + +/// Single UPnP ContentDirectory backend implementation. +#[derive(Clone, Debug)] +pub struct UpnpMediaServer { + info: MediaServerInfo, + timeout: Duration, +} + +impl UpnpMediaServer { + pub fn new(info: MediaServerInfo, timeout: Duration) -> Self { + Self { info, timeout } + } + + pub fn id(&self) -> &ServerId { + &self.info.id + } + + pub fn info(&self) -> &MediaServerInfo { + &self.info + } + + fn browse_with_flag( + &self, + object_id: &str, + browse_flag: &str, + start: u32, + count: u32, + ) -> Result> { + let start_str = start.to_string(); + let count_str = count.to_string(); + let args = vec![ + ("ObjectID", object_id.to_string()), + ("BrowseFlag", browse_flag.to_string()), + ("Filter", "*".to_string()), + ("StartingIndex", start_str), + ("RequestedCount", count_str), + ("SortCriteria", String::new()), + ]; + + let response = self.invoke_content_directory("Browse", None, args)?; + let envelope = response + .envelope + .ok_or_else(|| anyhow!("Missing SOAP envelope in Browse response"))?; + let didl_xml = extract_result_payload(&envelope, "BrowseResponse")?; + map_didl_entries(&didl_xml) + } + + fn search_impl( + &self, + container_id: &str, + query: &str, + start: u32, + count: u32, + ) -> Result> { + let start_str = start.to_string(); + let count_str = count.to_string(); + + let args = vec![ + ("ContainerID", container_id.to_string()), + ("SearchCriteria", query.to_string()), + ("Filter", "*".to_string()), + ("StartingIndex", start_str), + ("RequestedCount", count_str), + ("SortCriteria", String::new()), + ]; + + let response = self.invoke_content_directory("Search", Some("search"), args)?; + let envelope = response + .envelope + .ok_or_else(|| anyhow!("Missing SOAP envelope in Search response"))?; + let didl_xml = extract_result_payload(&envelope, "SearchResponse")?; + map_didl_entries(&didl_xml) + } + + fn invoke_content_directory( + &self, + action: &str, + op_name: Option<&str>, + args: Vec<(&'static str, String)>, + ) -> Result { + let op = op_name.unwrap_or(action); + let (control_url, service_type) = self.content_directory_endpoints(op)?; + let borrowed_args: Vec<(&str, &str)> = args.iter().map(|(k, v)| (*k, v.as_str())).collect(); + + let call_result = invoke_upnp_action_with_timeout( + control_url, + service_type, + action, + &borrowed_args, + Some(self.timeout), + )?; + + if !call_result.status.is_success() { + if let Some(env) = &call_result.envelope { + if let Some(err) = parse_upnp_error(env) { + if should_map_to_not_supported(action, op_name, err.error_code) { + return Err(server_op_not_supported(op, "UpnpMediaServer")); + } + + return Err(anyhow!( + "{} failed with UPnP error {}: {}", + action, + err.error_code, + err.error_description + )); + } + } + + return Err(anyhow!( + "{} failed with HTTP status {} and body: {}", + action, + call_result.status, + call_result.raw_body + )); + } + + if let Some(env) = &call_result.envelope { + if let Some(err) = parse_upnp_error(env) { + if should_map_to_not_supported(action, op_name, err.error_code) { + return Err(server_op_not_supported(op, "UpnpMediaServer")); + } + + return Err(anyhow!( + "{} returned UPnP error {}: {}", + action, + err.error_code, + err.error_description + )); + } + } + + Ok(call_result) + } + + fn content_directory_endpoints(&self, op_name: &str) -> Result<(&str, &str)> { + if !self.info.has_content_directory { + return Err(server_op_not_supported(op_name, "UpnpMediaServer")); + } + + let control_url = self + .info + .content_directory_control_url + .as_deref() + .ok_or_else(|| server_op_not_supported(op_name, "UpnpMediaServer"))?; + let service_type = self + .info + .content_directory_service_type + .as_deref() + .ok_or_else(|| server_op_not_supported(op_name, "UpnpMediaServer"))?; + + Ok((control_url, service_type)) + } +} + +impl MediaBrowser for UpnpMediaServer { + fn browse_root(&self) -> Result> { + self.browse_with_flag("0", "BrowseDirectChildren", 0, 0) + } + + fn browse_children(&self, object_id: &str, start: u32, count: u32) -> Result> { + self.browse_with_flag(object_id, "BrowseDirectChildren", start, count) + } + + fn browse_object(&self, object_id: &str) -> Result { + let entries = self.browse_with_flag(object_id, "BrowseMetadata", 0, 1)?; + entries + .into_iter() + .next() + .ok_or_else(|| anyhow!("Object {} was not returned by the server", object_id)) + } + + fn search( + &self, + container_id: &str, + query: &str, + start: u32, + count: u32, + ) -> Result> { + self.search_impl(container_id, query, start, count) + } +} + +fn map_didl_entries(xml: &str) -> Result> { + let trimmed = xml.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + + let didl: DIDLLite = pmodidl::parse_metadata::(trimmed) + .map_err(|err| anyhow!("Failed to parse DIDL-Lite payload: {}", err))? + .data; + + let mut entries = Vec::new(); + + for container in didl.containers { + entries.push(MediaEntry { + id: container.id, + parent_id: container.parent_id, + title: container.title, + is_container: true, + class: container.class, + resources: Vec::new(), + artist: None, + album: None, + genre: None, + album_art_uri: None, + date: None, + track_number: None, + creator: None, + }); + } + + for item in didl.items { + let resources = item + .resources + .into_iter() + .filter_map(|res| { + if res.url.trim().is_empty() { + return None; + } + Some(MediaResource { + uri: res.url, + protocol_info: res.protocol_info, + duration: res.duration, + }) + }) + .collect(); + + entries.push(MediaEntry { + id: item.id, + parent_id: item.parent_id, + title: item.title, + is_container: false, + class: item.class, + resources, + artist: item.artist, + album: item.album, + genre: item.genre, + album_art_uri: item.album_art, + date: item.date, + track_number: item.original_track_number, + creator: item.creator, + }); + } + + Ok(entries) +} + +fn extract_result_payload(envelope: &SoapEnvelope, response_suffix: &str) -> Result { + let response = find_child_with_suffix(&envelope.body.content, response_suffix) + .ok_or_else(|| anyhow!("Missing {} element in SOAP body", response_suffix))?; + let result_elem = find_child_with_suffix(response, "Result") + .ok_or_else(|| anyhow!("Missing Result element in {}", response_suffix))?; + + let payload = result_elem + .get_text() + .map(|t| t.to_string()) + .unwrap_or_default(); + + Ok(payload) +} + +fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> { + parent.children.iter().find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem), + _ => None, + }) +} + +fn parse_upnp_error(envelope: &SoapEnvelope) -> Option { + let fault = find_child_with_suffix(&envelope.body.content, "Fault")?; + let detail = find_child_with_suffix(fault, "detail")?; + let upnp_error = find_child_with_suffix(detail, "UPnPError")?; + + let error_code_elem = upnp_error.children.iter().find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem), + _ => None, + })?; + let error_code_text = error_code_elem.get_text()?.trim().to_string(); + let error_code = error_code_text.parse::().ok()?; + + let error_description = upnp_error + .children + .iter() + .find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => { + elem.get_text().map(|t| t.trim().to_string()) + } + _ => None, + }) + .unwrap_or_default(); + + Some(UpnpError { + error_code, + error_description, + }) +} + +fn should_map_to_not_supported(action: &str, op_name: Option<&str>, error_code: u32) -> bool { + if op_name.is_none() { + return false; + } + + let optional_code = error_codes::OPTIONAL_ACTION_NOT_IMPLEMENTED + .parse::() + .unwrap_or(602); + let invalid_action_code = error_codes::INVALID_ACTION.parse::().unwrap_or(401); + + let cd_not_supported = matches!(action, "Search"); + + cd_not_supported && (error_code == optional_code || error_code == invalid_action_code) +} + +fn server_op_not_supported(op: &str, backend: &str) -> anyhow::Error { + anyhow!( + "MusicServer operation '{}' is not supported by backend '{}'", + op, + backend + ) +} + +#[derive(Debug, Clone)] +struct UpnpError { + pub error_code: u32, + pub error_description: String, +} diff --git a/pmocontrol/src/media_server_events.rs b/pmocontrol/src/media_server_events.rs new file mode 100644 index 00000000..8c7e9b47 --- /dev/null +++ b/pmocontrol/src/media_server_events.rs @@ -0,0 +1,824 @@ +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>, + 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::(); + 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, + body: Vec, +} + +fn run_http_listener(listener: TcpListener, notify_tx: Sender) { + 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, + body: Vec, +} + +fn read_http_request(stream: &mut TcpStream) -> io::Result { + 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>, + bus: MediaServerEventBus, + http_timeout: Duration, + notify_rx: Receiver, + listener_port: u16, + subscriptions: HashMap, + path_index: HashMap, +} + +impl MediaServerEventWorker { + fn new( + registry: Arc>, + bus: MediaServerEventBus, + http_timeout: Duration, + notify_rx: Receiver, + 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 = 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 = 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(¬ify.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, ¬ify.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, ¬ify.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, + sid: Option, + expires_at: Option, + 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 { + 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 = None; + let mut container_ids: Vec = 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::() { + 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 { + 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 { + 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> { + 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 { + 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::() { + 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 { + 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() +} diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs new file mode 100644 index 00000000..8b4e5baa --- /dev/null +++ b/pmocontrol/src/model.rs @@ -0,0 +1,132 @@ +use crate::capabilities::{PlaybackPositionInfo, PlaybackState}; +use crate::control_point::PlaylistBinding; +use crate::media_server::ServerId; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct RendererId(pub String); + +#[derive(Clone, Debug, PartialEq)] +pub struct TrackMetadata { + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub album_art_uri: Option, + pub date: Option, + pub track_number: Option, + pub creator: Option, +} + +#[derive(Clone, Debug)] +pub enum RendererProtocol { + UpnpAvOnly, + OpenHomeOnly, + Hybrid, +} + +#[derive(Clone, Debug, Default)] +pub struct RendererCapabilities { + pub has_avtransport: bool, + /// True if the renderer is known to support AVTransport.SetNextAVTransportURI. + /// + /// This is discovered lazily at runtime; default is false. + pub has_avtransport_set_next: bool, + pub has_rendering_control: bool, + pub has_connection_manager: bool, + pub has_linkplay_http: bool, + pub has_arylic_tcp: bool, + + pub has_oh_playlist: bool, + pub has_oh_volume: bool, + pub has_oh_info: bool, + pub has_oh_time: bool, + pub has_oh_radio: bool, +} + +impl RendererCapabilities { + pub fn supports_set_next(&self) -> bool { + self.has_avtransport && self.has_avtransport_set_next + } +} + +#[derive(Clone, Debug)] +pub struct RendererInfo { + pub id: RendererId, + pub udn: String, + pub friendly_name: String, + pub model_name: String, + pub manufacturer: String, + + pub protocol: RendererProtocol, + pub capabilities: RendererCapabilities, + + pub location: String, + pub server_header: String, + pub online: bool, + pub last_seen: std::time::SystemTime, + pub max_age: u32, + + pub avtransport_service_type: Option, + pub avtransport_control_url: Option, + pub rendering_control_service_type: Option, + pub rendering_control_control_url: Option, + pub connection_manager_service_type: Option, + pub connection_manager_control_url: Option, + pub oh_playlist_service_type: Option, + pub oh_playlist_control_url: Option, + pub oh_playlist_event_sub_url: Option, + pub oh_info_service_type: Option, + pub oh_info_control_url: Option, + pub oh_info_event_sub_url: Option, + pub oh_time_service_type: Option, + pub oh_time_control_url: Option, + pub oh_time_event_sub_url: Option, + pub oh_volume_service_type: Option, + pub oh_volume_control_url: Option, + pub oh_radio_service_type: Option, + pub oh_radio_control_url: Option, +} + +#[derive(Clone, Debug)] +pub enum RendererEvent { + StateChanged { + id: RendererId, + state: PlaybackState, + }, + PositionChanged { + id: RendererId, + position: PlaybackPositionInfo, + }, + VolumeChanged { + id: RendererId, + volume: u16, + }, + MuteChanged { + id: RendererId, + mute: bool, + }, + MetadataChanged { + id: RendererId, + metadata: TrackMetadata, + }, + QueueUpdated { + id: RendererId, + queue_length: usize, + }, + BindingChanged { + id: RendererId, + binding: Option, + }, +} + +#[derive(Clone, Debug)] +pub enum MediaServerEvent { + GlobalUpdated { + server_id: ServerId, + system_update_id: Option, + }, + ContainersUpdated { + server_id: ServerId, + container_ids: Vec, + }, +} diff --git a/pmocontrol/src/music_renderer.rs b/pmocontrol/src/music_renderer.rs new file mode 100644 index 00000000..85c4a50f --- /dev/null +++ b/pmocontrol/src/music_renderer.rs @@ -0,0 +1,371 @@ +//! Backend-agnostic music renderer façade for PMOMusic. +//! +//! `MusicRenderer` wraps every supported backend (UPnP AV/DLNA, OpenHome, +//! LinkPlay HTTP, Arylic TCP, and the hybrid UPnP + Arylic pairing) behind a +//! single control surface. Higher layers in PMOMusic must only interact with +//! renderers through this type so that transport, volume, and state queries +//! stay backend-neutral. + +use std::sync::{Arc, RwLock}; + +use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus}; +use crate::model::{RendererId, RendererInfo, RendererProtocol}; +use crate::openhome_playlist::OpenHomePlaylistSnapshot; +use crate::{ + ArylicTcpRenderer, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer, PlaybackPosition, + PlaybackState, TransportControl, UpnpRenderer, VolumeControl, +}; +use anyhow::{Result, anyhow}; +use tracing::warn; + +/// Backend-agnostic façade exposing transport, volume, and status contracts. +#[derive(Clone, Debug)] +pub enum MusicRenderer { + /// Classic UPnP AV / DLNA renderer (AVTransport + RenderingControl). + Upnp(UpnpRenderer), + /// Renderer powered by OpenHome services. + OpenHome(OpenHomeRenderer), + /// Renderer controlled via the LinkPlay HTTP API. + LinkPlay(LinkPlayRenderer), + /// Renderer reachable through the Arylic TCP control protocol (port 8899). + ArylicTcp(ArylicTcpRenderer), + /// Combined backend using UPnP for transport + volume writes and Arylic TCP + /// to read detailed playback information as well as live volume/mute state. + HybridUpnpArylic { + upnp: UpnpRenderer, + arylic: ArylicTcpRenderer, + }, +} + +/// Build a standardized error when an operation is not supported by a backend. +pub(crate) fn op_not_supported(op: &str, backend: &str) -> anyhow::Error { + anyhow!( + "MusicRenderer operation '{}' is not supported by backend '{}'", + op, + backend + ) +} + +impl MusicRenderer { + /// Renderer identifier (stable within the registry). + pub fn id(&self) -> &RendererId { + match self { + MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.id(), + MusicRenderer::OpenHome(r) => r.id(), + MusicRenderer::Upnp(r) => r.id(), + MusicRenderer::LinkPlay(r) => r.id(), + MusicRenderer::ArylicTcp(r) => r.id(), + } + } + + /// Human-friendly name reported by the device. + pub fn friendly_name(&self) -> &str { + match self { + MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.friendly_name(), + MusicRenderer::OpenHome(r) => r.friendly_name(), + MusicRenderer::Upnp(r) => r.friendly_name(), + MusicRenderer::LinkPlay(r) => r.friendly_name(), + MusicRenderer::ArylicTcp(r) => r.friendly_name(), + } + } + + /// Protocol classification (UPnP AV only, OpenHome only, hybrid). + pub fn protocol(&self) -> &RendererProtocol { + &self.info().protocol + } + + /// Full static info as stored in the registry. + pub fn info(&self) -> &RendererInfo { + match self { + MusicRenderer::HybridUpnpArylic { arylic, .. } => &arylic.info, + MusicRenderer::OpenHome(r) => &r.info, + MusicRenderer::Upnp(r) => &r.info, + MusicRenderer::LinkPlay(r) => &r.info, + MusicRenderer::ArylicTcp(r) => &r.info, + } + } + + /// Return a reference to the underlying UPnP backend, if any. + pub fn as_upnp(&self) -> Option<&UpnpRenderer> { + match self { + MusicRenderer::Upnp(r) => Some(r), + _ => None, + } + } + + /// Construct a music renderer from a [`RendererInfo`] and the registry. + /// + /// Returns `None` when no supported backend can be built for this renderer. + /// UPnP AV / hybrid renderers map either to [`MusicRenderer::LinkPlay`] (when supported) + /// or [`MusicRenderer::Upnp`]. + pub fn from_registry_info( + info: RendererInfo, + registry: &Arc>, + ) -> Option { + if matches!( + info.protocol, + RendererProtocol::OpenHomeOnly | RendererProtocol::Hybrid + ) { + if let Some(renderer) = { + let renderer = OpenHomeRenderer::new(info.clone()); + renderer.has_any_openhome_service().then_some(renderer) + } { + return Some(MusicRenderer::OpenHome(renderer)); + } + + if matches!(info.protocol, RendererProtocol::OpenHomeOnly) { + warn!( + renderer = info.friendly_name.as_str(), + "Renderer advertises OpenHome only but exposes no usable services" + ); + return None; + } + } + + match info.protocol { + RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => { + let has_arylic = info.capabilities.has_arylic_tcp; + let has_avtransport = info.capabilities.has_avtransport; + + if has_arylic && has_avtransport { + // Construire UpnpRenderer + let upnp = UpnpRenderer::from_registry(info.clone(), registry); + + // Construire ArylicTcpRenderer + match ArylicTcpRenderer::from_renderer_info(info.clone()) { + Ok(arylic) => { + return Some(MusicRenderer::HybridUpnpArylic { upnp, arylic }); + } + Err(err) => { + warn!( + "Failed to build Arylic TCP backend for {}: {}. Falling back to UPnP only.", + info.friendly_name, err + ); + return Some(MusicRenderer::Upnp(upnp)); + } + } + } + + // Pas d’Arylic : logique existante + if info.capabilities.has_linkplay_http { + if let Ok(lp) = LinkPlayRenderer::from_renderer_info(info.clone()) { + return Some(MusicRenderer::LinkPlay(lp)); + } + } + + Some(MusicRenderer::Upnp(UpnpRenderer::from_registry( + info, registry, + ))) + } + RendererProtocol::OpenHomeOnly => None, + } + } + + pub fn openhome_playlist_snapshot(&self) -> Result { + match self { + MusicRenderer::OpenHome(renderer) => renderer.snapshot_openhome_playlist(), + _ => Err(op_not_supported( + "openhome_playlist_snapshot", + self.unsupported_backend_name(), + )), + } + } + + pub fn openhome_playlist_len(&self) -> Result { + match self { + MusicRenderer::OpenHome(renderer) => renderer.openhome_playlist_len(), + _ => Err(op_not_supported( + "openhome_playlist_len", + self.unsupported_backend_name(), + )), + } + } + + pub fn openhome_playlist_ids(&self) -> Result> { + match self { + MusicRenderer::OpenHome(renderer) => renderer.openhome_playlist_ids(), + _ => Err(op_not_supported( + "openhome_playlist_ids", + self.unsupported_backend_name(), + )), + } + } + + pub fn openhome_playlist_clear(&self) -> Result<()> { + match self { + MusicRenderer::OpenHome(renderer) => renderer.clear_openhome_playlist(), + _ => Err(op_not_supported( + "openhome_playlist_clear", + self.unsupported_backend_name(), + )), + } + } + + pub fn openhome_playlist_add_track( + &self, + uri: &str, + metadata: &str, + after_id: Option, + play: bool, + ) -> Result { + match self { + MusicRenderer::OpenHome(renderer) => { + renderer.add_track_openhome(uri, metadata, after_id, play) + } + _ => Err(op_not_supported( + "openhome_playlist_add_track", + self.unsupported_backend_name(), + )), + } + } + + pub fn openhome_playlist_play_id(&self, id: u32) -> Result<()> { + match self { + MusicRenderer::OpenHome(renderer) => renderer.play_openhome_track_id(id), + _ => Err(op_not_supported( + "openhome_playlist_play_id", + self.unsupported_backend_name(), + )), + } + } + + fn unsupported_backend_name(&self) -> &'static str { + match self { + MusicRenderer::Upnp(_) => "UPnP", + MusicRenderer::OpenHome(_) => "OpenHome", + MusicRenderer::LinkPlay(_) => "LinkPlay", + MusicRenderer::ArylicTcp(_) => "ArylicTcp", + MusicRenderer::HybridUpnpArylic { .. } => "HybridUpnpArylic", + } + } +} + +/// Transport control façade that dispatches to whichever backend can fulfill +/// the request, returning a standardized error if the backend lacks support. +impl TransportControl for MusicRenderer { + fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { + match self { + MusicRenderer::Upnp(upnp) => upnp.play_uri(uri, meta), + MusicRenderer::OpenHome(oh) => oh.play_uri(uri, meta), + MusicRenderer::LinkPlay(lp) => lp.play_uri(uri, meta), + MusicRenderer::ArylicTcp(_) => Err(op_not_supported("play_uri", "ArylicTcp")), + MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta), + } + } + + fn play(&self) -> Result<()> { + match self { + MusicRenderer::Upnp(upnp) => upnp.play(), + MusicRenderer::OpenHome(oh) => oh.play(), + MusicRenderer::LinkPlay(lp) => lp.play(), + MusicRenderer::ArylicTcp(ary) => ary.play(), + MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.play(), + } + } + + fn pause(&self) -> Result<()> { + match self { + MusicRenderer::Upnp(upnp) => upnp.pause(), + MusicRenderer::OpenHome(oh) => oh.pause(), + MusicRenderer::LinkPlay(lp) => lp.pause(), + MusicRenderer::ArylicTcp(ary) => ary.pause(), + MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.pause(), + } + } + + fn stop(&self) -> Result<()> { + match self { + MusicRenderer::Upnp(upnp) => upnp.stop(), + MusicRenderer::OpenHome(oh) => oh.stop(), + MusicRenderer::LinkPlay(lp) => lp.stop(), + MusicRenderer::ArylicTcp(ary) => ary.stop(), + MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.stop(), + } + } + + fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { + match self { + MusicRenderer::Upnp(upnp) => upnp.seek_rel_time(hhmmss), + MusicRenderer::OpenHome(oh) => oh.seek_rel_time(hhmmss), + MusicRenderer::LinkPlay(lp) => lp.seek_rel_time(hhmmss), + MusicRenderer::ArylicTcp(_) => Err(op_not_supported("seek_rel_time", "ArylicTcp")), + MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss), + } + } +} + +/// Volume and mute controls exposed via the façade. +/// +/// Hybrid backends may read via Arylic TCP and write via UPnP, but callers +/// always depend on a single [`VolumeControl`] entry point. +impl VolumeControl for MusicRenderer { + fn volume(&self) -> Result { + match self { + MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.volume(), + MusicRenderer::ArylicTcp(ary) => ary.volume(), + MusicRenderer::OpenHome(oh) => oh.volume(), + MusicRenderer::Upnp(upnp) => upnp.volume(), + MusicRenderer::LinkPlay(lp) => lp.volume(), + } + } + + fn set_volume(&self, vol: u16) -> Result<()> { + match self { + MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol), + MusicRenderer::ArylicTcp(ary) => ary.set_volume(vol), + MusicRenderer::OpenHome(oh) => oh.set_volume(vol), + MusicRenderer::Upnp(upnp) => upnp.set_volume(vol), + MusicRenderer::LinkPlay(lp) => lp.set_volume(vol), + } + } + + fn mute(&self) -> Result { + match self { + MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.mute(), + MusicRenderer::OpenHome(r) => r.mute(), + MusicRenderer::Upnp(r) => r.get_master_mute(), + MusicRenderer::LinkPlay(r) => r.mute(), + MusicRenderer::ArylicTcp(r) => r.mute(), + } + } + + fn set_mute(&self, m: bool) -> Result<()> { + match self { + MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m), + MusicRenderer::OpenHome(r) => r.set_mute(m), + MusicRenderer::Upnp(r) => r.set_master_mute(m), + MusicRenderer::LinkPlay(r) => r.set_mute(m), + MusicRenderer::ArylicTcp(r) => r.set_mute(m), + } + } +} + +/// Playback-state queries sourced from the backend best suited for the job. +/// +/// Each backend reports into [`PlaybackState`], ensuring consumers never have +/// to reason about protocol-specific state machines. +impl PlaybackStatus for MusicRenderer { + fn playback_state(&self) -> Result { + match self { + MusicRenderer::Upnp(r) => PlaybackStatus::playback_state(r), + MusicRenderer::OpenHome(r) => PlaybackStatus::playback_state(r), + MusicRenderer::LinkPlay(r) => r.playback_state(), + MusicRenderer::ArylicTcp(r) => r.playback_state(), + MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_state(), + } + } +} + +/// Playback-position queries that always yield a [`PlaybackPositionInfo`] +/// regardless of the backend providing the raw transport data. +impl PlaybackPosition for MusicRenderer { + fn playback_position(&self) -> Result { + match self { + MusicRenderer::Upnp(r) => r.playback_position(), + MusicRenderer::OpenHome(r) => r.playback_position(), + MusicRenderer::LinkPlay(r) => r.playback_position(), + MusicRenderer::ArylicTcp(r) => r.playback_position(), + MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_position(), + } + } +} diff --git a/pmocontrol/src/openapi.rs b/pmocontrol/src/openapi.rs new file mode 100644 index 00000000..e6de0c55 --- /dev/null +++ b/pmocontrol/src/openapi.rs @@ -0,0 +1,426 @@ +//! Documentation OpenAPI et DTOs pour l'API ControlPoint +//! +//! Ce module fournit les types de réponse / payloads pour l'API REST du ControlPoint, +//! ainsi que la documentation OpenAPI via `utoipa`. + +#[cfg(feature = "pmoserver")] +use serde::{Deserialize, Serialize}; +#[cfg(feature = "pmoserver")] +use utoipa::{OpenApi, ToSchema}; + +// ============================================================================ +// RENDERERS +// ============================================================================ + +/// Résumé d'un renderer découvert +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct RendererSummary { + /// ID unique du renderer + pub id: String, + /// Nom convivial + pub friendly_name: String, + /// Modèle du renderer + pub model_name: String, + /// Protocole (UPnP pur, OpenHome pur, hybride) + pub protocol: RendererProtocolSummary, + /// Capacités détectées + pub capabilities: RendererCapabilitiesSummary, + /// Renderer en ligne + pub online: bool, +} + +/// Protocole exposé par le renderer +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum RendererProtocolSummary { + Upnp, + Openhome, + Hybrid, +} + +/// Drapeaux de capacités renderer (transport, volume, services OpenHome, etc.) +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct RendererCapabilitiesSummary { + pub has_avtransport: bool, + pub has_avtransport_set_next: bool, + pub has_rendering_control: bool, + pub has_connection_manager: bool, + pub has_linkplay_http: bool, + pub has_arylic_tcp: bool, + pub has_oh_playlist: bool, + pub has_oh_volume: bool, + pub has_oh_info: bool, + pub has_oh_time: bool, + pub has_oh_radio: bool, +} + +/// État détaillé d'un renderer +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct RendererState { + /// ID unique du renderer + pub id: String, + /// Nom convivial + pub friendly_name: String, + /// État de transport ("PLAYING", "PAUSED", "STOPPED", etc.) + pub transport_state: String, + /// Position courante en millisecondes + pub position_ms: Option, + /// Durée totale en millisecondes + pub duration_ms: Option, + /// Volume (0-100) + pub volume: Option, + /// Mute actif + pub mute: Option, + /// Nombre d'items dans la queue + pub queue_len: usize, + /// Playlist attachée (si applicable) + pub attached_playlist: Option, + /// Métadonnées du morceau courant (si en lecture) + pub current_track: Option, +} + +/// Métadonnées du morceau en cours de lecture +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct CurrentTrackMetadata { + /// Titre du morceau + pub title: Option, + /// Artiste + pub artist: Option, + /// Album + pub album: Option, + /// URI de la pochette d'album + pub album_art_uri: Option, +} + +/// Information sur la playlist attachée +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct AttachedPlaylistInfo { + /// ID du serveur de médias + pub server_id: String, + /// ID du container playlist + pub container_id: String, + /// True si au moins une mise à jour a été vue + pub has_seen_update: bool, +} + +// ============================================================================ +// QUEUE +// ============================================================================ + +/// Item de la queue de lecture +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct QueueItem { + /// Index dans la queue (0-based) + pub index: usize, + /// URI de la ressource + pub uri: String, + /// Titre du morceau + pub title: Option, + /// Artiste + pub artist: Option, + /// Album + pub album: Option, + /// URI de la pochette d'album + pub album_art_uri: Option, + /// ID du serveur source + pub server_id: Option, + /// ID de l'objet DIDL-Lite + pub object_id: Option, +} + +/// Snapshot de la queue d'un renderer +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct QueueSnapshot { + /// ID du renderer + pub renderer_id: String, + /// Items de la queue (playlist complète) + pub items: Vec, + /// Index courant dans la playlist (None si rien n'est en cours) + pub current_index: Option, +} + +// ============================================================================ +// OPENHOME PLAYLIST +// ============================================================================ + +#[cfg(feature = "pmoserver")] +pub use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; + +/// Requête pour ajouter un track à la playlist OpenHome +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct OpenHomePlaylistAddRequest { + /// URI du flux à insérer + pub uri: String, + /// Métadonnées DIDL-Lite complètes + pub metadata: String, + /// ID devant lequel insérer (None => fin de playlist) + pub after_id: Option, + /// Si true, démarre immédiatement la lecture du track inséré + #[serde(default)] + pub play: bool, +} + +// ============================================================================ +// MEDIA SERVERS +// ============================================================================ + +/// Résumé d'un serveur de médias découvert +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct MediaServerSummary { + /// ID unique du serveur + pub id: String, + /// Nom convivial + pub friendly_name: String, + /// Modèle du serveur + pub model_name: String, + /// Serveur en ligne + pub online: bool, +} + +/// Entrée de navigation (container ou item) +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ContainerEntry { + /// ID de l'objet + pub id: String, + /// Titre + pub title: String, + /// Classe UPnP (object.container.*, object.item.*, etc.) + pub class: String, + /// True si c'est un container (navigable) + pub is_container: bool, + /// Nombre d'enfants (si container) + pub child_count: Option, + /// Artiste (si item audio) + pub artist: Option, + /// Album (si item audio) + pub album: Option, + /// URI de la pochette d'album + pub album_art_uri: Option, +} + +/// Résultat de navigation dans un container +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct BrowseResponse { + /// ID du container browsé + pub container_id: String, + /// Entrées du container + pub entries: Vec, +} + +// ============================================================================ +// SNAPSHOT AGGRÉGÉ +// ============================================================================ + +/// Alias de lisibilité pour les view-models déjà existants. +#[cfg(feature = "pmoserver")] +pub type RendererStateView = RendererState; +#[cfg(feature = "pmoserver")] +pub type QueueSnapshotView = QueueSnapshot; +#[cfg(feature = "pmoserver")] +pub type RendererBindingView = AttachedPlaylistInfo; + +/// Instantané complet et cohérent d'un renderer. +/// +/// Le ControlPoint est la source de vérité : ce snapshot agrège l'état, +/// la queue et le binding observés atomiquement côté serveur. +#[cfg(feature = "pmoserver")] +#[derive(Clone, Debug, Serialize, ToSchema)] +pub struct FullRendererSnapshot { + pub state: RendererStateView, + pub queue: QueueSnapshotView, + pub binding: Option, +} + +// ============================================================================ +// PAYLOADS DE COMMANDES +// ============================================================================ + +/// Requête pour définir le volume +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct VolumeSetRequest { + /// Nouveau volume (0-100) + pub volume: u8, +} + +/// Requête pour attacher une playlist +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct AttachPlaylistRequest { + /// ID du serveur de médias + pub server_id: String, + /// ID du container playlist + pub container_id: String, + /// Si true, démarre la lecture automatiquement après le refresh + #[serde(default)] + pub auto_play: bool, +} + +/// Requête pour lire ou ajouter du contenu à la queue +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct PlayContentRequest { + /// ID du serveur de médias + pub server_id: String, + /// ID de l'objet (container ou item) + pub object_id: String, +} + +/// Réponse générique de succès +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SuccessResponse { + /// Message de succès + pub message: String, +} + +/// Réponse d'erreur +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ErrorResponse { + /// Message d'erreur + pub error: String, +} + +// ============================================================================ +// DOCUMENTATION OPENAPI +// ============================================================================ + +/// Documentation OpenAPI pour l'API ControlPoint +#[cfg(feature = "pmoserver")] +#[derive(OpenApi)] +#[openapi( + info( + title = "PMOMusic Control Point API", + version = "1.0.0", + description = r#" +# API REST pour le Control Point PMOMusic + +Cette API permet de contrôler les renderers UPnP et de naviguer dans les serveurs de médias. + +## Fonctionnalités + +### Renderers +- **Découverte** : Liste des renderers disponibles +- **État** : Récupération de l'état détaillé d'un renderer +- **Contrôle transport** : Play, pause, stop, next +- **Contrôle volume** : Lecture et modification du volume / mute +- **Queue** : Gestion de la queue de lecture + +### Playlists +- **Binding** : Attachement de la queue à un container playlist d'un serveur +- **Synchronisation automatique** : Mise à jour de la queue lors des changements côté serveur + +### Serveurs de médias +- **Découverte** : Liste des serveurs disponibles +- **Navigation** : Exploration de la hiérarchie des containers + +## Architecture + +Le Control Point PMOMusic est un point de contrôle UPnP qui : +1. Découvre automatiquement les renderers et serveurs via SSDP +2. Maintient un registre des devices actifs +3. Permet le contrôle unifié des renderers (UPnP AV, LinkPlay, Arylic TCP) +4. Gère une queue de lecture locale avec synchronisation optionnelle + +## Exemples d'utilisation + +### Lister les renderers +``` +GET /control/renderers +``` + +### Contrôler un renderer +``` +POST /control/renderers/{renderer_id}/play +POST /control/renderers/{renderer_id}/pause +POST /control/renderers/{renderer_id}/volume/set + Body: {"volume": 50} +``` + +### Attacher une playlist +``` +POST /control/renderers/{renderer_id}/binding/attach + Body: { + "server_id": "uuid:...", + "container_id": "0$/Music/MyPlaylist" + } +``` + +### Naviguer dans un serveur +``` +GET /control/servers/{server_id}/containers/{container_id} +``` + "#, + contact( + name = "PMOMusic", + ), + license( + name = "MIT", + ), + ), + paths( + crate::pmoserver_ext::list_renderers, + crate::pmoserver_ext::get_renderer_state, + crate::pmoserver_ext::get_renderer_queue, + crate::pmoserver_ext::get_renderer_binding, + crate::pmoserver_ext::get_openhome_playlist, + crate::pmoserver_ext::clear_openhome_playlist, + crate::pmoserver_ext::add_openhome_playlist_item, + crate::pmoserver_ext::play_openhome_track, + crate::pmoserver_ext::play_renderer, + crate::pmoserver_ext::pause_renderer, + crate::pmoserver_ext::stop_renderer, + crate::pmoserver_ext::next_renderer, + crate::pmoserver_ext::set_renderer_volume, + crate::pmoserver_ext::volume_up_renderer, + crate::pmoserver_ext::volume_down_renderer, + crate::pmoserver_ext::toggle_mute_renderer, + crate::pmoserver_ext::attach_playlist_binding, + crate::pmoserver_ext::detach_playlist_binding, + crate::pmoserver_ext::play_content, + crate::pmoserver_ext::add_to_queue, + crate::pmoserver_ext::list_servers, + crate::pmoserver_ext::browse_container, + crate::sse::all_events_sse, + crate::sse::renderer_events_sse, + crate::sse::media_server_events_sse, + ), + components(schemas( + RendererSummary, + RendererProtocolSummary, + RendererCapabilitiesSummary, + RendererState, + CurrentTrackMetadata, + AttachedPlaylistInfo, + QueueItem, + QueueSnapshot, + OpenHomePlaylistSnapshot, + OpenHomePlaylistTrack, + OpenHomePlaylistAddRequest, + MediaServerSummary, + ContainerEntry, + BrowseResponse, + VolumeSetRequest, + AttachPlaylistRequest, + PlayContentRequest, + SuccessResponse, + ErrorResponse, + )), + tags( + (name = "control", description = "Contrôle des renderers et navigation des serveurs") + ) +)] +pub struct ApiDoc; diff --git a/pmocontrol/src/openhome_client.rs b/pmocontrol/src/openhome_client.rs new file mode 100644 index 00000000..e9bb8fee --- /dev/null +++ b/pmocontrol/src/openhome_client.rs @@ -0,0 +1,680 @@ +use crate::model::TrackMetadata; +use crate::soap_client::{SoapCallResult, invoke_upnp_action}; +use anyhow::{Result, anyhow}; +use pmoupnp::soap::SoapEnvelope; +use xmltree::{Element, XMLNode}; + +#[derive(Debug, Clone)] +pub struct OhTrackEntry { + pub id: u32, + pub uri: String, + pub metadata_xml: String, +} + +#[derive(Debug, Clone)] +pub struct OhInfoTrack { + pub uri: String, + pub metadata_xml: Option, +} + +impl OhInfoTrack { + pub fn metadata(&self) -> Option { + self.metadata_xml + .as_deref() + .and_then(parse_track_metadata_from_didl) + } +} + +#[derive(Debug, Clone)] +pub struct OhTimePosition { + pub track_count: u32, + pub duration_secs: u32, + pub elapsed_secs: u32, +} + +#[derive(Debug, Clone)] +pub struct OhRadioChannel { + pub uri: String, + pub metadata_xml: Option, +} + +#[derive(Debug, Clone)] +pub struct OhPlaylistClient { + pub control_url: String, + pub service_type: String, +} + +impl OhPlaylistClient { + pub fn new(control_url: String, service_type: String) -> Self { + Self { + control_url, + service_type, + } + } + + pub fn read_list(&self, id_list: &[u32]) -> Result> { + if id_list.is_empty() { + return Ok(Vec::new()); + } + + let id_list_csv = id_list + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + let args = [("aIdList", id_list_csv.as_str())]; + + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "ReadList", &args)?; + + let envelope = ensure_success("ReadList", &call_result)?; + let response = find_child_with_suffix(&envelope.body.content, "ReadListResponse") + .ok_or_else(|| anyhow!("Missing ReadListResponse element in SOAP body"))?; + + let track_list_xml = extract_child_text(response, "aTrackList")?; + parse_track_list(&track_list_xml) + } + + pub fn insert(&self, after_id: u32, uri: &str, metadata: &str) -> Result { + let after_id_str = after_id.to_string(); + let args = [ + ("aAfterId", after_id_str.as_str()), + ("aUri", uri), + ("aMetadata", metadata), + ]; + + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "Insert", &args)?; + + let envelope = ensure_success("Insert", &call_result)?; + let response = find_child_with_suffix(&envelope.body.content, "InsertResponse") + .ok_or_else(|| anyhow!("Missing InsertResponse element in SOAP body"))?; + let new_id_text = extract_child_text(response, "aNewId")?; + let new_id = new_id_text + .parse::() + .map_err(|_| anyhow!("Invalid aNewId value: {}", new_id_text))?; + + Ok(new_id) + } + + pub fn play_id(&self, id: u32) -> Result<()> { + let id_str = id.to_string(); + let args = [("aId", id_str.as_str())]; + + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "PlayId", &args)?; + + handle_action_response("PlayId", &call_result) + } + + pub fn play(&self) -> Result<()> { + let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Play", &[])?; + handle_action_response("Play", &call_result) + } + + pub fn pause(&self) -> Result<()> { + let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Pause", &[])?; + handle_action_response("Pause", &call_result) + } + + pub fn stop(&self) -> Result<()> { + let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Stop", &[])?; + handle_action_response("Stop", &call_result) + } + + pub fn next(&self) -> Result<()> { + let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Next", &[])?; + handle_action_response("Next", &call_result) + } + + pub fn previous(&self) -> Result<()> { + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "Previous", &[])?; + handle_action_response("Previous", &call_result) + } + + pub fn seek_second_absolute(&self, second: u32) -> Result<()> { + let second_str = second.to_string(); + let args = [("aSecond", second_str.as_str())]; + let call_result = invoke_upnp_action( + &self.control_url, + &self.service_type, + "SeekSecondAbsolute", + &args, + )?; + + handle_action_response("SeekSecondAbsolute", &call_result) + } + + pub fn delete_id(&self, id: u32) -> Result<()> { + let id_str = id.to_string(); + let args = [("aId", id_str.as_str())]; + + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "DeleteId", &args)?; + + handle_action_response("DeleteId", &call_result) + } + + pub fn delete_all(&self) -> Result<()> { + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "DeleteAll", &[])?; + handle_action_response("DeleteAll", &call_result) + } + + pub fn tracks_max(&self) -> Result { + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "TracksMax", &[])?; + + let envelope = ensure_success("TracksMax", &call_result)?; + let response = find_child_with_suffix(&envelope.body.content, "TracksMaxResponse") + .ok_or_else(|| anyhow!("Missing TracksMaxResponse element in SOAP body"))?; + let value_text = extract_child_text(response, "aValue")?; + let value = value_text + .parse::() + .map_err(|_| anyhow!("Invalid TracksMax value: {}", value_text))?; + + Ok(value) + } + + pub fn id_array(&self) -> Result> { + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "IdArray", &[])?; + let envelope = ensure_success("IdArray", &call_result)?; + let response = find_child_with_suffix(&envelope.body.content, "IdArrayResponse") + .ok_or_else(|| anyhow!("Missing IdArrayResponse element in SOAP body"))?; + + // Try to extract the array element. If missing, assume empty playlist. + let array_text = match extract_child_text_any(response, &["aArray", "aIdArray"]) { + Ok(text) => text, + Err(_) => { + // Element not found - playlist is likely empty + return Ok(Vec::new()); + } + }; + + // Handle empty string (another way renderers indicate empty playlist) + if array_text.trim().is_empty() { + return Ok(Vec::new()); + } + + let bytes = decode_base64(&array_text)?; + if bytes.len() % 4 != 0 { + return Err(anyhow!( + "Invalid IdArray payload length {} (expected multiple of 4)", + bytes.len() + )); + } + + let mut ids = Vec::with_capacity(bytes.len() / 4); + for chunk in bytes.chunks_exact(4) { + ids.push(u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); + } + Ok(ids) + } + + pub fn read_all_tracks(&self) -> Result> { + let ids = self.id_array()?; + if ids.is_empty() { + return Ok(Vec::new()); + } + + const MAX_BATCH: usize = 64; + let mut entries = Vec::with_capacity(ids.len()); + for chunk in ids.chunks(MAX_BATCH) { + let mut batch = self.read_list(chunk)?; + entries.append(&mut batch); + } + Ok(entries) + } +} + +#[derive(Debug, Clone)] +pub struct OhInfoClient { + pub control_url: String, + pub service_type: String, +} + +impl OhInfoClient { + pub fn new(control_url: String, service_type: String) -> Self { + Self { + control_url, + service_type, + } + } + + pub fn track(&self) -> Result { + use tracing::debug; + + let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Track", &[])?; + + let envelope = ensure_success("Track", &call_result)?; + let response = find_child_with_suffix(&envelope.body.content, "TrackResponse") + .ok_or_else(|| anyhow!("Missing TrackResponse element in SOAP body"))?; + + let uri = extract_child_text(response, "aUri")?; + let metadata_xml = extract_child_text_optional(response, "aMetadata") + .unwrap_or(None) + .filter(|s| !s.is_empty()); + + debug!( + uri = uri.as_str(), + has_metadata = metadata_xml.is_some(), + metadata_length = metadata_xml.as_ref().map(|s| s.len()), + "OpenHome Info.Track() returned" + ); + + if let Some(ref xml) = metadata_xml { + debug!(metadata_xml = xml.as_str(), "OpenHome metadata XML content"); + } + + Ok(OhInfoTrack { uri, metadata_xml }) + } + + pub fn next(&self) -> Result { + let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Next", &[])?; + + let envelope = ensure_success("Next", &call_result)?; + let response = find_child_with_suffix(&envelope.body.content, "NextResponse") + .ok_or_else(|| anyhow!("Missing NextResponse element in SOAP body"))?; + + let uri = extract_child_text(response, "aUri")?; + let metadata_xml = extract_child_text_optional(response, "aMetadata") + .unwrap_or(None) + .filter(|s| !s.is_empty()); + + Ok(OhInfoTrack { uri, metadata_xml }) + } + + pub fn id(&self) -> Result { + let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?; + + let envelope = ensure_success("Id", &call_result)?; + let response = find_child_with_suffix(&envelope.body.content, "IdResponse") + .ok_or_else(|| anyhow!("Missing IdResponse element in SOAP body"))?; + let id_text = extract_child_text(response, "aId")?; + let id = id_text + .parse::() + .map_err(|_| anyhow!("Invalid Info.Id value: {}", id_text))?; + Ok(id) + } + + pub fn transport_state(&self) -> Result { + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "TransportState", &[])?; + + let envelope = ensure_success("TransportState", &call_result)?; + let response = find_child_with_suffix(&envelope.body.content, "TransportStateResponse") + .ok_or_else(|| anyhow!("Missing TransportStateResponse element in SOAP body"))?; + let state = extract_child_text(response, "aState")?; + Ok(state) + } + + pub fn read_current_metadata(&self) -> Result> { + let track = self.track()?; + Ok(track.metadata()) + } +} + +#[derive(Debug, Clone)] +pub struct OhTimeClient { + pub control_url: String, + pub service_type: String, +} + +impl OhTimeClient { + pub fn new(control_url: String, service_type: String) -> Self { + Self { + control_url, + service_type, + } + } + + pub fn position(&self) -> Result { + let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Time", &[])?; + + let envelope = ensure_success("Time", &call_result)?; + let response = find_child_with_suffix(&envelope.body.content, "TimeResponse") + .ok_or_else(|| anyhow!("Missing TimeResponse element in SOAP body"))?; + + let track_count = extract_child_text(response, "aTrackCount")? + .parse::() + .map_err(|_| anyhow!("Invalid aTrackCount value in Time response"))?; + let duration_secs = extract_child_text(response, "aDuration")? + .parse::() + .map_err(|_| anyhow!("Invalid aDuration value in Time response"))?; + let elapsed_secs = extract_child_text(response, "aSeconds")? + .parse::() + .map_err(|_| anyhow!("Invalid aSeconds value in Time response"))?; + + Ok(OhTimePosition { + track_count, + duration_secs, + elapsed_secs, + }) + } +} + +#[derive(Debug, Clone)] +pub struct OhVolumeClient { + pub control_url: String, + pub service_type: String, +} + +impl OhVolumeClient { + pub fn new(control_url: String, service_type: String) -> Self { + Self { + control_url, + service_type, + } + } + + pub fn volume(&self) -> Result { + let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Volume", &[])?; + let envelope = ensure_success("Volume", &call_result)?; + let response = find_child_with_suffix(&envelope.body.content, "VolumeResponse") + .ok_or_else(|| anyhow!("Missing VolumeResponse element in SOAP body"))?; + let value = extract_child_text(response, "aVolume")?; + let parsed = value + .parse::() + .map_err(|_| anyhow!("Invalid volume value: {}", value))?; + Ok(parsed.min(u16::MAX as u32) as u16) + } + + pub fn set_volume(&self, vol: u16) -> Result<()> { + let vol_str = vol.to_string(); + let args = [("aVolume", vol_str.as_str())]; + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "SetVolume", &args)?; + handle_action_response("SetVolume", &call_result) + } + + pub fn mute(&self) -> Result { + let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Mute", &[])?; + let envelope = ensure_success("Mute", &call_result)?; + let response = find_child_with_suffix(&envelope.body.content, "MuteResponse") + .ok_or_else(|| anyhow!("Missing MuteResponse element in SOAP body"))?; + let value = extract_child_text(response, "aMute")?; + parse_bool(&value) + } + + pub fn set_mute(&self, mute: bool) -> Result<()> { + let mute_str = if mute { "1" } else { "0" }; + let args = [("aMute", mute_str)]; + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "SetMute", &args)?; + handle_action_response("SetMute", &call_result) + } +} + +#[derive(Debug, Clone)] +pub struct OhRadioClient { + pub control_url: String, + pub service_type: String, +} + +impl OhRadioClient { + pub fn new(control_url: String, service_type: String) -> Self { + Self { + control_url, + service_type, + } + } + + pub fn play_channel(&self, id: u32) -> Result<()> { + let id_str = id.to_string(); + let args = [("aId", id_str.as_str())]; + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "PlayChannel", &args)?; + handle_action_response("PlayChannel", &call_result) + } + + pub fn channel(&self, id: u32) -> Result { + let id_str = id.to_string(); + let args = [("aId", id_str.as_str())]; + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "Channel", &args)?; + + let envelope = ensure_success("Channel", &call_result)?; + let response = find_child_with_suffix(&envelope.body.content, "ChannelResponse") + .ok_or_else(|| anyhow!("Missing ChannelResponse element in SOAP body"))?; + + let uri = extract_child_text(response, "aUri")?; + let metadata_xml = extract_child_text_optional(response, "aMetadata") + .unwrap_or(None) + .filter(|s| !s.is_empty()); + + Ok(OhRadioChannel { uri, metadata_xml }) + } +} + +pub fn parse_track_metadata_from_didl(xml: &str) -> Option { + use tracing::debug; + + if xml.trim().is_empty() { + return None; + } + + let parsed = pmodidl::parse_metadata::(xml).ok()?; + let item = parsed.data.items.first()?; + + debug!( + title = item.title.as_str(), + has_album_art = item.album_art.is_some(), + album_art_uri = item.album_art.as_deref(), + "Parsed DIDL metadata for track" + ); + + Some(TrackMetadata { + title: Some(item.title.clone()), + artist: item.artist.clone(), + album: item.album.clone(), + genre: item.genre.clone(), + album_art_uri: item.album_art.clone(), + date: item.date.clone(), + track_number: item.original_track_number.clone(), + creator: item.creator.clone(), + }) +} + +fn parse_track_list(xml: &str) -> Result> { + if xml.trim().is_empty() { + return Ok(Vec::new()); + } + + let mut reader = std::io::Cursor::new(xml.as_bytes()); + let root = Element::parse(&mut reader) + .map_err(|err| anyhow!("Failed to parse OpenHome TrackList XML: {}", err))?; + let mut entries = Vec::new(); + + for node in &root.children { + if let XMLNode::Element(elem) = node { + if elem.name.ends_with("Entry") { + entries.push(parse_track_entry(elem)?); + } + } + } + + Ok(entries) +} + +fn parse_track_entry(elem: &Element) -> Result { + let id_text = extract_child_text(elem, "Id")?; + let id = id_text + .parse::() + .map_err(|_| anyhow!("Invalid OpenHome Entry Id: {}", id_text))?; + let uri = extract_child_text(elem, "Uri")?; + let metadata_xml = extract_child_text_optional(elem, "Metadata")?.unwrap_or_default(); + + Ok(OhTrackEntry { + id, + uri, + metadata_xml, + }) +} + +fn ensure_success<'a>(action: &str, call_result: &'a SoapCallResult) -> Result<&'a SoapEnvelope> { + if !call_result.status.is_success() { + if let Some(env) = &call_result.envelope { + if let Some(err) = parse_upnp_error(env) { + return Err(anyhow!( + "{action} failed with UPnP error {}: {} (HTTP status {})", + err.error_code, + err.error_description, + call_result.status + )); + } + } + + return Err(anyhow!( + "{action} failed with HTTP status {} and body: {}", + call_result.status, + call_result.raw_body + )); + } + + let envelope = call_result + .envelope + .as_ref() + .ok_or_else(|| anyhow!("Missing SOAP envelope in {action} response"))?; + + if let Some(err) = parse_upnp_error(envelope) { + return Err(anyhow!( + "{action} returned UPnP error {}: {} (HTTP status {})", + err.error_code, + err.error_description, + call_result.status + )); + } + + Ok(envelope) +} + +fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> { + ensure_success(action, call_result)?; + Ok(()) +} + +#[derive(Debug, Clone)] +struct UpnpError { + pub error_code: u32, + pub error_description: String, +} + +fn parse_upnp_error(envelope: &SoapEnvelope) -> Option { + let fault = find_child_with_suffix(&envelope.body.content, "Fault")?; + let detail = find_child_with_suffix(fault, "detail")?; + let upnp_error = find_child_with_suffix(detail, "UPnPError")?; + + let error_code_elem = upnp_error.children.iter().find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem), + _ => None, + })?; + + let error_code_text = error_code_elem.get_text()?.trim().to_string(); + let error_code = error_code_text.parse::().ok()?; + + let error_description = upnp_error + .children + .iter() + .find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => { + elem.get_text().map(|t| t.trim().to_string()) + } + _ => None, + }) + .unwrap_or_default(); + + Some(UpnpError { + error_code, + error_description, + }) +} + +fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> { + parent.children.iter().find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem), + _ => None, + }) +} + +fn extract_child_text(parent: &Element, suffix: &str) -> Result { + let child = find_child_with_suffix(parent, suffix) + .ok_or_else(|| anyhow!("Missing {suffix} element in response"))?; + + let text = child + .get_text() + .map(|t| t.trim().to_string()) + .ok_or_else(|| anyhow!("{suffix} element missing text in response"))?; + + Ok(text) +} + +fn extract_child_text_optional(parent: &Element, suffix: &str) -> Result> { + if let Some(child) = find_child_with_suffix(parent, suffix) { + let text = child + .get_text() + .map(|t| t.trim().to_string()) + .unwrap_or_default(); + Ok(Some(text)) + } else { + Ok(None) + } +} + +fn extract_child_text_any(parent: &Element, suffixes: &[&str]) -> Result { + for suffix in suffixes { + if let Ok(text) = extract_child_text(parent, suffix) { + return Ok(text); + } + } + Err(anyhow!( + "Missing {} element in response", + suffixes.join(" or ") + )) +} + +fn parse_bool(value: &str) -> Result { + match value.trim() { + "0" => Ok(false), + "1" => Ok(true), + other => Err(anyhow!("Invalid boolean value '{}'", other)), + } +} + +pub(crate) fn decode_base64(input: &str) -> Result> { + fn value(byte: u8) -> Option { + match byte { + b'A'..=b'Z' => Some(byte - b'A'), + b'a'..=b'z' => Some(byte - b'a' + 26), + b'0'..=b'9' => Some(byte - b'0' + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + } + + let mut output = Vec::new(); + let mut buffer: u32 = 0; + let mut bits_collected: u8 = 0; + + for byte in input.bytes() { + if byte == b'=' { + break; + } + if byte == b'\r' || byte == b'\n' || byte == b' ' || byte == b'\t' { + continue; + } + let val = + value(byte).ok_or_else(|| anyhow!("Invalid base64 character '{}'", byte as char))?; + buffer = (buffer << 6) | (val as u32); + bits_collected += 6; + if bits_collected >= 8 { + bits_collected -= 8; + let out = (buffer >> bits_collected) & 0xFF; + output.push(out as u8); + } + } + + Ok(output) +} diff --git a/pmocontrol/src/openhome_playlist.rs b/pmocontrol/src/openhome_playlist.rs new file mode 100644 index 00000000..7ff368da --- /dev/null +++ b/pmocontrol/src/openhome_playlist.rs @@ -0,0 +1,29 @@ +/// Snapshot de la playlist native OpenHome pour un renderer donné. +#[cfg_attr(feature = "pmoserver", derive(serde::Serialize, utoipa::ToSchema))] +#[derive(Debug, Clone)] +pub struct OpenHomePlaylistSnapshot { + /// ID du renderer concerné. + pub renderer_id: String, + /// ID courant dans la playlist (si connu). + pub current_id: Option, + /// Tracks présents dans la playlist native. + pub tracks: Vec, +} + +/// Représentation d'un track OpenHome tel qu'exposé par la playlist native. +#[cfg_attr(feature = "pmoserver", derive(serde::Serialize, utoipa::ToSchema))] +#[derive(Debug, Clone)] +pub struct OpenHomePlaylistTrack { + /// ID interne OpenHome du track. + pub id: u32, + /// URI de lecture. + pub uri: String, + /// Titre (optionnel si non fourni par le renderer). + pub title: Option, + /// Artiste (optionnel). + pub artist: Option, + /// Album (optionnel). + pub album: Option, + /// URI de pochette (optionnelle). + pub album_art_uri: Option, +} diff --git a/pmocontrol/src/openhome_renderer.rs b/pmocontrol/src/openhome_renderer.rs new file mode 100644 index 00000000..e92195d8 --- /dev/null +++ b/pmocontrol/src/openhome_renderer.rs @@ -0,0 +1,352 @@ +use crate::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, + VolumeControl, +}; +use crate::model::{RendererId, RendererInfo, RendererProtocol}; +use crate::music_renderer::op_not_supported; +use crate::openhome_client::{ + OhInfoClient, OhPlaylistClient, OhRadioClient, OhTimeClient, OhTrackEntry, OhVolumeClient, + parse_track_metadata_from_didl, +}; +use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; +use anyhow::{Result, anyhow}; +use tracing::debug; + +#[derive(Clone, Debug)] +pub struct OpenHomeRenderer { + pub info: RendererInfo, + playlist: Option, + info_client: Option, + time_client: Option, + volume_client: Option, + #[allow(dead_code)] + radio_client: Option, +} + +impl OpenHomeRenderer { + pub fn new(info: RendererInfo) -> Self { + Self { + playlist: build_playlist_client(&info), + info_client: build_info_client(&info), + time_client: build_time_client(&info), + volume_client: build_volume_client(&info), + radio_client: build_radio_client(&info), + info, + } + } + + pub fn id(&self) -> &RendererId { + &self.info.id + } + + pub fn friendly_name(&self) -> &str { + &self.info.friendly_name + } + + pub fn protocol(&self) -> &RendererProtocol { + &self.info.protocol + } + + pub fn has_playlist(&self) -> bool { + self.playlist.is_some() + } + + pub fn has_info(&self) -> bool { + self.info_client.is_some() + } + + pub fn has_time(&self) -> bool { + self.time_client.is_some() + } + + pub fn has_volume(&self) -> bool { + self.volume_client.is_some() + } + + pub fn has_any_openhome_service(&self) -> bool { + self.has_playlist() || self.has_info() || self.has_time() || self.has_volume() + } + + fn playlist_client_for(&self, op: &str) -> Result<&OhPlaylistClient> { + self.playlist + .as_ref() + .ok_or_else(|| op_not_supported(op, "OpenHome Playlist")) + } + + fn info_client_for(&self, op: &str) -> Result<&OhInfoClient> { + self.info_client + .as_ref() + .ok_or_else(|| op_not_supported(op, "OpenHome Info")) + } + + fn time_client_for(&self, op: &str) -> Result<&OhTimeClient> { + self.time_client + .as_ref() + .ok_or_else(|| op_not_supported(op, "OpenHome Time")) + } + + fn volume_client_for(&self, op: &str) -> Result<&OhVolumeClient> { + self.volume_client + .as_ref() + .ok_or_else(|| op_not_supported(op, "OpenHome Volume")) + } + + pub(crate) fn snapshot_openhome_playlist(&self) -> Result { + let playlist = self.playlist_client_for("snapshot_openhome_playlist")?; + let entries = playlist.read_all_tracks()?; + let current_id = self + .info_client + .as_ref() + .and_then(|client| client.id().ok()); + + let tracks = entries.iter().map(convert_oh_track_entry).collect(); + + Ok(OpenHomePlaylistSnapshot { + renderer_id: self.info.id.0.clone(), + current_id, + tracks, + }) + } + + /// Retourne la longueur de la playlist OpenHome sans récupérer toutes les métadonnées. + /// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes. + pub(crate) fn openhome_playlist_len(&self) -> Result { + let playlist = self.playlist_client_for("openhome_playlist_len")?; + let ids = playlist.id_array()?; + Ok(ids.len()) + } + + /// Retourne les IDs des pistes de la playlist OpenHome. + /// Plus rapide que snapshot_openhome_playlist() car ne récupère pas les métadonnées. + pub(crate) fn openhome_playlist_ids(&self) -> Result> { + let playlist = self.playlist_client_for("openhome_playlist_ids")?; + playlist.id_array() + } + + pub(crate) fn clear_openhome_playlist(&self) -> Result<()> { + let playlist = self.playlist_client_for("clear_openhome_playlist")?; + playlist.delete_all() + } + + pub(crate) fn add_track_openhome( + &self, + uri: &str, + metadata: &str, + after_id: Option, + play: bool, + ) -> Result { + let playlist = self.playlist_client_for("add_track_openhome")?; + let insert_after = match after_id { + Some(id) => id, + None => playlist.id_array()?.last().copied().unwrap_or(0), + }; + + let new_id = playlist.insert(insert_after, uri, metadata)?; + if play { + playlist.play_id(new_id)?; + } + Ok(new_id) + } + + pub(crate) fn play_openhome_track_id(&self, id: u32) -> Result<()> { + let playlist = self.playlist_client_for("play_openhome_track_id")?; + playlist.play_id(id) + } +} + +impl TransportControl for OpenHomeRenderer { + fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { + let playlist = self.playlist_client_for("play_uri")?; + + if let Err(err) = playlist.delete_all() { + debug!( + renderer = self.info.id.0.as_str(), + error = %err, + "Failed to clear OpenHome playlist before insert" + ); + } + + let new_id = playlist.insert(0, uri, meta)?; + playlist.play_id(new_id) + } + + fn play(&self) -> Result<()> { + let playlist = self.playlist_client_for("play")?; + playlist.play() + } + + fn pause(&self) -> Result<()> { + let playlist = self.playlist_client_for("pause")?; + playlist.pause() + } + + fn stop(&self) -> Result<()> { + let playlist = self.playlist_client_for("stop")?; + playlist.stop() + } + + fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { + let seconds = parse_hms(hhmmss).ok_or_else(|| { + anyhow!( + "Invalid HH:MM:SS format for OpenHome SeekSecondAbsolute: {}", + hhmmss + ) + })?; + let playlist = self.playlist_client_for("seek_rel_time")?; + playlist.seek_second_absolute(seconds) + } +} + +impl VolumeControl for OpenHomeRenderer { + fn volume(&self) -> Result { + let client = self.volume_client_for("volume")?; + client.volume() + } + + fn set_volume(&self, v: u16) -> Result<()> { + let client = self.volume_client_for("set_volume")?; + client.set_volume(v) + } + + fn mute(&self) -> Result { + let client = self.volume_client_for("mute")?; + client.mute() + } + + fn set_mute(&self, m: bool) -> Result<()> { + let client = self.volume_client_for("set_mute")?; + client.set_mute(m) + } +} + +impl PlaybackStatus for OpenHomeRenderer { + fn playback_state(&self) -> Result { + let client = self.info_client_for("playback_state")?; + let state = client.transport_state()?; + Ok(map_openhome_state(&state)) + } +} + +impl PlaybackPosition for OpenHomeRenderer { + fn playback_position(&self) -> Result { + let time_info = self.time_client_for("playback_position")?.position()?; + + let mut track_id = None; + let mut track_uri = None; + let mut track_metadata_xml = None; + + if let Some(info_client) = &self.info_client { + match info_client.id() { + Ok(id) => track_id = Some(id), + Err(err) => debug!( + renderer = self.info.id.0.as_str(), + error = %err, + "Failed to read OpenHome track id" + ), + } + + match info_client.track() { + Ok(track) => { + track_uri = Some(track.uri); + track_metadata_xml = track.metadata_xml; + } + Err(err) => debug!( + renderer = self.info.id.0.as_str(), + error = %err, + "Failed to read OpenHome track metadata" + ), + } + } + + Ok(PlaybackPositionInfo { + track: track_id, + rel_time: Some(format_seconds(time_info.elapsed_secs)), + abs_time: None, + track_duration: Some(format_seconds(time_info.duration_secs)), + track_metadata: track_metadata_xml, + track_uri, + }) + } +} + +fn parse_hms(input: &str) -> Option { + let parts: Vec<&str> = input.split(':').collect(); + if parts.is_empty() || parts.len() > 3 { + return None; + } + + let mut total = 0u32; + for part in parts { + let value = part.parse::().ok()?; + total = total * 60 + value; + } + Some(total) +} + +pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState { + match raw.trim().to_ascii_uppercase().as_str() { + "PLAYING" => PlaybackState::Playing, + "PAUSED" | "PAUSED_PLAYBACK" => PlaybackState::Paused, + "STOPPED" => PlaybackState::Stopped, + "BUFFERING" | "TRANSITIONING" => PlaybackState::Transitioning, + other => PlaybackState::Unknown(other.to_string()), + } +} + +pub(crate) fn format_seconds(seconds: u32) -> String { + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let secs = seconds % 60; + format!("{hours:02}:{minutes:02}:{secs:02}") +} + +fn convert_oh_track_entry(entry: &OhTrackEntry) -> OpenHomePlaylistTrack { + let metadata = parse_track_metadata_from_didl(&entry.metadata_xml); + OpenHomePlaylistTrack { + id: entry.id, + uri: entry.uri.clone(), + title: metadata.as_ref().and_then(|m| m.title.clone()), + artist: metadata.as_ref().and_then(|m| m.artist.clone()), + album: metadata.as_ref().and_then(|m| m.album.clone()), + album_art_uri: metadata.and_then(|m| m.album_art_uri), + } +} + +fn build_playlist_client(info: &RendererInfo) -> Option { + let control_url = info.oh_playlist_control_url.as_ref()?; + let service_type = info.oh_playlist_service_type.as_ref()?; + Some(OhPlaylistClient::new( + control_url.clone(), + service_type.clone(), + )) +} + +fn build_info_client(info: &RendererInfo) -> Option { + let control_url = info.oh_info_control_url.as_ref()?; + let service_type = info.oh_info_service_type.as_ref()?; + Some(OhInfoClient::new(control_url.clone(), service_type.clone())) +} + +fn build_time_client(info: &RendererInfo) -> Option { + let control_url = info.oh_time_control_url.as_ref()?; + let service_type = info.oh_time_service_type.as_ref()?; + Some(OhTimeClient::new(control_url.clone(), service_type.clone())) +} + +fn build_volume_client(info: &RendererInfo) -> Option { + let control_url = info.oh_volume_control_url.as_ref()?; + let service_type = info.oh_volume_service_type.as_ref()?; + Some(OhVolumeClient::new( + control_url.clone(), + service_type.clone(), + )) +} + +fn build_radio_client(info: &RendererInfo) -> Option { + let control_url = info.oh_radio_control_url.as_ref()?; + let service_type = info.oh_radio_service_type.as_ref()?; + Some(OhRadioClient::new( + control_url.clone(), + service_type.clone(), + )) +} diff --git a/pmocontrol/src/playback_queue.rs b/pmocontrol/src/playback_queue.rs new file mode 100644 index 00000000..6e47b5dd --- /dev/null +++ b/pmocontrol/src/playback_queue.rs @@ -0,0 +1,241 @@ +use crate::media_server::ServerId; + +#[derive(Clone, Debug)] +pub struct PlaybackItem { + pub uri: String, + pub title: Option, + pub server_id: Option, + pub object_id: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub album_art_uri: Option, + pub date: Option, + pub track_number: Option, + pub creator: Option, + pub protocol_info: Option, +} + +impl PlaybackItem { + pub fn new(uri: impl Into) -> Self { + Self { + uri: uri.into(), + title: None, + server_id: None, + object_id: None, + artist: None, + album: None, + genre: None, + album_art_uri: None, + date: None, + track_number: None, + creator: None, + protocol_info: None, + } + } + + /// Convert PlaybackItem to DIDL-Lite XML metadata for SetAVTransportURI + pub fn to_didl_metadata(&self) -> String { + use quick_xml::escape::escape; + use tracing::debug; + + let title = self.title.as_deref().unwrap_or("Unknown"); + let escaped_uri = escape(&self.uri); + let escaped_title = escape(title); + + let mut didl = String::from( + r#""#, + ); + // Use proper ID from object_id if available, otherwise use "0" + let item_id = self.object_id.as_deref().unwrap_or("0"); + let escaped_item_id = escape(item_id); + didl.push_str(&format!( + r#""#, + escaped_item_id + )); + didl.push_str(&format!("{}", escaped_title)); + + if let Some(artist) = &self.artist { + let escaped_artist = escape(artist); + didl.push_str(&format!("{}", escaped_artist)); + didl.push_str(&format!("{}", escaped_artist)); + } + + if let Some(album) = &self.album { + let escaped_album = escape(album); + didl.push_str(&format!("{}", escaped_album)); + } + + if let Some(genre) = &self.genre { + let escaped_genre = escape(genre); + didl.push_str(&format!("{}", escaped_genre)); + } + + if let Some(album_art) = &self.album_art_uri { + let escaped_art = escape(album_art); + debug!( + title = title, + album_art_uri = album_art.as_str(), + "Including albumArtURI in DIDL metadata" + ); + didl.push_str(&format!( + "{}", + escaped_art + )); + } else { + debug!( + title = title, + "No album_art_uri in PlaybackItem - skipping albumArtURI in DIDL" + ); + } + + if let Some(date) = &self.date { + let escaped_date = escape(date); + didl.push_str(&format!("{}", escaped_date)); + } + + if let Some(track_num) = &self.track_number { + let escaped_track = escape(track_num); + didl.push_str(&format!( + "{}", + escaped_track + )); + } + + // Add resource with URI + // Use the original protocolInfo if available, otherwise use a generic one + let protocol_info = self + .protocol_info + .as_deref() + .unwrap_or("http-get:*:audio/*:*"); + + // For protocolInfo, we only need to escape XML special chars, not ':' + // We manually escape only the necessary characters to preserve the protocolInfo format + let safe_protocol_info = protocol_info + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """); + + didl.push_str(&format!( + r#"{}"#, + safe_protocol_info, escaped_uri + )); + + didl.push_str(r#"object.item.audioItem.musicTrack"#); + didl.push_str(""); + didl.push_str(""); + + didl + } +} + +#[derive(Clone, Debug, Default)] +pub struct PlaybackQueue { + items: Vec, + current_index: Option, +} + +impl PlaybackQueue { + pub fn new() -> Self { + Self { + items: Vec::new(), + current_index: None, + } + } + + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn clear(&mut self) { + self.items.clear(); + self.current_index = None; + } + + pub fn enqueue(&mut self, item: PlaybackItem) { + self.items.push(item); + } + + pub fn enqueue_many>(&mut self, items: I) { + for item in items { + self.items.push(item); + } + } + + pub fn enqueue_front(&mut self, item: PlaybackItem) { + let insert_at = match self.current_index { + Some(idx) => { + let next = idx.saturating_add(1); + next.min(self.items.len()) + } + None => 0, + }; + self.items.insert(insert_at, item); + // current_index remains unchanged; insertion happens after the cursor. + } + + pub fn dequeue(&mut self) -> Option { + if self.items.is_empty() { + return None; + } + + match self.current_index { + None => { + self.current_index = Some(0); + self.items.get(0).cloned() + } + Some(idx) => { + let next_idx = idx + 1; + if next_idx >= self.items.len() { + None + } else { + self.current_index = Some(next_idx); + self.items.get(next_idx).cloned() + } + } + } + } + + pub fn peek(&self) -> Option<&PlaybackItem> { + if let Some(idx) = self.current_index { + self.items.get(idx) + } else { + self.items.first() + } + } + + pub fn snapshot(&self) -> Vec { + match self.current_index { + None => self.items.clone(), + Some(idx) => self.items.iter().skip(idx + 1).cloned().collect(), + } + } + + pub fn upcoming_len(&self) -> usize { + match self.current_index { + None => self.items.len(), + Some(idx) => self.items.len().saturating_sub(idx + 1), + } + } + + pub fn full_snapshot(&self) -> (Vec, Option) { + (self.items.clone(), self.current_index) + } + + pub fn set_current_index(&mut self, index: Option) { + if let Some(idx) = index { + if idx < self.items.len() { + self.current_index = Some(idx); + } else { + self.current_index = None; + } + } else { + self.current_index = None; + } + } +} diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs new file mode 100644 index 00000000..b043fcb8 --- /dev/null +++ b/pmocontrol/src/pmoserver_ext.rs @@ -0,0 +1,2137 @@ +//! Extension pmoserver pour le Control Point +//! +//! Ce module fournit une API REST pour contrôler les renderers UPnP +//! et naviguer dans les serveurs de médias. + +#[cfg(feature = "pmoserver")] +use crate::control_point::{ControlPoint, OpenHomeAccessError}; +#[cfg(feature = "pmoserver")] +use crate::media_server::{MediaBrowser, MediaEntry, MediaResource, MusicServer, ServerId}; +#[cfg(feature = "pmoserver")] +use crate::model::{RendererCapabilities, RendererId, RendererProtocol}; +#[cfg(feature = "pmoserver")] +use crate::openapi::{ + AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse, + FullRendererSnapshot, MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot, + PlayContentRequest, QueueItem, QueueSnapshot, RendererCapabilitiesSummary, + RendererProtocolSummary, RendererState, RendererSummary, SuccessResponse, VolumeSetRequest, +}; +#[cfg(feature = "pmoserver")] +use crate::playback_queue::PlaybackItem; +#[cfg(feature = "pmoserver")] +use crate::{PlaybackPosition, PlaybackStatus, TransportControl, VolumeControl}; + +#[cfg(feature = "pmoserver")] +use async_trait::async_trait; +#[cfg(feature = "pmoserver")] +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + routing::{get, post}, +}; +#[cfg(feature = "pmoserver")] +use std::sync::Arc; +#[cfg(feature = "pmoserver")] +use std::time::Duration; +#[cfg(feature = "pmoserver")] +use tokio::time; +#[cfg(feature = "pmoserver")] +use tracing::{debug, warn}; +#[cfg(feature = "pmoserver")] +use utoipa::OpenApi; + +#[cfg(feature = "pmoserver")] +const BROWSE_PAGE_SIZE: u32 = 100; +#[cfg(feature = "pmoserver")] +const MEDIA_SERVER_SOAP_TIMEOUT: Duration = Duration::from_secs(15); +#[cfg(feature = "pmoserver")] +const BROWSE_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); + +// Timeouts for simple commands (play/pause/stop) +#[cfg(feature = "pmoserver")] +const TRANSPORT_COMMAND_TIMEOUT: Duration = Duration::from_secs(5); + +// Timeouts for volume/mute commands (faster than transport) +#[cfg(feature = "pmoserver")] +const VOLUME_COMMAND_TIMEOUT: Duration = Duration::from_secs(3); + +// Timeout for queue operations +#[cfg(feature = "pmoserver")] +const QUEUE_COMMAND_TIMEOUT: Duration = Duration::from_secs(10); + +// Timeout for attach playlist (includes browse + cache + queue update) +#[cfg(feature = "pmoserver")] +const ATTACH_PLAYLIST_TIMEOUT: Duration = Duration::from_secs(60); + +/// État partagé pour l'API ControlPoint +#[cfg(feature = "pmoserver")] +#[derive(Clone)] +pub struct ControlPointState { + control_point: Arc, +} + +#[cfg(feature = "pmoserver")] +impl ControlPointState { + pub fn new(control_point: Arc) -> Self { + Self { control_point } + } +} + +// ============================================================================ +// HANDLERS - RENDERERS +// ============================================================================ + +/// GET /control/renderers - Liste tous les renderers +#[cfg(feature = "pmoserver")] +#[utoipa::path( + get, + path = "/renderers", + responses( + (status = 200, description = "Liste des renderers", body = Vec) + ), + tag = "control" +)] +async fn list_renderers(State(state): State) -> Json> { + let renderers = state.control_point.list_music_renderers(); + + let summaries: Vec = renderers + .into_iter() + .map(|r| { + let info = r.info(); + RendererSummary { + id: info.id.0.clone(), + friendly_name: info.friendly_name.clone(), + model_name: info.model_name.clone(), + protocol: protocol_summary(&info.protocol), + capabilities: capability_summary(&info.capabilities), + online: info.online, + } + }) + .collect(); + + Json(summaries) +} + +/// GET /control/renderers/{renderer_id} - Récupère l'état d'un renderer +#[cfg(feature = "pmoserver")] +#[utoipa::path( + get, + path = "/renderers/{renderer_id}", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + responses( + (status = 200, description = "État du renderer", body = RendererState), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse) + ), + tag = "control" +)] +async fn get_renderer_state( + State(state): State, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + let snapshot = state + .control_point + .renderer_full_snapshot(&rid) + .map_err(|err| map_snapshot_error(renderer_id, err))?; + + Ok(Json(snapshot.state)) +} + +#[cfg(feature = "pmoserver")] +#[utoipa::path( + get, + path = "/renderers/{renderer_id}/full", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + responses( + (status = 200, description = "Snapshot complet du renderer", body = FullRendererSnapshot), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse) + ), + tag = "control" +)] +async fn get_renderer_full_snapshot( + State(state): State, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + let snapshot = state + .control_point + .renderer_full_snapshot(&rid) + .map_err(|err| map_snapshot_error(renderer_id, err))?; + + Ok(Json(snapshot)) +} + +/// GET /control/renderers/{renderer_id}/queue - Récupère la queue d'un renderer +#[cfg(feature = "pmoserver")] +#[utoipa::path( + get, + path = "/renderers/{renderer_id}/queue", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + responses( + ( + status = 200, + description = "Playlist complète du renderer (avec index courant)", + body = QueueSnapshot + ), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse) + ), + tag = "control" +)] +async fn get_renderer_queue( + State(state): State, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + let snapshot = state + .control_point + .renderer_full_snapshot(&rid) + .map_err(|err| map_snapshot_error(renderer_id, err))?; + + Ok(Json(snapshot.queue)) +} + +/// GET /control/renderers/{renderer_id}/binding - Récupère le binding playlist +#[cfg(feature = "pmoserver")] +#[utoipa::path( + get, + path = "/renderers/{renderer_id}/binding", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + responses( + (status = 200, description = "Binding playlist", body = Option), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse) + ), + tag = "control" +)] +async fn get_renderer_binding( + State(state): State, + Path(renderer_id): Path, +) -> Result>, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + let snapshot = state + .control_point + .renderer_full_snapshot(&rid) + .map_err(|err| map_snapshot_error(renderer_id, err))?; + + Ok(Json(snapshot.binding)) +} + +// ============================================================================ +// HANDLERS - TRANSPORT CONTROLS +// ============================================================================ + +/// POST /control/renderers/{renderer_id}/play - Démarre la lecture +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/play", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + responses( + (status = 200, description = "Lecture démarrée", body = SuccessResponse), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse), + (status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse) + ), + tag = "control" +)] +async fn play_renderer( + State(state): State, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + let renderer = state + .control_point + .music_renderer_by_id(&rid) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Renderer {} not found", renderer_id), + }), + ) + })?; + + let renderer_clone = renderer.clone(); + let play_task = tokio::task::spawn_blocking(move || renderer_clone.play()); + + time::timeout(TRANSPORT_COMMAND_TIMEOUT, play_task) + .await + .map_err(|_| { + warn!( + "Play command for renderer {} exceeded {:?}", + renderer_id, TRANSPORT_COMMAND_TIMEOUT + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Play command timed out after {}s", + TRANSPORT_COMMAND_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!("Task join error during play: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!("Failed to play renderer {}: {}", renderer_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to play: {}", e), + }), + ) + })?; + + Ok(Json(SuccessResponse { + message: "Playback started".to_string(), + })) +} + +/// POST /control/renderers/{renderer_id}/pause - Met en pause +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/pause", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + responses( + (status = 200, description = "Lecture en pause", body = SuccessResponse), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse), + (status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse) + ), + tag = "control" +)] +async fn pause_renderer( + State(state): State, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + let renderer = state + .control_point + .music_renderer_by_id(&rid) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Renderer {} not found", renderer_id), + }), + ) + })?; + + let renderer_clone = renderer.clone(); + let pause_task = tokio::task::spawn_blocking(move || renderer_clone.pause()); + + time::timeout(TRANSPORT_COMMAND_TIMEOUT, pause_task) + .await + .map_err(|_| { + warn!( + "Pause command for renderer {} exceeded {:?}", + renderer_id, TRANSPORT_COMMAND_TIMEOUT + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Pause command timed out after {}s", + TRANSPORT_COMMAND_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!("Task join error during pause: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!("Failed to pause renderer {}: {}", renderer_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to pause: {}", e), + }), + ) + })?; + + Ok(Json(SuccessResponse { + message: "Playback paused".to_string(), + })) +} + +/// POST /control/renderers/{renderer_id}/stop - Arrête la lecture +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/stop", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + responses( + (status = 200, description = "Lecture arrêtée", body = SuccessResponse), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse), + (status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse) + ), + tag = "control" +)] +async fn stop_renderer( + State(state): State, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + state + .control_point + .music_renderer_by_id(&rid) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Renderer {} not found", renderer_id), + }), + ) + })?; + + let control_point = Arc::clone(&state.control_point); + let rid_for_task = rid.clone(); + let stop_task = tokio::task::spawn_blocking(move || control_point.user_stop(&rid_for_task)); + + time::timeout(TRANSPORT_COMMAND_TIMEOUT, stop_task) + .await + .map_err(|_| { + warn!( + "Stop command for renderer {} exceeded {:?}", + renderer_id, TRANSPORT_COMMAND_TIMEOUT + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Stop command timed out after {}s", + TRANSPORT_COMMAND_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!("Task join error during stop: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!("Failed to stop renderer {}: {}", renderer_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to stop: {}", e), + }), + ) + })?; + + Ok(Json(SuccessResponse { + message: "Playback stopped".to_string(), + })) +} + +/// POST /control/renderers/{renderer_id}/resume - Reprend la lecture depuis la queue +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/resume", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + responses( + (status = 200, description = "Lecture reprise", body = SuccessResponse), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse), + (status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse) + ), + tag = "control" +)] +async fn resume_renderer( + State(state): State, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + state + .control_point + .music_renderer_by_id(&rid) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Renderer {} not found", renderer_id), + }), + ) + })?; + + let control_point = Arc::clone(&state.control_point); + let rid_for_task = rid.clone(); + let resume_task = + tokio::task::spawn_blocking(move || control_point.play_current_from_queue(&rid_for_task)); + + time::timeout(TRANSPORT_COMMAND_TIMEOUT, resume_task) + .await + .map_err(|_| { + warn!( + "Resume command for renderer {} exceeded {:?}", + renderer_id, TRANSPORT_COMMAND_TIMEOUT + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Resume command timed out after {}s", + TRANSPORT_COMMAND_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!("Task join error during resume: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!( + "Failed to resume playback for renderer {}: {}", + renderer_id, e + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to resume playback: {}", e), + }), + ) + })?; + + Ok(Json(SuccessResponse { + message: "Playback resumed".to_string(), + })) +} + +/// POST /control/renderers/{renderer_id}/next - Passe au morceau suivant +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/next", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + responses( + (status = 200, description = "Piste suivante lancée", body = SuccessResponse), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse), + (status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse) + ), + tag = "control" +)] +async fn next_renderer( + State(state): State, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + state + .control_point + .music_renderer_by_id(&rid) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Renderer {} not found", renderer_id), + }), + ) + })?; + + let control_point = Arc::clone(&state.control_point); + let rid_for_task = rid.clone(); + let next_task = + tokio::task::spawn_blocking(move || control_point.play_next_from_queue(&rid_for_task)); + + time::timeout(TRANSPORT_COMMAND_TIMEOUT, next_task) + .await + .map_err(|_| { + warn!( + "Next command for renderer {} exceeded {:?}", + renderer_id, TRANSPORT_COMMAND_TIMEOUT + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Next command timed out after {}s", + TRANSPORT_COMMAND_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!("Task join error during next: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!( + "Failed to skip to next track for renderer {}: {}", + renderer_id, e + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to skip to next track: {}", e), + }), + ) + })?; + + Ok(Json(SuccessResponse { + message: "Skipped to next track".to_string(), + })) +} + +/// POST /control/renderers/{renderer_id}/volume/set - Définit le volume +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/volume/set", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + request_body = VolumeSetRequest, + responses( + (status = 200, description = "Volume défini", body = SuccessResponse), + (status = 400, description = "Requête invalide", body = ErrorResponse), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse), + (status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse) + ), + tag = "control" +)] +async fn set_renderer_volume( + State(state): State, + Path(renderer_id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + + let renderer = state + .control_point + .music_renderer_by_id(&rid) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Renderer {} not found", renderer_id), + }), + ) + })?; + + let renderer_clone = renderer.clone(); + let volume = req.volume; + let volume_task = tokio::task::spawn_blocking(move || renderer_clone.set_volume(volume as u16)); + + time::timeout(VOLUME_COMMAND_TIMEOUT, volume_task) + .await + .map_err(|_| { + warn!( + "Set volume command for renderer {} exceeded {:?}", + renderer_id, VOLUME_COMMAND_TIMEOUT + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Set volume command timed out after {}s", + VOLUME_COMMAND_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!("Task join error during set volume: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!("Failed to set volume for renderer {}: {}", renderer_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to set volume: {}", e), + }), + ) + })?; + + Ok(Json(SuccessResponse { + message: format!("Volume set to {}", volume), + })) +} + +/// POST /control/renderers/{renderer_id}/volume/up - Augmente le volume +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/volume/up", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + responses( + (status = 200, description = "Volume augmenté", body = SuccessResponse), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse), + (status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse) + ), + tag = "control" +)] +async fn volume_up_renderer( + State(state): State, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + + let renderer = state + .control_point + .music_renderer_by_id(&rid) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Renderer {} not found", renderer_id), + }), + ) + })?; + + let renderer_clone = renderer.clone(); + let volume_task = tokio::task::spawn_blocking(move || { + let current = renderer_clone.volume()?; + let new_volume = (current + 5).min(100); + renderer_clone.set_volume(new_volume)?; + Ok::(new_volume) + }); + + let new_volume = time::timeout(VOLUME_COMMAND_TIMEOUT, volume_task) + .await + .map_err(|_| { + warn!( + "Volume up command for renderer {} exceeded {:?}", + renderer_id, VOLUME_COMMAND_TIMEOUT + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Volume up command timed out after {}s", + VOLUME_COMMAND_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!("Task join error during volume up: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!( + "Failed to increase volume for renderer {}: {}", + renderer_id, e + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to increase volume: {}", e), + }), + ) + })?; + + Ok(Json(SuccessResponse { + message: format!("Volume increased to {}", new_volume), + })) +} + +/// POST /control/renderers/{renderer_id}/volume/down - Diminue le volume +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/volume/down", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + responses( + (status = 200, description = "Volume diminué", body = SuccessResponse), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse), + (status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse) + ), + tag = "control" +)] +async fn volume_down_renderer( + State(state): State, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + + let renderer = state + .control_point + .music_renderer_by_id(&rid) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Renderer {} not found", renderer_id), + }), + ) + })?; + + let renderer_clone = renderer.clone(); + let volume_task = tokio::task::spawn_blocking(move || { + let current = renderer_clone.volume()?; + let new_volume = current.saturating_sub(5); + renderer_clone.set_volume(new_volume)?; + Ok::(new_volume) + }); + + let new_volume = time::timeout(VOLUME_COMMAND_TIMEOUT, volume_task) + .await + .map_err(|_| { + warn!( + "Volume down command for renderer {} exceeded {:?}", + renderer_id, VOLUME_COMMAND_TIMEOUT + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Volume down command timed out after {}s", + VOLUME_COMMAND_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!("Task join error during volume down: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!( + "Failed to decrease volume for renderer {}: {}", + renderer_id, e + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to decrease volume: {}", e), + }), + ) + })?; + + Ok(Json(SuccessResponse { + message: format!("Volume decreased to {}", new_volume), + })) +} + +/// POST /control/renderers/{renderer_id}/mute/toggle - Bascule le mute +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/mute/toggle", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + responses( + (status = 200, description = "Mute basculé", body = SuccessResponse), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse), + (status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse) + ), + tag = "control" +)] +async fn toggle_mute_renderer( + State(state): State, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + + let renderer = state + .control_point + .music_renderer_by_id(&rid) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Renderer {} not found", renderer_id), + }), + ) + })?; + + let renderer_clone = renderer.clone(); + let mute_task = tokio::task::spawn_blocking(move || { + let current_mute = renderer_clone.mute()?; + let new_mute = !current_mute; + renderer_clone.set_mute(new_mute)?; + Ok::(new_mute) + }); + + let new_mute = time::timeout(VOLUME_COMMAND_TIMEOUT, mute_task) + .await + .map_err(|_| { + warn!( + "Toggle mute command for renderer {} exceeded {:?}", + renderer_id, VOLUME_COMMAND_TIMEOUT + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Toggle mute command timed out after {}s", + VOLUME_COMMAND_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!("Task join error during toggle mute: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!("Failed to toggle mute for renderer {}: {}", renderer_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to toggle mute: {}", e), + }), + ) + })?; + + Ok(Json(SuccessResponse { + message: format!("Mute {}", if new_mute { "enabled" } else { "disabled" }), + })) +} + +// ============================================================================ +// HANDLERS - BINDING PLAYLIST +// ============================================================================ + +/// POST /control/renderers/{renderer_id}/binding/attach - Attache une playlist +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/binding/attach", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + request_body = AttachPlaylistRequest, + responses( + (status = 200, description = "Playlist attachée", body = SuccessResponse), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse) + ), + tag = "control" +)] +async fn attach_playlist_binding( + State(state): State, + Path(renderer_id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + let sid = ServerId(req.server_id.clone()); + let container_id = req.container_id.clone(); + let control_point = Arc::clone(&state.control_point); + + // Spawn blocking task and wait for completion with timeout + let attach_task = tokio::task::spawn_blocking(move || { + control_point.attach_queue_to_playlist_with_options(&rid, sid, container_id, req.auto_play) + }); + + time::timeout(ATTACH_PLAYLIST_TIMEOUT, attach_task) + .await + .map_err(|_| { + warn!( + "Attach playlist for renderer {} exceeded {:?}", + renderer_id, ATTACH_PLAYLIST_TIMEOUT + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Attach playlist timed out after {}s", + ATTACH_PLAYLIST_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!("Task join error during attach playlist: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!( + renderer = renderer_id.as_str(), + server = req.server_id.as_str(), + container = req.container_id.as_str(), + error = %e, + "Failed to attach playlist" + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to attach playlist: {}", e), + }), + ) + })?; + + debug!( + renderer = renderer_id.as_str(), + server = req.server_id.as_str(), + container = req.container_id.as_str(), + auto_play = req.auto_play, + "Playlist attached via HTTP API" + ); + + Ok(Json(SuccessResponse { + message: format!("Playlist {} attached to renderer", req.container_id), + })) +} + +/// POST /control/renderers/{renderer_id}/binding/detach - Détache la playlist +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/binding/detach", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + responses( + (status = 200, description = "Playlist détachée", body = SuccessResponse) + ), + tag = "control" +)] +async fn detach_playlist_binding( + State(state): State, + Path(renderer_id): Path, +) -> Json { + let rid = RendererId(renderer_id.clone()); + + state.control_point.detach_queue_playlist(&rid); + + debug!( + renderer = renderer_id.as_str(), + "Playlist detached via HTTP API" + ); + + Json(SuccessResponse { + message: "Playlist detached".to_string(), + }) +} + +// ============================================================================ +// HANDLERS - OPENHOME PLAYLIST +// ============================================================================ + +/// GET /control/renderers/{renderer_id}/oh/playlist - Snapshot de la playlist OH +#[cfg(feature = "pmoserver")] +#[utoipa::path( + get, + path = "/renderers/{renderer_id}/oh/playlist", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + responses( + (status = 200, description = "Playlist OpenHome", body = OpenHomePlaylistSnapshot), + (status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse) + ), + tag = "control" +)] +async fn get_openhome_playlist( + State(state): State, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + let control_point = Arc::clone(&state.control_point); + let rid_for_task = rid.clone(); + + let fetch_task = tokio::task::spawn_blocking(move || { + control_point.get_openhome_playlist_snapshot(&rid_for_task) + }); + + let snapshot = fetch_task + .await + .map_err(|e| { + warn!( + renderer = renderer_id.as_str(), + error = %e, + "Join error while fetching OpenHome playlist" + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!( + renderer = renderer_id.as_str(), + error = %e, + "Failed to read OpenHome playlist" + ); + map_openhome_error(&rid, e, "read OpenHome playlist") + })?; + + Ok(Json(snapshot)) +} + +/// POST /control/renderers/{renderer_id}/oh/playlist/clear - Vide la playlist OH +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/oh/playlist/clear", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + responses( + (status = 200, description = "Playlist vidée", body = SuccessResponse), + (status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse) + ), + tag = "control" +)] +async fn clear_openhome_playlist( + State(state): State, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + let control_point = Arc::clone(&state.control_point); + let rid_for_task = rid.clone(); + + let clear_task = + tokio::task::spawn_blocking(move || control_point.clear_openhome_playlist(&rid_for_task)); + + time::timeout(QUEUE_COMMAND_TIMEOUT, clear_task) + .await + .map_err(|_| { + warn!( + renderer = renderer_id.as_str(), + timeout = QUEUE_COMMAND_TIMEOUT.as_secs(), + "Clearing OpenHome playlist timed out" + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Clear playlist timed out after {}s", + QUEUE_COMMAND_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!( + renderer = renderer_id.as_str(), + error = %e, + "Join error while clearing OpenHome playlist" + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!( + renderer = renderer_id.as_str(), + error = %e, + "Failed to clear OpenHome playlist" + ); + map_openhome_error(&rid, e, "clear OpenHome playlist") + })?; + + Ok(Json(SuccessResponse { + message: "OpenHome playlist cleared".to_string(), + })) +} + +/// POST /control/renderers/{renderer_id}/oh/playlist/add - Ajoute un track OH +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/oh/playlist/add", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + request_body = OpenHomePlaylistAddRequest, + responses( + (status = 200, description = "Track ajouté", body = SuccessResponse), + (status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse) + ), + tag = "control" +)] +async fn add_openhome_playlist_item( + State(state): State, + Path(renderer_id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + let control_point = Arc::clone(&state.control_point); + let rid_for_task = rid.clone(); + + let add_task = tokio::task::spawn_blocking(move || { + control_point.add_openhome_track( + &rid_for_task, + &req.uri, + &req.metadata, + req.after_id, + req.play, + ) + }); + + time::timeout(QUEUE_COMMAND_TIMEOUT, add_task) + .await + .map_err(|_| { + warn!( + renderer = renderer_id.as_str(), + timeout = QUEUE_COMMAND_TIMEOUT.as_secs(), + "Adding OpenHome track timed out" + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Add track timed out after {}s", + QUEUE_COMMAND_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!( + renderer = renderer_id.as_str(), + error = %e, + "Join error while adding OpenHome track" + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!( + renderer = renderer_id.as_str(), + error = %e, + "Failed to add OpenHome track" + ); + map_openhome_error(&rid, e, "add OpenHome track") + })?; + + Ok(Json(SuccessResponse { + message: "Track added to OpenHome playlist".to_string(), + })) +} + +/// POST /control/renderers/{renderer_id}/oh/playlist/play/{track_id} - PlayId OH +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/oh/playlist/play/{track_id}", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer"), + ("track_id" = String, Path, description = "ID OpenHome du morceau") + ), + responses( + (status = 200, description = "Lecture démarrée", body = SuccessResponse), + (status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse) + ), + tag = "control" +)] +async fn play_openhome_track( + State(state): State, + Path((renderer_id, track_id)): Path<(String, String)>, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + let parsed_id = track_id.parse::().map_err(|e| { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: format!("Invalid track id '{}': {}", track_id, e), + }), + ) + })?; + + let control_point = Arc::clone(&state.control_point); + let rid_for_task = rid.clone(); + + let play_task = tokio::task::spawn_blocking(move || { + control_point.play_openhome_track_id(&rid_for_task, parsed_id) + }); + + time::timeout(QUEUE_COMMAND_TIMEOUT, play_task) + .await + .map_err(|_| { + warn!( + renderer = renderer_id.as_str(), + timeout = QUEUE_COMMAND_TIMEOUT.as_secs(), + "PlayId command timed out" + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Play track timed out after {}s", + QUEUE_COMMAND_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!( + renderer = renderer_id.as_str(), + error = %e, + "Join error while playing OpenHome track" + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!( + renderer = renderer_id.as_str(), + error = %e, + track_id = parsed_id, + "Failed to start OpenHome track" + ); + map_openhome_error(&rid, e, "play OpenHome track") + })?; + + Ok(Json(SuccessResponse { + message: format!("Playing OpenHome track {}", parsed_id), + })) +} + +// ============================================================================ +// HANDLERS - QUEUE CONTENT +// ============================================================================ + +/// POST /control/renderers/{renderer_id}/queue/play - Lire du contenu immédiatement +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/queue/play", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + request_body = PlayContentRequest, + responses( + (status = 200, description = "Contenu en cours de lecture", body = SuccessResponse), + (status = 404, description = "Renderer ou serveur non trouvé", body = ErrorResponse), + (status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse) + ), + tag = "control" +)] +async fn play_content( + State(state): State, + Path(renderer_id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + let sid = ServerId(req.server_id.clone()); + let object_id = req.object_id.clone(); + let object_id_for_log = object_id.clone(); + + // Get renderer to verify it exists + state + .control_point + .music_renderer_by_id(&rid) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Renderer {} not found", renderer_id), + }), + ) + })?; + + let control_point = Arc::clone(&state.control_point); + + // Spawn blocking task for content loading + let play_task = tokio::task::spawn_blocking(move || { + // Fetch playback items from server + let items = fetch_playback_items(&control_point, &sid, &object_id)?; + + if items.is_empty() { + return Err(anyhow::anyhow!("No playable content found")); + } + + if items.len() > 1 { + debug!( + renderer = rid.0.as_str(), + server = sid.0.as_str(), + object = object_id.as_str(), + item_count = items.len(), + "Auto-binding playlist to renderer queue (auto_play = true)" + ); + control_point.attach_queue_to_playlist_with_options( + &rid, + sid.clone(), + object_id.clone(), + true, + )?; + return Ok(()); + } + + // Clear queue + control_point.clear_queue(&rid)?; + + // Enqueue items + control_point.enqueue_items(&rid, items)?; + + // Start playback + // Pour les renderers OpenHome, play_current_from_queue() va gérer automatiquement + // la lecture depuis la playlist native si elle existe + control_point.play_current_from_queue(&rid)?; + + Ok::<(), anyhow::Error>(()) + }); + + time::timeout(QUEUE_COMMAND_TIMEOUT, play_task) + .await + .map_err(|_| { + warn!( + "Play content command for renderer {} exceeded {:?}", + renderer_id, QUEUE_COMMAND_TIMEOUT + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Play content timed out after {}s", + QUEUE_COMMAND_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!("Task join error during play content: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!("Failed to play content on renderer {}: {}", renderer_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to play content: {}", e), + }), + ) + })?; + + debug!( + renderer = renderer_id.as_str(), + server = req.server_id.as_str(), + object = object_id_for_log.as_str(), + "Content playing via HTTP API" + ); + + Ok(Json(SuccessResponse { + message: "Content playing".to_string(), + })) +} + +/// POST /control/renderers/{renderer_id}/queue/add - Ajouter du contenu à la queue +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/queue/add", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + request_body = PlayContentRequest, + responses( + (status = 200, description = "Contenu ajouté à la queue", body = SuccessResponse), + (status = 404, description = "Renderer ou serveur non trouvé", body = ErrorResponse), + (status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse) + ), + tag = "control" +)] +async fn add_to_queue( + State(state): State, + Path(renderer_id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + let sid = ServerId(req.server_id.clone()); + let object_id = req.object_id.clone(); + let object_id_for_log = object_id.clone(); + + // Verify renderer exists + state + .control_point + .music_renderer_by_id(&rid) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Renderer {} not found", renderer_id), + }), + ) + })?; + + let control_point = Arc::clone(&state.control_point); + + // Spawn blocking task for content loading + let add_task = tokio::task::spawn_blocking(move || { + // Fetch playback items from server + let items = fetch_playback_items(&control_point, &sid, &object_id)?; + + if items.is_empty() { + return Err(anyhow::anyhow!("No playable content found")); + } + + // Enqueue items + control_point.enqueue_items(&rid, items)?; + + Ok::<(), anyhow::Error>(()) + }); + + time::timeout(QUEUE_COMMAND_TIMEOUT, add_task) + .await + .map_err(|_| { + warn!( + "Add to queue command for renderer {} exceeded {:?}", + renderer_id, QUEUE_COMMAND_TIMEOUT + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Add to queue timed out after {}s", + QUEUE_COMMAND_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!("Task join error during add to queue: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!( + "Failed to add content to queue for renderer {}: {}", + renderer_id, e + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to add to queue: {}", e), + }), + ) + })?; + + debug!( + renderer = renderer_id.as_str(), + server = req.server_id.as_str(), + object = object_id_for_log.as_str(), + "Content added to queue via HTTP API" + ); + + Ok(Json(SuccessResponse { + message: "Content added to queue".to_string(), + })) +} + +// ============================================================================ +// HANDLERS - MEDIA SERVERS +// ============================================================================ + +/// GET /control/servers - Liste tous les serveurs de médias +#[cfg(feature = "pmoserver")] +#[utoipa::path( + get, + path = "/servers", + responses( + (status = 200, description = "Liste des serveurs de médias", body = Vec) + ), + tag = "control" +)] +async fn list_servers(State(state): State) -> Json> { + let servers = state.control_point.list_media_servers(); + + let summaries: Vec = servers + .into_iter() + .map(|s| MediaServerSummary { + id: s.id.0, + friendly_name: s.friendly_name, + model_name: s.model_name, + online: s.online, + }) + .collect(); + + Json(summaries) +} + +/// GET /control/servers/{server_id}/containers/{container_id} - Browse un container +#[cfg(feature = "pmoserver")] +#[utoipa::path( + get, + path = "/servers/{server_id}/containers/{container_id}", + params( + ("server_id" = String, Path, description = "ID unique du serveur"), + ("container_id" = String, Path, description = "ID du container (use '0' for root)") + ), + responses( + (status = 200, description = "Contenu du container", body = BrowseResponse), + (status = 404, description = "Serveur non trouvé", body = ErrorResponse), + (status = 500, description = "Erreur lors du browse", body = ErrorResponse) + ), + tag = "control" +)] +async fn browse_container( + State(state): State, + Path((server_id, container_id)): Path<(String, String)>, +) -> Result, (StatusCode, Json)> { + let sid = ServerId(server_id.clone()); + + let server_info = state.control_point.media_server(&sid).ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Server {} not found", server_id), + }), + ) + })?; + + if !server_info.online { + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: format!("Server {} is offline", server_id), + }), + )); + } + + if !server_info.has_content_directory { + return Err(( + StatusCode::NOT_IMPLEMENTED, + Json(ErrorResponse { + error: format!("Server {} does not support ContentDirectory", server_id), + }), + )); + } + + let music_server = + MusicServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT).map_err(|e| { + warn!("Failed to create MusicServer for {}: {}", server_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to initialize server: {}", e), + }), + ) + })?; + + // Use spawn_blocking to avoid blocking the async runtime with synchronous SOAP calls + let container_id_clone = container_id.clone(); + let browse_task = tokio::task::spawn_blocking(move || { + music_server.browse_children(&container_id_clone, 0, BROWSE_PAGE_SIZE) + }); + + let entries = time::timeout(BROWSE_REQUEST_TIMEOUT, browse_task) + .await + .map_err(|_| { + warn!( + "Browse request for container {} on server {} exceeded {:?}", + container_id, server_id, BROWSE_REQUEST_TIMEOUT + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Browse request timed out after {}s", + BROWSE_REQUEST_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!("Task join error during browse: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!( + "Failed to browse container {} on server {}: {}", + container_id, server_id, e + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to browse container: {}", e), + }), + ) + })?; + + let container_entries: Vec = entries + .into_iter() + .map(|e| ContainerEntry { + id: e.id, + title: e.title, + class: e.class, + is_container: e.is_container, + child_count: None, // Could be extracted from DIDL-Lite if needed + artist: e.artist, + album: e.album, + album_art_uri: e.album_art_uri, + }) + .collect(); + + Ok(Json(BrowseResponse { + container_id, + entries: container_entries, + })) +} + +// ============================================================================ +// HELPERS +// ============================================================================ + +#[cfg(feature = "pmoserver")] +fn map_snapshot_error( + renderer_id: String, + err: anyhow::Error, +) -> (StatusCode, Json) { + warn!( + renderer = renderer_id.as_str(), + error = %err, + "Failed to build renderer snapshot" + ); + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Renderer {} not found", renderer_id), + }), + ) +} + +#[cfg(feature = "pmoserver")] +fn map_openhome_error( + renderer_id: &RendererId, + err: anyhow::Error, + context: &str, +) -> (StatusCode, Json) { + if err.downcast_ref::().is_some() { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: err.to_string(), + }), + ) + } else { + ( + StatusCode::BAD_GATEWAY, + Json(ErrorResponse { + error: format!( + "Failed to {context} for renderer {}: {}", + renderer_id.0, err + ), + }), + ) + } +} + +/// Helper to fetch playback items from a media server object (container or item). +/// +/// This function browses the server to get the entries and converts them to PlaybackItem. +/// For containers, it browses children. For items, it browses metadata. +#[cfg(feature = "pmoserver")] +fn fetch_playback_items( + control_point: &ControlPoint, + server_id: &ServerId, + object_id: &str, +) -> anyhow::Result> { + // Get server info from registry + let server_info = control_point + .media_server(server_id) + .ok_or_else(|| anyhow::anyhow!("Server {} not found", server_id.0))?; + + if !server_info.online { + return Err(anyhow::anyhow!("Server {} is offline", server_id.0)); + } + + if !server_info.has_content_directory { + return Err(anyhow::anyhow!( + "Server {} does not support ContentDirectory", + server_id.0 + )); + } + + // Create MusicServer + let music_server = MusicServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT)?; + + // Browse the object to get entries + let entries = music_server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?; + + // Convert to PlaybackItem + let items: Vec = entries + .iter() + .filter_map(|entry| playback_item_from_entry(&music_server, entry)) + .collect(); + + Ok(items) +} + +/// Helper to convert a MediaEntry to a PlaybackItem. +#[cfg(feature = "pmoserver")] +fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option { + // Ignore containers + if entry.is_container { + return None; + } + + // Skip "live stream" entries + if entry.title.to_ascii_lowercase().contains("live stream") { + return None; + } + + // Find an audio resource + let resource = entry.resources.iter().find(|res| is_audio_resource(res))?; + + let mut item = PlaybackItem::new(resource.uri.clone()); + item.title = Some(entry.title.clone()); + item.server_id = Some(server.id().clone()); + item.object_id = Some(entry.id.clone()); + item.artist = entry.artist.clone(); + item.album = entry.album.clone(); + item.genre = entry.genre.clone(); + item.album_art_uri = entry.album_art_uri.clone(); + item.date = entry.date.clone(); + item.track_number = entry.track_number.clone(); + item.creator = entry.creator.clone(); + item.protocol_info = Some(resource.protocol_info.clone()); + + Some(item) +} + +/// Helper to detect if a MediaResource is audio content. +#[cfg(feature = "pmoserver")] +fn is_audio_resource(res: &MediaResource) -> bool { + let lower = res.protocol_info.to_ascii_lowercase(); + if lower.contains("audio/") { + return true; + } + // Check MIME type in protocolInfo (format: protocol:network:contentFormat:additionalInfo) + lower + .split(':') + .nth(2) + .map(|mime| mime.starts_with("audio/")) + .unwrap_or(false) +} + +#[cfg(feature = "pmoserver")] +fn protocol_summary(protocol: &RendererProtocol) -> RendererProtocolSummary { + match protocol { + RendererProtocol::UpnpAvOnly => RendererProtocolSummary::Upnp, + RendererProtocol::OpenHomeOnly => RendererProtocolSummary::Openhome, + RendererProtocol::Hybrid => RendererProtocolSummary::Hybrid, + } +} + +#[cfg(feature = "pmoserver")] +fn capability_summary(caps: &RendererCapabilities) -> RendererCapabilitiesSummary { + RendererCapabilitiesSummary { + has_avtransport: caps.has_avtransport, + has_avtransport_set_next: caps.has_avtransport_set_next, + has_rendering_control: caps.has_rendering_control, + has_connection_manager: caps.has_connection_manager, + has_linkplay_http: caps.has_linkplay_http, + has_arylic_tcp: caps.has_arylic_tcp, + has_oh_playlist: caps.has_oh_playlist, + has_oh_volume: caps.has_oh_volume, + has_oh_info: caps.has_oh_info, + has_oh_time: caps.has_oh_time, + has_oh_radio: caps.has_oh_radio, + } +} + +#[cfg(feature = "pmoserver")] +fn state_to_string(state: crate::PlaybackState) -> String { + use crate::PlaybackState; + match state { + PlaybackState::Stopped => "STOPPED".to_string(), + PlaybackState::Playing => "PLAYING".to_string(), + PlaybackState::Paused => "PAUSED".to_string(), + PlaybackState::Transitioning => "TRANSITIONING".to_string(), + PlaybackState::NoMedia => "NO_MEDIA".to_string(), + PlaybackState::Unknown(s) => s, + } +} + +// ============================================================================ +// ROUTER & TRAIT +// ============================================================================ + +/// Crée le router pour l'API Control Point +#[cfg(feature = "pmoserver")] +pub fn create_api_router(state: ControlPointState, control_point: Arc) -> Router { + Router::new() + // Renderers + .route("/renderers", get(list_renderers)) + .route("/renderers/{renderer_id}", get(get_renderer_state)) + .route( + "/renderers/{renderer_id}/full", + get(get_renderer_full_snapshot), + ) + .route("/renderers/{renderer_id}/queue", get(get_renderer_queue)) + .route( + "/renderers/{renderer_id}/binding", + get(get_renderer_binding), + ) + // Transport control + .route("/renderers/{renderer_id}/play", post(play_renderer)) + .route("/renderers/{renderer_id}/pause", post(pause_renderer)) + .route("/renderers/{renderer_id}/stop", post(stop_renderer)) + .route("/renderers/{renderer_id}/resume", post(resume_renderer)) + .route("/renderers/{renderer_id}/next", post(next_renderer)) + // Volume control + .route( + "/renderers/{renderer_id}/volume/set", + post(set_renderer_volume), + ) + .route( + "/renderers/{renderer_id}/volume/up", + post(volume_up_renderer), + ) + .route( + "/renderers/{renderer_id}/volume/down", + post(volume_down_renderer), + ) + .route( + "/renderers/{renderer_id}/mute/toggle", + post(toggle_mute_renderer), + ) + // Playlist binding + .route( + "/renderers/{renderer_id}/binding/attach", + post(attach_playlist_binding), + ) + .route( + "/renderers/{renderer_id}/binding/detach", + post(detach_playlist_binding), + ) + // OpenHome playlist + .route( + "/renderers/{renderer_id}/oh/playlist", + get(get_openhome_playlist), + ) + .route( + "/renderers/{renderer_id}/oh/playlist/clear", + post(clear_openhome_playlist), + ) + .route( + "/renderers/{renderer_id}/oh/playlist/add", + post(add_openhome_playlist_item), + ) + .route( + "/renderers/{renderer_id}/oh/playlist/play/{track_id}", + post(play_openhome_track), + ) + // Queue content + .route("/renderers/{renderer_id}/queue/play", post(play_content)) + .route("/renderers/{renderer_id}/queue/add", post(add_to_queue)) + // Servers + .route("/servers", get(list_servers)) + .route( + "/servers/{server_id}/containers/{container_id}", + get(browse_container), + ) + .with_state(state) + // SSE events - merge the SSE router + .merge(crate::sse::create_sse_router(control_point)) +} + +/// Trait d'extension pour pmoserver::Server +/// +/// Permet d'initialiser le ControlPoint avec routes HTTP complètes +#[cfg(feature = "pmoserver")] +#[async_trait] +pub trait ControlPointExt { + /// Enregistre et initialise le Control Point avec son API complète + /// + /// Cette fonction de haut niveau : + /// 1. Lance le runtime du ControlPoint (découverte SSDP, polling renderers, etc.) + /// 2. Enregistre toutes les routes HTTP REST + /// 3. Enregistre tous les endpoints SSE pour les événements + /// 4. Génère la documentation OpenAPI + /// + /// # Routes créées + /// + /// - API REST: `/api/control/*` + /// - `/renderers` - Liste et état des renderers + /// - `/servers` - Liste et navigation des serveurs de médias + /// - Contrôles de transport, volume, queue, binding + /// - SSE Events: `/api/control/events/*` + /// - `/events` - Tous les événements (renderers + serveurs) + /// - `/events/renderers` - Événements renderers uniquement + /// - `/events/servers` - Événements serveurs uniquement + /// - Swagger: `/swagger-ui/control` + /// + /// # Arguments + /// + /// * `timeout_secs` - Timeout HTTP pour les requêtes UPnP (recommandé: 5 secondes) + /// + /// # Returns + /// + /// Retourne l'instance du ControlPoint dans un Arc pour permettre + /// d'interagir avec depuis l'application. + /// + /// # Errors + /// + /// Retourne une erreur si le runtime SSDP ne peut pas être démarré. + /// + /// # Examples + /// + /// ```ignore + /// use pmocontrol::ControlPointExt; + /// use pmoserver::Server; + /// + /// let server = Server::create_upnp_server().await?; + /// + /// // Enregistrer le Control Point avec timeout de 5 secondes + /// let control_point = server + /// .write() + /// .await + /// .register_control_point(5) + /// .await?; + /// + /// // Le Control Point est maintenant actif et ses routes HTTP/SSE sont enregistrées + /// // On peut l'utiliser directement si besoin + /// let renderers = control_point.list_music_renderers(); + /// ``` + async fn register_control_point( + &mut self, + timeout_secs: u64, + ) -> std::io::Result>; + + /// Initialise l'API Control Point (bas niveau) + /// + /// Cette méthode est appelée automatiquement par `register_control_point()`. + /// Utilisez `register_control_point()` pour la plupart des cas d'usage. + /// + /// # Routes créées + /// + /// - API REST: `/api/control/*` + /// - SSE Events: `/api/control/events/*` + /// - Swagger: `/swagger-ui/control` + /// + /// # Arguments + /// + /// * `control_point` - Instance du ControlPoint déjà créée + async fn init_control_point(&mut self, control_point: Arc); +} + +#[cfg(feature = "pmoserver")] +#[async_trait] +impl ControlPointExt for pmoserver::Server { + async fn register_control_point( + &mut self, + timeout_secs: u64, + ) -> std::io::Result> { + use tracing::info; + + info!("🎛️ Initializing Control Point..."); + + // 1. Lancer le runtime du ControlPoint + let control_point = ControlPoint::spawn(timeout_secs)?; + let control_point = Arc::new(control_point); + + info!("✅ Control Point runtime started"); + info!(" - SSDP discovery active"); + info!(" - Renderer polling active (1s interval)"); + info!(" - MediaServer event subscriptions active"); + + // 2. Enregistrer les routes HTTP REST et SSE + self.init_control_point(control_point.clone()).await; + + info!("✅ Control Point API registered:"); + info!(" - REST API: /api/control/*"); + info!(" - SSE Events: /api/control/events/*"); + info!(" - OpenAPI docs: /swagger-ui/control"); + + Ok(control_point) + } + + async fn init_control_point(&mut self, control_point: Arc) { + let state = ControlPointState::new(control_point.clone()); + + // Créer le router API (inclut REST et SSE) + let api_router = create_api_router(state, control_point); + + // L'enregistrer avec OpenAPI + self.add_openapi(api_router, crate::openapi::ApiDoc::openapi(), "control") + .await; + } +} diff --git a/pmocontrol/src/provider.rs b/pmocontrol/src/provider.rs new file mode 100644 index 00000000..98f8f589 --- /dev/null +++ b/pmocontrol/src/provider.rs @@ -0,0 +1,663 @@ +use std::io::BufReader; +use std::time::{Duration, SystemTime}; + +use quick_xml::{Error as XmlError, Reader, events::Event}; +use thiserror::Error; +use tracing::{debug, warn}; + +use crate::arylic_tcp::detect_arylic_tcp; +use crate::avtransport_client::AvTransportClient; +use crate::discovery::{DeviceDescriptionProvider, DiscoveredEndpoint}; +use crate::linkplay::detect_linkplay_http; +use crate::media_server::{MediaServerInfo, ServerId}; +use crate::model::{RendererCapabilities, RendererId, RendererInfo, RendererProtocol}; + +use ureq::Agent; + +#[derive(Debug, Error)] +pub enum DescriptionError { + #[error("HTTP request failed: {0}")] + Http(#[from] ureq::Error), + + #[error("Failed to read HTTP body: {0}")] + HttpIo(#[from] std::io::Error), + + #[error("XML parsing error: {0}")] + Xml(#[from] quick_xml::Error), + + #[error("Missing required device element: {0}")] + MissingField(&'static str), +} + +/// Parsed device description, plus (optionally) AVTransport endpoint. +#[derive(Debug, Default)] +struct ParsedDeviceDescription { + udn: Option, + device_type: Option, + friendly_name: Option, + manufacturer: Option, + model_name: Option, + service_types: Vec, + + // New: AVTransport endpoint (if present in serviceList) + avtransport_service_type: Option, + avtransport_control_url: Option, + + // RenderingControl endpoint (if present in serviceList) + rendering_control_service_type: Option, + rendering_control_control_url: Option, + + // ConnectionManager endpoint (if present in serviceList) + connection_manager_service_type: Option, + connection_manager_control_url: Option, + + // ContentDirectory endpoint (if present in serviceList) + content_directory_service_type: Option, + content_directory_control_url: Option, + + // OpenHome endpoints (if present in serviceList) + oh_playlist_service_type: Option, + oh_playlist_control_url: Option, + oh_playlist_event_sub_url: Option, + oh_info_service_type: Option, + oh_info_control_url: Option, + oh_info_event_sub_url: Option, + oh_time_service_type: Option, + oh_time_control_url: Option, + oh_time_event_sub_url: Option, + oh_volume_service_type: Option, + oh_volume_control_url: Option, + oh_radio_service_type: Option, + oh_radio_control_url: Option, +} + +impl ParsedDeviceDescription { + fn require_fields(self) -> Result { + if self.device_type.is_none() { + return Err(DescriptionError::MissingField("deviceType")); + } + if self.friendly_name.is_none() { + return Err(DescriptionError::MissingField("friendlyName")); + } + if self.model_name.is_none() { + return Err(DescriptionError::MissingField("modelName")); + } + Ok(self) + } +} + +/// HTTP-based XML description provider (UPnP device description.xml) +pub struct HttpXmlDescriptionProvider { + timeout_secs: u64, +} + +impl HttpXmlDescriptionProvider { + pub fn new(timeout_secs: u64) -> Self { + Self { timeout_secs } + } + + /// Fetch and parse the device description.xml at endpoint.location. + fn fetch_and_parse( + &self, + endpoint: &DiscoveredEndpoint, + ) -> Result { + debug!( + "Fetching description for {} at {}", + endpoint.udn, endpoint.location + ); + + let config = Agent::config_builder() + .timeout_global(Some(std::time::Duration::from_secs(self.timeout_secs))) + .build(); + + let agent: Agent = config.into(); + + let response = agent.get(&endpoint.location).call()?; + + // response: http::Response + let (_parts, body) = response.into_parts(); + + // body.into_reader() -> impl Read + 'static + let body_reader = body.into_reader(); + + let mut reader = Reader::from_reader(BufReader::new(body_reader)); + reader.config_mut().trim_text(true); + debug!( + "Parsing description XML for {} at {}", + endpoint.udn, endpoint.location + ); + + let mut buf = Vec::new(); + let mut parsed = ParsedDeviceDescription::default(); + + let mut in_device = false; + let mut in_service = false; + let mut current_tag: Option = None; + + // New: track current serviceType + controlURL while inside ... + let mut current_service_type: Option = None; + let mut current_control_url: Option = None; + let mut current_event_sub_url: Option = None; + + loop { + match reader.read_event_into(&mut buf)? { + Event::Start(e) => { + let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); + match name.as_str() { + "device" => { + in_device = true; + current_tag = None; + } + "service" => { + if in_device { + in_service = true; + current_tag = None; + current_service_type = None; + current_control_url = None; + } + } + _ => { + if in_device { + current_tag = Some(name); + } + } + } + } + Event::End(e) => { + let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); + match name.as_str() { + "device" => { + in_device = false; + } + "service" => { + if in_device && in_service { + // We just finished a block: if this is AVTransport, + // store its endpoint in parsed.* + if let (Some(st), Some(ctrl)) = + (¤t_service_type, ¤t_control_url) + { + let lower = st.to_ascii_lowercase(); + if lower.contains("urn:schemas-upnp-org:service:avtransport:") { + // Only set once; if multiple AVTransport services exist, + // we keep the first one. + if parsed.avtransport_service_type.is_none() { + parsed.avtransport_service_type = Some(st.clone()); + parsed.avtransport_control_url = Some(ctrl.clone()); + debug!( + "Found AVTransport service for {}: type={} controlURL={}", + endpoint.udn, st, ctrl + ); + } + } + + if lower + .contains("urn:schemas-upnp-org:service:renderingcontrol:") + { + if parsed.rendering_control_service_type.is_none() { + parsed.rendering_control_service_type = + Some(st.clone()); + parsed.rendering_control_control_url = + Some(ctrl.clone()); + debug!( + "Found RenderingControl service for {}: type={} controlURL={}", + endpoint.udn, st, ctrl + ); + } + } + + if lower + .contains("urn:schemas-upnp-org:service:connectionmanager:") + { + if parsed.connection_manager_service_type.is_none() { + parsed.connection_manager_service_type = + Some(st.clone()); + parsed.connection_manager_control_url = + Some(ctrl.clone()); + debug!( + "Found ConnectionManager service for {}: type={} controlURL={}", + endpoint.udn, st, ctrl + ); + } + } + + if lower + .contains("urn:schemas-upnp-org:service:contentdirectory:") + { + if parsed.content_directory_service_type.is_none() { + parsed.content_directory_service_type = + Some(st.clone()); + parsed.content_directory_control_url = + Some(ctrl.clone()); + debug!( + "Found ContentDirectory service for {}: type={} controlURL={}", + endpoint.udn, st, ctrl + ); + } + } + + if lower.contains("urn:av-openhome-org:service:playlist:") { + if parsed.oh_playlist_service_type.is_none() { + parsed.oh_playlist_service_type = Some(st.clone()); + parsed.oh_playlist_control_url = Some(ctrl.clone()); + if parsed.oh_playlist_event_sub_url.is_none() { + parsed.oh_playlist_event_sub_url = + current_event_sub_url.clone(); + } + debug!( + "Found OpenHome Playlist for {}: type={} controlURL={}", + endpoint.udn, st, ctrl + ); + } + } + + if lower.contains("urn:av-openhome-org:service:info:") { + if parsed.oh_info_service_type.is_none() { + parsed.oh_info_service_type = Some(st.clone()); + parsed.oh_info_control_url = Some(ctrl.clone()); + if parsed.oh_info_event_sub_url.is_none() { + parsed.oh_info_event_sub_url = + current_event_sub_url.clone(); + } + debug!( + "Found OpenHome Info for {}: type={} controlURL={}", + endpoint.udn, st, ctrl + ); + } + } + + if lower.contains("urn:av-openhome-org:service:time:") { + if parsed.oh_time_service_type.is_none() { + parsed.oh_time_service_type = Some(st.clone()); + parsed.oh_time_control_url = Some(ctrl.clone()); + if parsed.oh_time_event_sub_url.is_none() { + parsed.oh_time_event_sub_url = + current_event_sub_url.clone(); + } + debug!( + "Found OpenHome Time for {}: type={} controlURL={}", + endpoint.udn, st, ctrl + ); + } + } + + if lower.contains("urn:av-openhome-org:service:volume:") { + if parsed.oh_volume_service_type.is_none() { + parsed.oh_volume_service_type = Some(st.clone()); + parsed.oh_volume_control_url = Some(ctrl.clone()); + debug!( + "Found OpenHome Volume for {}: type={} controlURL={}", + endpoint.udn, st, ctrl + ); + } + } + + if lower.contains("urn:av-openhome-org:service:radio:") { + if parsed.oh_radio_service_type.is_none() { + parsed.oh_radio_service_type = Some(st.clone()); + parsed.oh_radio_control_url = Some(ctrl.clone()); + debug!( + "Found OpenHome Radio for {}: type={} controlURL={}", + endpoint.udn, st, ctrl + ); + } + } + } + + in_service = false; + current_service_type = None; + current_control_url = None; + current_event_sub_url = None; + } + } + _ => {} + } + current_tag = None; + } + Event::Text(e) => { + if in_device { + if let Some(tag) = ¤t_tag { + // quick-xml ≥ 0.37 : unescape() → decode() + let text = e.decode().map_err(XmlError::Encoding)?.into_owned(); + + match tag.as_str() { + "UDN" => { + parsed.udn = Some(text); + } + "deviceType" => { + parsed.device_type = Some(text); + } + "friendlyName" => { + parsed.friendly_name = Some(text); + } + "manufacturer" => { + parsed.manufacturer = Some(text); + } + "modelName" => { + parsed.model_name = Some(text); + } + "serviceType" if in_service => { + parsed.service_types.push(text.clone()); + current_service_type = Some(text); + } + "controlURL" if in_service => { + current_control_url = Some(text); + } + "eventSubURL" if in_service => { + current_event_sub_url = Some(text); + } + _ => {} + } + } + } + } + Event::Eof => break, + _ => {} + } + + buf.clear(); + } + + parsed.require_fields() + } + + fn build_renderer( + &self, + endpoint: &DiscoveredEndpoint, + parsed: &ParsedDeviceDescription, + ) -> Option { + let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase(); + if !device_type.contains("urn:schemas-upnp-org:device:mediarenderer:") + && !device_type.contains("urn:av-openhome-org:device:mediarenderer:") + && !device_type.contains("urn:av-openhome-org:device:source:") + { + debug!( + "build_renderer: ignoring deviceType for {}: {}", + endpoint.udn, device_type + ); + return None; + } + + let raw_udn = parsed + .udn + .as_deref() + .unwrap_or_else(|| endpoint.udn.as_str()); + let udn = raw_udn.to_ascii_lowercase(); + let mut caps = detect_renderer_capabilities(&parsed.service_types); + if detect_linkplay_http( + &endpoint.location, + Duration::from_secs(self.timeout_secs.max(1)), + ) { + caps.has_linkplay_http = true; + } + if detect_arylic_tcp( + &endpoint.location, + Duration::from_secs(self.timeout_secs.max(1)), + ) { + caps.has_arylic_tcp = true; + } + let protocol = detect_renderer_protocol(&caps); + let now = SystemTime::now(); + + Some(RendererInfo { + id: RendererId(udn.clone()), + udn, + friendly_name: parsed.friendly_name.clone().unwrap_or_default(), + model_name: parsed.model_name.clone().unwrap_or_default(), + manufacturer: parsed.manufacturer.clone().unwrap_or_default(), + protocol, + capabilities: caps, + location: endpoint.location.clone(), + server_header: endpoint.server_header.clone(), + online: true, + last_seen: now, + max_age: endpoint.max_age, + avtransport_service_type: parsed.avtransport_service_type.clone(), + avtransport_control_url: parsed + .avtransport_control_url + .as_ref() + .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), + rendering_control_service_type: parsed.rendering_control_service_type.clone(), + rendering_control_control_url: parsed + .rendering_control_control_url + .as_ref() + .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), + connection_manager_service_type: parsed.connection_manager_service_type.clone(), + connection_manager_control_url: parsed + .connection_manager_control_url + .as_ref() + .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), + oh_playlist_service_type: parsed.oh_playlist_service_type.clone(), + oh_playlist_control_url: parsed + .oh_playlist_control_url + .as_ref() + .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), + oh_playlist_event_sub_url: parsed + .oh_playlist_event_sub_url + .as_ref() + .map(|url| resolve_control_url(&endpoint.location, url)), + oh_info_service_type: parsed.oh_info_service_type.clone(), + oh_info_control_url: parsed + .oh_info_control_url + .as_ref() + .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), + oh_info_event_sub_url: parsed + .oh_info_event_sub_url + .as_ref() + .map(|url| resolve_control_url(&endpoint.location, url)), + oh_time_service_type: parsed.oh_time_service_type.clone(), + oh_time_control_url: parsed + .oh_time_control_url + .as_ref() + .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), + oh_time_event_sub_url: parsed + .oh_time_event_sub_url + .as_ref() + .map(|url| resolve_control_url(&endpoint.location, url)), + oh_volume_service_type: parsed.oh_volume_service_type.clone(), + oh_volume_control_url: parsed + .oh_volume_control_url + .as_ref() + .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), + oh_radio_service_type: parsed.oh_radio_service_type.clone(), + oh_radio_control_url: parsed + .oh_radio_control_url + .as_ref() + .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), + }) + } + + fn build_server( + &self, + endpoint: &DiscoveredEndpoint, + parsed: &ParsedDeviceDescription, + ) -> Option { + let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase(); + if !device_type.contains("urn:schemas-upnp-org:device:mediaserver:") { + return None; + } + + let raw_udn = parsed + .udn + .as_deref() + .unwrap_or_else(|| endpoint.udn.as_str()); + let udn = raw_udn.to_ascii_lowercase(); + let has_content_directory = parsed.service_types.iter().any(|st| { + st.to_ascii_lowercase() + .contains("urn:schemas-upnp-org:service:contentdirectory:") + }); + let now = SystemTime::now(); + + let content_directory_control_url = parsed + .content_directory_control_url + .as_ref() + .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)); + + Some(MediaServerInfo { + id: ServerId(udn.clone()), + udn, + friendly_name: parsed.friendly_name.clone().unwrap_or_default(), + model_name: parsed.model_name.clone().unwrap_or_default(), + manufacturer: parsed.manufacturer.clone().unwrap_or_default(), + location: endpoint.location.clone(), + server_header: endpoint.server_header.clone(), + online: true, + last_seen: now, + max_age: endpoint.max_age, + has_content_directory, + content_directory_service_type: parsed.content_directory_service_type.clone(), + content_directory_control_url, + }) + } + + /// New helper: build an AvTransportClient directly from a discovered endpoint. + /// + /// Returns Ok(Some(client)) if an AVTransport service with a controlURL is present, + /// Ok(None) if no AVTransport service was found. + pub fn build_avtransport_client( + &self, + endpoint: &DiscoveredEndpoint, + ) -> Result, DescriptionError> { + let parsed = self.fetch_and_parse(endpoint)?; + + let service_type = match &parsed.avtransport_service_type { + Some(st) => st.clone(), + None => return Ok(None), + }; + + let raw_control = match &parsed.avtransport_control_url { + Some(ctrl) => ctrl.clone(), + None => return Ok(None), + }; + + let control_url = resolve_control_url(&endpoint.location, &raw_control); + debug!( + "AVTransport client for {}: service_type={} control_url={}", + endpoint.udn, service_type, control_url + ); + + Ok(Some(AvTransportClient::new(control_url, service_type))) + } +} + +// --- capabilities detection unchanged --- + +fn detect_renderer_capabilities(service_types: &[String]) -> RendererCapabilities { + let mut caps = RendererCapabilities::default(); + + for st in service_types { + let lower = st.to_ascii_lowercase(); + + if lower.contains("urn:schemas-upnp-org:service:avtransport:") { + caps.has_avtransport = true; + } + if lower.contains("urn:schemas-upnp-org:service:renderingcontrol:") { + caps.has_rendering_control = true; + } + if lower.contains("urn:schemas-upnp-org:service:connectionmanager:") { + caps.has_connection_manager = true; + } + if lower.contains("urn:av-openhome-org:service:playlist:") { + caps.has_oh_playlist = true; + } + if lower.contains("urn:av-openhome-org:service:volume:") { + caps.has_oh_volume = true; + } + if lower.contains("urn:av-openhome-org:service:info:") { + caps.has_oh_info = true; + } + if lower.contains("urn:av-openhome-org:service:time:") { + caps.has_oh_time = true; + } + if lower.contains("urn:av-openhome-org:service:radio:") { + caps.has_oh_radio = true; + } + } + + caps +} + +fn detect_renderer_protocol(caps: &RendererCapabilities) -> RendererProtocol { + let has_upnp_av = + caps.has_avtransport || caps.has_rendering_control || caps.has_connection_manager; + let has_openhome = caps.has_oh_playlist + || caps.has_oh_volume + || caps.has_oh_info + || caps.has_oh_time + || caps.has_oh_radio; + + match (has_upnp_av, has_openhome) { + (true, true) => RendererProtocol::Hybrid, + (true, false) => RendererProtocol::UpnpAvOnly, + (false, true) => RendererProtocol::OpenHomeOnly, + (false, false) => RendererProtocol::UpnpAvOnly, + } +} + +/// Resolve a possibly relative controlURL against the description URL. +/// +/// - 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`. +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(); + } + + // Extract "scheme://host[:port]" from description_url + if let Some((scheme, rest)) = description_url.split_once("://") { + if let Some(pos) = rest.find('/') { + let authority = &rest[..pos]; + let base = format!("{}://{}", scheme, authority); + + if control_url.starts_with('/') { + return format!("{}{}", base, control_url); + } else { + return format!("{}/{}", base, control_url); + } + } + } + + // Fallback: just return the raw control_url if we cannot parse + control_url.to_string() +} + +impl DeviceDescriptionProvider for HttpXmlDescriptionProvider { + fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option { + match self.fetch_and_parse(endpoint) { + Ok(parsed) => { + let device_type = parsed.device_type.as_deref().unwrap_or("unknown"); + debug!( + "Renderer description OK for {} at {} (deviceType={})", + endpoint.udn, endpoint.location, device_type + ); + self.build_renderer(endpoint, &parsed) + } + Err(err) => { + warn!( + "Failed to fetch/parse renderer description for {} at {}: {}", + endpoint.udn, endpoint.location, err + ); + None + } + } + } + + fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option { + match self.fetch_and_parse(endpoint) { + Ok(parsed) => { + let device_type = parsed.device_type.as_deref().unwrap_or("unknown"); + debug!( + "Server description OK for {} at {} (deviceType={})", + endpoint.udn, endpoint.location, device_type + ); + self.build_server(endpoint, &parsed) + } + Err(err) => { + warn!( + "Failed to fetch/parse server description for {} at {}: {}", + endpoint.udn, endpoint.location, err + ); + None + } + } + } +} diff --git a/pmocontrol/src/registry.rs b/pmocontrol/src/registry.rs new file mode 100644 index 00000000..c8e54eca --- /dev/null +++ b/pmocontrol/src/registry.rs @@ -0,0 +1,206 @@ +use std::collections::HashMap; +use std::time::SystemTime; + +use crate::avtransport_client::AvTransportClient; +use crate::connection_manager_client::ConnectionManagerClient; +use crate::media_server::{MediaServerInfo, ServerId}; +use crate::model::{RendererId, RendererInfo}; +use crate::rendering_control_client::RenderingControlClient; +use tracing::debug; + +#[derive(Clone, Debug)] +enum DeviceKey { + Renderer(RendererId), + Server(ServerId), +} + +#[derive(Debug, Default)] +pub struct DeviceRegistry { + renderers: HashMap, + servers: HashMap, + udn_index: HashMap, +} + +/// Read-only view / trait for registry access. +/// +/// Pour l’instant, on ne rajoute pas AVTransport ici, on se contente +/// d’ajouter les helpers dans `impl DeviceRegistry`. +pub trait DeviceRegistryRead { + fn list_renderers(&self) -> Vec; + fn list_servers(&self) -> Vec; + + fn get_renderer(&self, id: &RendererId) -> Option; + fn get_server(&self, id: &ServerId) -> Option; +} + +impl DeviceRegistryRead for DeviceRegistry { + fn list_renderers(&self) -> Vec { + self.renderers.values().cloned().collect() + } + + fn list_servers(&self) -> Vec { + self.servers.values().cloned().collect() + } + + fn get_renderer(&self, id: &RendererId) -> Option { + self.renderers.get(id).cloned() + } + + fn get_server(&self, id: &ServerId) -> Option { + self.servers.get(id).cloned() + } +} + +#[derive(Debug)] +pub enum DeviceUpdate { + RendererOnline(RendererInfo), + RendererOfflineById(RendererId), + RendererOfflineByUdn(String), + + ServerOnline(MediaServerInfo), + ServerOfflineById(ServerId), + ServerOfflineByUdn(String), +} + +impl DeviceRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn apply_update(&mut self, update: DeviceUpdate) { + match update { + DeviceUpdate::RendererOnline(info) => { + let udn = info.udn.to_ascii_lowercase(); + let id = info.id.clone(); + let mut info = info; + + info.online = true; + info.last_seen = SystemTime::now(); + + self.renderers.insert(id.clone(), info); + self.udn_index.insert(udn, DeviceKey::Renderer(id)); + } + DeviceUpdate::RendererOfflineById(id) => { + if let Some(info) = self.renderers.get_mut(&id) { + info.online = false; + info.last_seen = SystemTime::now(); + } + } + DeviceUpdate::RendererOfflineByUdn(udn) => { + let lookup = udn.to_ascii_lowercase(); + if let Some(DeviceKey::Renderer(id)) = self.udn_index.get(&lookup) { + if let Some(info) = self.renderers.get_mut(id) { + info.online = false; + info.last_seen = SystemTime::now(); + } + } + } + DeviceUpdate::ServerOnline(info) => { + let udn = info.udn.to_ascii_lowercase(); + let id = info.id.clone(); + let mut info = info; + + info.online = true; + info.last_seen = SystemTime::now(); + + self.servers.insert(id.clone(), info); + self.udn_index.insert(udn, DeviceKey::Server(id)); + } + DeviceUpdate::ServerOfflineById(id) => { + if let Some(info) = self.servers.get_mut(&id) { + info.online = false; + info.last_seen = SystemTime::now(); + } + } + DeviceUpdate::ServerOfflineByUdn(udn) => { + let lookup = udn.to_ascii_lowercase(); + if let Some(DeviceKey::Server(id)) = self.udn_index.get(&lookup) { + if let Some(info) = self.servers.get_mut(id) { + info.online = false; + info.last_seen = SystemTime::now(); + } + } + } + } + } + + /// Helper: get a renderer by UDN (case-insensitive, via udn_index). + pub fn get_renderer_by_udn(&self, udn: &str) -> Option { + let lookup = udn.to_ascii_lowercase(); + match self.udn_index.get(&lookup) { + Some(DeviceKey::Renderer(id)) => self.renderers.get(id).cloned(), + _ => None, + } + } + + /// Construct an AvTransportClient for a given renderer id, if possible. + /// + /// Returns: + /// - Some(client) if the renderer exists AND has avtransport_* fields set + /// - None if renderer not found or no AVTransport service. + pub fn avtransport_client_for_renderer(&self, id: &RendererId) -> Option { + let info = self.renderers.get(id)?; + + let service_type = info.avtransport_service_type.as_ref()?; + let control_url = info.avtransport_control_url.as_ref()?; + + Some(AvTransportClient::new( + control_url.clone(), + service_type.clone(), + )) + } + + /// Construct an AvTransportClient for a given UDN, if possible. + pub fn avtransport_client_for_udn(&self, udn: &str) -> Option { + let info = self.get_renderer_by_udn(udn)?; + + let service_type = info.avtransport_service_type?; + let control_url = info.avtransport_control_url?; + + Some(AvTransportClient::new(control_url, service_type)) + } + + /// Construct a RenderingControlClient for a given renderer id, if possible. + pub fn rendering_control_client_for_renderer( + &self, + id: &RendererId, + ) -> Option { + let info = self.renderers.get(id)?; + + let service_type = info.rendering_control_service_type.as_ref()?; + let control_url = info.rendering_control_control_url.as_ref()?; + + Some(RenderingControlClient::new( + control_url.clone(), + service_type.clone(), + )) + } + + /// Construct a ConnectionManagerClient for a given renderer id, if possible. + pub fn connection_manager_client_for_renderer( + &self, + id: &RendererId, + ) -> Option { + let info = self.renderers.get(id)?; + + let service_type = info.connection_manager_service_type.as_ref()?; + let control_url = info.connection_manager_control_url.as_ref()?; + + Some(ConnectionManagerClient::new( + control_url.clone(), + service_type.clone(), + )) + } + + pub fn mark_renderer_supports_set_next(&mut self, id: &RendererId) { + if let Some(info) = self.renderers.get_mut(id) { + if !info.capabilities.has_avtransport_set_next { + info.capabilities.has_avtransport_set_next = true; + debug!( + renderer = id.0.as_str(), + "Renderer now marked as supporting AVTransport.SetNextAVTransportURI" + ); + } + } + } +} diff --git a/pmocontrol/src/rendering_control_client.rs b/pmocontrol/src/rendering_control_client.rs new file mode 100644 index 00000000..c9358b73 --- /dev/null +++ b/pmocontrol/src/rendering_control_client.rs @@ -0,0 +1,231 @@ +use crate::soap_client::{SoapCallResult, invoke_upnp_action}; +use anyhow::{Result, anyhow}; +use pmoupnp::soap::SoapEnvelope; +use xmltree::{Element, XMLNode}; + +#[derive(Debug, Clone)] +pub struct RenderingControlClient { + pub control_url: String, + pub service_type: String, +} + +impl RenderingControlClient { + pub fn new(control_url: String, service_type: String) -> Self { + Self { + control_url, + service_type, + } + } + + /// RenderingControl:1 — GetVolume + pub fn get_volume(&self, instance_id: u32, channel: &str) -> Result { + let instance_id_str = instance_id.to_string(); + let args = [ + ("InstanceID", instance_id_str.as_str()), + ("Channel", channel), + ]; + + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "GetVolume", &args)?; + + ensure_success("GetVolume", &call_result)?; + + let envelope = call_result + .envelope + .as_ref() + .ok_or_else(|| anyhow!("Missing SOAP envelope in GetVolume response"))?; + + if let Some(err) = parse_upnp_error(envelope) { + return Err(anyhow!( + "GetVolume returned UPnP error {}: {} (HTTP status {})", + err.error_code, + err.error_description, + call_result.status + )); + } + + let response = find_child_with_suffix(&envelope.body.content, "GetVolumeResponse") + .ok_or_else(|| anyhow!("Missing GetVolumeResponse element in SOAP body"))?; + + let text = extract_child_text(response, "CurrentVolume")?; + let volume = text + .parse::() + .map_err(|_| anyhow!("Invalid CurrentVolume value: {}", text))?; + + Ok(volume) + } + + /// RenderingControl:1 — SetVolume + pub fn set_volume(&self, instance_id: u32, channel: &str, volume: u16) -> Result<()> { + let instance_id_str = instance_id.to_string(); + let volume_str = volume.to_string(); + let args = [ + ("InstanceID", instance_id_str.as_str()), + ("Channel", channel), + ("DesiredVolume", volume_str.as_str()), + ]; + + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "SetVolume", &args)?; + + handle_action_response("SetVolume", &call_result) + } + + /// RenderingControl:1 — GetMute + pub fn get_mute(&self, instance_id: u32, channel: &str) -> Result { + let instance_id_str = instance_id.to_string(); + let args = [ + ("InstanceID", instance_id_str.as_str()), + ("Channel", channel), + ]; + + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "GetMute", &args)?; + + ensure_success("GetMute", &call_result)?; + + let envelope = call_result + .envelope + .as_ref() + .ok_or_else(|| anyhow!("Missing SOAP envelope in GetMute response"))?; + + if let Some(err) = parse_upnp_error(envelope) { + return Err(anyhow!( + "GetMute returned UPnP error {}: {} (HTTP status {})", + err.error_code, + err.error_description, + call_result.status + )); + } + + let response = find_child_with_suffix(&envelope.body.content, "GetMuteResponse") + .ok_or_else(|| anyhow!("Missing GetMuteResponse element in SOAP body"))?; + + let text = extract_child_text(response, "CurrentMute")?; + let mute = match text.as_str() { + "0" => false, + "1" => true, + _ => { + return Err(anyhow!( + "Invalid CurrentMute value: {} (expected 0 or 1)", + text + )); + } + }; + + Ok(mute) + } + + /// RenderingControl:1 — SetMute + pub fn set_mute(&self, instance_id: u32, channel: &str, mute: bool) -> Result<()> { + let instance_id_str = instance_id.to_string(); + let mute_str = if mute { "1" } else { "0" }; + let args = [ + ("InstanceID", instance_id_str.as_str()), + ("Channel", channel), + ("DesiredMute", mute_str), + ]; + + let call_result = + invoke_upnp_action(&self.control_url, &self.service_type, "SetMute", &args)?; + + handle_action_response("SetMute", &call_result) + } +} + +fn ensure_success(action: &str, call_result: &SoapCallResult) -> Result<()> { + if call_result.status.is_success() { + return Ok(()); + } + + if let Some(env) = &call_result.envelope { + if let Some(err) = parse_upnp_error(env) { + return Err(anyhow!( + "{action} failed with UPnP error {}: {} (HTTP status {})", + err.error_code, + err.error_description, + call_result.status + )); + } + } + + Err(anyhow!( + "{action} failed with HTTP status {} and body: {}", + call_result.status, + call_result.raw_body + )) +} + +fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> { + ensure_success(action, call_result)?; + + if let Some(env) = &call_result.envelope { + if let Some(err) = parse_upnp_error(env) { + return Err(anyhow!( + "{action} returned UPnP error {}: {} (HTTP status {})", + err.error_code, + err.error_description, + call_result.status + )); + } + } + + Ok(()) +} + +#[derive(Debug, Clone)] +struct UpnpError { + pub error_code: u32, + pub error_description: String, +} + +fn parse_upnp_error(envelope: &SoapEnvelope) -> Option { + let fault = find_child_with_suffix(&envelope.body.content, "Fault")?; + let detail = find_child_with_suffix(fault, "detail")?; + let upnp_error = find_child_with_suffix(detail, "UPnPError")?; + + let error_code_elem = upnp_error.children.iter().find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem), + _ => None, + })?; + + let binding = error_code_elem.get_text()?; + let error_code_text = binding.trim(); + let error_code = error_code_text.parse::().ok()?; + + let error_description = upnp_error + .children + .iter() + .find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => { + elem.get_text().map(|t| t.trim().to_string()) + } + _ => None, + }) + .unwrap_or_else(|| String::from("")); + + Some(UpnpError { + error_code, + error_description, + }) +} + +fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> { + parent.children.iter().find_map(|node| match node { + XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem), + _ => None, + }) +} + +fn extract_child_text(parent: &Element, suffix: &str) -> Result { + let child = find_child_with_suffix(parent, suffix) + .ok_or_else(|| anyhow!("Missing {suffix} element in response"))?; + + let text = child + .get_text() + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .ok_or_else(|| anyhow!("{suffix} element missing text in response"))?; + + Ok(text) +} diff --git a/pmocontrol/src/soap_client.rs b/pmocontrol/src/soap_client.rs new file mode 100644 index 00000000..77c0a5a3 --- /dev/null +++ b/pmocontrol/src/soap_client.rs @@ -0,0 +1,84 @@ +use std::time::Duration; + +use anyhow::{Context, Result}; +use pmoupnp::soap::{SoapEnvelope, build_soap_request, parse_soap_envelope}; +use ureq::Agent; + +/// Result of a SOAP call: +/// - HTTP status code +/// - raw XML body (always) +/// - parsed SOAP envelope if parsing succeeded +pub struct SoapCallResult { + pub status: ureq::http::StatusCode, + pub raw_body: String, + pub envelope: Option, +} + +/// Invoke a UPnP SOAP action on a control URL. +/// +/// - `control_url`: full HTTP URL of the service control endpoint +/// - `service_type`: service URN +/// - `action`: action name +/// - `args`: list of (name, value) +pub fn invoke_upnp_action( + control_url: &str, + service_type: &str, + action: &str, + args: &[(&str, &str)], +) -> Result { + invoke_upnp_action_with_timeout(control_url, service_type, action, args, None) +} + +pub fn invoke_upnp_action_with_timeout( + control_url: &str, + service_type: &str, + action: &str, + args: &[(&str, &str)], + timeout: Option, +) -> Result { + let body_xml = build_soap_request(service_type, action, args) + .context("Failed to build SOAP request body")?; + + let mut builder = Agent::config_builder(); + builder = builder.http_status_as_error(false); + if let Some(duration) = timeout { + builder = builder.timeout_global(Some(duration)); + } + + let config = builder.build(); + let agent: Agent = config.into(); + + // 3. SOAPAction header + let soap_action_header = format!(r#""{}#{}""#, service_type, action); + + // 4. HTTP POST + let mut response = agent + .post(control_url) + .header("Content-Type", r#"text/xml; charset="utf-8""#) + .header("SOAPAction", &soap_action_header) + .send(body_xml) + .with_context(|| format!("HTTP error when sending SOAP request to {}", control_url))?; + + let status = response.status(); + + // 5. Read full body + // + // API réelle (ureq 3.1.4): + // body_mut().read_to_string() -> Result + let raw_body = response + .body_mut() + .read_to_string() + .context("Failed to read SOAP response body")?; + + // 6. Try to parse SOAP envelope; non-fatal on failure + let envelope = match parse_soap_envelope(raw_body.as_bytes()) { + Ok(env) => Some(env), + Err(_) => None, + }; + + Ok(SoapCallResult { + status, + raw_body, + envelope, + }) +} diff --git a/pmocontrol/src/sse.rs b/pmocontrol/src/sse.rs new file mode 100644 index 00000000..c6bf21ec --- /dev/null +++ b/pmocontrol/src/sse.rs @@ -0,0 +1,456 @@ +//! SSE endpoints pour les événements du Control Point +//! +//! Ce module fournit des endpoints Server-Sent Events pour permettre aux clients +//! web de recevoir en temps réel : +//! - Les événements des renderers (state, volume, position, queue, etc.) +//! - Les événements des serveurs de médias (global updates, container updates) +//! +//! Routes: +//! - GET /api/control/events/renderers - Événements renderers uniquement +//! - GET /api/control/events/servers - Événements serveurs uniquement +//! - GET /api/control/events - Tous les événements (agrégés) +//! +//! ⚠️ Les payloads SSE servent uniquement de signaux de rafraîchissement : +//! l'UI doit toujours refetch l'instantané complet auprès du ControlPoint, +//! seule source de vérité de l'état renderer. + +#[cfg(feature = "pmoserver")] +use crate::PlaybackState; +#[cfg(feature = "pmoserver")] +use crate::control_point::ControlPoint; +#[cfg(feature = "pmoserver")] +use crate::model::{MediaServerEvent, RendererEvent}; +#[cfg(feature = "pmoserver")] +use async_stream::stream; +#[cfg(feature = "pmoserver")] +use axum::{ + Router, + extract::State, + response::IntoResponse, + response::sse::{Event, KeepAlive, Sse}, +}; +#[cfg(feature = "pmoserver")] +use serde::Serialize; +#[cfg(feature = "pmoserver")] +use std::sync::Arc; + +// ============================================================================ +// PAYLOADS SSE +// ============================================================================ + +/// Payload SSE pour un événement renderer +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RendererEventPayload { + StateChanged { + renderer_id: String, + state: String, + timestamp: chrono::DateTime, + }, + PositionChanged { + renderer_id: String, + track: Option, + rel_time: Option, + track_duration: Option, + timestamp: chrono::DateTime, + }, + VolumeChanged { + renderer_id: String, + volume: u16, + timestamp: chrono::DateTime, + }, + MuteChanged { + renderer_id: String, + mute: bool, + timestamp: chrono::DateTime, + }, + MetadataChanged { + renderer_id: String, + title: Option, + artist: Option, + album: Option, + album_art_uri: Option, + timestamp: chrono::DateTime, + }, + QueueUpdated { + renderer_id: String, + queue_length: usize, + timestamp: chrono::DateTime, + }, + BindingChanged { + renderer_id: String, + server_id: Option, + container_id: Option, + timestamp: chrono::DateTime, + }, +} + +/// Payload SSE pour un événement serveur de médias +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum MediaServerEventPayload { + GlobalUpdated { + server_id: String, + system_update_id: Option, + timestamp: chrono::DateTime, + }, + ContainersUpdated { + server_id: String, + container_ids: Vec, + timestamp: chrono::DateTime, + }, +} + +/// Payload SSE unifié pour tous les événements +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "category", rename_all = "snake_case")] +pub enum UnifiedEventPayload { + Renderer(RendererEventPayload), + MediaServer(MediaServerEventPayload), +} + +// ============================================================================ +// HANDLERS SSE +// ============================================================================ + +/// Handler SSE pour les événements renderers +/// +/// Route: GET /api/control/events/renderers +/// +/// Diffuse tous les événements liés aux renderers (state, volume, position, queue, etc.) +/// en temps réel via Server-Sent Events. +#[cfg(feature = "pmoserver")] +#[utoipa::path( + get, + path = "/events/renderers", + responses( + (status = 200, description = "Flux SSE des événements renderers", content_type = "text/event-stream") + ), + tag = "control" +)] +pub async fn renderer_events_sse( + State(control_point): State>, +) -> impl IntoResponse { + // Convert crossbeam channel to tokio channel for async compatibility + let (tx, mut rx_tokio) = tokio::sync::mpsc::unbounded_channel(); + let rx = control_point.subscribe_events(); + + // Spawn blocking task to bridge crossbeam -> tokio + tokio::task::spawn_blocking(move || { + while let Ok(event) = rx.recv() { + if tx.send(event).is_err() { + break; + } + } + }); + + let stream = stream! { + while let Some(event) = rx_tokio.recv().await { + let timestamp = chrono::Utc::now(); + + let payload = match event { + RendererEvent::StateChanged { id, state } => { + RendererEventPayload::StateChanged { + renderer_id: id.0, + state: state_to_string(state), + timestamp, + } + } + RendererEvent::PositionChanged { id, position } => { + RendererEventPayload::PositionChanged { + renderer_id: id.0, + track: position.track, + rel_time: position.rel_time, + track_duration: position.track_duration, + timestamp, + } + } + RendererEvent::VolumeChanged { id, volume } => { + RendererEventPayload::VolumeChanged { + renderer_id: id.0, + volume, + timestamp, + } + } + RendererEvent::MuteChanged { id, mute } => { + RendererEventPayload::MuteChanged { + renderer_id: id.0, + mute, + timestamp, + } + } + RendererEvent::MetadataChanged { id, metadata } => { + RendererEventPayload::MetadataChanged { + renderer_id: id.0, + title: metadata.title, + artist: metadata.artist, + album: metadata.album, + album_art_uri: metadata.album_art_uri, + timestamp, + } + } + RendererEvent::QueueUpdated { id, queue_length } => { + RendererEventPayload::QueueUpdated { + renderer_id: id.0, + queue_length, + timestamp, + } + } + RendererEvent::BindingChanged { id, binding } => { + RendererEventPayload::BindingChanged { + renderer_id: id.0, + server_id: binding.as_ref().map(|b| b.server_id.0.clone()), + container_id: binding.as_ref().map(|b| b.container_id.clone()), + timestamp, + } + } + }; + + if let Ok(json) = serde_json::to_string(&payload) { + yield Ok::<_, axum::Error>(Event::default().event("renderer").data(json)); + } + } + }; + + Sse::new(stream).keep_alive(KeepAlive::default()) +} + +/// Handler SSE pour les événements serveurs de médias +/// +/// Route: GET /api/control/events/servers +/// +/// Diffuse tous les événements liés aux serveurs de médias (global updates, container updates) +/// en temps réel via Server-Sent Events. +#[cfg(feature = "pmoserver")] +#[utoipa::path( + get, + path = "/events/servers", + responses( + (status = 200, description = "Flux SSE des événements serveurs de médias", content_type = "text/event-stream") + ), + tag = "control" +)] +pub async fn media_server_events_sse( + State(control_point): State>, +) -> impl IntoResponse { + // Convert crossbeam channel to tokio channel for async compatibility + let (tx, mut rx_tokio) = tokio::sync::mpsc::unbounded_channel(); + let rx = control_point.subscribe_media_server_events(); + + // Spawn blocking task to bridge crossbeam -> tokio + tokio::task::spawn_blocking(move || { + while let Ok(event) = rx.recv() { + if tx.send(event).is_err() { + break; + } + } + }); + + let stream = stream! { + while let Some(event) = rx_tokio.recv().await { + let timestamp = chrono::Utc::now(); + + let payload = match event { + MediaServerEvent::GlobalUpdated { server_id, system_update_id } => { + MediaServerEventPayload::GlobalUpdated { + server_id: server_id.0, + system_update_id, + timestamp, + } + } + MediaServerEvent::ContainersUpdated { server_id, container_ids } => { + MediaServerEventPayload::ContainersUpdated { + server_id: server_id.0, + container_ids, + timestamp, + } + } + }; + + if let Ok(json) = serde_json::to_string(&payload) { + yield Ok::<_, axum::Error>(Event::default().event("media_server").data(json)); + } + } + }; + + Sse::new(stream).keep_alive(KeepAlive::default()) +} + +/// Handler SSE pour tous les événements (renderers + serveurs) +/// +/// Route: GET /api/control/events +/// +/// Diffuse tous les événements du control point (renderers et serveurs) en temps réel. +/// Chaque événement est catégorisé et inclut un timestamp. +#[cfg(feature = "pmoserver")] +#[utoipa::path( + get, + path = "/events", + responses( + (status = 200, description = "Flux SSE de tous les événements du control point", content_type = "text/event-stream") + ), + tag = "control" +)] +pub async fn all_events_sse(State(control_point): State>) -> impl IntoResponse { + // Convert crossbeam channels to tokio channels for async compatibility + let (renderer_tx, mut renderer_rx_tokio) = tokio::sync::mpsc::unbounded_channel(); + let (server_tx, mut server_rx_tokio) = tokio::sync::mpsc::unbounded_channel(); + + let renderer_rx = control_point.subscribe_events(); + let server_rx = control_point.subscribe_media_server_events(); + + // Spawn blocking tasks to bridge crossbeam -> tokio + tokio::task::spawn_blocking(move || { + while let Ok(event) = renderer_rx.recv() { + if renderer_tx.send(event).is_err() { + break; + } + } + }); + + tokio::task::spawn_blocking(move || { + while let Ok(event) = server_rx.recv() { + if server_tx.send(event).is_err() { + break; + } + } + }); + + let stream = stream! { + loop { + tokio::select! { + Some(event) = renderer_rx_tokio.recv() => { + let timestamp = chrono::Utc::now(); + + let renderer_payload = match event { + RendererEvent::StateChanged { id, state } => { + RendererEventPayload::StateChanged { + renderer_id: id.0, + state: state_to_string(state), + timestamp, + } + } + RendererEvent::PositionChanged { id, position } => { + RendererEventPayload::PositionChanged { + renderer_id: id.0, + track: position.track, + rel_time: position.rel_time, + track_duration: position.track_duration, + timestamp, + } + } + RendererEvent::VolumeChanged { id, volume } => { + RendererEventPayload::VolumeChanged { + renderer_id: id.0, + volume, + timestamp, + } + } + RendererEvent::MuteChanged { id, mute } => { + RendererEventPayload::MuteChanged { + renderer_id: id.0, + mute, + timestamp, + } + } + RendererEvent::MetadataChanged { id, metadata } => { + RendererEventPayload::MetadataChanged { + renderer_id: id.0, + title: metadata.title, + artist: metadata.artist, + album: metadata.album, + album_art_uri: metadata.album_art_uri, + timestamp, + } + } + RendererEvent::QueueUpdated { id, queue_length } => { + RendererEventPayload::QueueUpdated { + renderer_id: id.0, + queue_length, + timestamp, + } + } + RendererEvent::BindingChanged { id, binding } => { + RendererEventPayload::BindingChanged { + renderer_id: id.0, + server_id: binding.as_ref().map(|b| b.server_id.0.clone()), + container_id: binding.as_ref().map(|b| b.container_id.clone()), + timestamp, + } + } + }; + + let payload = UnifiedEventPayload::Renderer(renderer_payload); + + if let Ok(json) = serde_json::to_string(&payload) { + yield Ok::<_, axum::Error>(Event::default().event("control").data(json)); + } + } + Some(event) = server_rx_tokio.recv() => { + let timestamp = chrono::Utc::now(); + + let server_payload = match event { + MediaServerEvent::GlobalUpdated { server_id, system_update_id } => { + MediaServerEventPayload::GlobalUpdated { + server_id: server_id.0, + system_update_id, + timestamp, + } + } + MediaServerEvent::ContainersUpdated { server_id, container_ids } => { + MediaServerEventPayload::ContainersUpdated { + server_id: server_id.0, + container_ids, + timestamp, + } + } + }; + + let payload = UnifiedEventPayload::MediaServer(server_payload); + + if let Ok(json) = serde_json::to_string(&payload) { + yield Ok::<_, axum::Error>(Event::default().event("control").data(json)); + } + } + else => break + } + } + }; + + Sse::new(stream).keep_alive(KeepAlive::default()) +} + +// ============================================================================ +// ROUTER +// ============================================================================ + +/// Crée le router SSE pour les événements du Control Point +#[cfg(feature = "pmoserver")] +pub fn create_sse_router(control_point: Arc) -> Router { + use axum::routing::get; + + Router::new() + .route("/events", get(all_events_sse)) + .route("/events/renderers", get(renderer_events_sse)) + .route("/events/servers", get(media_server_events_sse)) + .with_state(control_point) +} + +// ============================================================================ +// HELPERS +// ============================================================================ + +#[cfg(feature = "pmoserver")] +fn state_to_string(state: PlaybackState) -> String { + match state { + PlaybackState::Stopped => "STOPPED".to_string(), + PlaybackState::Playing => "PLAYING".to_string(), + PlaybackState::Paused => "PAUSED".to_string(), + PlaybackState::Transitioning => "TRANSITIONING".to_string(), + PlaybackState::NoMedia => "NO_MEDIA".to_string(), + PlaybackState::Unknown(s) => s, + } +} diff --git a/pmocontrol/src/upnp_renderer.rs b/pmocontrol/src/upnp_renderer.rs new file mode 100644 index 00000000..f50aa702 --- /dev/null +++ b/pmocontrol/src/upnp_renderer.rs @@ -0,0 +1,310 @@ +use std::sync::{Arc, RwLock}; + +use anyhow::{Result, anyhow}; + +use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus}; +use crate::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo}; +use crate::music_renderer::op_not_supported; +use crate::rendering_control_client::RenderingControlClient; +use crate::{ + AvTransportClient, DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, RendererId, + RendererInfo, TransportControl, VolumeControl, +}; + +/// High-level handle representing a renderer and its optional AVTransport client. +#[derive(Clone, Debug)] +pub struct UpnpRenderer { + pub info: RendererInfo, + registry: Arc>, + avtransport: Option, + rendering_control: Option, + connection_manager: Option, +} + +impl UpnpRenderer { + pub fn id(&self) -> &RendererId { + &self.info.id + } + + pub fn friendly_name(&self) -> &str { + &self.info.friendly_name + } + + pub fn has_avtransport(&self) -> bool { + self.avtransport.is_some() + } + + pub fn has_rendering_control(&self) -> bool { + self.rendering_control.is_some() + } + + pub fn has_connection_manager(&self) -> bool { + self.connection_manager.is_some() + } + + /// Returns true if this renderer is known to support SetNextAVTransportURI. + pub fn supports_set_next(&self) -> bool { + self.info.capabilities.supports_set_next() + } + + pub fn avtransport(&self) -> Result<&AvTransportClient> { + self.avtransport + .as_ref() + .ok_or_else(|| anyhow!("Renderer has no AVTransport service")) + } + + pub fn rendering_control(&self) -> Result<&RenderingControlClient> { + self.rendering_control + .as_ref() + .ok_or_else(|| anyhow!("Renderer has no RenderingControl service")) + } + + pub fn connection_manager(&self) -> Result<&ConnectionManagerClient> { + self.connection_manager + .as_ref() + .ok_or_else(|| anyhow!("Renderer has no ConnectionManager service")) + } + + pub fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { + let avt = self.avtransport()?; + avt.set_av_transport_uri(uri, meta)?; + avt.play(0, "1") + } + + /// Best-effort attempt to configure the next URI via AVTransport SetNextAVTransportURI. + pub fn set_next_uri(&self, next_uri: &str, next_meta: &str) -> Result<()> { + if !self.info.capabilities.has_avtransport { + return Err(op_not_supported("SetNextAVTransportURI", "AVTransport")); + } + + let client = self.avtransport()?; + let result = client.set_next_av_transport_uri(next_uri, next_meta); + + if result.is_ok() { + let mut reg = self.registry.write().unwrap(); + reg.mark_renderer_supports_set_next(&self.info.id); + } + + result + } + + pub fn pause(&self) -> Result<()> { + let avt = self.avtransport()?; + avt.pause(0) + } + + pub fn stop(&self) -> Result<()> { + let avt = self.avtransport()?; + avt.stop(0) + } + + pub fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { + let avt = self.avtransport()?; + avt.seek(0, "REL_TIME", hhmmss) + } + + pub fn get_master_volume(&self) -> Result { + let rc = self.rendering_control()?; + rc.get_volume(0, "Master") + } + + pub fn set_master_volume(&self, volume: u16) -> Result<()> { + let rc = self.rendering_control()?; + rc.set_volume(0, "Master", volume) + } + + pub fn get_master_mute(&self) -> Result { + let rc = self.rendering_control()?; + rc.get_mute(0, "Master") + } + + pub fn set_master_mute(&self, mute: bool) -> Result<()> { + let rc = self.rendering_control()?; + rc.set_mute(0, "Master", mute) + } + + pub fn protocol_info(&self) -> Result { + let cm = self.connection_manager()?; + cm.get_protocol_info() + } + + pub fn connection_ids(&self) -> Result> { + let cm = self.connection_manager()?; + cm.get_current_connection_ids() + } + + pub fn connection_info(&self, connection_id: i32) -> Result { + let cm = self.connection_manager()?; + cm.get_current_connection_info(connection_id) + } + + pub fn from_registry(info: RendererInfo, registry: &Arc>) -> Self { + let (avtransport, rendering_control, connection_manager) = { + let reg = registry.read().unwrap(); + ( + reg.avtransport_client_for_renderer(&info.id), + reg.rendering_control_client_for_renderer(&info.id), + reg.connection_manager_client_for_renderer(&info.id), + ) + }; + Self { + info, + registry: Arc::clone(registry), + avtransport, + rendering_control, + connection_manager, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{RendererCapabilities, RendererProtocol}; + use crate::registry::{DeviceRegistry, DeviceUpdate}; + use std::sync::{Arc, RwLock}; + use std::time::SystemTime; + + fn renderer_info(id_suffix: &str, with_avtransport: bool) -> RendererInfo { + RendererInfo { + id: RendererId(format!("renderer-{id_suffix}")), + udn: format!("uuid:renderer-{id_suffix}"), + friendly_name: format!("Renderer {id_suffix}"), + model_name: "Model".into(), + manufacturer: "Manufacturer".into(), + protocol: RendererProtocol::UpnpAvOnly, + capabilities: RendererCapabilities { + has_avtransport: with_avtransport, + ..RendererCapabilities::default() + }, + location: "http://127.0.0.1/device.xml".into(), + server_header: "TestServer/1.0".into(), + online: true, + last_seen: SystemTime::now(), + max_age: 1800, + avtransport_service_type: with_avtransport + .then(|| "urn:schemas-upnp-org:service:AVTransport:1".into()), + avtransport_control_url: with_avtransport + .then(|| "http://127.0.0.1/avtransport".into()), + rendering_control_service_type: None, + rendering_control_control_url: None, + connection_manager_service_type: None, + connection_manager_control_url: None, + oh_playlist_service_type: None, + oh_playlist_control_url: None, + oh_playlist_event_sub_url: None, + oh_info_service_type: None, + oh_info_control_url: None, + oh_info_event_sub_url: None, + oh_time_service_type: None, + oh_time_control_url: None, + oh_time_event_sub_url: None, + oh_volume_service_type: None, + oh_volume_control_url: None, + oh_radio_service_type: None, + oh_radio_control_url: None, + } + } + + fn registry_with_renderer(info: RendererInfo) -> Arc> { + let mut registry = DeviceRegistry::new(); + registry.apply_update(DeviceUpdate::RendererOnline(info)); + Arc::new(RwLock::new(registry)) + } + + #[test] + fn renderer_without_avtransport() { + let info = renderer_info("no-avt", false); + let registry = registry_with_renderer(info.clone()); + let renderer = UpnpRenderer::from_registry(info, ®istry); + + assert_eq!(renderer.has_avtransport(), false); + assert_eq!(renderer.id().0, "renderer-no-avt"); + } + + #[test] + fn renderer_with_avtransport() { + let info = renderer_info("with-avt", true); + let registry = registry_with_renderer(info.clone()); + let renderer = UpnpRenderer::from_registry(info, ®istry); + + assert!(renderer.has_avtransport()); + assert_eq!(renderer.friendly_name(), "Renderer with-avt"); + } +} + +/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`]. +/// +/// Cette impl se base sur AVTransport (InstanceID = 0). +impl TransportControl for UpnpRenderer { + fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { + self.play_uri(uri, meta) + } + + fn play(&self) -> Result<()> { + let avt = self.avtransport()?; + avt.play(0, "1") + } + + fn pause(&self) -> Result<()> { + self.pause() + } + + fn stop(&self) -> Result<()> { + self.stop() + } + + fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { + self.seek_rel_time(hhmmss) + } +} + +/// Implémentation UPnP RenderingControl de `VolumeControl` pour [`UpnpRenderer`]. +/// +/// Cette impl se base sur le channel "Master" (InstanceID = 0). +impl VolumeControl for UpnpRenderer { + fn volume(&self) -> Result { + self.get_master_volume() + } + + fn set_volume(&self, v: u16) -> Result<()> { + self.set_master_volume(v) + } + + fn mute(&self) -> Result { + self.get_master_mute() + } + + fn set_mute(&self, m: bool) -> Result<()> { + self.set_master_mute(m) + } +} + +/// Implémentation UPnP AV de `PlaybackStatus` pour [`UpnpRenderer`]. +/// +/// Utilise AVTransport::GetTransportInfo(InstanceID=0). +impl PlaybackStatus for UpnpRenderer { + fn playback_state(&self) -> Result { + let avt = self.avtransport()?; + let info = avt.get_transport_info(0)?; + Ok(PlaybackState::from_upnp_state( + &info.current_transport_state, + )) + } +} + +impl PlaybackPosition for UpnpRenderer { + fn playback_position(&self) -> Result { + let avt = self.avtransport()?; + let raw: PositionInfo = avt.get_position_info(0)?; + + Ok(PlaybackPositionInfo { + track: Some(raw.track), + rel_time: raw.rel_time, + abs_time: raw.abs_time, + track_duration: raw.track_duration, + track_metadata: raw.track_metadata, + track_uri: raw.track_uri, + }) + } +} diff --git a/pmocontrol/ssdp_capture.txt b/pmocontrol/ssdp_capture.txt new file mode 100644 index 00000000..a3dc98fe --- /dev/null +++ b/pmocontrol/ssdp_capture.txt @@ -0,0 +1,17811 @@ +tcpdump: data link type PKTAP +tcpdump: verbose output suppressed, use -v[v]... for full protocol decode +listening on any, link-type PKTAP (Apple DLT_PKTAP), snapshot length 524288 bytes +17:48:10.547807 IP pizzicato.lan.53438 > 239.255.255.250.ssdp: UDP, length 513 +E...._@...D............l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:10.548740 IP pizzicato.lan.53438 > 239.255.255.250.ssdp: UDP, length 522 +E..&.`@...C............l..i.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:10.549426 IP pizzicato.lan.53438 > 239.255.255.250.ssdp: UDP, length 565 +E..Q.a@...C............l.=8.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:10.550320 IP pizzicato.lan.53438 > 239.255.255.250.ssdp: UDP, length 577 +E..].b@...C............l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:10.551236 IP pizzicato.lan.53438 > 239.255.255.250.ssdp: UDP, length 579 +E.._.c@...C............l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:10.552055 IP pizzicato.lan.53438 > 239.255.255.250.ssdp: UDP, length 513 +E....y@...C............l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:10.552901 IP pizzicato.lan.53438 > 239.255.255.250.ssdp: UDP, length 522 +E..&.z@...C............l..i.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:10.553941 IP pizzicato.lan.53438 > 239.255.255.250.ssdp: UDP, length 565 +E..Q.{@...C............l.=8.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:10.554733 IP pizzicato.lan.53438 > 239.255.255.250.ssdp: UDP, length 577 +E..].|@...C............l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:10.555644 IP pizzicato.lan.53438 > 239.255.255.250.ssdp: UDP, length 579 +E.._.}@...C............l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:10.757244 IP pizzicato.lan.53438 > 239.255.255.250.ssdp: UDP, length 513 +E.....@...C............l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:10.757246 IP pizzicato.lan.53438 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@...C............l.=8.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:10.757247 IP pizzicato.lan.53438 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@...C............l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.162137 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 492 +E.....@...C..........c.l...6NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.163007 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 501 +E.....@...C..........c.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.163729 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...C..........c.l..fxNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.164536 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...C..........c.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.165432 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@...C..........c.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.166244 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@...Cu.........c.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.167114 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@...Cz.........c.l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.168095 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...C..........c.l...BNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.168728 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...C..........c.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.169594 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@...Cy.........c.l. .1NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.264452 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 492 +E.....@...C..........c.l...6NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.265219 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 501 +E.....@...C..........c.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.265968 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...Cl.........c.l..fxNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.266994 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...Cm.........c.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.267511 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@...Ch.........c.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.268673 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@...C].........c.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.269553 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@...Cb.........c.l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.270080 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...Cg.........c.l...BNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.271174 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...Ch.........c.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.272086 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@...Ca.........c.l. .1NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.366883 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 492 +E.....@...C}.........c.l...6NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.367623 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 501 +E.....@...Cs.........c.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.368575 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...CS.........c.l..fxNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.369324 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...CT.........c.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.370065 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@...CO.........c.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.371005 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@...CD.........c.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.371631 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@...CI.........c.l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.372908 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...CN.........c.l...BNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.373396 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...CO.........c.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:11.374188 IP pizzicato.lan.40803 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@...CH.........c.l. .1NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:25.703657 IP pizzicato.lan.39699 > 239.255.255.250.ssdp: UDP, length 513 +E.....@...;............l. K.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:25.704496 IP pizzicato.lan.39699 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@...;............l...yNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:25.705379 IP pizzicato.lan.39699 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@...;]...........l.=n.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:25.706291 IP pizzicato.lan.39699 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@...;P...........l.I.pNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:25.707305 IP pizzicato.lan.39699 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@...;M...........l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:25.805152 IP pizzicato.lan.39699 > 239.255.255.250.ssdp: UDP, length 513 +E.....@...;............l. K.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:25.805940 IP pizzicato.lan.39699 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@...;w...........l...yNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:25.806744 IP pizzicato.lan.39699 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@...;K...........l.=n.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:25.807754 IP pizzicato.lan.39699 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@...;>...........l.I.pNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:25.808715 IP pizzicato.lan.39699 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@...;;...........l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:25.907531 IP pizzicato.lan.39699 > 239.255.255.250.ssdp: UDP, length 513 +E.....@...;v...........l. K.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:25.908282 IP pizzicato.lan.39699 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@...;l...........l...yNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:25.908938 IP pizzicato.lan.39699 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@...;@...........l.=n.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:25.909920 IP pizzicato.lan.39699 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@...;3...........l.I.pNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:25.910759 IP pizzicato.lan.39699 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@...;0...........l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.319863 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 492 +E.....@...;g...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.319868 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 501 +E.....@...;]...........l...CNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.320558 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...;=...........l..x.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.320909 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...;>...........l...dNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.321721 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@...;9...........l...rNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.322597 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@...;............l.(.@NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.323563 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@...;3...........l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.325441 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...;8...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.326299 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...;9...........l...gNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.327246 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@...;2...........l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.419846 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 492 +E.... @...;Z...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.420415 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 501 +E....!@...;P...........l...CNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.421275 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 532 +E..0."@...;0...........l..x.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.422021 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 530 +E....#@...;1...........l...dNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.422891 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 534 +E..2.$@...;,...........l...rNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.423699 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 544 +E..<.%@...;!...........l.(.@NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.424575 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 538 +E..6.&@...;&...........l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.425591 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 532 +E..0.'@...;+...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.426486 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 530 +E....(@...;,...........l...gNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.427043 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 536 +E..4.)@...;%...........l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.522058 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 492 +E....8@...;B...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.522708 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 501 +E....9@...;8...........l...CNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.523610 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 532 +E..0.:@...;............l..x.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.524428 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 530 +E....;@...;............l...dNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.525315 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 534 +E..2.<@...;............l...rNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.526164 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 544 +E..<.=@...; ...........l.(.@NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.526716 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 538 +E..6.>@...;............l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.527747 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 532 +E..0.?@...;............l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.528728 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 530 +E....@@...;............l...gNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:26.529580 IP pizzicato.lan.36075 > 239.255.255.250.ssdp: UDP, length 536 +E..4.A@...;............l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:29.628118 IP localhost.63793 > 239.255.255.250.ssdp: UDP, length 149 +E....6..... +.........1.l..o.M-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:29.628164 IP localhost.63793 > 239.255.255.250.ssdp: UDP, length 149 +E....6..... +.........1.l..o.M-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:29.629672 IP mac.lan.60504 > 239.255.255.250.ssdp: UDP, length 149 +E....................X.l....M-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:29.629729 IP mac.lan.60504 > 239.255.255.250.ssdp: UDP, length 149 +E....................X.l....M-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:29.629793 IP mac.lan.60504 > 239.255.255.250.ssdp: UDP, length 149 +E....................X.l..\.M-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:29.630516 IP 192.168.194.0.56685 > 239.255.255.250.ssdp: UDP, length 149 +E....p....p(.........m.l..sRM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:29.630570 IP 192.168.194.0.56685 > 239.255.255.250.ssdp: UDP, length 149 +E....p....p(.........m.l..sRM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:29.630961 IP 192.168.139.3.49465 > 239.255.255.250.ssdp: UDP, length 149 +E...;.....>..........9.l.. 239.255.255.250.ssdp: UDP, length 149 +E...;.....>..........9.l.. 239.255.255.250.ssdp: UDP, length 149 +E....Z....I>.........F.l...RM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:29.631267 IP 192.168.215.0.53318 > 239.255.255.250.ssdp: UDP, length 149 +E....Z....I>.........F.l...RM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:29.631918 IP mac.lan.ssdp > mac.lan.60504: UDP, length 311 +E..S.T..@............l.X.?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:29 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:48:29.631960 IP mac.lan.ssdp > mac.lan.60504: UDP, length 311 +E..S.T..@............l.X.?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:29 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:48:29.631962 IP mac.lan.ssdp > mac.lan.60504: UDP, length 311 +E..SE...@............l.X.?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:29 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:48:29.632117 IP 192.168.97.0.58416 > 239.255.255.250.ssdp: UDP, length 149 +E.........#...a......0.l...RM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:29.632135 IP mac.lan.ssdp > mac.lan.60504: UDP, length 311 +E..SE...@............l.X.?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:29 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:48:29.632138 IP 192.168.97.0.58416 > 239.255.255.250.ssdp: UDP, length 149 +E.........#...a......0.l...RM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:29.637495 IP babel.lan.ssdp > mac.lan.60504: UDP, length 339 +E..o..@.@......c.....l.X.[qJHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1900 +ST: upnp:rootdevice +USN: uuid:73796E6F-6473-6D00-0000-9009d018cd37::upnp:rootdevice +EXT: +SERVER: Synology/DSM/192.168.0.99 +LOCATION: http://192.168.0.99:5000/ssdp/desc-DSM-eth0.xml +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: 1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1337 + + +17:48:29.637496 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 230 +E.....@.@..Q.........l.X..1,HTTP/1.1 200 OK +SERVER: Linux/2.6 UPnP/1.0 fbxigdd/1.1 +LOCATION: http://192.168.0.254:5678/desc/root +EXT: +CACHE-CONTROL: max-age=1800 +ST: upnp:rootdevice +USN: uuid:igd73616d61-6a65-7374-650a-2066cf5e30fb::upnp:rootdevice + + +17:48:30.128839 IP localhost.63793 > 239.255.255.250.ssdp: UDP, length 149 +E....Y....p..........1.l..o.M-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.128863 IP localhost.63793 > 239.255.255.250.ssdp: UDP, length 149 +E....Y....p..........1.l..o.M-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.128901 IP mac.lan.60504 > 239.255.255.250.ssdp: UDP, length 149 +E...D......#.........X.l....M-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.128924 IP mac.lan.60504 > 239.255.255.250.ssdp: UDP, length 149 +E...D......#.........X.l..\.M-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.128983 IP mac.lan.60504 > 239.255.255.250.ssdp: UDP, length 149 +E...D......#.........X.l....M-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.129176 IP 192.168.194.0.56685 > 239.255.255.250.ssdp: UDP, length 149 +E....~...............m.l..sRM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.129212 IP 192.168.194.0.56685 > 239.255.255.250.ssdp: UDP, length 149 +E....~...............m.l..sRM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.129218 IP mac.lan.ssdp > mac.lan.60504: UDP, length 311 +E..SqR..@............l.X.?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:30 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:48:30.129248 IP mac.lan.ssdp > mac.lan.60504: UDP, length 311 +E..S....@............l.X.?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:30 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:48:30.129302 IP mac.lan.ssdp > mac.lan.60504: UDP, length 311 +E..SqR..@............l.X.?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:30 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:48:30.129304 IP mac.lan.ssdp > mac.lan.60504: UDP, length 311 +E..S....@............l.X.?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:30 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:48:30.129492 IP 192.168.139.3.49465 > 239.255.255.250.ssdp: UDP, length 149 +E...cF.....P.........9.l.. 239.255.255.250.ssdp: UDP, length 149 +E...cF.....P.........9.l.. 239.255.255.250.ssdp: UDP, length 149 +E....M....7K.........F.l...RM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.130890 IP 192.168.215.0.53318 > 239.255.255.250.ssdp: UDP, length 149 +E....M....7K.........F.l...RM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.130892 IP 192.168.97.0.58416 > 239.255.255.250.ssdp: UDP, length 149 +E...>.....e...a......0.l...RM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.131018 IP 192.168.97.0.58416 > 239.255.255.250.ssdp: UDP, length 149 +E...>.....e...a......0.l...RM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.132628 IP babel.lan.ssdp > mac.lan.60504: UDP, length 339 +E..o..@.@..\...c.....l.X.[qJHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1900 +ST: upnp:rootdevice +USN: uuid:73796E6F-6473-6D00-0000-9009d018cd37::upnp:rootdevice +EXT: +SERVER: Synology/DSM/192.168.0.99 +LOCATION: http://192.168.0.99:5000/ssdp/desc-DSM-eth0.xml +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: 1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1337 + + +17:48:30.132629 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 230 +E.....@.@..P.........l.X..1,HTTP/1.1 200 OK +SERVER: Linux/2.6 UPnP/1.0 fbxigdd/1.1 +LOCATION: http://192.168.0.254:5678/desc/root +EXT: +CACHE-CONTROL: max-age=1800 +ST: upnp:rootdevice +USN: uuid:igd73616d61-6a65-7374-650a-2066cf5e30fb::upnp:rootdevice + + +17:48:30.291302 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 320 +E..\..@.@............l.X.H8.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:30 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf::urn:schemas-upnp-org:device:MediaServer:1 + + +17:48:30.429635 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 320 +E..\..@.@............l.X.H8.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:30 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf::urn:schemas-upnp-org:device:MediaServer:1 + + +17:48:30.630431 IP localhost.63793 > 239.255.255.250.ssdp: UDP, length 149 +E...$8....# .........1.l..o.M-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.630468 IP localhost.63793 > 239.255.255.250.ssdp: UDP, length 149 +E...$8....# .........1.l..o.M-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.630534 IP mac.lan.60504 > 239.255.255.250.ssdp: UDP, length 149 +E.........n..........X.l....M-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.630569 IP mac.lan.60504 > 239.255.255.250.ssdp: UDP, length 149 +E.........n..........X.l....M-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.630654 IP mac.lan.60504 > 239.255.255.250.ssdp: UDP, length 149 +E.........n..........X.l..\.M-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.630863 IP 192.168.194.0.56685 > 239.255.255.250.ssdp: UDP, length 149 +E...x................m.l..sRM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.630887 IP 192.168.194.0.56685 > 239.255.255.250.ssdp: UDP, length 149 +E...x................m.l..sRM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.630957 IP 192.168.139.3.49465 > 239.255.255.250.ssdp: UDP, length 149 +E....L.....I.........9.l.. 239.255.255.250.ssdp: UDP, length 149 +E....L.....I.........9.l.. mac.lan.60504: UDP, length 334 +E..j..@.@............l.X.V#.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:30 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:48:30.631404 IP mac.lan.ssdp > mac.lan.60504: UDP, length 311 +E..S....@............l.X.?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:30 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:48:30.631421 IP 192.168.215.0.53318 > 239.255.255.250.ssdp: UDP, length 149 +E...4C.....U.........F.l...RM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.631476 IP mac.lan.ssdp > mac.lan.60504: UDP, length 311 +E..S....@............l.X.?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:30 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:48:30.631480 IP 192.168.215.0.53318 > 239.255.255.250.ssdp: UDP, length 149 +E...4C.....U.........F.l...RM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.631500 IP 192.168.97.0.58416 > 239.255.255.250.ssdp: UDP, length 149 +E...?.....d...a......0.l...RM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.631590 IP mac.lan.ssdp > mac.lan.60504: UDP, length 311 +E..S.x..@............l.X.?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:30 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:48:30.631636 IP 192.168.97.0.58416 > 239.255.255.250.ssdp: UDP, length 149 +E...?.....d...a......0.l...RM-SEARCH * HTTP/1.1 +Host: 239.255.255.250:1900 +Man: "ssdp:discover" +ST: upnp:rootdevice +MX: 3 +User-Agent: Darwin/24.6.0 UPnP/1.0 GUPnP/1.6.9 + + +17:48:30.631638 IP mac.lan.ssdp > mac.lan.60504: UDP, length 311 +E..S.x..@............l.X.?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:30 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:48:30.634146 IP babel.lan.ssdp > mac.lan.60504: UDP, length 339 +E..o..@.@..P...c.....l.X.[qJHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1900 +ST: upnp:rootdevice +USN: uuid:73796E6F-6473-6D00-0000-9009d018cd37::upnp:rootdevice +EXT: +SERVER: Synology/DSM/192.168.0.99 +LOCATION: http://192.168.0.99:5000/ssdp/desc-DSM-eth0.xml +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: 1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1337 + + +17:48:30.634149 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 230 +E.....@.@..6.........l.X..1,HTTP/1.1 200 OK +SERVER: Linux/2.6 UPnP/1.0 fbxigdd/1.1 +LOCATION: http://192.168.0.254:5678/desc/root +EXT: +CACHE-CONTROL: max-age=1800 +ST: upnp:rootdevice +USN: uuid:igd73616d61-6a65-7374-650a-2066cf5e30fb::upnp:rootdevice + + +17:48:30.668410 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 332 +E..h..@.@............l.X.T|&HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:30 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:48:30.859090 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 332 +E..h..@.@............l.X.T{&HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:31 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:48:31.234413 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 332 +E..h..@.@............l.X.T{&HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:31 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:48:31.437602 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 348 +E..x..@.@............l.X.d..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:31 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: urn:microsoft.com:service:X_MS_MediaReceiverRegistrar:1 +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf::urn:microsoft.com:service:X_MS_MediaReceiverRegistrar:1 + + +17:48:31.489460 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 268 +E..(..@.@............l.X....HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:31 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: upnp:rootdevice +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf::upnp:rootdevice + + +17:48:31.496640 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 334 +E..j..@.@............l.X.V".HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:31 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:48:31.599262 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 348 +E..x..@.@............l.X.d..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:31 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: urn:microsoft.com:service:X_MS_MediaReceiverRegistrar:1 +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf::urn:microsoft.com:service:X_MS_MediaReceiverRegistrar:1 + + +17:48:31.613022 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 277 +E..1..@.@............l.X...CHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:31 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf + + +17:48:31.763087 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 268 +E..(..@.@............l.X....HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:32 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: upnp:rootdevice +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf::upnp:rootdevice + + +17:48:31.907850 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 277 +E..1..@.@............l.X...CHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:32 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf + + +17:48:32.206616 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 320 +E..\.&@.@............l.X.H6.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:32 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf::urn:schemas-upnp-org:device:MediaServer:1 + + +17:48:32.349709 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 268 +E..(.+@.@............l.X....HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:32 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: upnp:rootdevice +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf::upnp:rootdevice + + +17:48:32.427286 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 277 +E..1.1@.@............l.X...CHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:32 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf + + +17:48:33.009041 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 348 +E..x.N@.@..M.........l.X.d..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:33 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: urn:microsoft.com:service:X_MS_MediaReceiverRegistrar:1 +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf::urn:microsoft.com:service:X_MS_MediaReceiverRegistrar:1 + + +17:48:33.042005 IP 192.168.0.254.ssdp > mac.lan.60504: UDP, length 334 +E..j.P@.@..Y.........l.X.V .HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:33 GMT +EXT: +LOCATION: http://192.168.0.254:52424/device.xml +SERVER: Linux/2.6 UPnP/1.0 fbxupnpav/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:e929a46e-d218-377d-2dde-32bd8080dfbf::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:48:40.858990 IP pizzicato.lan.48401 > 239.255.255.250.ssdp: UDP, length 513 +E.....@...4............l. ).NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:40.859748 IP pizzicato.lan.48401 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@...4............l..}{NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:40.860608 IP pizzicato.lan.48401 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@...4v...........l.=L.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:40.861509 IP pizzicato.lan.48401 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@...4i...........l.I.rNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:40.862449 IP pizzicato.lan.48401 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@...4f...........l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:40.960255 IP pizzicato.lan.48401 > 239.255.255.250.ssdp: UDP, length 513 +E.....@...4............l. ).NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:40.961023 IP pizzicato.lan.48401 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@...4............l..}{NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:40.961939 IP pizzicato.lan.48401 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@...4k...........l.=L.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:40.962923 IP pizzicato.lan.48401 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@...4^...........l.I.rNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:40.963809 IP pizzicato.lan.48401 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@...4[...........l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.062703 IP pizzicato.lan.48401 > 239.255.255.250.ssdp: UDP, length 513 +E.....@...4............l. ).NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.063494 IP pizzicato.lan.48401 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@...4............l..}{NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.064380 IP pizzicato.lan.48401 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@...4\...........l.=L.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.065220 IP pizzicato.lan.48401 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@...4O...........l.I.rNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.066269 IP pizzicato.lan.48401 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@...4L...........l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.574536 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 492 +E....<@...4>.........[.l...>NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.575334 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 501 +E....=@...44.........[.l..9.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.576194 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 532 +E..0.>@...4..........[.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.576872 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 530 +E....?@...4..........[.l..o.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.577663 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 534 +E..2.@@...4..........[.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.578769 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 544 +E..<.A@...4..........[.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.579621 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 538 +E..6.B@...4 +.........[.l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.580240 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 532 +E..0.C@...4..........[.l..UJNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.581320 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 530 +E....D@...4..........[.l..r.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.581901 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 536 +E..4.E@...4 .........[.l. v9NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.582750 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 492 +E....[@...4..........[.l...>NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.583734 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 501 +E....\@...4..........[.l..9.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.584695 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 532 +E..0.]@...3..........[.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.585522 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 530 +E....^@...3..........[.l..o.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.677237 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 534 +E..2._@...3..........[.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.678045 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 544 +E..<.`@...3..........[.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.678853 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 538 +E..6.a@...3..........[.l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.679778 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 532 +E..0.b@...3..........[.l..UJNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.680722 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 530 +E....c@...3..........[.l..r.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.681574 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 536 +E..4.d@...3..........[.l. v9NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.682727 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 492 +E....}@...3..........[.l...>NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.683522 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 501 +E....~@...3..........[.l..9.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.684342 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...3..........[.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.685256 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...3..........[.l..o.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.686063 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@...3..........[.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.686926 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@...3..........[.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.687794 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@...3..........[.l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.688435 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...3..........[.l..UJNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.689542 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...3..........[.l..r.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:41.690390 IP pizzicato.lan.59483 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@...3..........[.l. v9NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:44.288818 IP localhost.64618 > 239.255.255.250.ssdp: UDP, length 179 +E... #....A..........j.l..o.M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.288857 IP localhost.64618 > 239.255.255.250.ssdp: UDP, length 179 +E... #....A..........j.l..o.M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.289439 IP localhost.ssdp > localhost.64618: UDP, length 367 +E.......@............l.j.w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:48:44.289477 IP localhost.ssdp > localhost.64618: UDP, length 367 +E.......@............l.j.w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:48:44.290480 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E.........i0.........W.l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.290523 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E.........i0.........W.l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.290562 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E.........i0.........W.l..l|M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.291260 IP 192.168.194.0.50778 > 239.255.255.250.ssdp: UDP, length 179 +E....|....S..........Z.l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.291312 IP 192.168.194.0.50778 > 239.255.255.250.ssdp: UDP, length 179 +E....|....S..........Z.l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.291595 IP 192.168.139.3.49767 > 239.255.255.250.ssdp: UDP, length 179 +E....................g.l.. 239.255.255.250.ssdp: UDP, length 179 +E....................g.l.. mac.lan.59479: UDP, length 367 +E.......@............l.W.w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:48:44.294781 IP mac.lan.ssdp > mac.lan.59479: UDP, length 367 +E.......@............l.W.w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:48:44.295717 IP 192.168.215.0.61883 > 239.255.255.250.ssdp: UDP, length 179 +E..........`...........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.295739 IP 192.168.215.0.61883 > 239.255.255.250.ssdp: UDP, length 179 +E..........`...........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.296567 IP 192.168.97.0.62499 > 239.255.255.250.ssdp: UDP, length 179 +E.............a......#.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.296643 IP 192.168.97.0.62499 > 239.255.255.250.ssdp: UDP, length 179 +E.............a......#.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.398827 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E....................W.l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.398863 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E....................W.l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.398903 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E....................W.l..l|M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.398966 IP 192.168.194.0.50778 > 239.255.255.250.ssdp: UDP, length 179 +E....P....|*.........Z.l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.399023 IP 192.168.139.3.49767 > 239.255.255.250.ssdp: UDP, length 179 +E....C.....4.........g.l.. 239.255.255.250.ssdp: UDP, length 179 +E.........Z............l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.399122 IP 192.168.97.0.62499 > 239.255.255.250.ssdp: UDP, length 179 +E...S.....S...a......#.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.399185 IP 192.168.194.0.50778 > 239.255.255.250.ssdp: UDP, length 179 +E....P....|*.........Z.l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.399188 IP 192.168.139.3.49767 > 239.255.255.250.ssdp: UDP, length 179 +E....C.....4.........g.l.. 239.255.255.250.ssdp: UDP, length 179 +E.........Z............l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.399191 IP 192.168.97.0.62499 > 239.255.255.250.ssdp: UDP, length 179 +E...S.....S...a......#.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.399250 IP mac.lan.ssdp > mac.lan.59479: UDP, length 367 +E.......@............l.W.w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:48:44.399445 IP mac.lan.ssdp > mac.lan.59479: UDP, length 367 +E.......@............l.W.w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:48:44.499869 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E.........*`.........W.l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.499937 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E.........*`.........W.l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.499963 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E.........*`.........W.l..l|M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.500069 IP 192.168.194.0.50778 > 239.255.255.250.ssdp: UDP, length 179 +E...l3.....G.........Z.l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.500331 IP 192.168.139.3.49767 > 239.255.255.250.ssdp: UDP, length 179 +E.........._.........g.l.. 239.255.255.250.ssdp: UDP, length 179 +E...l3.....G.........Z.l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.500410 IP 192.168.139.3.49767 > 239.255.255.250.ssdp: UDP, length 179 +E.........._.........g.l.. 239.255.255.250.ssdp: UDP, length 179 +E....H....k2...........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.500520 IP mac.lan.ssdp > mac.lan.59479: UDP, length 367 +E....S..@............l.W.w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:48:44.500668 IP 192.168.215.0.61883 > 239.255.255.250.ssdp: UDP, length 179 +E....H....k2...........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.500672 IP mac.lan.ssdp > mac.lan.59479: UDP, length 367 +E....S..@............l.W.w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:48:44.501415 IP 192.168.97.0.62499 > 239.255.255.250.ssdp: UDP, length 179 +E.............a......#.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.501440 IP 192.168.97.0.62499 > 239.255.255.250.ssdp: UDP, length 179 +E.............a......#.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.602451 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E...A......7.........W.l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.602485 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E...A......7.........W.l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.602537 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E...A......7.........W.l..l|M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.602911 IP 192.168.194.0.50778 > 239.255.255.250.ssdp: UDP, length 179 +E....>....z<.........Z.l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.602957 IP mac.lan.ssdp > mac.lan.59479: UDP, length 367 +E.......@............l.W.w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:48:44.602964 IP 192.168.194.0.50778 > 239.255.255.250.ssdp: UDP, length 179 +E....>....z<.........Z.l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.603113 IP mac.lan.ssdp > mac.lan.59479: UDP, length 367 +E.......@............l.W.w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:48:44.603155 IP 192.168.139.3.49767 > 239.255.255.250.ssdp: UDP, length 179 +E...;.....A..........g.l.. 239.255.255.250.ssdp: UDP, length 179 +E...;.....A..........g.l.. 239.255.255.250.ssdp: UDP, length 179 +E......................l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.603281 IP 192.168.215.0.61883 > 239.255.255.250.ssdp: UDP, length 179 +E......................l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.603316 IP 192.168.97.0.62499 > 239.255.255.250.ssdp: UDP, length 179 +E...iO....>,..a......#.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.603354 IP 192.168.97.0.62499 > 239.255.255.250.ssdp: UDP, length 179 +E...iO....>,..a......#.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.704590 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E....<....X..........W.l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.704630 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E....<....X..........W.l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.704679 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E....<....X..........W.l..l|M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.704897 IP 192.168.194.0.50778 > 239.255.255.250.ssdp: UDP, length 179 +E....P.....*.........Z.l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.704994 IP 192.168.194.0.50778 > 239.255.255.250.ssdp: UDP, length 179 +E....P.....*.........Z.l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.705318 IP mac.lan.ssdp > mac.lan.59479: UDP, length 367 +E.......@............l.W.w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:48:44.705343 IP mac.lan.ssdp > mac.lan.59479: UDP, length 367 +E.......@............l.W.w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:48:44.705608 IP 192.168.139.3.49767 > 239.255.255.250.ssdp: UDP, length 179 +E...W[....&..........g.l.. 239.255.255.250.ssdp: UDP, length 179 +E...W[....&..........g.l.. 239.255.255.250.ssdp: UDP, length 179 +E..........e...........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.705890 IP 192.168.215.0.61883 > 239.255.255.250.ssdp: UDP, length 179 +E..........e...........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.706132 IP 192.168.97.0.62499 > 239.255.255.250.ssdp: UDP, length 179 +E.............a......#.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.706162 IP 192.168.97.0.62499 > 239.255.255.250.ssdp: UDP, length 179 +E.............a......#.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.806853 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E....................W.l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.806894 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E....................W.l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.806923 IP mac.lan.59479 > 239.255.255.250.ssdp: UDP, length 179 +E....................W.l..l|M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.807010 IP 192.168.194.0.50778 > 239.255.255.250.ssdp: UDP, length 179 +E....;....7@.........Z.l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.807195 IP 192.168.194.0.50778 > 239.255.255.250.ssdp: UDP, length 179 +E....;....7@.........Z.l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.807677 IP mac.lan.ssdp > mac.lan.59479: UDP, length 367 +E....X..@............l.W.w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:48:44.807733 IP mac.lan.ssdp > mac.lan.59479: UDP, length 367 +E....X..@............l.W.w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:48:44.807970 IP 192.168.139.3.49767 > 239.255.255.250.ssdp: UDP, length 179 +E..........X.........g.l.. 239.255.255.250.ssdp: UDP, length 179 +E..........X.........g.l.. 239.255.255.250.ssdp: UDP, length 179 +E.........;............l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.808222 IP 192.168.215.0.61883 > 239.255.255.250.ssdp: UDP, length 179 +E.........;............l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.808473 IP 192.168.97.0.62499 > 239.255.255.250.ssdp: UDP, length 179 +E.............a......#.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:44.808507 IP 192.168.97.0.62499 > 239.255.255.250.ssdp: UDP, length 179 +E.............a......#.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:48:56.115546 IP pizzicato.lan.55156 > 239.255.255.250.ssdp: UDP, length 513 +E.....@...-V.........t.l. .fNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.116500 IP pizzicato.lan.55156 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@...-L.........t.l..c.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.117092 IP pizzicato.lan.55156 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@...- .........t.l.=2.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.118039 IP pizzicato.lan.55156 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@...-..........t.l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.119099 IP pizzicato.lan.55156 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@...-..........t.l.K.xNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.217868 IP pizzicato.lan.55156 > 239.255.255.250.ssdp: UDP, length 513 +E....$@...-A.........t.l. .fNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.218454 IP pizzicato.lan.55156 > 239.255.255.250.ssdp: UDP, length 522 +E..&.%@...-7.........t.l..c.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.219643 IP pizzicato.lan.55156 > 239.255.255.250.ssdp: UDP, length 565 +E..Q.&@...-..........t.l.=2.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.220500 IP pizzicato.lan.55156 > 239.255.255.250.ssdp: UDP, length 577 +E..].'@...,..........t.l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.221104 IP pizzicato.lan.55156 > 239.255.255.250.ssdp: UDP, length 579 +E.._.(@...,..........t.l.K.xNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.320096 IP pizzicato.lan.55156 > 239.255.255.250.ssdp: UDP, length 513 +E....*@...-;.........t.l. .fNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.320915 IP pizzicato.lan.55156 > 239.255.255.250.ssdp: UDP, length 522 +E..&.+@...-1.........t.l..c.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.321752 IP pizzicato.lan.55156 > 239.255.255.250.ssdp: UDP, length 565 +E..Q.,@...-..........t.l.=2.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.322760 IP pizzicato.lan.55156 > 239.255.255.250.ssdp: UDP, length 577 +E..].-@...,..........t.l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.323316 IP pizzicato.lan.55156 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@...,..........t.l.K.xNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.730142 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 492 +E.....@...,............l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.731104 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 501 +E.....@...,............l...[NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.731834 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...,............l..j.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.732649 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...,............l...|NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.733430 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@...,............l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.734137 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@...,............l.(.XNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.735132 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@...,............l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.736050 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...,............l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.736935 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...,............l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.737959 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@...,............l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.834144 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 501 +E.....@...,............l...[NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.835119 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...,............l...|NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.835120 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@...,............l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.837099 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@...,............l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.838006 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...,............l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.838890 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...,............l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.839467 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@...,............l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.935697 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 492 +E.....@...,............l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.936437 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 501 +E.....@...,............l...[NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.937293 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...,............l..j.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.938180 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...,............l...|NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.938873 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@...,............l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.939917 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@...,............l.(.XNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.943347 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@...,............l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.944163 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...,............l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.951032 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...,............l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:56.951796 IP pizzicato.lan.39891 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@...,............l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:48:57.036533 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 142 +E....R@....,...".......l.."LM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +USER-AGENT: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +MAN: "ssdp:discover" +ST: ssdp:all +MX: 3 + + +17:48:57.038059 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\.k..@.x).......".l...H.(HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + + +17:48:57.038303 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..SA...@..........".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:48:57.038497 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 363 +E...U#..@..F.......".l...sP.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:device:MediaServer:1 + + +17:48:57.038664 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E.......@..........".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:48:57.039086 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E....T..@..........".l.....MHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:48:57.039916 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\.,..@..h.......".l...H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + + +17:48:57.040109 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..S....@.-........".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:48:57.040292 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 367 +E.......@.g........".l...wx.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:48:57.040346 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 365 +E.......@.k........".l...u..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:AVTransport:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:AVTransport:1 + + +17:48:57.040383 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E...oo..@..........".l.....~HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:48:57.040419 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E...,J..@..........".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:RenderingControl:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:RenderingControl:1 + + +17:48:57.138914 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 142 +E.....@...-....".......l.."LM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +USER-AGENT: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +MAN: "ssdp:discover" +ST: ssdp:all +MX: 3 + + +17:48:57.139384 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\z...@.|........".l...H.(HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + + +17:48:57.139423 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..SS...@..........".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:48:57.139448 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 363 +E...z...@.|........".l...sP.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:device:MediaServer:1 + + +17:48:57.139471 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E.......@.E........".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:48:57.139499 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E...A...@..........".l.....MHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:48:57.139524 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\+R..@..B.......".l...H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + + +17:48:57.139550 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..S....@.=........".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:48:57.139571 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 367 +E.......@.B........".l...wx.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:48:57.139598 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 365 +E... +U..@..........".l...u..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:AVTransport:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:AVTransport:1 + + +17:48:57.139623 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E....c..@..........".l.....~HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:48:57.139646 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E.......@..........".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:RenderingControl:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:RenderingControl:1 + + +17:48:57.242152 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 142 +E.....@...-....".......l.."LM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +USER-AGENT: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +MAN: "ssdp:discover" +ST: ssdp:all +MX: 3 + + +17:48:57.242630 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\....@.\........".l...H.(HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + + +17:48:57.242669 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..S.9..@..d.......".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:48:57.242698 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 363 +E.......@.#........".l...sP.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:device:MediaServer:1 + + +17:48:57.242724 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E...[...@..........".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:48:57.242749 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E...N...@..}.......".l.....MHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:48:57.242779 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\.{..@.A........".l...H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + + +17:48:57.242809 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..Sw +..@..........".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:48:57.242836 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 367 +E....B..@.M#.......".l...wx.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:48:57.242859 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 365 +E...j...@..O.......".l...u..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:AVTransport:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:AVTransport:1 + + +17:48:57.242887 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E.......@.N........".l.....~HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:48:57.242910 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E.......@.eW.......".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:48:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:RenderingControl:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:RenderingControl:1 + + +17:49:11.270560 IP pizzicato.lan.56952 > 239.255.255.250.ssdp: UDP, length 513 +E....|@...#..........x.l. .bNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.271667 IP pizzicato.lan.56952 > 239.255.255.250.ssdp: UDP, length 522 +E..&.}@...#..........x.l..\.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.272166 IP pizzicato.lan.56952 > 239.255.255.250.ssdp: UDP, length 565 +E..Q.~@...#..........x.l.=+*NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.273084 IP pizzicato.lan.56952 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@...#..........x.l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.274056 IP pizzicato.lan.56952 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@...#..........x.l.K.tNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.373003 IP pizzicato.lan.56952 > 239.255.255.250.ssdp: UDP, length 513 +E.....@...#..........x.l. .bNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.373692 IP pizzicato.lan.56952 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@...#..........x.l..\.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.374620 IP pizzicato.lan.56952 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@...#..........x.l.=+*NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.375554 IP pizzicato.lan.56952 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@...#..........x.l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.376127 IP pizzicato.lan.56952 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@...#..........x.l.K.tNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.475290 IP pizzicato.lan.56952 > 239.255.255.250.ssdp: UDP, length 513 +E.....@...#..........x.l. .bNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.476055 IP pizzicato.lan.56952 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@...#..........x.l..\.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.476929 IP pizzicato.lan.56952 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@...#}.........x.l.=+*NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.477821 IP pizzicato.lan.56952 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@...#p.........x.l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.478838 IP pizzicato.lan.56952 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@...#m.........x.l.K.tNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.989239 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 492 +E.....@...#............l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.989986 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 501 +E.....@...#............l..J>NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.990806 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...#............l..-.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.991624 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...#............l..._NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.992336 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@...#............l...mNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.993337 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@...#t...........l.(.:NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.994230 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@...#y...........l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.994883 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...#~...........l..e.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.995785 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...#............l...bNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:11.996556 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@...#x...........l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:12.012827 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 492 +E.....@...#............l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:12.013594 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 501 +E.....@...#............l..J>NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:12.014235 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...#i...........l..-.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:12.015168 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...#j...........l..._NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:12.016201 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@...#e...........l...mNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:12.017050 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@...#Z...........l.(.:NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:12.017929 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@...#_...........l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:12.018746 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@...#d...........l..e.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:12.019622 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...#e...........l...bNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:12.020228 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@...#^...........l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:12.195015 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 530 +E.....@...#O...........l..._NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:12.195361 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@...#J...........l...mNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:12.197095 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@...#D...........l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:12.197970 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 532 +E..0. @...#I...........l..e.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:12.198599 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 530 +E.... +@...#J...........l...bNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:12.199537 IP pizzicato.lan.55280 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@...#C...........l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:26.534877 IP pizzicato.lan.57053 > 239.255.255.250.ssdp: UDP, length 513 +E.....@... ............l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:26.534883 IP pizzicato.lan.57053 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@... ............l..[.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:26.534885 IP pizzicato.lan.57053 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@... U...........l.=*.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:26.630603 IP pizzicato.lan.57053 > 239.255.255.250.ssdp: UDP, length 513 +E.....@... ............l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:26.631400 IP pizzicato.lan.57053 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@... w...........l..[.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:26.632297 IP pizzicato.lan.57053 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@... K...........l.=*.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:26.633283 IP pizzicato.lan.57053 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@... >...........l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:26.633937 IP pizzicato.lan.57053 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@... ;...........l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:26.732683 IP pizzicato.lan.57053 > 239.255.255.250.ssdp: UDP, length 513 +E.....@... o...........l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:26.733522 IP pizzicato.lan.57053 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@... e...........l..[.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:26.734396 IP pizzicato.lan.57053 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@... 9...........l.=*.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:26.735331 IP pizzicato.lan.57053 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@... ,...........l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:26.736286 IP pizzicato.lan.57053 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@... )...........l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.142689 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 492 +E....1@... I.........=.l...\NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.143760 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 501 +E....2@... ?.........=.l..B.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.144231 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 532 +E..0.3@... ..........=.l..&.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.145076 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 530 +E....4@... .........=.l..y.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.145893 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 534 +E..2.5@... ..........=.l... NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.146778 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 544 +E..<.6@... ..........=.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.147763 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 538 +E..6.7@... ..........=.l.".=NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.148604 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 532 +E..0.8@... ..........=.l..^hNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.149403 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 530 +E....9@... ..........=.l..|.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.151863 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 536 +E..4.:@... ..........=.l. .WNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.245095 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 492 +E....M@... -.........=.l...\NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.245743 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 501 +E....N@... #.........=.l..B.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.246585 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 532 +E..0.O@... ..........=.l..&.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.247349 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 530 +E....P@... ..........=.l..y.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.248350 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 534 +E..2.Q@..............=.l... NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.249174 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 544 +E..<.R@..............=.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.249820 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 538 +E..6.S@..............=.l.".=NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.250956 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 532 +E..0.T@..............=.l..^hNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.251749 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 530 +E....U@..............=.l..|.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.252528 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 536 +E..4.V@..............=.l. .WNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.347331 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 492 +E....m@... ..........=.l...\NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.348272 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 501 +E....n@... ..........=.l..B.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.348974 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 532 +E..0.o@..............=.l..&.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.349766 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 530 +E....p@..............=.l..y.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.350695 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 534 +E..2.q@..............=.l... NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.351449 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 544 +E..<.r@..............=.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.352437 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 538 +E..6.s@..............=.l.".=NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.353114 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 532 +E..0.t@..............=.l..^hNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.354056 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 530 +E....u@..............=.l..|.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:27.354831 IP pizzicato.lan.57149 > 239.255.255.250.ssdp: UDP, length 536 +E..4.v@..............=.l. .WNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:41.683158 IP pizzicato.lan.59064 > 239.255.255.250.ssdp: UDP, length 513 +E.....@................l. ."NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:41.684008 IP pizzicato.lan.59064 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@................l..S.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:41.684807 IP pizzicato.lan.59064 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@....c...........l.=".NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:41.685677 IP pizzicato.lan.59064 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@....V...........l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:41.686729 IP pizzicato.lan.59064 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@....S...........l.K.4NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:41.786025 IP pizzicato.lan.59064 > 239.255.255.250.ssdp: UDP, length 513 +E.....@................l. ."NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:41.786898 IP pizzicato.lan.59064 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@................l..S.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:41.787785 IP pizzicato.lan.59064 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@....V...........l.=".NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:41.788705 IP pizzicato.lan.59064 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@....I...........l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:41.789314 IP pizzicato.lan.59064 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@....F...........l.K.4NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:41.890740 IP pizzicato.lan.59064 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@....h...........l..S.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:41.891877 IP pizzicato.lan.59064 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@..../...........l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:41.891882 IP pizzicato.lan.59064 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@....,...........l.K.4NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.399148 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 492 +E.....@....g.........A.l...XNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.399900 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 501 +E.....@....].........A.l..z.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.400764 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....=.........A.l..^.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.401607 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....>.........A.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.402463 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@....9.........A.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.403399 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@..............A.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.404319 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@....3.........A.l.".9NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.404991 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....8.........A.l...dNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.405915 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....9.........A.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.406755 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@....2.........A.l. .SNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.407328 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 492 +E....1@....I.........A.l...XNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.408133 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 501 +E....2@....?.........A.l..z.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.409264 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 532 +E..0.3@..............A.l..^.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.410113 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 530 +E....4@.... .........A.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.410944 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 534 +E..2.5@..............A.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.411823 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 544 +E..<.6@..............A.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.412685 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 538 +E..6.7@..............A.l.".9NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.413555 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 532 +E..0.8@..............A.l...dNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.414397 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 530 +E....9@..............A.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.415334 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 536 +E..4.:@..............A.l. .SNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.504049 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 492 +E....E@....5.........A.l...XNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.504748 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 501 +E....F@....+.........A.l..z.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.505572 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 532 +E..0.G@..............A.l..^.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.506298 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 530 +E....H@..............A.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.507273 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 534 +E..2.I@..............A.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.508128 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 544 +E..<.J@..............A.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.509026 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 538 +E..6.K@..............A.l.".9NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.509829 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 532 +E..0.L@..............A.l...dNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.510726 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 530 +E....M@..............A.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:42.511590 IP pizzicato.lan.42817 > 239.255.255.250.ssdp: UDP, length 536 +E..4.N@..............A.l. .SNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:44.811124 IP localhost.56969 > 239.255.255.250.ssdp: UDP, length 179 +E..........F...........l..o.M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.811184 IP localhost.56969 > 239.255.255.250.ssdp: UDP, length 179 +E..........F...........l..o.M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.811652 IP localhost.ssdp > localhost.56969: UDP, length 367 +E...cW..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:49:44.811728 IP localhost.ssdp > localhost.56969: UDP, length 367 +E...cW..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:49:44.812595 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E....,....}............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.812646 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E....,....}............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.812674 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E....,....}............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.813192 IP 192.168.194.0.61830 > 239.255.255.250.ssdp: UDP, length 179 +E...tt.................l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.813256 IP 192.168.194.0.61830 > 239.255.255.250.ssdp: UDP, length 179 +E...tt.................l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.813931 IP mac.lan.ssdp > mac.lan.54250: UDP, length 367 +E....=..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:49:44.813961 IP mac.lan.ssdp > mac.lan.54250: UDP, length 367 +E....=..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:49:44.814506 IP 192.168.139.3.62636 > 239.255.255.250.ssdp: UDP, length 179 +E...W.....%............l.. 239.255.255.250.ssdp: UDP, length 179 +E...W.....%............l.. 239.255.255.250.ssdp: UDP, length 179 +E....P....{*...........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.814822 IP 192.168.215.0.49675 > 239.255.255.250.ssdp: UDP, length 179 +E....P....{*...........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.814993 IP 192.168.97.0.50148 > 239.255.255.250.ssdp: UDP, length 179 +E....3.....G..a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.815021 IP 192.168.97.0.50148 > 239.255.255.250.ssdp: UDP, length 179 +E....3.....G..a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.915758 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E.........K............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.915805 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E.........K............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.915824 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E.........K............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.916935 IP mac.lan.ssdp > mac.lan.54250: UDP, length 367 +E.......@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:49:44.916961 IP mac.lan.ssdp > mac.lan.54250: UDP, length 367 +E.......@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:44 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:49:44.917070 IP 192.168.194.0.61830 > 239.255.255.250.ssdp: UDP, length 179 +E......................l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.917122 IP 192.168.194.0.61830 > 239.255.255.250.ssdp: UDP, length 179 +E......................l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.917302 IP 192.168.139.3.62636 > 239.255.255.250.ssdp: UDP, length 179 +E....~.................l.. 239.255.255.250.ssdp: UDP, length 179 +E....~.................l.. 239.255.255.250.ssdp: UDP, length 179 +E......................l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.917543 IP 192.168.215.0.49675 > 239.255.255.250.ssdp: UDP, length 179 +E......................l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.917946 IP 192.168.97.0.50148 > 239.255.255.250.ssdp: UDP, length 179 +E...(@.....;..a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:44.917997 IP 192.168.97.0.50148 > 239.255.255.250.ssdp: UDP, length 179 +E...(@.....;..a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.019385 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E...Jo.................l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.019423 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E...Jo.................l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.019491 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E...Jo.................l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.019942 IP 192.168.194.0.61830 > 239.255.255.250.ssdp: UDP, length 179 +E...M..................l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.020011 IP 192.168.194.0.61830 > 239.255.255.250.ssdp: UDP, length 179 +E...M..................l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.020099 IP mac.lan.ssdp > mac.lan.54250: UDP, length 367 +E....+..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:49:45.020129 IP mac.lan.ssdp > mac.lan.54250: UDP, length 367 +E....+..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:49:45.020197 IP 192.168.139.3.62636 > 239.255.255.250.ssdp: UDP, length 179 +E......................l.. 239.255.255.250.ssdp: UDP, length 179 +E......................l.. 239.255.255.250.ssdp: UDP, length 179 +E......................l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.020437 IP 192.168.215.0.49675 > 239.255.255.250.ssdp: UDP, length 179 +E......................l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.020537 IP 192.168.97.0.50148 > 239.255.255.250.ssdp: UDP, length 179 +E...Wy....P...a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.020596 IP 192.168.97.0.50148 > 239.255.255.250.ssdp: UDP, length 179 +E...Wy....P...a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.121748 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E...ki.................l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.121782 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E...ki.................l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.121808 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E...ki.................l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.121916 IP 192.168.194.0.61830 > 239.255.255.250.ssdp: UDP, length 179 +E...>..................l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.122020 IP 192.168.139.3.62636 > 239.255.255.250.ssdp: UDP, length 179 +E...AN....<*...........l.. 239.255.255.250.ssdp: UDP, length 179 +E...>..................l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.122058 IP 192.168.139.3.62636 > 239.255.255.250.ssdp: UDP, length 179 +E...AN....<*...........l.. 239.255.255.250.ssdp: UDP, length 179 +E....O.....+...........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.122124 IP mac.lan.ssdp > mac.lan.54250: UDP, length 367 +E.......@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:49:45.122150 IP 192.168.97.0.50148 > 239.255.255.250.ssdp: UDP, length 179 +E...Y.....M...a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.122192 IP 192.168.215.0.49675 > 239.255.255.250.ssdp: UDP, length 179 +E....O.....+...........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.122195 IP mac.lan.ssdp > mac.lan.54250: UDP, length 367 +E.......@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:49:45.122198 IP 192.168.97.0.50148 > 239.255.255.250.ssdp: UDP, length 179 +E...Y.....M...a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.223937 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E....f....*............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.223958 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E....f....*............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.224265 IP mac.lan.ssdp > mac.lan.54250: UDP, length 367 +E.......@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:49:45.224279 IP mac.lan.ssdp > mac.lan.54250: UDP, length 367 +E.......@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:49:45.224333 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E....f....*............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.224527 IP 192.168.194.0.61830 > 239.255.255.250.ssdp: UDP, length 179 +E...&..................l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.224538 IP 192.168.194.0.61830 > 239.255.255.250.ssdp: UDP, length 179 +E...&..................l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.224570 IP 192.168.139.3.62636 > 239.255.255.250.ssdp: UDP, length 179 +E...[.....!............l.. 239.255.255.250.ssdp: UDP, length 179 +E...[.....!............l.. 239.255.255.250.ssdp: UDP, length 179 +E.... +....Vp...........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.224633 IP 192.168.215.0.49675 > 239.255.255.250.ssdp: UDP, length 179 +E.... +....Vp...........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.224656 IP 192.168.97.0.50148 > 239.255.255.250.ssdp: UDP, length 179 +E......... ...a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.224665 IP 192.168.97.0.50148 > 239.255.255.250.ssdp: UDP, length 179 +E......... ...a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.325048 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E.........W............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.325080 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E.........W............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.325124 IP mac.lan.54250 > 239.255.255.250.ssdp: UDP, length 179 +E.........W............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.325186 IP 192.168.194.0.61830 > 239.255.255.250.ssdp: UDP, length 179 +E...X..................l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.325229 IP 192.168.139.3.62636 > 239.255.255.250.ssdp: UDP, length 179 +E.... +....kn...........l.. 239.255.255.250.ssdp: UDP, length 179 +E......................l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.325282 IP 192.168.97.0.50148 > 239.255.255.250.ssdp: UDP, length 179 +E.............a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.325412 IP 192.168.194.0.61830 > 239.255.255.250.ssdp: UDP, length 179 +E...X..................l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.325413 IP 192.168.139.3.62636 > 239.255.255.250.ssdp: UDP, length 179 +E.... +....kn...........l.. 239.255.255.250.ssdp: UDP, length 179 +E......................l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.325415 IP 192.168.97.0.50148 > 239.255.255.250.ssdp: UDP, length 179 +E.............a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:49:45.325562 IP mac.lan.ssdp > mac.lan.54250: UDP, length 367 +E....*..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:49:45.325635 IP mac.lan.ssdp > mac.lan.54250: UDP, length 367 +E....*..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:49:56.940588 IP pizzicato.lan.56890 > 239.255.255.250.ssdp: UDP, length 513 +E....p@..............:.l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:56.941441 IP pizzicato.lan.56890 > 239.255.255.250.ssdp: UDP, length 522 +E..&.q@..............:.l..\RNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:56.942349 IP pizzicato.lan.56890 > 239.255.255.250.ssdp: UDP, length 565 +E..Q.r@..............:.l.=+hNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:56.943299 IP pizzicato.lan.56890 > 239.255.255.250.ssdp: UDP, length 577 +E..].s@..............:.l.I.INOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:56.944200 IP pizzicato.lan.56890 > 239.255.255.250.ssdp: UDP, length 579 +E.._.t@..............:.l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.043034 IP pizzicato.lan.56890 > 239.255.255.250.ssdp: UDP, length 513 +E.....@..............:.l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.043682 IP pizzicato.lan.56890 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@..............:.l..\RNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.044609 IP pizzicato.lan.56890 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@..............:.l.=+hNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.045526 IP pizzicato.lan.56890 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@..............:.l.I.INOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.046347 IP pizzicato.lan.56890 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@..............:.l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.047074 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 358 +E....t@....2...".......l.n=LNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: upnp:rootdevice +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf::upnp:rootdevice + + +17:49:57.047455 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 367 +E....u@....(...".......l.w}.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: uuid:526dedec-fde2-4224-bac6-06f7b11711cf +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf + + +17:49:57.048271 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 410 +E....v@........".......l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf::urn:schemas-upnp-org:device:MediaServer:1 + + +17:49:57.048900 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 367 +E....w@....&...".......l.w}.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: uuid:526dedec-fde2-4224-bac6-06f7b11711cf +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf + + +17:49:57.049747 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 422 +E....x@........".......l..).NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:49:57.050132 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 367 +E....y@....$...".......l.w}.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: uuid:526dedec-fde2-4224-bac6-06f7b11711cf +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf + + +17:49:57.051175 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 424 +E....z@........".......l...[NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:49:57.051177 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 142 +E....{@........".......l.."LM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +USER-AGENT: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +MAN: "ssdp:discover" +ST: ssdp:all +MX: 3 + + +17:49:57.051608 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\....@.o........".l...H.'HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + + +17:49:57.051673 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..S%...@..........".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:49:57.051707 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 363 +E.......@..].......".l...sP.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:device:MediaServer:1 + + +17:49:57.051740 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E....-..@.e0.......".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:49:57.051770 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E....x..@.%........".l.....LHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:49:57.051803 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\:...@..........".l...H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + + +17:49:57.051831 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..Sa ..@..........".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:49:57.051862 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 367 +E...H]..@..........".l...wx.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:49:57.051891 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 365 +E.......@. ........".l...u..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:AVTransport:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:AVTransport:1 + + +17:49:57.051921 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E....@..@.=........".l.....}HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:49:57.051955 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E...a...@..].......".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:RenderingControl:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:RenderingControl:1 + + +17:49:57.145410 IP pizzicato.lan.56890 > 239.255.255.250.ssdp: UDP, length 513 +E.....@..............:.l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.146181 IP pizzicato.lan.56890 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@..............:.l..\RNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.147168 IP pizzicato.lan.56890 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@..............:.l.=+hNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.148073 IP pizzicato.lan.56890 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@..............:.l.I.INOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.149101 IP pizzicato.lan.56890 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@..............:.l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.149725 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 424 +E.....@........".......l...[NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:49:57.150121 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 367 +E.....@........".......l.w}.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: uuid:526dedec-fde2-4224-bac6-06f7b11711cf +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf + + +17:49:57.151093 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 422 +E.....@........".......l..).NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:49:57.151337 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 367 +E.....@........".......l.w}.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: uuid:526dedec-fde2-4224-bac6-06f7b11711cf +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf + + +17:49:57.152385 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 410 +E.....@........".......l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf::urn:schemas-upnp-org:device:MediaServer:1 + + +17:49:57.153025 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 367 +E.....@........".......l.w}.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: uuid:526dedec-fde2-4224-bac6-06f7b11711cf +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf + + +17:49:57.153640 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 358 +E.....@........".......l.n=LNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: upnp:rootdevice +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf::upnp:rootdevice + + +17:49:57.153643 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 142 +E.....@........".......l.."LM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +USER-AGENT: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +MAN: "ssdp:discover" +ST: ssdp:all +MX: 3 + + +17:49:57.154091 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\....@..........".l...H.'HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + + +17:49:57.154146 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..S....@..........".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:49:57.154174 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 363 +E...D...@..........".l...sP.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:device:MediaServer:1 + + +17:49:57.154240 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E...Y ..@..=.......".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:49:57.154290 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E...n...@..=.......".l.....LHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:49:57.154326 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\.|..@.A........".l...H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + + +17:49:57.154357 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..Sy...@.}........".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:49:57.154385 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 367 +E....x..@.h........".l...wx.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:49:57.154414 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 365 +E.......@..........".l...u..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:AVTransport:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:AVTransport:1 + + +17:49:57.154445 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E...k=..@..........".l.....}HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:49:57.154473 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E...2...@..........".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:RenderingControl:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:RenderingControl:1 + + +17:49:57.554968 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 492 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.555779 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 501 +E.....@....{...........l..;(NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.556721 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....[...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.557449 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....\...........l..qINOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.558387 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@....W...........l...WNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.559236 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@....L...........l.(.$NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.559811 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@....Q...........l.".tNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.561005 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....V...........l..V.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.561823 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....W...........l..tLNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.562779 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@....P...........l. w.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.657016 IP 192.168.0.254.ssdp > 239.255.255.250.ssdp: UDP, length 271 +E..+.J@..............l.l..|.NOTIFY * HTTP/1.1 +SERVER: Linux/2.6 UPnP/1.0 fbxigdd/1.1 +HOST: 239.255.255.250:1900 +LOCATION: http://192.168.0.254:5678/desc/root +CACHE-CONTROL: max-age=2700 +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:igd73616d61-6a65-7374-650a-2066cf5e30fb::upnp:rootdevice + + +17:49:57.657862 IP 192.168.0.254.ssdp > 239.255.255.250.ssdp: UDP, length 283 +E..7.K@..............l.l.#..NOTIFY * HTTP/1.1 +SERVER: Linux/2.6 UPnP/1.0 fbxigdd/1.1 +HOST: 239.255.255.250:1900 +LOCATION: http://192.168.0.254:5678/desc/root +CACHE-CONTROL: max-age=2700 +NT: uuid:igd73616d61-6a65-7374-650a-2066cf5e30fb +NTS: ssdp:alive +USN: uuid:igd73616d61-6a65-7374-650a-2066cf5e30fb + + +17:49:57.658058 IP 192.168.0.254.ssdp > 239.255.255.250.ssdp: UDP, length 343 +E..s.L@..............l.l._..NOTIFY * HTTP/1.1 +SERVER: Linux/2.6 UPnP/1.0 fbxigdd/1.1 +HOST: 239.255.255.250:1900 +LOCATION: http://192.168.0.254:5678/desc/root +CACHE-CONTROL: max-age=2700 +NT: urn:schemas-upnp-org:device:InternetGatewayDevice:1 +NTS: ssdp:alive +USN: uuid:igd73616d61-6a65-7374-650a-2066cf5e30fb::urn:schemas-upnp-org:device:InternetGatewayDevice:1 + + +17:49:57.658644 IP 192.168.0.254.ssdp > 239.255.255.250.ssdp: UDP, length 335 +E..k.M@..............l.l.W.sNOTIFY * HTTP/1.1 +SERVER: Linux/2.6 UPnP/1.0 fbxigdd/1.1 +HOST: 239.255.255.250:1900 +LOCATION: http://192.168.0.254:5678/desc/root +CACHE-CONTROL: max-age=2700 +NT: urn:schemas-upnp-org:service:Layer3Forwarding:1 +NTS: ssdp:alive +USN: uuid:igd73616d61-6a65-7374-650a-2066cf5e30fb::urn:schemas-upnp-org:service:Layer3Forwarding:1 + + +17:49:57.659140 IP 192.168.0.254.ssdp > 239.255.255.250.ssdp: UDP, length 283 +E..7.N@..............l.l.#.'NOTIFY * HTTP/1.1 +SERVER: Linux/2.6 UPnP/1.0 fbxigdd/1.1 +HOST: 239.255.255.250:1900 +LOCATION: http://192.168.0.254:5678/desc/root +CACHE-CONTROL: max-age=2700 +NT: uuid:wan73616d61-6a65-7374-650a-2066cf5e30fb +NTS: ssdp:alive +USN: uuid:wan73616d61-6a65-7374-650a-2066cf5e30fb + + +17:49:57.659717 IP 192.168.0.254.ssdp > 239.255.255.250.ssdp: UDP, length 319 +E..[.O@..............l.l.GN.NOTIFY * HTTP/1.1 +SERVER: Linux/2.6 UPnP/1.0 fbxigdd/1.1 +HOST: 239.255.255.250:1900 +LOCATION: http://192.168.0.254:5678/desc/root +CACHE-CONTROL: max-age=2700 +NT: urn:schemas-upnp-org:device:WANDevice:1 +NTS: ssdp:alive +USN: uuid:wan73616d61-6a65-7374-650a-2066cf5e30fb::urn:schemas-upnp-org:device:WANDevice:1 + + +17:49:57.660326 IP 192.168.0.254.ssdp > 239.255.255.250.ssdp: UDP, length 351 +E..{.P@..............l.l.g.ENOTIFY * HTTP/1.1 +SERVER: Linux/2.6 UPnP/1.0 fbxigdd/1.1 +HOST: 239.255.255.250:1900 +LOCATION: http://192.168.0.254:5678/desc/root +CACHE-CONTROL: max-age=2700 +NT: urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1 +NTS: ssdp:alive +USN: uuid:wan73616d61-6a65-7374-650a-2066cf5e30fb::urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1 + + +17:49:57.660858 IP 192.168.0.254.ssdp > 239.255.255.250.ssdp: UDP, length 285 +E..9.Q@..............l.l.%}.NOTIFY * HTTP/1.1 +SERVER: Linux/2.6 UPnP/1.0 fbxigdd/1.1 +HOST: 239.255.255.250:1900 +LOCATION: http://192.168.0.254:5678/desc/root +CACHE-CONTROL: max-age=2700 +NT: uuid:wanc73616d61-6a65-7374-650a-2066cf5e30fb +NTS: ssdp:alive +USN: uuid:wanc73616d61-6a65-7374-650a-2066cf5e30fb + + +17:49:57.661459 IP 192.168.0.254.ssdp > 239.255.255.250.ssdp: UDP, length 340 +E..p.R@..............l.l.\..NOTIFY * HTTP/1.1 +SERVER: Linux/2.6 UPnP/1.0 fbxigdd/1.1 +HOST: 239.255.255.250:1900 +LOCATION: http://192.168.0.254:5678/desc/root +CACHE-CONTROL: max-age=2700 +NT: urn:schemas-upnp-org:device:WANConnectionDevice:1 +NTS: ssdp:alive +USN: uuid:wanc73616d61-6a65-7374-650a-2066cf5e30fb::urn:schemas-upnp-org:device:WANConnectionDevice:1 + + +17:49:57.662046 IP 192.168.0.254.ssdp > 239.255.255.250.ssdp: UDP, length 334 +E..j.S@..............l.l.VA.NOTIFY * HTTP/1.1 +SERVER: Linux/2.6 UPnP/1.0 fbxigdd/1.1 +HOST: 239.255.255.250:1900 +LOCATION: http://192.168.0.254:5678/desc/root +CACHE-CONTROL: max-age=2700 +NT: urn:schemas-upnp-org:service:WANIPConnection:1 +NTS: ssdp:alive +USN: uuid:wanc73616d61-6a65-7374-650a-2066cf5e30fb::urn:schemas-upnp-org:service:WANIPConnection:1 + + +17:49:57.662854 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 492 +E.....@....s...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.663709 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 501 +E.....@....i...........l..;(NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.664552 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 532 +E..0. @....I...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.665450 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 530 +E.... +@....J...........l..qINOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.666320 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@....E...........l...WNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.667142 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@....:...........l.(.$NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.667967 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@....?...........l.".tNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.668828 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....D...........l..V.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.669703 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....E...........l..tLNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.670557 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@....>...........l. w.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.759699 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 492 +E.....@....`...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.760436 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 501 +E.....@....V...........l..;(NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.761293 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....6...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.762146 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....7...........l..qINOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.762991 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@....2...........l...WNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.763865 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@....'...........l.(.$NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.764725 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 538 +E..6. @....,...........l.".tNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.765583 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 532 +E..0.!@....1...........l..V.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.766447 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 530 +E...."@....2...........l..tLNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.767306 IP pizzicato.lan.59142 > 239.255.255.250.ssdp: UDP, length 536 +E..4.#@....+...........l. w.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:49:57.964049 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 142 +E.....@........".......l.."LM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +USER-AGENT: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +MAN: "ssdp:discover" +ST: ssdp:all +MX: 3 + + +17:49:57.964582 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 358 +E.....@........".......l.n=LNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: upnp:rootdevice +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf::upnp:rootdevice + + +17:49:57.964603 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\u...@..........".l...H.'HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + + +17:49:57.964649 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..S....@.G........".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:49:57.964682 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 363 +E....b..@.O........".l...sP.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:device:MediaServer:1 + + +17:49:57.964711 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E.......@.Vm.......".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:49:57.964788 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E...g|..@..........".l.....LHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:49:57.964861 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\....@..........".l...H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + + +17:49:57.964900 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..S(...@..........".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:49:57.964931 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 367 +E... +...@..........".l...wx.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:49:57.964961 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 365 +E...mf..@..........".l...u..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:AVTransport:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:AVTransport:1 + + +17:49:57.964991 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E...T...@..........".l.....}HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:49:57.965023 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E...O...@..k.......".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:RenderingControl:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:RenderingControl:1 + + +17:49:57.965226 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 367 +E.....@........".......l.w}.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: uuid:526dedec-fde2-4224-bac6-06f7b11711cf +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf + + +17:49:57.965866 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 410 +E.....@....}...".......l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf::urn:schemas-upnp-org:device:MediaServer:1 + + +17:49:57.966321 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 367 +E.....@........".......l.w}.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: uuid:526dedec-fde2-4224-bac6-06f7b11711cf +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf + + +17:49:57.967270 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 422 +E.....@....o...".......l..).NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:49:57.967891 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 367 +E.....@........".......l.w}.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: uuid:526dedec-fde2-4224-bac6-06f7b11711cf +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf + + +17:49:57.968692 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 424 +E.....@....k...".......l...[NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +DATE: Wed, 03 Dec 2025 16:49:57 GMT +CACHE-CONTROL: max-age = 1800 +LOCATION: http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml +SERVER: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +NTS: ssdp:alive +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:526dedec-fde2-4224-bac6-06f7b11711cf::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:50:12.095634 IP pizzicato.lan.37933 > 239.255.255.250.ssdp: UDP, length 513 +E....e@..............-.l. R.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.096418 IP pizzicato.lan.37933 > 239.255.255.250.ssdp: UDP, length 522 +E..&.f@..............-.l..._NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.097323 IP pizzicato.lan.37933 > 239.255.255.250.ssdp: UDP, length 565 +E..Q.g@..............-.l.=uuNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.098244 IP pizzicato.lan.37933 > 239.255.255.250.ssdp: UDP, length 577 +E..].h@..............-.l.I.WNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.098893 IP pizzicato.lan.37933 > 239.255.255.250.ssdp: UDP, length 579 +E.._.i@..............-.l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.198110 IP pizzicato.lan.37933 > 239.255.255.250.ssdp: UDP, length 513 +E.....@..............-.l. R.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.198930 IP pizzicato.lan.37933 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@..............-.l..._NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.199769 IP pizzicato.lan.37933 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@..............-.l.=uuNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.200657 IP pizzicato.lan.37933 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@..............-.l.I.WNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.201538 IP pizzicato.lan.37933 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@..............-.l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.300470 IP pizzicato.lan.37933 > 239.255.255.250.ssdp: UDP, length 513 +E.....@..............-.l. R.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.301299 IP pizzicato.lan.37933 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@..............-.l..._NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.301916 IP pizzicato.lan.37933 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@..............-.l.=uuNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.303176 IP pizzicato.lan.37933 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@..............-.l.I.WNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.303782 IP pizzicato.lan.37933 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@..............-.l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.812510 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 492 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.813320 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 501 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.814041 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....p...........l...ONOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.814888 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....q...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.815819 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@....l...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.816606 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@....a...........l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.817528 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@....f...........l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.818414 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....k...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.819244 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....l...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.819841 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@....e...........l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.820642 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 492 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.821688 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 501 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.822614 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....b...........l...ONOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.823265 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....c...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.824316 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@....^...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.825158 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@....S...........l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.826074 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@....X...........l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.826600 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....]...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.827752 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....^...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.828300 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@....W...........l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.914834 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 492 +E.....@....~...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.915640 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 501 +E.....@....t...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.916181 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....T...........l...ONOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.917380 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....U...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.918233 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@....P...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.919074 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@....E...........l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.919810 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@....J...........l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.920783 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....O...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.921343 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....P...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:12.922360 IP pizzicato.lan.33676 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@....I...........l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.353294 IP pizzicato.lan.52562 > 239.255.255.250.ssdp: UDP, length 513 +E.....@... +..........R.l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.354118 IP pizzicato.lan.52562 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@... +..........R.l..m:NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.354946 IP pizzicato.lan.52562 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@... +..........R.l.= 239.255.255.250.ssdp: UDP, length 577 +E..]..@... +s.........R.l.I.1NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.356833 IP pizzicato.lan.52562 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@... +p.........R.l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.455579 IP pizzicato.lan.52562 > 239.255.255.250.ssdp: UDP, length 513 +E.....@... +..........R.l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.456376 IP pizzicato.lan.52562 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@... +..........R.l..m:NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.457364 IP pizzicato.lan.52562 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@... +t.........R.l.= 239.255.255.250.ssdp: UDP, length 577 +E..]..@... +g.........R.l.I.1NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.459068 IP pizzicato.lan.52562 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@... +d.........R.l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.557997 IP pizzicato.lan.52562 > 239.255.255.250.ssdp: UDP, length 513 +E.....@... +..........R.l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.558741 IP pizzicato.lan.52562 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@... +..........R.l..m:NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.559625 IP pizzicato.lan.52562 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@... +\.........R.l.= 239.255.255.250.ssdp: UDP, length 577 +E..]..@... +O.........R.l.I.1NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.561536 IP pizzicato.lan.52562 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@... +L.........R.l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.967718 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 492 +E.....@... +..........+.l...nNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.968733 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 501 +E.....@... +..........+.l..N.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.969195 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@... +_.........+.l..1.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.970056 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 530 +E.....@... +`.........+.l...$NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.970908 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@... +[.........+.l...2NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.971751 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@... +P.........+.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.972652 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@... +U.........+.l.".ONOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.973563 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@... +Z.........+.l..izNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.974354 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 530 +E.....@... +[.........+.l...'NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:27.974924 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@... +T.........+.l. .iNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.069940 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 492 +E.....@... +x.........+.l...nNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.070683 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 501 +E.....@... +n.........+.l..N.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.071543 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@... +N.........+.l..1.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.072392 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 530 +E.....@... +O.........+.l...$NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.073054 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@... +J.........+.l...2NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.074143 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@... +?.........+.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.075086 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@... +D.........+.l.".ONOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.075624 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 532 +E..0. @... +I.........+.l..izNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.076659 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 530 +E.... +@... +J.........+.l...'NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.077310 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@... +C.........+.l. .iNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.172260 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 492 +E.....@... +_.........+.l...nNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.173013 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 501 +E.....@... +U.........+.l..N.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.173874 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@... +5.........+.l..1.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.174576 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 530 +E.....@... +6.........+.l...$NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.175459 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@... +1.........+.l...2NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.176215 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 544 +E..<. @... +&.........+.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.177194 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 538 +E..6.!@... ++.........+.l.".ONOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.178192 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 532 +E..0."@... +0.........+.l..izNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.179011 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 530 +E....#@... +1.........+.l...'NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:28.179953 IP pizzicato.lan.54315 > 239.255.255.250.ssdp: UDP, length 536 +E..4.$@... +*.........+.l. .iNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:37.899895 IP xantico.pmo.56369 > 239.255.255.250.ssdp: UDP, length 90 +E..v..@...C....$.....1.l.bGnM-SEARCH * HTTP/1.1 +HOST:239.255.255.250:1900 +MAN:"ssdp:discover" +MX:4 +ST:ssdp:all + + +17:50:37.899902 IP xantico.pmo.41767 > broadcasthost.ssdp: UDP, length 90 +E..v..@.@.~....$.....'.l.bpsM-SEARCH * HTTP/1.1 +HOST:239.255.255.250:1900 +MAN:"ssdp:discover" +MX:4 +ST:ssdp:all + + +17:50:37.900792 IP xantico.pmo.56369 > broadcasthost.ssdp: UDP, length 90 +E..v..@.@.}....$.....1.l.b7iM-SEARCH * HTTP/1.1 +HOST:239.255.255.250:1900 +MAN:"ssdp:discover" +MX:4 +ST:ssdp:all + + +17:50:37.901340 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 320 +E..\....@. +........$.l.1.H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + + +17:50:37.901391 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 311 +E..So,..@..o.......$.l.1.?.WHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:50:37.901423 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 363 +E.......@..M.......$.l.1.s6.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:device:MediaServer:1 + + +17:50:37.901459 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 375 +E....8..@..#.......$.l.1..y.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:50:37.901486 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 377 +E.......@..........$.l.1....HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:50:37.901514 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 320 +E..\0...@..........$.l.1.H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + + +17:50:37.901545 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 311 +E..SU ..@..{.......$.l.1.?u.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:50:37.901574 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 367 +E.......@..........$.l.1.w^HHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:37.901604 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 365 +E...f...@..........$.l.1.uk.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:AVTransport:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:AVTransport:1 + + +17:50:37.901633 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 377 +E.......@..t.......$.l.1... xantico.pmo.56369: UDP, length 375 +E....Y..@..........$.l.1..sMHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:RenderingControl:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:RenderingControl:1 + + +17:50:37.901706 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 320 +E..\....@..........$.l.1.H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + + +17:50:37.901734 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 311 +E..S$...@..........$.l.1.?.WHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:50:37.901763 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 363 +E....R..@..........$.l.1.s6.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:device:MediaServer:1 + + +17:50:37.901789 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 375 +E.......@.AI.......$.l.1..y.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:50:37.901818 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 377 +E...a$..@..5.......$.l.1....HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:50:37.901847 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 320 +E..\....@.T|.......$.l.1.H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + + +17:50:37.901875 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 311 +E..S./..@.wl.......$.l.1.?u.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:50:37.901959 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 367 +E....5..@..........$.l.1.w^HHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:37.902006 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 365 +E...S...@..........$.l.1.uk.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:AVTransport:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:AVTransport:1 + + +17:50:37.902040 IP mac.lan.ssdp > xantico.pmo.56369: UDP, length 377 +E.......@..........$.l.1... xantico.pmo.56369: UDP, length 375 +E....D..@.K........$.l.1..sMHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:RenderingControl:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:RenderingControl:1 + + +17:50:37.902126 IP mac.lan.ssdp > xantico.pmo.41767: UDP, length 320 +E..\....@.E........$.l.'.H<.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + + +17:50:37.902155 IP mac.lan.ssdp > xantico.pmo.41767: UDP, length 311 +E..S#Z..@..A.......$.l.'.?.aHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:50:37.902180 IP mac.lan.ssdp > xantico.pmo.41767: UDP, length 363 +E...4...@..........$.l.'.so.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:device:MediaServer:1 + + +17:50:37.902204 IP mac.lan.ssdp > xantico.pmo.41767: UDP, length 375 +E...+...@..q.......$.l.'....HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:50:37.902233 IP mac.lan.ssdp > xantico.pmo.41767: UDP, length 377 +E.......@.%X.......$.l.'....HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:50:37.902258 IP mac.lan.ssdp > xantico.pmo.41767: UDP, length 320 +E..\.d..@.G........$.l.'.H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + + +17:50:37.902286 IP mac.lan.ssdp > xantico.pmo.41767: UDP, length 311 +E..S....@.Z........$.l.'.?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:50:37.902312 IP mac.lan.ssdp > xantico.pmo.41767: UDP, length 367 +E.......@.q\.......$.l.'.w.RHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:37.902341 IP mac.lan.ssdp > xantico.pmo.41767: UDP, length 365 +E...?...@..........$.l.'.u..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:AVTransport:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:AVTransport:1 + + +17:50:37.902368 IP mac.lan.ssdp > xantico.pmo.41767: UDP, length 377 +E...)...@..........$.l.'.. GHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:50:37.902395 IP mac.lan.ssdp > xantico.pmo.41767: UDP, length 375 +E.......@..h.......$.l.'...WHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:37 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:RenderingControl:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:RenderingControl:1 + + +17:50:42.509406 IP pizzicato.lan.53975 > 239.255.255.250.ssdp: UDP, length 513 +E.....@................l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:42.510268 IP pizzicato.lan.53975 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@................l..g.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:42.511163 IP pizzicato.lan.53975 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@....u...........l.=6.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:42.512075 IP pizzicato.lan.53975 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@....h...........l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:42.512979 IP pizzicato.lan.53975 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@....e...........l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:42.610656 IP pizzicato.lan.53975 > 239.255.255.250.ssdp: UDP, length 513 +E.....@................l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:42.611441 IP pizzicato.lan.53975 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@................l..g.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:42.612285 IP pizzicato.lan.53975 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@....d...........l.=6.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:42.613207 IP pizzicato.lan.53975 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@....W...........l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:42.613813 IP pizzicato.lan.53975 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@....T...........l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:42.713005 IP pizzicato.lan.53975 > 239.255.255.250.ssdp: UDP, length 513 +E.....@................l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:42.713810 IP pizzicato.lan.53975 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@................l..g.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:42.714568 IP pizzicato.lan.53975 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@....X...........l.=6.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:42.715646 IP pizzicato.lan.53975 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@....K...........l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:42.716250 IP pizzicato.lan.53975 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@....H...........l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.124363 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 492 +E.....@..............;.l...^NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.125136 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 501 +E.....@....y.........;.l..z.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.125975 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....Y.........;.l..^.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.126814 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....Z.........;.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.127540 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@....U.........;.l..."NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.128353 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@....J.........;.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.129297 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@....O.........;.l.".?NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.130269 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....T.........;.l...jNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.131108 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....U.........;.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.131714 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@....N.........;.l. .YNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.226775 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 492 +E.....@....j.........;.l...^NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.227486 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 501 +E.....@....`.........;.l..z.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.228368 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....@.........;.l..^.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.229102 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....A.........;.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.230022 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@....<.........;.l..."NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.230737 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@....1.........;.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.231791 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@....6.........;.l.".?NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.232708 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....;.........;.l...jNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.233456 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....<.........;.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.234529 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@....5.........;.l. .YNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.330480 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 492 +E....&@....T.........;.l...^NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.330796 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 532 +E..0.(@....*.........;.l..^.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.330798 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 530 +E....)@....+.........;.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.332718 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 544 +E..<.+@..............;.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.333080 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 538 +E..6.,@.... .........;.l.".?NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.334108 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 532 +E..0.-@....%.........;.l...jNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.335041 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....&.........;.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:43.335755 IP pizzicato.lan.42811 > 239.255.255.250.ssdp: UDP, length 536 +E..4./@..............;.l. .YNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:45.328615 IP localhost.59976 > 239.255.255.250.ssdp: UDP, length 179 +E...q................H.l..o.M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.328632 IP localhost.59976 > 239.255.255.250.ssdp: UDP, length 179 +E...q................H.l..o.M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.328868 IP localhost.ssdp > localhost.59976: UDP, length 367 +E...v...@............l.H.w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:45.328881 IP localhost.ssdp > localhost.59976: UDP, length 367 +E...v...@............l.H.w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:45.329043 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E...0..................l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.329067 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E...0..................l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.329070 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E...0..................l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.329421 IP 192.168.194.0.64983 > 239.255.255.250.ssdp: UDP, length 179 +E...X).....Q...........l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.329457 IP 192.168.194.0.64983 > 239.255.255.250.ssdp: UDP, length 179 +E...X).....Q...........l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.329502 IP 192.168.139.3.50766 > 239.255.255.250.ssdp: UDP, length 179 +E....................N.l.. 239.255.255.250.ssdp: UDP, length 179 +E....................N.l.. mac.lan.52920: UDP, length 367 +E....2..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:45.329791 IP mac.lan.ssdp > mac.lan.52920: UDP, length 367 +E....2..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:45.330264 IP 192.168.215.0.63837 > 239.255.255.250.ssdp: UDP, length 179 +E....{...............].l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.330277 IP 192.168.215.0.63837 > 239.255.255.250.ssdp: UDP, length 179 +E....{...............].l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.330456 IP 192.168.97.0.49160 > 239.255.255.250.ssdp: UDP, length 179 +E....,.....N..a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.330468 IP 192.168.97.0.49160 > 239.255.255.250.ssdp: UDP, length 179 +E....,.....N..a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.430853 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E......................l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.430866 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E......................l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.430902 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E......................l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.431107 IP 192.168.194.0.64983 > 239.255.255.250.ssdp: UDP, length 179 +E... +S....<(...........l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.431117 IP 192.168.194.0.64983 > 239.255.255.250.ssdp: UDP, length 179 +E... +S....<(...........l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.431176 IP 192.168.139.3.50766 > 239.255.255.250.ssdp: UDP, length 179 +E...s2.... +F.........N.l.. mac.lan.52920: UDP, length 367 +E...E...@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:45.431193 IP 192.168.139.3.50766 > 239.255.255.250.ssdp: UDP, length 179 +E...s2.... +F.........N.l.. mac.lan.52920: UDP, length 367 +E...E...@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:45.431202 IP 192.168.215.0.63837 > 239.255.255.250.ssdp: UDP, length 179 +E....P....^*.........].l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.431219 IP 192.168.97.0.49160 > 239.255.255.250.ssdp: UDP, length 179 +E....5.....F..a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.431241 IP 192.168.215.0.63837 > 239.255.255.250.ssdp: UDP, length 179 +E....P....^*.........].l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.431242 IP 192.168.97.0.49160 > 239.255.255.250.ssdp: UDP, length 179 +E....5.....F..a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.532028 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E.........&+...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.532043 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E.........&+...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.532067 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E.........&+...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.532114 IP 192.168.194.0.64983 > 239.255.255.250.ssdp: UDP, length 179 +E......................l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.532150 IP 192.168.139.3.50766 > 239.255.255.250.ssdp: UDP, length 179 +E....;.....<.........N.l.. 239.255.255.250.ssdp: UDP, length 179 +E...M................].l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.532180 IP 192.168.97.0.49160 > 239.255.255.250.ssdp: UDP, length 179 +E...K.....[...a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.532219 IP 192.168.194.0.64983 > 239.255.255.250.ssdp: UDP, length 179 +E......................l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.532220 IP 192.168.139.3.50766 > 239.255.255.250.ssdp: UDP, length 179 +E....;.....<.........N.l.. 239.255.255.250.ssdp: UDP, length 179 +E...M................].l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.532221 IP 192.168.97.0.49160 > 239.255.255.250.ssdp: UDP, length 179 +E...K.....[...a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.532383 IP mac.lan.ssdp > mac.lan.52920: UDP, length 367 +E...o?..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:45.532395 IP mac.lan.ssdp > mac.lan.52920: UDP, length 367 +E...o?..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:45.633757 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E....{....[v...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.633779 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E....{....[v...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.633811 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E....{....[v...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.633854 IP 192.168.194.0.64983 > 239.255.255.250.ssdp: UDP, length 179 +E....r.................l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.634077 IP 192.168.194.0.64983 > 239.255.255.250.ssdp: UDP, length 179 +E....r.................l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.634096 IP 192.168.139.3.50766 > 239.255.255.250.ssdp: UDP, length 179 +E....................N.l.. mac.lan.52920: UDP, length 367 +E...J...@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:45.634167 IP 192.168.215.0.63837 > 239.255.255.250.ssdp: UDP, length 179 +E.........E..........].l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.634171 IP 192.168.139.3.50766 > 239.255.255.250.ssdp: UDP, length 179 +E....................N.l.. mac.lan.52920: UDP, length 367 +E...J...@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:45.634209 IP 192.168.97.0.49160 > 239.255.255.250.ssdp: UDP, length 179 +E....,.....N..a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.634267 IP 192.168.215.0.63837 > 239.255.255.250.ssdp: UDP, length 179 +E.........E..........].l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.634269 IP 192.168.97.0.49160 > 239.255.255.250.ssdp: UDP, length 179 +E....,.....N..a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.734519 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E....Y....I............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.734536 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E....Y....I............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.734573 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E....Y....I............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.734625 IP 192.168.194.0.64983 > 239.255.255.250.ssdp: UDP, length 179 +E.........|............l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.734668 IP 192.168.139.3.50766 > 239.255.255.250.ssdp: UDP, length 179 +E....................N.l.. 239.255.255.250.ssdp: UDP, length 179 +E....r....4..........].l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.734734 IP 192.168.97.0.49160 > 239.255.255.250.ssdp: UDP, length 179 +E...<.....j...a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.734809 IP 192.168.194.0.64983 > 239.255.255.250.ssdp: UDP, length 179 +E.........|............l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.734811 IP 192.168.139.3.50766 > 239.255.255.250.ssdp: UDP, length 179 +E....................N.l.. 239.255.255.250.ssdp: UDP, length 179 +E....r....4..........].l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.734814 IP 192.168.97.0.49160 > 239.255.255.250.ssdp: UDP, length 179 +E...<.....j...a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.734904 IP mac.lan.ssdp > mac.lan.52920: UDP, length 367 +E...o4..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:45.735092 IP mac.lan.ssdp > mac.lan.52920: UDP, length 367 +E...o4..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:45.835506 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E...I......D...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.835542 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E...I......D...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.835607 IP mac.lan.52920 > 239.255.255.250.ssdp: UDP, length 179 +E...I......D...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.835876 IP 192.168.194.0.64983 > 239.255.255.250.ssdp: UDP, length 179 +E...a......|...........l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.835890 IP 192.168.194.0.64983 > 239.255.255.250.ssdp: UDP, length 179 +E...a......|...........l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.836023 IP mac.lan.ssdp > mac.lan.52920: UDP, length 367 +E.......@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:45.836040 IP mac.lan.ssdp > mac.lan.52920: UDP, length 367 +E.......@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:45.836126 IP 192.168.139.3.50766 > 239.255.255.250.ssdp: UDP, length 179 +E....s....z..........N.l.. 239.255.255.250.ssdp: UDP, length 179 +E....s....z..........N.l.. 239.255.255.250.ssdp: UDP, length 179 +E.........Ce.........].l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.836215 IP 192.168.97.0.49160 > 239.255.255.250.ssdp: UDP, length 179 +E....J.....1..a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.836226 IP 192.168.215.0.63837 > 239.255.255.250.ssdp: UDP, length 179 +E.........Ce.........].l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:45.836228 IP 192.168.97.0.49160 > 239.255.255.250.ssdp: UDP, length 179 +E....J.....1..a........l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:50:57.048411 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 142 +E...O2@...vL...".......l.."LM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +USER-AGENT: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +MAN: "ssdp:discover" +ST: ssdp:all +MX: 3 + + +17:50:57.050177 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\....@.E........".l...H.0HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + + +17:50:57.050222 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..S$&..@..w.......".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:50:57.050249 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 363 +E.......@.%........".l...sO.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:device:MediaServer:1 + + +17:50:57.050273 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E....q..@.3........".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:50:57.050329 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E.......@..........".l.....UHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:50:57.050369 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\.`..@..4.......".l...H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + + +17:50:57.050393 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..S....@.5........".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:50:57.050421 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 367 +E...sH..@..........".l...ww.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:57.050444 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 365 +E...w*..@..=.......".l...u..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:AVTransport:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:AVTransport:1 + + +17:50:57.050472 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E.......@..........".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:50:57.050506 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E...I^..@..........".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:RenderingControl:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:RenderingControl:1 + + +17:50:57.150850 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 142 +E...O.@...u....".......l.."LM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +USER-AGENT: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +MAN: "ssdp:discover" +ST: ssdp:all +MX: 3 + + +17:50:57.151226 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\.]..@..7.......".l...H.0HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + + +17:50:57.151263 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..SdG..@..V.......".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:50:57.151287 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 363 +E.......@..L.......".l...sO.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:device:MediaServer:1 + + +17:50:57.151315 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E...=...@..e.......".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:50:57.151343 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E...P...@..o.......".l.....UHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:50:57.151369 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\....@.J........".l...H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + + +17:50:57.151391 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..S8...@..........".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:50:57.151416 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 367 +E....u..@. ........".l...ww.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:57.151440 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 365 +E.......@..P.......".l...u..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:AVTransport:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:AVTransport:1 + + +17:50:57.151464 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E.......@.t........".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:50:57.151511 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E.......@. +h.......".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:RenderingControl:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:RenderingControl:1 + + +17:50:57.253213 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 142 +E...O.@...u....".......l.."LM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +USER-AGENT: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +MAN: "ssdp:discover" +ST: ssdp:all +MX: 3 + + +17:50:57.253609 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\+#..@..q.......".l...H.0HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + + +17:50:57.253695 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..S....@.d........".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:50:57.253727 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 363 +E....n..@.h........".l...sO.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:device:MediaServer:1 + + +17:50:57.253754 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E....<..@..!.......".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:50:57.253787 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E..."...@..........".l.....UHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:50:57.253810 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\....@..........".l...H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + + +17:50:57.253833 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..S....@.=........".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:50:57.253857 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 367 +E....v..@..........".l...ww.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:50:57.253879 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 365 +E...s...@..u.......".l...u..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:AVTransport:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:AVTransport:1 + + +17:50:57.253903 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E....L..@..........".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:50:57.253925 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E....&..@..7.......".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:50:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:RenderingControl:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:RenderingControl:1 + + +17:50:57.765717 IP pizzicato.lan.35157 > 239.255.255.250.ssdp: UDP, length 513 +E.....@....T.........U.l. ].NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:57.766473 IP pizzicato.lan.35157 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@....J.........U.l...7NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:57.767372 IP pizzicato.lan.35157 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@..............U.l.=.MNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:57.768351 IP pizzicato.lan.35157 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@..............U.l.I./NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:57.768919 IP pizzicato.lan.35157 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@..............U.l.K).NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:57.868097 IP pizzicato.lan.35157 > 239.255.255.250.ssdp: UDP, length 513 +E.....@....F.........U.l. ].NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:57.868830 IP pizzicato.lan.35157 > 239.255.255.250.ssdp: UDP, length 522 +E..&. @....<.........U.l...7NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:57.869731 IP pizzicato.lan.35157 > 239.255.255.250.ssdp: UDP, length 565 +E..Q.!@..............U.l.=.MNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:57.870429 IP pizzicato.lan.35157 > 239.255.255.250.ssdp: UDP, length 577 +E..]."@..............U.l.I./NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:57.871416 IP pizzicato.lan.35157 > 239.255.255.250.ssdp: UDP, length 579 +E.._.#@..............U.l.K).NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:57.970509 IP pizzicato.lan.35157 > 239.255.255.250.ssdp: UDP, length 513 +E....*@....;.........U.l. ].NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:57.971269 IP pizzicato.lan.35157 > 239.255.255.250.ssdp: UDP, length 522 +E..&.+@....1.........U.l...7NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:57.972180 IP pizzicato.lan.35157 > 239.255.255.250.ssdp: UDP, length 565 +E..Q.,@..............U.l.=.MNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:57.973002 IP pizzicato.lan.35157 > 239.255.255.250.ssdp: UDP, length 577 +E..].-@..............U.l.I./NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:57.973917 IP pizzicato.lan.35157 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@..............U.l.K).NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.380047 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 492 +E....{@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.380830 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 501 +E....|@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.381674 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 532 +E..0.}@................l..r@NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.382549 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 530 +E....~@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.383498 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.384166 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@................l.( .NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.385134 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@................l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.386053 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@................l... +NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.386891 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 530 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.387474 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@................l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.482776 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 492 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.483700 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 501 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.484557 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@................l..r@NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.485371 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 530 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.486070 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.487019 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@................l.( .NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.487983 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@................l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.488841 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@................l... +NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.489691 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 530 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.490319 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@................l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.584866 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 492 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.585646 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 501 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.586479 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@................l..r@NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.587342 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 530 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.588181 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.589116 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@................l.( .NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.590019 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@................l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.590820 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@................l... +NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.591366 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 530 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:50:58.592154 IP pizzicato.lan.37787 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@................l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:12.920871 IP pizzicato.lan.50365 > 239.255.255.250.ssdp: UDP, length 513 +E.....@....]...........l. ".NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:12.921710 IP pizzicato.lan.50365 > 239.255.255.250.ssdp: UDP, length 522 +E..&. @....S...........l..u.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:12.922469 IP pizzicato.lan.50365 > 239.255.255.250.ssdp: UDP, length 565 +E..Q. +@....'...........l.=D.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:12.923435 IP pizzicato.lan.50365 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@................l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:12.924332 IP pizzicato.lan.50365 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@................l.K./NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.025394 IP pizzicato.lan.50365 > 239.255.255.250.ssdp: UDP, length 513 +E....!@....D...........l. ".NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.026566 IP pizzicato.lan.50365 > 239.255.255.250.ssdp: UDP, length 565 +E..Q.#@................l.=D.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.026571 IP pizzicato.lan.50365 > 239.255.255.250.ssdp: UDP, length 577 +E..].$@................l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.125693 IP pizzicato.lan.50365 > 239.255.255.250.ssdp: UDP, length 513 +E....0@....5...........l. ".NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.126482 IP pizzicato.lan.50365 > 239.255.255.250.ssdp: UDP, length 522 +E..&.1@....+...........l..u.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.127118 IP pizzicato.lan.50365 > 239.255.255.250.ssdp: UDP, length 565 +E..Q.2@................l.=D.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.128029 IP pizzicato.lan.50365 > 239.255.255.250.ssdp: UDP, length 577 +E..].3@................l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.129068 IP pizzicato.lan.50365 > 239.255.255.250.ssdp: UDP, length 579 +E.._.4@................l.K./NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.535267 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 492 +E....`@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.536280 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 501 +E....a@................l..GNNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.536795 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 532 +E..0.b@................l..*.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.537631 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 530 +E....c@................l..}oNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.538520 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 534 +E..2.d@................l...}NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.539251 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 544 +E..<.e@................l.(.JNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.540125 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 538 +E..6.f@................l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.541106 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 532 +E..0.g@................l..b.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.541969 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 530 +E....h@................l...rNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.542510 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 536 +E..4.i@................l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.637648 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 492 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.638375 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 501 +E.....@................l..GNNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.639241 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@................l..*.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.640125 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 530 +E.....@................l..}oNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.640872 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@................l...}NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.641763 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@................l.(.JNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.642592 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@................l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.643582 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@................l..b.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.644363 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 530 +E.....@................l...rNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.644944 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@................l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.740019 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 492 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.740761 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 501 +E.....@................l..GNNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.741729 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@................l..*.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.742428 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 530 +E.....@................l..}oNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.743393 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@................l...}NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.744268 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@................l.(.JNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.745086 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@................l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.745943 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@................l..b.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.746787 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 530 +E.....@................l...rNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:13.747378 IP pizzicato.lan.56032 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@................l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.180182 IP pizzicato.lan.45953 > 239.255.255.250.ssdp: UDP, length 513 +E....t@................l. 3YNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.180985 IP pizzicato.lan.45953 > 239.255.255.250.ssdp: UDP, length 522 +E..&.u@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.181877 IP pizzicato.lan.45953 > 239.255.255.250.ssdp: UDP, length 565 +E..Q.v@................l.=V!NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.182805 IP pizzicato.lan.45953 > 239.255.255.250.ssdp: UDP, length 577 +E..].w@................l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.183706 IP pizzicato.lan.45953 > 239.255.255.250.ssdp: UDP, length 579 +E.._.x@................l.K.kNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.184534 IP pizzicato.lan.45953 > 239.255.255.250.ssdp: UDP, length 513 +E....{@................l. 3YNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.185360 IP pizzicato.lan.45953 > 239.255.255.250.ssdp: UDP, length 522 +E..&.|@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.185980 IP pizzicato.lan.45953 > 239.255.255.250.ssdp: UDP, length 565 +E..Q.}@................l.=V!NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.187210 IP pizzicato.lan.45953 > 239.255.255.250.ssdp: UDP, length 577 +E..].~@................l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.187834 IP pizzicato.lan.45953 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@................l.K.kNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.389496 IP pizzicato.lan.45953 > 239.255.255.250.ssdp: UDP, length 513 +E.....@................l. 3YNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.389499 IP pizzicato.lan.45953 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.389500 IP pizzicato.lan.45953 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@................l.=V!NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.389501 IP pizzicato.lan.45953 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@................l.K.kNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.792699 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 492 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.793605 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 501 +E.....@................l..t{NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.794308 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@................l..X(NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.795156 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 530 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.796114 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.796931 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@................l.(.wNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.797510 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@................l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.798627 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.799243 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 530 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.800042 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@................l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.895227 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 492 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.895835 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 501 +E.....@................l..t{NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.896684 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@................l..X(NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.897556 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 530 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.898207 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.899177 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@................l.(.wNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.900160 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@................l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.901018 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.901918 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 530 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.902445 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@................l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.997556 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 492 +E.....@................l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.998287 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 501 +E.....@................l..t{NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:28.999094 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....q...........l..X(NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:29.000009 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....r...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:29.000897 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@....m...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:29.001485 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@....b...........l.(.wNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:29.002608 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@....g...........l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:29.003413 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....l...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:29.003989 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....m...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:29.004872 IP pizzicato.lan.44467 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@....f...........l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.333519 IP pizzicato.lan.52751 > 239.255.255.250.ssdp: UDP, length 513 +E.....@................l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.334258 IP pizzicato.lan.52751 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@................l..l}NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.335167 IP pizzicato.lan.52751 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@................l.=;.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.335981 IP pizzicato.lan.52751 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@....{...........l.I.tNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.336975 IP pizzicato.lan.52751 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@....x...........l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.435779 IP pizzicato.lan.52751 > 239.255.255.250.ssdp: UDP, length 513 +E.....@................l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.436570 IP pizzicato.lan.52751 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@................l..l}NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.437425 IP pizzicato.lan.52751 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@....k...........l.=;.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.438371 IP pizzicato.lan.52751 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@....^...........l.I.tNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.439007 IP pizzicato.lan.52751 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@....[...........l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.538182 IP pizzicato.lan.52751 > 239.255.255.250.ssdp: UDP, length 513 +E.....@................l. ..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.538950 IP pizzicato.lan.52751 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@................l..l}NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.539760 IP pizzicato.lan.52751 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@....[...........l.=;.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.540774 IP pizzicato.lan.52751 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@....N...........l.I.tNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.541788 IP pizzicato.lan.52751 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@....K...........l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.947958 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 492 +E.....@....k.........g.l...2NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.948989 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 501 +E.....@....a.........g.l..g.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.949505 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....A.........g.l..KtNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.950310 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....B.........g.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.951127 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@....=.........g.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.952171 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@....2.........g.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.952851 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@....7.........g.l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.953740 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....<.........g.l...>NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.954429 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....=.........g.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:43.955569 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@....6.........g.l. .-NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.050189 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 492 +E....-@....L.........g.l...2NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.050926 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 501 +E.....@....B.........g.l..g.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.051846 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 532 +E..0./@....".........g.l..KtNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.052555 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 530 +E....0@....#.........g.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.053585 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 534 +E..2.1@..............g.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.054462 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 544 +E..<.2@..............g.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.055011 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 538 +E..6.3@..............g.l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.055979 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 532 +E..0.4@..............g.l...>NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.057029 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 530 +E....5@..............g.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.057839 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 536 +E..4.6@..............g.l. .-NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.153828 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 492 +E....O@....*.........g.l...2NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.154631 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 501 +E....P@.... .........g.l..g.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.155493 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 532 +E..0.Q@..............g.l..KtNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.156375 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 530 +E....R@..............g.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.157056 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 534 +E..2.S@..............g.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.157865 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 544 +E..<.T@..............g.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.158756 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 538 +E..6.U@..............g.l."..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.159828 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 532 +E..0.V@..............g.l...>NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.160402 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 530 +E....W@..............g.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:44.161401 IP pizzicato.lan.47719 > 239.255.255.250.ssdp: UDP, length 536 +E..4.X@..............g.l. .-NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:45.837806 IP localhost.59327 > 239.255.255.250.ssdp: UDP, length 179 +E.........d............l..o.M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.837859 IP localhost.59327 > 239.255.255.250.ssdp: UDP, length 179 +E.........d............l..o.M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.838213 IP localhost.ssdp > localhost.59327: UDP, length 367 +E.......@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:51:45.838228 IP localhost.ssdp > localhost.59327: UDP, length 367 +E.......@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:51:45.838597 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E......... +b...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.838613 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E......... +b...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.838634 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E......... +b...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.838924 IP 192.168.194.0.55072 > 239.255.255.250.ssdp: UDP, length 179 +E....*....rP......... .l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.838944 IP 192.168.194.0.55072 > 239.255.255.250.ssdp: UDP, length 179 +E....*....rP......... .l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.839111 IP 192.168.139.3.65336 > 239.255.255.250.ssdp: UDP, length 179 +E....$.....S.........8.l.. 239.255.255.250.ssdp: UDP, length 179 +E....$.....S.........8.l.. mac.lan.53718: UDP, length 367 +E....z..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:51:45.839904 IP mac.lan.ssdp > mac.lan.53718: UDP, length 367 +E....z..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:51:45.840315 IP 192.168.215.0.50469 > 239.255.255.250.ssdp: UDP, length 179 +E.........Z..........%.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.840382 IP 192.168.215.0.50469 > 239.255.255.250.ssdp: UDP, length 179 +E.........Z..........%.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.840443 IP 192.168.97.0.60969 > 239.255.255.250.ssdp: UDP, length 179 +E....-.....M..a......).l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.840505 IP 192.168.97.0.60969 > 239.255.255.250.ssdp: UDP, length 179 +E....-.....M..a......).l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.940897 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E...(......7...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.940949 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E...(......7...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.941000 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E...(......7...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.941034 IP 192.168.194.0.55072 > 239.255.255.250.ssdp: UDP, length 179 +E...+................ .l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.941067 IP 192.168.139.3.65336 > 239.255.255.250.ssdp: UDP, length 179 +E....................8.l.. 239.255.255.250.ssdp: UDP, length 179 +E...2T.....&.........%.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.941124 IP 192.168.97.0.60969 > 239.255.255.250.ssdp: UDP, length 179 +E...l.....;m..a......).l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.941246 IP 192.168.194.0.55072 > 239.255.255.250.ssdp: UDP, length 179 +E...+................ .l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.941254 IP 192.168.139.3.65336 > 239.255.255.250.ssdp: UDP, length 179 +E....................8.l.. 239.255.255.250.ssdp: UDP, length 179 +E...2T.....&.........%.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.941257 IP 192.168.97.0.60969 > 239.255.255.250.ssdp: UDP, length 179 +E...l.....;m..a......).l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:45.941321 IP mac.lan.ssdp > mac.lan.53718: UDP, length 367 +E.......@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:51:45.941430 IP mac.lan.ssdp > mac.lan.53718: UDP, length 367 +E.......@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:45 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:51:46.042517 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E...;......k...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.042571 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E...;......k...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.042928 IP mac.lan.ssdp > mac.lan.53718: UDP, length 367 +E....k..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:46 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:51:46.042958 IP mac.lan.ssdp > mac.lan.53718: UDP, length 367 +E....k..@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:46 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:51:46.043857 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E...;......k...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.044138 IP 192.168.194.0.55072 > 239.255.255.250.ssdp: UDP, length 179 +E...:................ .l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.044188 IP 192.168.139.3.65336 > 239.255.255.250.ssdp: UDP, length 179 +E....................8.l.. 239.255.255.250.ssdp: UDP, length 179 +E...:................ .l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.044220 IP 192.168.139.3.65336 > 239.255.255.250.ssdp: UDP, length 179 +E....................8.l.. 239.255.255.250.ssdp: UDP, length 179 +E....................%.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.044302 IP 192.168.97.0.60969 > 239.255.255.250.ssdp: UDP, length 179 +E....X.....#..a......).l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.044390 IP 192.168.215.0.50469 > 239.255.255.250.ssdp: UDP, length 179 +E....................%.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.044392 IP 192.168.97.0.60969 > 239.255.255.250.ssdp: UDP, length 179 +E....X.....#..a......).l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.145498 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E.........#............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.145537 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E.........#............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.145612 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E.........#............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.145916 IP 192.168.194.0.55072 > 239.255.255.250.ssdp: UDP, length 179 +E....]............... .l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.145967 IP 192.168.194.0.55072 > 239.255.255.250.ssdp: UDP, length 179 +E....]............... .l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.146060 IP 192.168.139.3.65336 > 239.255.255.250.ssdp: UDP, length 179 +E...1!....LW.........8.l.. mac.lan.53718: UDP, length 367 +E...y...@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:46 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:51:46.146119 IP 192.168.215.0.50469 > 239.255.255.250.ssdp: UDP, length 179 +E....................%.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.146130 IP 192.168.139.3.65336 > 239.255.255.250.ssdp: UDP, length 179 +E...1!....LW.........8.l.. mac.lan.53718: UDP, length 367 +E...y...@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:46 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:51:46.146161 IP 192.168.97.0.60969 > 239.255.255.250.ssdp: UDP, length 179 +E...m3....:H..a......).l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.146248 IP 192.168.215.0.50469 > 239.255.255.250.ssdp: UDP, length 179 +E....................%.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.146250 IP 192.168.97.0.60969 > 239.255.255.250.ssdp: UDP, length 179 +E...m3....:H..a......).l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.246529 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E.... ....c............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.246553 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E.... ....c............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.246636 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E.... ....c............l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.246763 IP 192.168.194.0.55072 > 239.255.255.250.ssdp: UDP, length 179 +E....\....L.......... .l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.246800 IP 192.168.139.3.65336 > 239.255.255.250.ssdp: UDP, length 179 +E....6....xB.........8.l.. 239.255.255.250.ssdp: UDP, length 179 +E....\....L.......... .l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.246844 IP 192.168.139.3.65336 > 239.255.255.250.ssdp: UDP, length 179 +E....6....xB.........8.l.. 239.255.255.250.ssdp: UDP, length 179 +E...4................%.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.246931 IP mac.lan.ssdp > mac.lan.53718: UDP, length 367 +E...f...@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:46 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:51:46.246978 IP 192.168.215.0.50469 > 239.255.255.250.ssdp: UDP, length 179 +E...4................%.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.246981 IP mac.lan.ssdp > mac.lan.53718: UDP, length 367 +E...f...@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:46 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:51:46.246996 IP 192.168.97.0.60969 > 239.255.255.250.ssdp: UDP, length 179 +E....t........a......).l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.247055 IP 192.168.97.0.60969 > 239.255.255.250.ssdp: UDP, length 179 +E....t........a......).l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.347831 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E.........^?...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.347851 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E.........^?...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.347884 IP mac.lan.53718 > 239.255.255.250.ssdp: UDP, length 179 +E.........^?...........l....M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.347916 IP 192.168.194.0.55072 > 239.255.255.250.ssdp: UDP, length 179 +E.................... .l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.347944 IP 192.168.139.3.65336 > 239.255.255.250.ssdp: UDP, length 179 +E....D.....3.........8.l.. 239.255.255.250.ssdp: UDP, length 179 +E...~S.....'.........%.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.347991 IP 192.168.97.0.60969 > 239.255.255.250.ssdp: UDP, length 179 +E....g........a......).l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.348113 IP 192.168.194.0.55072 > 239.255.255.250.ssdp: UDP, length 179 +E.................... .l..spM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.348122 IP 192.168.139.3.65336 > 239.255.255.250.ssdp: UDP, length 179 +E....D.....3.........8.l.. 239.255.255.250.ssdp: UDP, length 179 +E...~S.....'.........%.l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.348123 IP 192.168.97.0.60969 > 239.255.255.250.ssdp: UDP, length 179 +E....g........a......).l...pM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 2 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USER-AGENT: foobar2000/2.x UPnP/1.1 DLNADOC/1.50 + + +17:51:46.348205 IP mac.lan.ssdp > mac.lan.53718: UDP, length 367 +E...d...@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:46 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:51:46.348319 IP mac.lan.ssdp > mac.lan.53718: UDP, length 367 +E...d...@............l...w..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:46 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:51:57.056954 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 142 +E.....@........".......l.."LM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +USER-AGENT: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +MAN: "ssdp:discover" +ST: ssdp:all +MX: 3 + + +17:51:57.058201 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\u...@..........".l...H./HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + + +17:51:57.058247 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..S.E..@..X.......".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:51:57.058277 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 363 +E... ...@..........".l...sO.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:device:MediaServer:1 + + +17:51:57.058307 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E...S...@..O.......".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:51:57.058337 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E....x..@./........".l.....THTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:51:57.058367 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\z...@.|........".l...H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + + +17:51:57.058397 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..Ssr..@..+.......".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:51:57.058422 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 367 +E...1...@..|.......".l...ww.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:51:57.058450 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 365 +E...d...@..........".l...u..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:AVTransport:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:AVTransport:1 + + +17:51:57.058477 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E.......@..U.......".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:51:57.058504 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E.......@.[n.......".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:RenderingControl:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:RenderingControl:1 + + +17:51:57.156824 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 142 +E.....@........".......l.."LM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +USER-AGENT: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +MAN: "ssdp:discover" +ST: ssdp:all +MX: 3 + + +17:51:57.157371 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\.`..@.[4.......".l...H./HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + + +17:51:57.157416 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..S.Z..@..C.......".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:51:57.157448 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 363 +E.......@..U.......".l...sO.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:device:MediaServer:1 + + +17:51:57.157479 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E....p..@.Y........".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:51:57.157507 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E.......@..........".l.....THTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:51:57.157537 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\8P..@..D.......".l...H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + + +17:51:57.157569 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..S.*..@..s.......".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:51:57.157601 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 367 +E....k..@.a........".l...ww.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:51:57.157751 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 365 +E....8..@.a/.......".l...u..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:AVTransport:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:AVTransport:1 + + +17:51:57.157824 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E....9..@..".......".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:51:57.157864 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E...a...@..I.......".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:RenderingControl:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:RenderingControl:1 + + +17:51:57.259188 IP fenice.pmo.49896 > 239.255.255.250.ssdp: UDP, length 142 +E.....@........".......l.."LM-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +USER-AGENT: Unix/6.13.8.200 UPnP/1.0 RSSDP/1.0 +MAN: "ssdp:discover" +ST: ssdp:all +MX: 3 + + +17:51:57.259633 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\.y..@..........".l...H./HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + + +17:51:57.259672 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..S....@.w........".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:51:57.259697 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 363 +E....V..@.#........".l...sO.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:device:MediaServer:1 + + +17:51:57.259725 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E.......@..........".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:51:57.259753 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E....{..@..........".l.....THTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:51:57.259785 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 320 +E..\....@._........".l...H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + + +17:51:57.259812 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 311 +E..S.f..@..7.......".l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:51:57.259834 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 367 +E...\V..@..........".l...ww.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:51:57.259862 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 365 +E.......@..........".l...u..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:AVTransport:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:AVTransport:1 + + +17:51:57.259889 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 377 +E.......@.K........".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:51:57.259914 IP mac.lan.ssdp > fenice.pmo.49896: UDP, length 375 +E...E...@..........".l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:51:57 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:RenderingControl:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:RenderingControl:1 + + +17:51:58.488451 IP pizzicato.lan.35172 > 239.255.255.250.ssdp: UDP, length 513 +E.....@..............d.l. ]vNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:58.489215 IP pizzicato.lan.35172 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@..............d.l...(NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:58.490156 IP pizzicato.lan.35172 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@..............d.l.=.>NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:58.491078 IP pizzicato.lan.35172 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@..............d.l.I. NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:58.492030 IP pizzicato.lan.35172 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@..............d.l.K).NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:58.590836 IP pizzicato.lan.35172 > 239.255.255.250.ssdp: UDP, length 513 +E.....@..............d.l. ]vNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:58.591641 IP pizzicato.lan.35172 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@..............d.l...(NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:58.592468 IP pizzicato.lan.35172 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@..............d.l.=.>NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:58.593369 IP pizzicato.lan.35172 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@..............d.l.I. NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:58.594464 IP pizzicato.lan.35172 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@....}.........d.l.K).NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:58.693351 IP pizzicato.lan.35172 > 239.255.255.250.ssdp: UDP, length 513 +E.....@..............d.l. ]vNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:58.694242 IP pizzicato.lan.35172 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@..............d.l...(NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:58.694986 IP pizzicato.lan.35172 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@....s.........d.l.=.>NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:58.695858 IP pizzicato.lan.35172 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@....f.........d.l.I. NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:58.696876 IP pizzicato.lan.35172 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@....c.........d.l.K).NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.205225 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 492 +E.....@....g.........<.l...]NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.205996 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 501 +E.....@....].........<.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.206807 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....=.........<.l..n.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.207609 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....>.........<.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.208612 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 534 +E..2..@....9.........<.l...!NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.209153 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 544 +E..<..@..............<.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.210276 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 538 +E..6..@....3.........<.l.".>NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.210968 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 532 +E..0..@....8.........<.l...iNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.211857 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 530 +E.....@....9.........<.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.212886 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 536 +E..4..@....2.........<.l. .XNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.307601 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 492 +E..../@....J.........<.l...]NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.308387 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 501 +E....0@....@.........<.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.308999 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 532 +E..0.1@.... .........<.l..n.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.310129 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 530 +E....2@....!.........<.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.310949 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 534 +E..2.3@..............<.l...!NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.311534 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 544 +E..<.4@..............<.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.312457 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 538 +E..6.5@..............<.l.".>NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.313592 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 532 +E..0.6@..............<.l...iNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.314416 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 530 +E....7@..............<.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.314983 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 536 +E..4.8@..............<.l. .XNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.410034 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 492 +E....R@....'.........<.l...]NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.410799 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 501 +E....S@..............<.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.411695 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 532 +E..0.T@..............<.l..n.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:device:Source:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:device:Source:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.412589 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 530 +E....U@..............<.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Time:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Time:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.413172 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 534 +E..2.V@..............<.l...!NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Volume:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Volume:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.414219 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 544 +E..<.W@..............<.l.(..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Credentials:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Credentials:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.414861 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 538 +E..6.X@..............<.l.".>NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Playlist:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Playlist:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.415881 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 532 +E..0.Y@..............<.l...iNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Radio:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Radio:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.416850 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 530 +E....Z@..............<.l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Info:1 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Info:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:51:59.417419 IP pizzicato.lan.38716 > 239.255.255.250.ssdp: UDP, length 536 +E..4.[@..............<.l. .XNOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:av-openhome-org:service:Product:2 +NTS: ssdp:alive +USN: uuid:2899a4df-39ae-160a-6563-dca6329ead0d::urn:av-openhome-org:service:Product:2 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:52:02.379096 IP 192.168.0.38.48637 > broadcasthost.ssdp: UDP, length 90 +E..v>.@.@.;....&.......l.bT.M-SEARCH * HTTP/1.1 +HOST:255.255.255.255:1900 +MAN:"ssdp:discover" +MX:4 +ST:ssdp:all + + +17:52:02.380119 IP mac.lan.ssdp > 192.168.0.38.48637: UDP, length 320 +E..\.v..@.Z........&.l...H'.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:52:02 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + + +17:52:02.380157 IP mac.lan.ssdp > 192.168.0.38.48637: UDP, length 311 +E..S.Y..@.,@.......&.l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:52:02 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::upnp:rootdevice + + +17:52:02.380185 IP mac.lan.ssdp > 192.168.0.38.48637: UDP, length 363 +E...M...@..M.......&.l...sY.HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:52:02 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaServer:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:device:MediaServer:1 + + +17:52:02.380214 IP mac.lan.ssdp > 192.168.0.38.48637: UDP, length 375 +E...L...@..A.......&.l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:52:02 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ContentDirectory:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ContentDirectory:1 + + +17:52:02.380242 IP mac.lan.ssdp > 192.168.0.38.48637: UDP, length 377 +E.......@..........&.l.....?HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:52:02 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:52:02.380270 IP mac.lan.ssdp > 192.168.0.38.48637: UDP, length 320 +E..\%...@..........&.l...H..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:52:02 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + + +17:52:02.380297 IP mac.lan.ssdp > 192.168.0.38.48637: UDP, length 311 +E..S....@.w........&.l...?..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:52:02 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: upnp:rootdevice +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::upnp:rootdevice + + +17:52:02.380325 IP mac.lan.ssdp > 192.168.0.38.48637: UDP, length 367 +E.......@.4........&.l...w.{HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:52:02 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:device:MediaRenderer:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:device:MediaRenderer:1 + + +17:52:02.380352 IP mac.lan.ssdp > 192.168.0.38.48637: UDP, length 365 +E...A\..@..........&.l...u..HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:52:02 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:AVTransport:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:AVTransport:1 + + +17:52:02.380381 IP mac.lan.ssdp > 192.168.0.38.48637: UDP, length 377 +E.......@..=.......&.l.... +pHTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:52:02 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:ConnectionManager:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:ConnectionManager:1 + + +17:52:02.380425 IP mac.lan.ssdp > 192.168.0.38.48637: UDP, length 375 +E.......@..@.......&.l......HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: Wed, 03 Dec 2025 16:52:02 GMT +EXT: +LOCATION: http://192.168.0.138:8080/device/e4b68fbc-2bd5-4cea-98d8-be843fec0bd4/desc.xml +SERVER: Macos/15.6.0 UPnP/1.1 PMOMusic/1.0 +ST: urn:schemas-upnp-org:service:RenderingControl:1 +USN: uuid:e4b68fbc-2bd5-4cea-98d8-be843fec0bd4::urn:schemas-upnp-org:service:RenderingControl:1 + + +17:52:13.922082 IP pizzicato.lan.50125 > 239.255.255.250.ssdp: UDP, length 513 +E.....@................l. #.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:52:13.922940 IP pizzicato.lan.50125 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@................l..v.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:52:13.923824 IP pizzicato.lan.50125 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@....l...........l.=E.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:52:13.924720 IP pizzicato.lan.50125 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@...._...........l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:52:13.925713 IP pizzicato.lan.50125 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@....\...........l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:52:14.024425 IP pizzicato.lan.50125 > 239.255.255.250.ssdp: UDP, length 513 +E.....@................l. #.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:52:14.025230 IP pizzicato.lan.50125 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@................l..v.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:52:14.025965 IP pizzicato.lan.50125 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@....T...........l.=E.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:52:14.026884 IP pizzicato.lan.50125 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@....G...........l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:52:14.027814 IP pizzicato.lan.50125 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@....D...........l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:52:14.127033 IP pizzicato.lan.50125 > 239.255.255.250.ssdp: UDP, length 513 +E.....@....o...........l. #.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: upnp:rootdevice +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::upnp:rootdevice +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:52:14.127724 IP pizzicato.lan.50125 > 239.255.255.250.ssdp: UDP, length 522 +E..&..@....e...........l..v.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:52:14.128635 IP pizzicato.lan.50125 > 239.255.255.250.ssdp: UDP, length 565 +E..Q..@....9...........l.=E.NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:device:MediaServer:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:52:14.129497 IP pizzicato.lan.50125 > 239.255.255.250.ssdp: UDP, length 577 +E..]..@....,...........l.I..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ContentDirectory:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ContentDirectory:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:52:14.130389 IP pizzicato.lan.50125 > 239.255.255.250.ssdp: UDP, length 579 +E.._..@....)...........l.K..NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Portable SDK for UPnP devices/6.2.0 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User-Agent: redsonic +NT: urn:schemas-upnp-org:service:ConnectionManager:1 +NTS: ssdp:alive +USN: uuid:c110358f-d885-b44a-d6d3-dca6329ead0d::urn:schemas-upnp-org:service:ConnectionManager:1 +BOOTID.UPNP.ORG: 1 +CONFIGID.UPNP.ORG: 1 + + +17:52:14.536507 IP pizzicato.lan.60572 > 239.255.255.250.ssdp: UDP, length 492 +E....Y@.... ...........l....NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=90 +LOCATION: http://192.168.0.200:49152/uuid-2899a4df-39ae-160a-6563-dca6329ead0d/description.xml +SERVER: Linux/6.12.32-v8+ UPnP/1.1 Upmpdcli/1.9.5 +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: uuid:3614f0de-63a6-e2ca-6845-06f665f5167a +X-User \ No newline at end of file diff --git a/pmocovers/Cargo.toml b/pmocovers/Cargo.toml index 32db1101..f852291e 100644 --- a/pmocovers/Cargo.toml +++ b/pmocovers/Cargo.toml @@ -27,6 +27,7 @@ once_cell = "1.20" # Serveur HTTP (optionnel pour l'extension) pmoserver = { path = "../pmoserver", optional = true } pmoconfig = { path = "../pmoconfig", optional = true } +async-trait = { version = "0.1", optional = true } axum = { version = "0.8", optional = true } utoipa = { version = "5.3", features = ["axum_extras"], optional = true } @@ -38,4 +39,12 @@ tempfile = "3" [features] default = ["pmoserver"] pmoconfig = ["dep:pmoconfig", "pmocache/pmoconfig"] -pmoserver = ["pmoconfig", "dep:pmoserver", "dep:axum", "dep:utoipa", "pmocache/openapi", "pmocache/pmoserver"] +pmoserver = [ + "pmoconfig", + "dep:pmoserver", + "dep:axum", + "dep:utoipa", + "pmocache/openapi", + "pmocache/pmoserver", + "dep:async-trait", +] diff --git a/pmocovers/src/cache.rs b/pmocovers/src/cache.rs index 1cf5466d..c6f2521b 100644 --- a/pmocovers/src/cache.rs +++ b/pmocovers/src/cache.rs @@ -7,7 +7,10 @@ use anyhow::Result; use pmocache::{CacheConfig, StreamTransformer}; use std::sync::Arc; -/// Configuration pour le cache de couvertures +/// Configuration pour le cache de couvertures. +/// +/// Spécifie l'extension finale (`webp`), le type logique exposé (`image`) et +/// le nom de cache (`covers`) utilisés par les routes générées par `pmocache`. pub struct CoversConfig; impl CacheConfig for CoversConfig { @@ -24,12 +27,15 @@ impl CacheConfig for CoversConfig { } } -/// Type alias pour le cache de couvertures avec conversion WebP +/// Type alias pour le cache de couvertures avec conversion WebP. pub type Cache = pmocache::Cache; -/// Créateur de transformer WebP +/// Créateur de transformer WebP. /// /// Convertit automatiquement toute image téléchargée en format WebP +/// avant de l'écrire sur disque. Les octets d'entrée sont lus en mémoire, +/// décodés via `image`, ré-encodés en WebP puis persistés. La progression +/// est reportée pour que le cache puisse suivre la taille transformée. fn create_webp_transformer() -> StreamTransformer { Box::new(|mut input, mut file, context| { Box::pin(async move { @@ -55,7 +61,7 @@ fn create_webp_transformer() -> StreamTransformer { }) } -/// Crée un cache de couvertures avec conversion WebP automatique +/// Crée un cache de couvertures avec conversion WebP automatique. /// /// # Arguments /// @@ -77,3 +83,21 @@ pub fn new_cache(dir: &str, limit: usize) -> Result { let transformer_factory = Arc::new(|| create_webp_transformer()); Cache::with_transformer(dir, limit, Some(transformer_factory)) } + +/// Crée un cache de couvertures et lance une consolidation en arrière-plan. +/// +/// Idéal pour un démarrage de service : la consolidation supprime les fichiers +/// incomplets et recalcule les markers `.complete` au besoin avant d'accepter +/// des requêtes. +pub async fn new_cache_with_consolidation(dir: &str, limit: usize) -> Result> { + let cache = Arc::new(new_cache(dir, limit)?); + let cache_clone = cache.clone(); + tokio::spawn(async move { + if let Err(e) = cache_clone.consolidate().await { + tracing::warn!("Failed to consolidate cover cache on startup: {}", e); + } else { + tracing::info!("Cover cache consolidated successfully on startup"); + } + }); + Ok(cache) +} diff --git a/pmocovers/src/config_ext.rs b/pmocovers/src/config_ext.rs index 7899057e..f5a98178 100644 --- a/pmocovers/src/config_ext.rs +++ b/pmocovers/src/config_ext.rs @@ -11,10 +11,11 @@ use std::sync::Arc; const DEFAULT_COVER_CACHE_DIR: &str = "cache_covers"; const DEFAULT_COVER_CACHE_SIZE: usize = 2000; -/// Trait d'extension pour gérer le cache de couvertures dans pmoconfig +/// Trait d'extension pour gérer le cache de couvertures dans pmoconfig. /// -/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques -/// au cache de couvertures avec conversion WebP. +/// Fournit des helpers pour récupérer/définir le répertoire et la taille du +/// cache de couvertures, ainsi qu'une factory `create_cover_cache` prête à +/// l'emploi (conversion WebP activée). /// /// # Exemple /// diff --git a/pmocovers/src/lib.rs b/pmocovers/src/lib.rs index bc283057..b090caca 100644 --- a/pmocovers/src/lib.rs +++ b/pmocovers/src/lib.rs @@ -60,7 +60,7 @@ pub mod openapi; #[cfg(feature = "pmoconfig")] pub mod config_ext; -pub use cache::{new_cache, Cache, CoversConfig}; +pub use cache::{new_cache, new_cache_with_consolidation, Cache, CoversConfig}; #[cfg(feature = "pmoserver")] pub use openapi::ApiDoc; @@ -168,8 +168,73 @@ fn create_variant_generator() -> pmocache::pmoserver_ext::ParamGenerator>, + axum::extract::Path(pk): axum::extract::Path, +) -> axum::response::Response { + serve_jpeg_internal(cache, pk, None).await +} + +#[cfg(feature = "pmoserver")] +async fn serve_cover_jpeg_with_size( + axum::extract::State(cache): axum::extract::State>, + axum::extract::Path((pk, size)): axum::extract::Path<(String, String)>, +) -> axum::response::Response { + let size = size.parse::().ok(); + serve_jpeg_internal(cache, pk, size).await +} + +#[cfg(feature = "pmoserver")] +async fn serve_jpeg_internal( + cache: Arc, + pk: String, + size: Option, +) -> axum::response::Response { + use axum::http::StatusCode; + use axum::response::IntoResponse; + use image::ImageFormat; + use std::io::Cursor; + + let path = cache.get_file_path_with_qualifier( + &pk, + ::default_param(), + ); + if !path.exists() { + return (StatusCode::NOT_FOUND, "File not found").into_response(); + } + + let res = tokio::task::spawn_blocking(move || -> anyhow::Result> { + let mut img = image::open(&path)?; + if let Some(size) = size { + img = crate::webp::ensure_square(&img, size); + } + let mut buf = Cursor::new(Vec::new()); + img.write_to(&mut buf, ImageFormat::Jpeg)?; + Ok(buf.into_inner()) + }) + .await; + + match res { + Ok(Ok(data)) => (StatusCode::OK, [("content-type", "image/jpeg")], data).into_response(), + Ok(Err(e)) => { + tracing::warn!("JPEG transcode error for {}: {}", pk, e); + (StatusCode::INTERNAL_SERVER_ERROR, "Transcode error").into_response() + } + Err(e) => { + tracing::warn!("JPEG transcode join error for {}: {}", pk, e); + (StatusCode::INTERNAL_SERVER_ERROR, "Transcode error").into_response() + } + } +} + /// Trait d'extension pour ajouter le cache de couvertures à pmoserver #[cfg(feature = "pmoserver")] +#[async_trait::async_trait] pub trait CoverCacheExt { /// Initialise le cache d'images et enregistre les routes HTTP /// @@ -204,6 +269,7 @@ pub trait CoverCacheExt { } #[cfg(feature = "pmoserver")] +#[async_trait::async_trait] impl CoverCacheExt for pmoserver::Server { async fn init_cover_cache( &mut self, @@ -221,7 +287,20 @@ impl CoverCacheExt for pmoserver::Server { "image/webp", Some(create_variant_generator()), ); - self.add_router("/", file_router).await; + + // Router JPEG (transcodage à la volée depuis le WebP stocké) + // Routes: GET /covers/jpeg/{pk} et GET /covers/jpeg/{pk}/{size} + let jpeg_router = axum::Router::new() + .route("/covers/jpeg/{pk}", axum::routing::get(serve_cover_jpeg)) + .route( + "/covers/jpeg/{pk}/{size}", + axum::routing::get(serve_cover_jpeg_with_size), + ) + .with_state(cache.clone()); + + // Combiner WebP et JPEG dans un seul sous-router pour éviter tout overlap + let combined_router = file_router.merge(jpeg_router); + self.add_router("/", combined_router).await; // API REST générique (pmocache) // Routes: GET/POST/DELETE /api/covers, etc. @@ -229,6 +308,9 @@ impl CoverCacheExt for pmoserver::Server { let openapi = crate::ApiDoc::openapi(); self.add_openapi(api_router, openapi, "covers").await; + // Enregistrer dans le singleton global pour éviter des initialisations multiples + register_cover_cache(cache.clone()); + Ok(cache) } diff --git a/pmocovers/src/openapi.rs b/pmocovers/src/openapi.rs index 85e1b9e8..e4f1b675 100644 --- a/pmocovers/src/openapi.rs +++ b/pmocovers/src/openapi.rs @@ -70,12 +70,22 @@ Récupère l'image originale en WebP ### GET /covers/image/{pk}/{size} Récupère une variante redimensionnée (ex: /covers/image/abc123/256) +### GET /covers/jpeg/{pk} +Récupère l'image transcodée en JPEG (pour les clients qui ne supportent pas WebP) + +### GET /covers/jpeg/{pk}/{size} +Récupère une variante redimensionnée transcodée en JPEG (ex: /covers/jpeg/abc123/256) + ## Format des images Les images sont stockées au format WebP avec : - Une version originale (`{pk}.orig.webp`) - Des variantes de tailles générées à la demande (`{pk}.{size}.webp`) +Des routes JPEG sont proposées pour compatibilité UPnP (albumArtURI) : +- `/covers/jpeg/{pk}` (transcodage à la volée depuis le WebP) +- `/covers/jpeg/{pk}/{size}` (transcodage après redimensionnement) + ## Clés (pk) Chaque image est identifiée par une clé (pk) unique : diff --git a/pmocovers/src/webp.rs b/pmocovers/src/webp.rs index c6a246b0..c6cc51c8 100644 --- a/pmocovers/src/webp.rs +++ b/pmocovers/src/webp.rs @@ -2,7 +2,10 @@ use anyhow::Result; use image::{imageops::FilterType, DynamicImage}; use webp::{Encoder, WebPMemory}; -/// Encode une image en format WebP avec un niveau de qualité de 85% +/// Encode une image en format WebP avec un niveau de qualité de 85%. +/// +/// Utilise l'encodeur `webp` et retourne les octets encodés prêts à être +/// écrits sur disque ou envoyés sur le réseau. /// /// # Arguments /// @@ -28,7 +31,7 @@ pub fn encode_webp(img: &DynamicImage) -> Result> { Ok(webp_data.to_vec()) } -/// Redimensionne une image pour l'inscrire dans un carré de taille donnée +/// Redimensionne une image pour l'inscrire dans un carré de taille donnée. /// /// Cette fonction préserve le ratio d'aspect de l'image originale en la redimensionnant /// pour qu'elle tienne dans un carré, puis la centre sur un fond transparent. @@ -82,7 +85,11 @@ pub fn ensure_square(img: &DynamicImage, size: u32) -> DynamicImage { square } -/// Génère une variante redimensionnée d'une image en cache +/// Génère une variante redimensionnée d'une image en cache. +/// +/// Repose sur le fichier original (`orig`) du cache, applique `ensure_square`, +/// encode en WebP et persiste la variante `{pk}.{size}.webp` pour éviter les +/// recalculs sur les requêtes suivantes. /// /// Cette fonction crée (ou récupère si déjà existante) une variante redimensionnée /// d'une image. La variante est mise en cache sur disque pour éviter les diff --git a/pmocovers/tests/test_cache.rs b/pmocovers/tests/test_cache.rs index 7c95acf2..da0ec543 100644 --- a/pmocovers/tests/test_cache.rs +++ b/pmocovers/tests/test_cache.rs @@ -1,6 +1,6 @@ +use image::{ImageBuffer, Rgba}; use pmocovers::cache; use tempfile::TempDir; -use image::{ImageBuffer, Rgba}; fn create_test_cache() -> (TempDir, cache::Cache) { let temp_dir = tempfile::tempdir().unwrap(); @@ -19,8 +19,11 @@ fn create_test_image(width: u32, height: u32) -> Vec { }); let mut buffer = Vec::new(); - img.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png) - .unwrap(); + img.write_to( + &mut std::io::Cursor::new(&mut buffer), + image::ImageFormat::Png, + ) + .unwrap(); buffer } diff --git a/pmocovers/tests/test_webp.rs b/pmocovers/tests/test_webp.rs index 8bb7b1a2..4d70f665 100644 --- a/pmocovers/tests/test_webp.rs +++ b/pmocovers/tests/test_webp.rs @@ -90,8 +90,11 @@ async fn test_generate_variant() { // Créer et ajouter une image let img = create_test_image(400, 400); let mut buffer = Vec::new(); - img.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png) - .unwrap(); + img.write_to( + &mut std::io::Cursor::new(&mut buffer), + image::ImageFormat::Png, + ) + .unwrap(); let test_file = tempfile::NamedTempFile::with_suffix(".png").unwrap(); std::fs::write(test_file.path(), &buffer).unwrap(); @@ -130,8 +133,11 @@ async fn test_generate_variant_caching() { // Créer et ajouter une image let img = create_test_image(400, 400); let mut buffer = Vec::new(); - img.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png) - .unwrap(); + img.write_to( + &mut std::io::Cursor::new(&mut buffer), + image::ImageFormat::Png, + ) + .unwrap(); let test_file = tempfile::NamedTempFile::with_suffix(".png").unwrap(); std::fs::write(test_file.path(), &buffer).unwrap(); diff --git a/pmodidl/Cargo.toml b/pmodidl/Cargo.toml index 3ed5c8a0..d9fa43db 100644 --- a/pmodidl/Cargo.toml +++ b/pmodidl/Cargo.toml @@ -10,3 +10,5 @@ utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } quick-xml = { version = "0.38.3", features = ["serialize"] } bevy_reflect = "0.17.1" bevy_reflect_derive = "0.17.1" +pmoutils = { path = "../pmoutils" } +xmltree = "0.10" diff --git a/pmodidl/examples/test_serialization.rs b/pmodidl/examples/test_serialization.rs new file mode 100644 index 00000000..53795255 --- /dev/null +++ b/pmodidl/examples/test_serialization.rs @@ -0,0 +1,52 @@ +use pmodidl::{DIDLLite, Item, Resource}; + +fn main() { + let item1 = Item { + id: "test1".to_string(), + parent_id: "root".to_string(), + restricted: Some("1".to_string()), + title: "Test Song".to_string(), + creator: Some("Test Artist".to_string()), + class: "object.item.audioItem.musicTrack".to_string(), + artist: Some("Test Artist".to_string()), + album: None, // Pas d'album + genre: None, + album_art: None, // Pas d'albumArtURI + album_art_pk: None, + date: None, + original_track_number: None, + resources: vec![Resource { + protocol_info: "http-get:*:audio/flac:*".to_string(), + bits_per_sample: Some("16".to_string()), + sample_frequency: Some("44100".to_string()), + nr_audio_channels: Some("2".to_string()), + duration: Some("0:03:00".to_string()), + url: "http://example.com/test.flac".to_string(), + }], + descriptions: vec![], + }; + + let didl = DIDLLite { + xmlns: "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/".to_string(), + xmlns_upnp: Some("urn:schemas-upnp-org:metadata-1-0/upnp/".to_string()), + xmlns_dc: Some("http://purl.org/dc/elements/1.1/".to_string()), + xmlns_dlna: Some("urn:schemas-dlna-org:metadata-1-0/".to_string()), + xmlns_pv: None, + xmlns_sec: None, + containers: vec![], + items: vec![item1], + }; + + let xml = quick_xml::se::to_string(&didl).expect("Serialization failed"); + + println!("=== Output from quick_xml::se::to_string() ==="); + println!("{}", xml); + println!("\n=== Length: {} bytes ===", xml.len()); + println!( + "\n=== Starts with '{}", xml); + println!("{}", with_decl); +} diff --git a/pmodidl/src/lib.rs b/pmodidl/src/lib.rs index a85d5e0f..20864427 100644 --- a/pmodidl/src/lib.rs +++ b/pmodidl/src/lib.rs @@ -3,8 +3,13 @@ //! Parser et utilitaires pour le format DIDL-Lite utilisé dans UPnP/DLNA. use bevy_reflect::Reflect; +use pmoutils::ToXmlElement; use serde::{Deserialize, Serialize}; +use std::borrow::Cow; +use std::collections::HashSet; use std::fmt::Write; +use std::io::Cursor; +use xmltree::{Element, XMLNode}; // ============= Couche d'abstraction générique ============= @@ -68,7 +73,8 @@ impl MediaMetadataParser for DIDLLite { type Error = quick_xml::de::DeError; fn parse(input: &str) -> Result { - quick_xml::de::from_str(input) + let sanitized = sanitize_singleton_elements(input); + quick_xml::de::from_str(sanitized.as_ref()) } fn format_name() -> &'static str { @@ -116,7 +122,7 @@ pub struct Container { #[serde(rename = "@id")] pub id: String, - #[serde(rename = "@parentID")] + #[serde(rename = "@parentID", default)] pub parent_id: String, #[serde(rename = "@restricted", skip_serializing_if = "Option::is_none")] @@ -131,7 +137,7 @@ pub struct Container { #[serde(rename = "dc:title", alias = "title")] pub title: String, - #[serde(rename = "upnp:class", alias = "class")] + #[serde(rename = "upnp:class", alias = "class", default)] pub class: String, #[serde(rename = "container", default)] @@ -147,7 +153,7 @@ pub struct Item { #[serde(rename = "@id")] pub id: String, - #[serde(rename = "@parentID")] + #[serde(rename = "@parentID", default)] pub parent_id: String, #[serde(rename = "@restricted", skip_serializing_if = "Option::is_none")] @@ -163,7 +169,7 @@ pub struct Item { )] pub creator: Option, - #[serde(rename = "upnp:class", alias = "class")] + #[serde(rename = "upnp:class", alias = "class", default)] pub class: String, #[serde( @@ -221,7 +227,7 @@ pub struct Item { /// Ressource média (fichier audio) #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)] pub struct Resource { - #[serde(rename = "@protocolInfo")] + #[serde(rename = "@protocolInfo", default)] pub protocol_info: String, #[serde(rename = "@bitsPerSample", skip_serializing_if = "Option::is_none")] @@ -236,7 +242,7 @@ pub struct Resource { #[serde(rename = "@duration", skip_serializing_if = "Option::is_none")] pub duration: Option, - #[serde(rename = "$text")] + #[serde(rename = "$text", default)] pub url: String, } @@ -274,6 +280,26 @@ impl Default for DIDLLite { } impl DIDLLite { + /// Applique les namespaces sur un élément xmltree. + fn set_namespaces(&self, elem: &mut Element) { + elem.attributes.insert("xmlns".into(), self.xmlns.clone()); + if let Some(ref upnp) = self.xmlns_upnp { + elem.attributes.insert("xmlns:upnp".into(), upnp.clone()); + } + if let Some(ref dc) = self.xmlns_dc { + elem.attributes.insert("xmlns:dc".into(), dc.clone()); + } + if let Some(ref dlna) = self.xmlns_dlna { + elem.attributes.insert("xmlns:dlna".into(), dlna.clone()); + } + if let Some(ref sec) = self.xmlns_sec { + elem.attributes.insert("xmlns:sec".into(), sec.clone()); + } + if let Some(ref pv) = self.xmlns_pv { + elem.attributes.insert("xmlns:pv".into(), pv.clone()); + } + } + /// Itère sur tous les containers de manière récursive pub fn all_containers(&self) -> impl Iterator { AllContainersIter::new(&self.containers) @@ -378,6 +404,19 @@ impl Container { } impl Item { + /// Formate la date pour satisfaire les clients stricts (YYYY-MM-DD). Si seule + /// l'année est fournie, on complète avec "-01-01". + fn normalized_date(&self) -> Option { + self.date.as_ref().map(|d| { + let trimmed = d.trim(); + if trimmed.len() == 4 && trimmed.chars().all(|c| c.is_ascii_digit()) { + format!("{}-01-01", trimmed) + } else { + trimmed.to_string() + } + }) + } + /// Itère sur les ressources audio uniquement pub fn audio_resources(&self) -> impl Iterator { self.resources @@ -493,6 +532,238 @@ impl Item { } } +// ============= Implémentation ToXmlElement ============= + +fn text_element(name: &str, value: &str) -> Element { + let mut e = Element::new(name); + e.children.push(XMLNode::Text(value.to_string())); + e +} + +const SINGLETON_ELEMENTS: &[&str] = &[ + "dc:title", + "title", + "dc:creator", + "creator", + "upnp:class", + "class", + "upnp:artist", + "artist", + "upnp:album", + "album", + "upnp:genre", + "genre", + "upnp:albumArtURI", + "albumArtURI", + "dc:date", + "date", + "upnp:originalTrackNumber", + "originalTrackNumber", +]; + +fn sanitize_singleton_elements(input: &str) -> Cow<'_, str> { + if !SINGLETON_ELEMENTS.iter().any(|tag| input.contains(tag)) { + return Cow::Borrowed(input); + } + + let mut cursor = Cursor::new(input.as_bytes()); + let mut root = match Element::parse(&mut cursor) { + Ok(elem) => elem, + Err(_) => return Cow::Borrowed(input), + }; + + if !dedup_singleton_children(&mut root) { + return Cow::Borrowed(input); + } + + let mut buf = Vec::new(); + if root.write(&mut buf).is_err() { + return Cow::Borrowed(input); + } + + String::from_utf8(buf) + .map(Cow::Owned) + .unwrap_or_else(|_| Cow::Borrowed(input)) +} + +fn dedup_singleton_children(element: &mut Element) -> bool { + let mut changed = false; + let mut seen: HashSet = HashSet::new(); + let mut idx = 0; + + while idx < element.children.len() { + let mut remove_current = false; + if let XMLNode::Element(child_elem) = &mut element.children[idx] { + if SINGLETON_ELEMENTS.contains(&child_elem.name.as_str()) + && !seen.insert(child_elem.name.clone()) + { + remove_current = true; + changed = true; + } else if dedup_singleton_children(child_elem) { + changed = true; + } + } + + if remove_current { + element.children.remove(idx); + } else { + idx += 1; + } + } + + changed +} + +impl ToXmlElement for DIDLLite { + fn to_xml_element(&self) -> Element { + let mut root = Element::new("DIDL-Lite"); + self.set_namespaces(&mut root); + for c in &self.containers { + root.children.push(XMLNode::Element(c.to_xml_element())); + } + for i in &self.items { + root.children.push(XMLNode::Element(i.to_xml_element())); + } + root + } +} + +impl ToXmlElement for Container { + fn to_xml_element(&self) -> Element { + let mut elem = Element::new("container"); + elem.attributes.insert("id".into(), self.id.clone()); + elem.attributes + .insert("parentID".into(), self.parent_id.clone()); + if let Some(ref r) = self.restricted { + elem.attributes.insert("restricted".into(), r.clone()); + } + if let Some(ref cc) = self.child_count { + elem.attributes.insert("childCount".into(), cc.clone()); + } + if let Some(ref searchable) = self.searchable { + elem.attributes + .insert("searchable".into(), searchable.clone()); + } + + elem.children + .push(XMLNode::Element(text_element("dc:title", &self.title))); + elem.children + .push(XMLNode::Element(text_element("upnp:class", &self.class))); + + for c in &self.containers { + elem.children.push(XMLNode::Element(c.to_xml_element())); + } + for i in &self.items { + elem.children.push(XMLNode::Element(i.to_xml_element())); + } + + elem + } +} + +impl ToXmlElement for Item { + fn to_xml_element(&self) -> Element { + let mut elem = Element::new("item"); + elem.attributes.insert("id".into(), self.id.clone()); + elem.attributes + .insert("parentID".into(), self.parent_id.clone()); + if let Some(ref r) = self.restricted { + elem.attributes.insert("restricted".into(), r.clone()); + } + + elem.children + .push(XMLNode::Element(text_element("dc:title", &self.title))); + + if let Some(ref c) = self.creator { + elem.children + .push(XMLNode::Element(text_element("dc:creator", c))); + } + + elem.children + .push(XMLNode::Element(text_element("upnp:class", &self.class))); + + if let Some(ref artist) = self.artist { + elem.children + .push(XMLNode::Element(text_element("upnp:artist", artist))); + } + if let Some(ref album) = self.album { + elem.children + .push(XMLNode::Element(text_element("upnp:album", album))); + } + if let Some(ref genre) = self.genre { + elem.children + .push(XMLNode::Element(text_element("upnp:genre", genre))); + } + if let Some(ref art) = self.album_art { + elem.children + .push(XMLNode::Element(text_element("upnp:albumArtURI", art))); + } + if let Some(date) = self.normalized_date() { + elem.children + .push(XMLNode::Element(text_element("dc:date", &date))); + } + if let Some(ref track) = self.original_track_number { + elem.children.push(XMLNode::Element(text_element( + "upnp:originalTrackNumber", + track, + ))); + } + + for res in &self.resources { + elem.children.push(XMLNode::Element(res.to_xml_element())); + } + for desc in &self.descriptions { + elem.children.push(XMLNode::Element(desc.to_xml_element())); + } + + elem + } +} + +impl ToXmlElement for Resource { + fn to_xml_element(&self) -> Element { + let mut elem = Element::new("res"); + elem.attributes + .insert("protocolInfo".into(), self.protocol_info.clone()); + if let Some(ref bps) = self.bits_per_sample { + elem.attributes.insert("bitsPerSample".into(), bps.clone()); + } + if let Some(ref freq) = self.sample_frequency { + elem.attributes + .insert("sampleFrequency".into(), freq.clone()); + } + if let Some(ref ch) = self.nr_audio_channels { + elem.attributes.insert("nrAudioChannels".into(), ch.clone()); + } + if let Some(ref dur) = self.duration { + elem.attributes.insert("duration".into(), dur.clone()); + } + elem.children.push(XMLNode::Text(self.url.clone())); + elem + } +} + +impl ToXmlElement for Description { + fn to_xml_element(&self) -> Element { + let mut elem = Element::new("desc"); + if let Some(ref id) = self.id { + elem.attributes.insert("id".into(), id.clone()); + } + if let Some(ref ns) = self.namespace { + elem.attributes.insert("nameSpace".into(), ns.clone()); + } + if let Some(ref gain) = self.track_gain { + elem.children + .push(XMLNode::Element(text_element("track_gain", gain))); + } + if let Some(ref peak) = self.track_peak { + elem.children + .push(XMLNode::Element(text_element("track_peak", peak))); + } + elem + } +} + // ============= Itérateurs personnalisés ============= struct AllContainersIter<'a> { diff --git a/pmoflac/src/encoder.rs b/pmoflac/src/encoder.rs index 2383e71d..a32decef 100755 --- a/pmoflac/src/encoder.rs +++ b/pmoflac/src/encoder.rs @@ -98,6 +98,9 @@ struct ExtractedMetadata { year: Option, genre: Option, track_number: Option, + cover_pk: Option, + cover_url: Option, + server_base_url: Option, } /// Options for configuring FLAC encoding. @@ -121,6 +124,10 @@ pub struct EncoderOptions { /// Metadata to embed in the FLAC file (Vorbis Comments). /// Default: None (no metadata) pub metadata: Option>>, + + /// Base URL of the server for constructing cover URLs. + /// Default: None + pub server_base_url: Option, } impl Default for EncoderOptions { @@ -131,6 +138,7 @@ impl Default for EncoderOptions { total_samples: None, block_size: None, metadata: None, + server_base_url: None, } } } @@ -251,14 +259,15 @@ where let artist = metadata.get_artist().await.ok().flatten(); let album = metadata.get_album().await.ok().flatten(); let year = metadata.get_year().await.ok().flatten(); + let cover_pk = metadata.get_cover_pk().await.ok().flatten(); + let cover_url = metadata.get_cover_url().await.ok().flatten(); // Try to extract genre and track_number from extra fields let extra = metadata.get_extra().await.ok().flatten(); let genre = extra.as_ref().and_then(|e| e.get("genre").cloned()); - let track_number = extra.as_ref().and_then(|e| { - e.get("track_number") - .and_then(|s| s.parse::().ok()) - }); + let track_number = extra + .as_ref() + .and_then(|e| e.get("track_number").and_then(|s| s.parse::().ok())); Some(ExtractedMetadata { title, @@ -267,6 +276,9 @@ where year, genre, track_number, + cover_pk, + cover_url, + server_base_url: options.server_base_url.clone(), }) } else { None @@ -454,11 +466,17 @@ unsafe fn setup_metadata( if let Some(track_number) = metadata.track_number { append_comment("TRACKNUMBER", &track_number.to_string())?; } + // Construct cover URL: use cover_pk with server_base_url if available, fallback to cover_url + if let (Some(ref pk), Some(ref base_url)) = (&metadata.cover_pk, &metadata.server_base_url) { + let cover_url = format!("{}/covers/image/{}", base_url, pk); + append_comment("COVERART", &cover_url)?; + } else if let Some(cover_url) = &metadata.cover_url { + append_comment("COVERART", cover_url)?; + } // Set the metadata on the encoder let mut metadata_array = [meta]; - let set_success = - FLAC__stream_encoder_set_metadata(encoder, metadata_array.as_mut_ptr(), 1); + let set_success = FLAC__stream_encoder_set_metadata(encoder, metadata_array.as_mut_ptr(), 1); if set_success == 0 { return Err(FlacError::LibFlacInit( @@ -527,10 +545,18 @@ fn run_encoder( "set_verify failed", )?; if let Some(total) = options.total_samples { + tracing::debug!( + "FLAC encoder: setting total_samples_estimate = {} before init", + total + ); ensure( FLAC__stream_encoder_set_total_samples_estimate(encoder, total), "set_total_samples_estimate failed", )?; + } else { + tracing::warn!( + "FLAC encoder: total_samples is None, STREAMINFO will have total_samples=0" + ); } if let Some(block_size) = options.block_size { ensure( diff --git a/pmomediaserver/Cargo.toml b/pmomediaserver/Cargo.toml index 64327c8a..9852d980 100644 --- a/pmomediaserver/Cargo.toml +++ b/pmomediaserver/Cargo.toml @@ -18,6 +18,7 @@ quick-xml = { version = "0.38.3", features = ["serialize"] } thiserror = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +pmoutils = { path = "../pmoutils" } # Optional dependencies axum = { version = "0.8", optional = true } @@ -25,6 +26,11 @@ utoipa = { version = "5.3", optional = true } pmoqobuz = { path = "../pmoqobuz", optional = true } pmoparadise = { path = "../pmoparadise", optional = true } pmoconfig = { path = "../pmoconfig", optional = true } +anyhow = { version = "1.0", optional = true } +pmoaudiocache = { path = "../pmoaudiocache", optional = true } +pmocovers = { path = "../pmocovers", optional = true, features = ["pmoserver"] } +pmoplaylist = { path = "../pmoplaylist", optional = true } +tokio-util = { version = "0.7", features = ["io"], optional = true } [features] default = ["pmosource/server"] @@ -33,6 +39,16 @@ api = ["dep:axum", "dep:utoipa", "pmosource/server"] # Feature pour activer le support Qobuz configuré qobuz = ["api", "dep:pmoqobuz", "dep:pmoconfig", "pmoqobuz/server"] # Feature pour activer le support Radio Paradise -paradise = ["api", "dep:pmoparadise", "pmoparadise/server"] +paradise = [ + "api", + "dep:pmoparadise", + "pmoparadise/full", + "dep:anyhow", + "dep:pmoaudiocache", + "dep:pmocovers", + "pmocovers/pmoserver", + "dep:pmoplaylist", + "dep:tokio-util" +] # Feature pour activer l'API REST de Radio Paradise (en plus de la source UPnP) paradise-api = ["paradise", "pmoparadise/pmoserver"] diff --git a/pmomediaserver/src/content_handler.rs b/pmomediaserver/src/content_handler.rs index cf25ab13..327f56e4 100644 --- a/pmomediaserver/src/content_handler.rs +++ b/pmomediaserver/src/content_handler.rs @@ -13,6 +13,8 @@ use pmodidl::{Container, DIDLLite}; use pmosource::api::{get_source as get_source_from_registry, list_all_sources}; use pmosource::{BrowseResult, MusicSource, MusicSourceError}; +use pmoutils::ToXmlElement; +use std::collections::HashSet; use std::sync::Arc; /// Convertit des containers et items en XML DIDL-Lite @@ -28,12 +30,9 @@ fn to_didl_lite(containers: &[Container], items: &[pmodidl::Item]) -> Result{}", - body - )) + // Retourne uniquement le corps DIDL, sans préfixer une seconde déclaration XML. + let body = didl.to_xml(); + Ok(body) } /// Handler pour le service ContentDirectory @@ -87,6 +86,16 @@ impl ContentHandler { "ContentDirectory::Browse" ); + // Log rapide sur la branche flatten vs agrégée + if object_id == "0" { + let sources = list_all_sources().await; + tracing::info!( + "Browse root: sources.len() = {}, flatten = {}", + sources.len(), + sources.len() == 1 + ); + } + match browse_flag { "BrowseMetadata" => self.browse_metadata(object_id).await, "BrowseDirectChildren" => { @@ -100,9 +109,28 @@ impl ContentHandler { /// Browse les métadonnées d'un objet spécifique async fn browse_metadata(&self, object_id: &str) -> Result<(String, u32, u32, u32), String> { if object_id == "0" { - // Retourner le container racine + // Si un seul enfant, publier ce fils comme racine + let sources = list_all_sources().await; + if sources.len() == 1 { + let source = sources.into_iter().next().unwrap(); + let mut container = source + .root_container() + .await + .map_err(|e| format!("Failed to get root container: {}", e))?; + // Le présenter comme la racine (id=0, parent=-1) + container.id = "0".to_string(); + container.parent_id = "-1".to_string(); + container.child_count = None; // compatibilité CP + let didl = to_didl_lite(&[container], &[])?; + let update_id = source.update_id().await.max(1); + tracing::debug!("BrowseMetadata root (flatten) didl_len={}B", didl.len()); + return Ok((didl, 1, 1, update_id)); + } + + // Sinon retourner le container racine agrégé let root = self.build_root_container().await; let didl = to_didl_lite(&[root], &[])?; + tracing::debug!("BrowseMetadata root (aggregate) didl_len={}B", didl.len()); Ok((didl, 1, 1, 1)) } else { // Essayer de trouver l'objet dans les sources @@ -113,11 +141,38 @@ impl ContentHandler { .await .map_err(|e| format!("Failed to get root container: {}", e))?; let didl = to_didl_lite(&[container], &[])?; - let update_id = source.update_id().await; + let update_id = source.update_id().await.max(1); + tracing::debug!( + "BrowseMetadata source_root id={} didl_len={}B", + object_id, + didl.len() + ); 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.max(1); + tracing::debug!( + "BrowseMetadata item id={} didl_len={}B", + object_id, + didl.len() + ); + 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 = None; for source in list_all_sources().await { match source.browse(object_id).await { @@ -127,25 +182,25 @@ impl ContentHandler { BrowseResult::Containers(containers) => { if let Some(container) = containers.first() { let didl = to_didl_lite(&[container.clone()], &[])?; - let update_id = source.update_id().await; + let update_id = source.update_id().await.max(1); return Ok((didl, 1, 1, update_id)); } } BrowseResult::Items(items) => { if let Some(item) = items.first() { let didl = to_didl_lite(&[], &[item.clone()])?; - let update_id = source.update_id().await; + let update_id = source.update_id().await.max(1); return Ok((didl, 1, 1, update_id)); } } BrowseResult::Mixed { containers, items } => { if let Some(container) = containers.first() { let didl = to_didl_lite(&[container.clone()], &[])?; - let update_id = source.update_id().await; + let update_id = source.update_id().await.max(1); return Ok((didl, 1, 1, update_id)); } else if let Some(item) = items.first() { let didl = to_didl_lite(&[], &[item.clone()])?; - let update_id = source.update_id().await; + let update_id = source.update_id().await.max(1); return Ok((didl, 1, 1, update_id)); } } @@ -175,6 +230,53 @@ impl ContentHandler { requested_count: u32, ) -> Result<(String, u32, u32, u32), String> { if object_id == "0" { + // Si un seul enfant, publier directement ses enfants comme racine + let sources = list_all_sources().await; + if sources.len() == 1 { + let source = sources.into_iter().next().unwrap(); + let source_id = source.id().to_string(); + + // Récupérer les enfants du container racine de la source + let mut result = source + .browse(&source_id) + .await + .map_err(|e| format!("Browse failed: {}", e))?; + + // Re-mapper parentID sur "0" pour éviter des parents inexistants côté CP + match &mut result { + BrowseResult::Containers(c) => { + for cont in c.iter_mut() { + if cont.parent_id == source_id { + cont.parent_id = "0".to_string(); + } + } + } + BrowseResult::Items(i) => { + for item in i.iter_mut() { + if item.parent_id == source_id { + item.parent_id = "0".to_string(); + } + } + } + BrowseResult::Mixed { containers, items } => { + for cont in containers.iter_mut() { + if cont.parent_id == source_id { + cont.parent_id = "0".to_string(); + } + } + for item in items.iter_mut() { + if item.parent_id == source_id { + item.parent_id = "0".to_string(); + } + } + } + } + + return self + .browse_result_to_didl("0", result, source, starting_index, requested_count) + .await; + } + // Retourner toutes les sources comme enfants de la racine return self.browse_root(starting_index, requested_count).await; } @@ -192,7 +294,13 @@ 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 +368,60 @@ 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, 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 = containers + .into_iter() + .filter(|c| c.id != object_id) + .collect(); + let mut items = items; + + // Log avant déduplication + tracing::debug!( + "BrowseResult before dedup: containers={}, items={}", + containers.len(), + items.len() + ); + + // Deduplicate containers/items by id to avoid doubles in the response + let mut seen_containers = HashSet::new(); + containers.retain(|c| seen_containers.insert(c.id.clone())); + + let mut seen_items = HashSet::new(); + items.retain(|i| seen_items.insert(i.id.clone())); + + // Log après déduplication + tracing::debug!( + "BrowseResult after dedup: containers={}, items={}", + containers.len(), + items.len() + ); + // Calculer le total avant pagination let total = (containers.len() + items.len()) as u32; @@ -316,7 +455,7 @@ impl ContentHandler { let returned = (containers.len() + items.len()) as u32; let didl = to_didl_lite(&containers, &items)?; - let update_id = source.update_id().await; + let update_id = source.update_id().await.max(1); Ok((didl, returned, total, update_id)) } @@ -330,7 +469,8 @@ impl ContentHandler { id: "0".to_string(), parent_id: "-1".to_string(), restricted: Some("1".to_string()), - child_count: Some(child_count.to_string()), + // Laisser childCount absent sur la racine pour maximiser la compatibilité (BubbleUPnP) + child_count: None, searchable: Some("1".to_string()), title: "PMOMusic".to_string(), class: "object.container".to_string(), diff --git a/pmomediaserver/src/contentdirectory/mod.rs b/pmomediaserver/src/contentdirectory/mod.rs index 4f1054c3..fcec5894 100644 --- a/pmomediaserver/src/contentdirectory/mod.rs +++ b/pmomediaserver/src/contentdirectory/mod.rs @@ -76,13 +76,14 @@ use pmoupnp::define_service; pub mod actions; pub mod handlers; +pub mod state; pub mod variables; use actions::{BROWSE, GETSEARCHCAPABILITIES, GETSORTCAPABILITIES, GETSYSTEMUPDATEID, SEARCH}; use variables::{ A_ARG_TYPE_BROWSEFLAG, A_ARG_TYPE_COUNT, A_ARG_TYPE_FILTER, A_ARG_TYPE_INDEX, A_ARG_TYPE_OBJECTID, A_ARG_TYPE_RESULT, A_ARG_TYPE_SEARCHCRITERIA, A_ARG_TYPE_SORTCRITERIA, - A_ARG_TYPE_UPDATEID, SEARCHCAPABILITIES, SORTCAPABILITIES, SYSTEMUPDATEID, + A_ARG_TYPE_UPDATEID, CONTAINERUPDATEIDS, SEARCHCAPABILITIES, SORTCAPABILITIES, SYSTEMUPDATEID, }; // Service ContentDirectory:1 conforme à la spécification UPnP AV pour MediaServer @@ -102,6 +103,7 @@ define_service! { SEARCHCAPABILITIES, SORTCAPABILITIES, SYSTEMUPDATEID, + CONTAINERUPDATEIDS, ], actions: [ BROWSE, diff --git a/pmomediaserver/src/contentdirectory/state.rs b/pmomediaserver/src/contentdirectory/state.rs new file mode 100644 index 00000000..1b64c8ea --- /dev/null +++ b/pmomediaserver/src/contentdirectory/state.rs @@ -0,0 +1,83 @@ +use once_cell::sync::OnceCell; +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> = OnceCell::new(); +static SYSTEM_UPDATE_ID: AtomicU32 = AtomicU32::new(1); +static CONTAINER_UPDATE_IDS: Mutex = Mutex::new(String::new()); + +/// Enregistre l'instance ContentDirectory pour pouvoir pousser des notifications GENA. +pub fn register_instance(instance: &Arc) { + let _ = CONTENTDIR_INSTANCE.set(Arc::downgrade(instance)); + // Initialiser les valeurs + set_system_update_id(1); + set_container_update_ids(""); +} + +/// Notifie une mise à jour en incrémentant SystemUpdateID et ContainerUpdateIDs. +/// `container_ids` doit contenir les IDs des conteneurs impactés. +pub fn notify_containers_updated(container_ids: &[&str]) { + let new_id = SYSTEM_UPDATE_ID + .fetch_add(1, Ordering::Relaxed) + .saturating_add(1); + set_system_update_id(new_id); + + if !container_ids.is_empty() { + let mut buf = String::new(); + for (idx, cid) in container_ids.iter().enumerate() { + if idx > 0 { + buf.push(','); + } + buf.push_str(cid); + buf.push(','); + buf.push_str(&new_id.to_string()); + } + set_container_update_ids(&buf); + } +} + +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") { + spawn_set_value(var, StateValue::UI4(id), "SystemUpdateID"); + } + } +} + +fn set_container_update_ids(value: &str) { + tracing::info!("ContentDirectory: ContainerUpdateIDs -> {}", value); + { + let mut guard = CONTAINER_UPDATE_IDS.lock().unwrap(); + *guard = value.to_string(); + } + + if let Some(service) = CONTENTDIR_INSTANCE.get().and_then(|w| w.upgrade()) { + if let Some(var) = service.get_variable("ContainerUpdateIDs") { + spawn_set_value( + var, + StateValue::String(value.to_string()), + "ContainerUpdateIDs", + ); + } + } +} + +fn spawn_set_value(var: Arc, 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" + ); + } + }); +} diff --git a/pmomediaserver/src/contentdirectory/variables/containerupdateids.rs b/pmomediaserver/src/contentdirectory/variables/containerupdateids.rs new file mode 100644 index 00000000..8a0ace80 --- /dev/null +++ b/pmomediaserver/src/contentdirectory/variables/containerupdateids.rs @@ -0,0 +1,9 @@ +use pmoupnp::define_variable; + +// Liste des conteneurs modifiés (format "id,updateId,id,updateId,...") +define_variable! { + pub static CONTAINERUPDATEIDS: String = "ContainerUpdateIDs" { + evented: true, + // valeur initiale vide + } +} diff --git a/pmomediaserver/src/contentdirectory/variables/mod.rs b/pmomediaserver/src/contentdirectory/variables/mod.rs index 6660c23c..7277e589 100644 --- a/pmomediaserver/src/contentdirectory/variables/mod.rs +++ b/pmomediaserver/src/contentdirectory/variables/mod.rs @@ -7,6 +7,7 @@ mod a_arg_type_result; mod a_arg_type_searchcriteria; mod a_arg_type_sortcriteria; mod a_arg_type_updateid; +mod containerupdateids; mod searchcapabilities; mod sortcapabilities; mod systemupdateid; @@ -20,6 +21,7 @@ pub use a_arg_type_result::A_ARG_TYPE_RESULT; pub use a_arg_type_searchcriteria::A_ARG_TYPE_SEARCHCRITERIA; pub use a_arg_type_sortcriteria::A_ARG_TYPE_SORTCRITERIA; pub use a_arg_type_updateid::A_ARG_TYPE_UPDATEID; +pub use containerupdateids::CONTAINERUPDATEIDS; pub use searchcapabilities::SEARCHCAPABILITIES; pub use sortcapabilities::SORTCAPABILITIES; pub use systemupdateid::SYSTEMUPDATEID; diff --git a/pmomediaserver/src/device_ext.rs b/pmomediaserver/src/device_ext.rs new file mode 100644 index 00000000..1e70c09f --- /dev/null +++ b/pmomediaserver/src/device_ext.rs @@ -0,0 +1,85 @@ +///! 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 { + 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"); + } + } +} diff --git a/pmomediaserver/src/lib.rs b/pmomediaserver/src/lib.rs index 796e8ee2..074f0a7a 100644 --- a/pmomediaserver/src/lib.rs +++ b/pmomediaserver/src/lib.rs @@ -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; @@ -75,12 +76,20 @@ pub mod sources; #[cfg(any(feature = "qobuz", feature = "paradise"))] pub mod sources_api; +// Extension pour le streaming Paradise (requires feature paradise) +#[cfg(feature = "paradise")] +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}; +#[cfg(feature = "paradise")] +pub use paradise_streaming::ParadiseStreamingExt; + // Re-export sources when features are enabled #[cfg(feature = "qobuz")] pub use pmoqobuz; diff --git a/pmomediaserver/src/paradise_streaming.rs b/pmomediaserver/src/paradise_streaming.rs new file mode 100644 index 00000000..7d132870 --- /dev/null +++ b/pmomediaserver/src/paradise_streaming.rs @@ -0,0 +1,382 @@ +//! Extension pour l'initialisation des canaux de streaming Radio Paradise +//! +//! Ce module fournit un trait d'extension pour démarrer les pipelines de streaming +//! Radio Paradise avec caching audio/covers et historique. + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use axum::{ + Json, Router, + body::Body, + extract::{Path, State}, + http::{ + StatusCode, + header::{ACCEPT_RANGES, CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, + }, + response::{IntoResponse, Response}, + routing::get, +}; +use pmoaudiocache::{AudioCacheExt, Cache as AudioCache, get_audio_cache, register_audio_cache}; +use pmocovers::{Cache as CoverCache, CoverCacheExt, get_cover_cache, register_cover_cache}; +use pmoparadise::{ + ParadiseChannelManager, ParadiseHistoryBuilder, + channels::{ALL_CHANNELS, ChannelDescriptor}, + stream_channel::register_global_channel_manager, +}; +use pmoplaylist::register_audio_cache as register_playlist_audio_cache; +use pmoplaylist::{self, PlaylistEventKind}; +use std::sync::Arc; +use tokio_util::io::ReaderStream; +use tracing::{error, info}; + +/// État partagé pour les routes de streaming Paradise +#[derive(Clone)] +pub struct ParadiseStreamingState { + pub manager: Arc, +} + +/// Extension trait pour initialiser les canaux de streaming Radio Paradise +#[async_trait] +pub trait ParadiseStreamingExt { + /// Initialise les canaux de streaming Radio Paradise avec caching + /// + /// Cette méthode : + /// - Crée les caches audio et covers + /// - Initialise le ParadiseChannelManager avec historique + /// - Ajoute les routes de streaming HTTP (flac, ogg, history, metadata) + /// + /// # Routes créées + /// + /// Pour chaque canal (main, mellow, rock, eclectic) : + /// - `/radioparadise/stream/{slug}/flac` - Stream FLAC live + /// - `/radioparadise/stream/{slug}/ogg` - Stream OGG live + /// - `/radioparadise/stream/{slug}/historic/{client_id}/flac` - Historique FLAC + /// - `/radioparadise/stream/{slug}/historic/{client_id}/ogg` - Historique OGG + /// - `/radioparadise/metadata/{slug}` - Métadonnées en temps réel + /// + /// # Exemples + /// + /// ```ignore + /// use pmomediaserver::ParadiseStreamingExt; + /// + /// server.init_paradise_streaming().await?; + /// ``` + async fn init_paradise_streaming(&mut self) -> Result>; +} + +#[async_trait] +impl ParadiseStreamingExt for pmoserver::Server { + async fn init_paradise_streaming(&mut self) -> Result> { + info!("🎵 Initializing Radio Paradise streaming channels..."); + // Sentinel log pour vérifier qu'on exécute bien cette version du binaire + tracing::warn!( + "🔍 Rien de neuf: entering init_paradise_streaming with caches+history setup" + ); + + // Récupérer ou initialiser les caches singletons + info!("📦 Getting cache singletons..."); + let cover_cache = match get_cover_cache() { + Some(cache) => { + info!(" ✅ Using existing cover cache singleton"); + cache + } + None => { + info!(" 📦 Initializing new cover cache singleton"); + let cache = self + .init_cover_cache_configured() + .await + .context("Failed to initialize cover cache")?; + register_cover_cache(cache.clone()); + cache + } + }; + + let audio_cache = match get_audio_cache() { + Some(cache) => { + info!(" ✅ Using existing audio cache singleton"); + // S'assurer qu'il est aussi enregistré dans le playlist manager + register_playlist_audio_cache(cache.clone()); + cache + } + None => { + info!(" 📦 Initializing new audio cache singleton"); + let cache = self + .init_audio_cache_configured() + .await + .context("Failed to initialize audio cache")?; + register_audio_cache(cache.clone()); + register_playlist_audio_cache(cache.clone()); + cache + } + }; + + // Créer le builder d'historique + let mut history_builder = ParadiseHistoryBuilder::default(); + history_builder.playlist_prefix = "radio-paradise-history".into(); + history_builder.playlist_title_prefix = Some("Radio Paradise History".into()); + history_builder.max_history_tracks = Some(500); + history_builder.collection_prefix = Some("radioparadise".into()); + history_builder.replay_max_lead_seconds = 1.0; + + // Créer le manager de canaux + let base_url = Some(self.base_url()); + info!( + "📡 Creating ParadiseChannelManager (base_url={:?})...", + base_url + ); + // Si la création bloque (réseau RP lent), on coupe après 30s pour ne pas empêcher le serveur de démarrer. + let manager = match tokio::time::timeout( + std::time::Duration::from_secs(30), + ParadiseChannelManager::with_defaults_with_cover_cache( + Some(cover_cache.clone()), + Some(history_builder), + base_url, + ), + ) + .await + { + Ok(Ok(mgr)) => { + info!("✅ ParadiseChannelManager created"); + Arc::new(mgr) + } + Ok(Err(e)) => { + tracing::warn!("⚠️ Failed to create ParadiseChannelManager: {}", e); + return Err(e).context("Failed to create ParadiseChannelManager"); + } + Err(_) => { + let msg = "Timeout creating ParadiseChannelManager after 30s"; + tracing::warn!("⚠️ {}", msg); + return Err(anyhow::anyhow!(msg)); + } + }; + + register_global_channel_manager(manager.clone()); + spawn_playlist_event_handler(manager.clone()); + + let state = Arc::new(ParadiseStreamingState { + manager: manager.clone(), + }); + + // Ajouter les routes pour chaque canal + info!("🌐 Registering streaming routes..."); + for descriptor in ALL_CHANNELS.iter() { + let slug = descriptor.slug; + let channel_id = descriptor.id; + + // Route FLAC live + let flac_path = format!("/radioparadise/stream/{}/flac", slug); + self.add_handler_with_state( + &flac_path, + move |State(state): State>| { + let manager = state.manager.clone(); + async move { stream_flac(manager, channel_id).await } + }, + state.clone(), + ) + .await; + + // Route OGG live + let ogg_path = format!("/radioparadise/stream/{}/ogg", slug); + self.add_handler_with_state( + &ogg_path, + move |State(state): State>| { + let manager = state.manager.clone(); + async move { stream_ogg(manager, channel_id).await } + }, + state.clone(), + ) + .await; + + // Routes historique + let history_path = format!("/radioparadise/stream/{}/historic", slug); + let history_router = Router::new() + .route( + "/{client_id}/flac", + get({ + let manager = manager.clone(); + move |Path(client_id): Path| { + let manager = manager.clone(); + async move { stream_history_flac(manager, channel_id, client_id).await } + } + }), + ) + .route( + "/{client_id}/ogg", + get({ + let manager = manager.clone(); + move |Path(client_id): Path| { + let manager = manager.clone(); + async move { stream_history_ogg(manager, channel_id, client_id).await } + } + }), + ); + + self.add_router(&history_path, history_router).await; + + // Route métadonnées + let meta_path = format!("/radioparadise/metadata/{}", slug); + self.add_handler_with_state( + &meta_path, + move |State(state): State>| { + let manager = state.manager.clone(); + async move { get_metadata(manager, channel_id).await } + }, + state.clone(), + ) + .await; + + info!( + " ✅ {} - /radioparadise/stream/{}/{{flac,ogg}}", + descriptor.display_name, slug + ); + } + + info!("✅ Radio Paradise streaming channels initialized"); + + Ok(manager) + } +} + +// ============================================================================ +// Handlers de streaming +// ============================================================================ + +async fn stream_flac( + manager: Arc, + channel_id: u8, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.subscribe_flac(); + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "audio/flac") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} + +async fn stream_ogg( + manager: Arc, + channel_id: u8, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.subscribe_ogg(); + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/ogg") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} + +async fn get_metadata( + manager: Arc, + channel_id: u8, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let metadata = channel.metadata().await; + Ok(Json(metadata)) +} + +async fn stream_history_flac( + manager: Arc, + channel_id: u8, + client_id: String, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.stream_history_flac(&client_id).await.map_err(|e| { + error!( + "Failed to start historical FLAC stream for channel {} (client_id={}): {}", + channel_id, client_id, e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "audio/flac") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} + +async fn stream_history_ogg( + manager: Arc, + channel_id: u8, + client_id: String, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.stream_history_ogg(&client_id).await.map_err(|e| { + error!( + "Failed to start historical OGG stream for channel {} (client_id={}): {}", + channel_id, client_id, e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/ogg") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} + +fn spawn_playlist_event_handler(manager: Arc) { + tokio::spawn(async move { + let mut rx = pmoplaylist::subscribe_events(); + while let Ok(envelope) = rx.recv().await { + if let PlaylistEventKind::TrackPlayed { cache_pk, .. } = envelope.event.kind { + if let Some(descriptor) = channel_from_live_playlist(&envelope.event.playlist_id) { + if let Err(e) = manager.prefetch_until_horizon(descriptor.id).await { + tracing::warn!( + "Failed to prefetch for channel {}: {}", + descriptor.display_name, + e + ); + } + if let Err(e) = append_track_to_history(descriptor, &cache_pk).await { + tracing::warn!( + "Failed to update history for channel {}: {}", + descriptor.display_name, + e + ); + } + } + } + } + }); +} + +fn channel_from_live_playlist(playlist_id: &str) -> Option<&'static ChannelDescriptor> { + const PREFIX: &str = "radio-paradise-live-"; + let slug = playlist_id.strip_prefix(PREFIX)?; + ALL_CHANNELS + .iter() + .find(|descriptor| descriptor.slug == slug) +} + +async fn append_track_to_history(descriptor: &ChannelDescriptor, cache_pk: &str) -> Result<()> { + let playlist_id = format!("radio-paradise-history-{}", descriptor.slug); + let manager = pmoplaylist::PlaylistManager(); + let handle = manager + .get_persistent_write_handle(playlist_id.clone()) + .await + .with_context(|| format!("Failed to get history playlist {}", playlist_id))?; + + if handle.contains_pk(cache_pk).await? { + return Ok(()); + } + + handle + .push(cache_pk.to_string()) + .await + .with_context(|| format!("Failed to append {} to {}", cache_pk, playlist_id))?; + Ok(()) +} diff --git a/pmomediaserver/src/sources.rs b/pmomediaserver/src/sources.rs index aa8895b0..6c6b2ff0 100644 --- a/pmomediaserver/src/sources.rs +++ b/pmomediaserver/src/sources.rs @@ -173,23 +173,28 @@ impl SourcesExt for Server { #[cfg(feature = "paradise")] async fn register_paradise(&mut self) -> Result<()> { - use pmoparadise::{RadioParadiseClient, RadioParadiseExt, RadioParadiseSource}; + use crate::contentdirectory::state; + use pmoparadise::{RadioParadiseExt, RadioParadiseSource}; tracing::info!("Initializing Radio Paradise source..."); - // Créer le client (Radio Paradise ne nécessite pas d'authentification) - let client = RadioParadiseClient::new().await.map_err(|e| { - SourceInitError::ParadiseError(format!("Failed to create client: {}", e)) - })?; + // Obtenir l'URL de base du serveur + let base_url = self.base_url(); - // Créer la source depuis le registry avec capacité FIFO par défaut - let source = RadioParadiseSource::from_registry_default(client).map_err(|e| { - SourceInitError::ParadiseError(format!("Failed to create source: {}", e)) - })?; + // Créer la source Radio Paradise (utilise le singleton PlaylistManager) + let notifier = Arc::new(|containers: &[String]| { + let refs: Vec<&str> = containers.iter().map(|s| s.as_str()).collect(); + state::notify_containers_updated(&refs); + }); + let source = Arc::new( + RadioParadiseSource::new(base_url.to_string()).with_container_notifier(notifier), + ); + + // Brancher les callbacks de playlists (live/history) pour signaler les updates + source.attach_playlist_callbacks(); // Enregistrer la source - // Note: La FIFO sera peuplée automatiquement lors du premier browse - self.register_music_source(Arc::new(source)).await; + self.register_music_source(source.clone()).await; tracing::info!("✅ Radio Paradise source registered successfully"); diff --git a/pmomediaserver/src/sources_api.rs b/pmomediaserver/src/sources_api.rs index ee4b8193..7034056c 100644 --- a/pmomediaserver/src/sources_api.rs +++ b/pmomediaserver/src/sources_api.rs @@ -35,6 +35,10 @@ pub struct ParadiseParams { /// Capacité FIFO (optionnelle, 50 par défaut) #[serde(default)] pub fifo_capacity: Option, + + /// URL de base du serveur (optionnelle, "http://localhost:8080" par défaut) + #[serde(default)] + pub base_url: Option, } /// Réponse d'enregistrement de source @@ -136,34 +140,13 @@ async fn register_paradise(Json(params): Json) -> impl IntoRespo use pmoparadise::{RadioParadiseClient, RadioParadiseSource}; use pmosource::api::register_source; - // Créer le client (Radio Paradise ne nécessite pas d'auth) - let client = match RadioParadiseClient::new().await { - Ok(c) => c, - Err(e) => { - return ( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: format!("Failed to create Radio Paradise client: {}", e), - }), - ) - .into_response(); - } - }; + // Utiliser l'URL de base depuis les params ou une valeur par défaut + let base_url = params + .base_url + .unwrap_or_else(|| "http://localhost:8080".to_string()); - // Créer et enregistrer la source depuis le registry - // Note: params.fifo_capacity is currently not used by from_registry - let source = match RadioParadiseSource::from_registry(client) { - Ok(s) => Arc::new(s), - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: format!("Failed to create source: {}", e), - }), - ) - .into_response(); - } - }; + // Créer la source Radio Paradise (utilise le singleton PlaylistManager) + let source = Arc::new(RadioParadiseSource::new(base_url)); let source_id = source.as_ref().id().to_string(); diff --git a/pmometadata/src/lib.rs b/pmometadata/src/lib.rs index 4c9c6876..7243a45b 100755 --- a/pmometadata/src/lib.rs +++ b/pmometadata/src/lib.rs @@ -11,6 +11,7 @@ //! - **Error handling**: Distinguishes between transient errors (NotImplemented, ReadOnly) //! and backend errors that should be propagated //! - **Metadata copying**: Helper function to copy metadata between implementations +//! - **Fallback cover**: Provides a default cover image when none is available //! //! # Examples //! @@ -40,6 +41,60 @@ use std::{ }; use tokio::sync::RwLock; +/// Image SVG par défaut pour les covers manquantes. +/// +/// Cette constante contient un SVG élégant représentant une note de musique, +/// utilisé comme fallback quand aucune cover n'est disponible. +/// +/// # Utilisation +/// +/// ```rust +/// use pmometadata::DEFAULT_COVER_SVG; +/// +/// // Utiliser comme data URL +/// let data_url = format!("data:image/svg+xml;utf8,{}", DEFAULT_COVER_SVG); +/// ``` +pub const DEFAULT_COVER_SVG: &str = r#" + + + + + + + + + + + + + + + No Cover Available + +"#; + +/// Retourne l'URL de la cover par défaut comme data URL. +/// +/// Cette fonction peut être étendue à l'avenir pour accepter des paramètres +/// de personnalisation (taille, couleur, etc.). +/// +/// # Exemples +/// +/// ```rust +/// use pmometadata::get_default_cover_url; +/// +/// let url = get_default_cover_url(); +/// // url commence par "data:image/svg+xml;utf8, String { + format!("data:image/svg+xml;utf8,{}", DEFAULT_COVER_SVG) +} + /// Helper macro for copying a single metadata field. macro_rules! copy_a_metadata { ($src:ident, $dest:ident, $key:ident) => { @@ -186,6 +241,46 @@ pub trait TrackMetadata: Send + Sync { Err(MetadataError::NotImplemented) } + async fn get_genre(&self) -> MetadataResult { + Err(MetadataError::NotImplemented) + } + + async fn set_genre(&mut self, _value: Option) -> MetadataResult<()> { + Err(MetadataError::NotImplemented) + } + + async fn get_track_number(&self) -> MetadataResult { + Err(MetadataError::NotImplemented) + } + + async fn set_track_number(&mut self, _value: Option) -> MetadataResult<()> { + Err(MetadataError::NotImplemented) + } + + async fn get_track_total(&self) -> MetadataResult { + Err(MetadataError::NotImplemented) + } + + async fn set_track_total(&mut self, _value: Option) -> MetadataResult<()> { + Err(MetadataError::NotImplemented) + } + + async fn get_disc_number(&self) -> MetadataResult { + Err(MetadataError::NotImplemented) + } + + async fn set_disc_number(&mut self, _value: Option) -> MetadataResult<()> { + Err(MetadataError::NotImplemented) + } + + async fn get_disc_total(&self) -> MetadataResult { + Err(MetadataError::NotImplemented) + } + + async fn set_disc_total(&mut self, _value: Option) -> MetadataResult<()> { + Err(MetadataError::NotImplemented) + } + async fn get_duration(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } @@ -194,6 +289,46 @@ pub trait TrackMetadata: Send + Sync { Err(MetadataError::NotImplemented) } + async fn get_sample_rate(&self) -> MetadataResult { + Err(MetadataError::NotImplemented) + } + + async fn set_sample_rate(&mut self, _value: Option) -> MetadataResult<()> { + Err(MetadataError::NotImplemented) + } + + async fn get_total_samples(&self) -> MetadataResult { + Err(MetadataError::NotImplemented) + } + + async fn set_total_samples(&mut self, _value: Option) -> MetadataResult<()> { + Err(MetadataError::NotImplemented) + } + + async fn get_bits_per_sample(&self) -> MetadataResult { + Err(MetadataError::NotImplemented) + } + + async fn set_bits_per_sample(&mut self, _value: Option) -> MetadataResult<()> { + Err(MetadataError::NotImplemented) + } + + async fn get_channels(&self) -> MetadataResult { + Err(MetadataError::NotImplemented) + } + + async fn set_channels(&mut self, _value: Option) -> MetadataResult<()> { + Err(MetadataError::NotImplemented) + } + + async fn get_bitrate(&self) -> MetadataResult { + Err(MetadataError::NotImplemented) + } + + async fn set_bitrate(&mut self, _value: Option) -> MetadataResult<()> { + Err(MetadataError::NotImplemented) + } + async fn get_track_id(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } @@ -242,6 +377,105 @@ pub trait TrackMetadata: Send + Sync { Err(MetadataError::NotImplemented) } + /// Retourne l'URL de la cover avec logique de fallback. + /// + /// Cette méthode implémente la logique de priorité suivante : + /// 1. Si `cover_pk` est défini, retourne `Some(cover_pk)` (pour utilisation avec le cover cache) + /// 2. Sinon, si `cover_url` est défini, retourne `Some(cover_url)` (URL externe) + /// 3. Sinon, retourne `None` + /// + /// Si vous voulez toujours obtenir une URL (avec image par défaut), + /// utilisez [`get_cover_url_or_default`] à la place. + /// + /// Les implémentations peuvent overrider cette méthode si elles veulent un comportement + /// différent, mais l'implémentation par défaut devrait convenir à la plupart des cas. + /// + /// # Exemples + /// + /// ```rust + /// use pmometadata::{TrackMetadata, MemoryTrackMetadata}; + /// + /// # tokio_test::block_on(async { + /// let mut metadata = MemoryTrackMetadata::new(); + /// + /// // Cas 1: cover_pk défini (prioritaire) + /// metadata.set_cover_pk(Some("abc123".to_string())).await.unwrap(); + /// metadata.set_cover_url(Some("https://example.com/cover.jpg".to_string())).await.unwrap(); + /// assert_eq!( + /// metadata.get_cover_url_with_fallback().await.unwrap(), + /// Some("abc123".to_string()) + /// ); + /// + /// // Cas 2: seulement cover_url + /// let mut metadata2 = MemoryTrackMetadata::new(); + /// metadata2.set_cover_url(Some("https://example.com/cover.jpg".to_string())).await.unwrap(); + /// assert_eq!( + /// metadata2.get_cover_url_with_fallback().await.unwrap(), + /// Some("https://example.com/cover.jpg".to_string()) + /// ); + /// + /// // Cas 3: aucune cover + /// let metadata3 = MemoryTrackMetadata::new(); + /// assert_eq!(metadata3.get_cover_url_with_fallback().await.unwrap(), None); + /// # }); + /// ``` + async fn get_cover_url_with_fallback(&self) -> MetadataResult { + // Priorité 1: cover_pk (cache local) + if let Ok(Some(pk)) = self.get_cover_pk().await { + if !pk.is_empty() { + return Ok(Some(pk)); + } + } + + // Priorité 2: cover_url (URL externe) + if let Ok(Some(url)) = self.get_cover_url().await { + if !url.is_empty() { + return Ok(Some(url)); + } + } + + // Aucune cover disponible + Ok(None) + } + + /// Retourne l'URL de la cover ou l'image par défaut. + /// + /// Contrairement à [`get_cover_url_with_fallback`], cette méthode retourne **toujours** + /// une URL utilisable, en fournissant une image SVG par défaut si aucune cover n'est disponible. + /// + /// La logique de priorité est : + /// 1. `cover_pk` (cache local) + /// 2. `cover_url` (URL externe) + /// 3. Image SVG par défaut (data URL) + /// + /// # Exemples + /// + /// ```rust + /// use pmometadata::{TrackMetadata, MemoryTrackMetadata}; + /// + /// # tokio_test::block_on(async { + /// // Sans cover : retourne l'image par défaut + /// let metadata = MemoryTrackMetadata::new(); + /// let url = metadata.get_cover_url_or_default().await.unwrap(); + /// assert!(url.starts_with("data:image/svg+xml")); + /// + /// // Avec cover : retourne la cover + /// let mut metadata2 = MemoryTrackMetadata::new(); + /// metadata2.set_cover_pk(Some("abc123".to_string())).await.unwrap(); + /// assert_eq!( + /// metadata2.get_cover_url_or_default().await.unwrap(), + /// "abc123" + /// ); + /// # }); + /// ``` + async fn get_cover_url_or_default(&self) -> Result { + match self.get_cover_url_with_fallback().await { + Ok(Some(url)) => Ok(url), + Ok(None) => Ok(get_default_cover_url()), + Err(e) => Err(e), + } + } + async fn get_extra(&self) -> MetadataResult> { Err(MetadataError::NotImplemented) } @@ -318,8 +552,23 @@ where let src_guard = src.read().await; copy_metadata!( - src_guard, dest, title, artist, album, year, duration, track_id, channel_id, event, rating, - cover_url, cover_pk, extra + src_guard, + dest, + title, + artist, + album, + year, + duration, + sample_rate, + total_samples, + bits_per_sample, + track_id, + channel_id, + event, + rating, + cover_url, + cover_pk, + extra ); // Try to update the timestamp, but ignore transient errors @@ -337,7 +586,17 @@ pub struct MemoryTrackMetadata { artist: Option, album: Option, year: Option, + genre: Option, + track_number: Option, + track_total: Option, + disc_number: Option, + disc_total: Option, duration: Option, + sample_rate: Option, + total_samples: Option, + bits_per_sample: Option, + channels: Option, + bitrate: Option, track_id: Option, channel_id: Option, event: Option, @@ -396,6 +655,56 @@ impl TrackMetadata for MemoryTrackMetadata { Ok(Some(())) } + async fn get_genre(&self) -> MetadataResult { + Ok(self.genre.clone()) + } + + async fn set_genre(&mut self, value: Option) -> MetadataResult<()> { + self.genre = value; + self.touch().await?; + Ok(Some(())) + } + + async fn get_track_number(&self) -> MetadataResult { + Ok(self.track_number) + } + + async fn set_track_number(&mut self, value: Option) -> MetadataResult<()> { + self.track_number = value; + self.touch().await?; + Ok(Some(())) + } + + async fn get_track_total(&self) -> MetadataResult { + Ok(self.track_total) + } + + async fn set_track_total(&mut self, value: Option) -> MetadataResult<()> { + self.track_total = value; + self.touch().await?; + Ok(Some(())) + } + + async fn get_disc_number(&self) -> MetadataResult { + Ok(self.disc_number) + } + + async fn set_disc_number(&mut self, value: Option) -> MetadataResult<()> { + self.disc_number = value; + self.touch().await?; + Ok(Some(())) + } + + async fn get_disc_total(&self) -> MetadataResult { + Ok(self.disc_total) + } + + async fn set_disc_total(&mut self, value: Option) -> MetadataResult<()> { + self.disc_total = value; + self.touch().await?; + Ok(Some(())) + } + async fn get_duration(&self) -> MetadataResult { Ok(self.duration) } @@ -406,6 +715,56 @@ impl TrackMetadata for MemoryTrackMetadata { Ok(Some(())) } + async fn get_sample_rate(&self) -> MetadataResult { + Ok(self.sample_rate) + } + + async fn set_sample_rate(&mut self, value: Option) -> MetadataResult<()> { + self.sample_rate = value; + self.touch().await?; + Ok(Some(())) + } + + async fn get_total_samples(&self) -> MetadataResult { + Ok(self.total_samples) + } + + async fn set_total_samples(&mut self, value: Option) -> MetadataResult<()> { + self.total_samples = value; + self.touch().await?; + Ok(Some(())) + } + + async fn get_bits_per_sample(&self) -> MetadataResult { + Ok(self.bits_per_sample) + } + + async fn set_bits_per_sample(&mut self, value: Option) -> MetadataResult<()> { + self.bits_per_sample = value; + self.touch().await?; + Ok(Some(())) + } + + async fn get_channels(&self) -> MetadataResult { + Ok(self.channels) + } + + async fn set_channels(&mut self, value: Option) -> MetadataResult<()> { + self.channels = value; + self.touch().await?; + Ok(Some(())) + } + + async fn get_bitrate(&self) -> MetadataResult { + Ok(self.bitrate) + } + + async fn set_bitrate(&mut self, value: Option) -> MetadataResult<()> { + self.bitrate = value; + self.touch().await?; + Ok(Some(())) + } + async fn get_track_id(&self) -> MetadataResult { Ok(self.track_id.clone()) } @@ -499,6 +858,9 @@ mod tests { assert_eq!(metadata.get_album().await.unwrap(), None); assert_eq!(metadata.get_year().await.unwrap(), None); assert_eq!(metadata.get_duration().await.unwrap(), None); + assert_eq!(metadata.get_sample_rate().await.unwrap(), None); + assert_eq!(metadata.get_total_samples().await.unwrap(), None); + assert_eq!(metadata.get_bits_per_sample().await.unwrap(), None); assert_eq!(metadata.get_updated_at().await.unwrap(), None); } @@ -862,4 +1224,101 @@ mod tests { Some("Artist".to_string()) ); } + + #[tokio::test] + async fn test_get_cover_url_with_fallback_cover_pk_priority() { + let mut metadata = MemoryTrackMetadata::new(); + metadata + .set_cover_pk(Some("abc123".to_string())) + .await + .unwrap(); + metadata + .set_cover_url(Some("https://example.com/cover.jpg".to_string())) + .await + .unwrap(); + + // cover_pk should have priority + assert_eq!( + metadata.get_cover_url_with_fallback().await.unwrap(), + Some("abc123".to_string()) + ); + } + + #[tokio::test] + async fn test_get_cover_url_with_fallback_cover_url_only() { + let mut metadata = MemoryTrackMetadata::new(); + metadata + .set_cover_url(Some("https://example.com/cover.jpg".to_string())) + .await + .unwrap(); + + assert_eq!( + metadata.get_cover_url_with_fallback().await.unwrap(), + Some("https://example.com/cover.jpg".to_string()) + ); + } + + #[tokio::test] + async fn test_get_cover_url_with_fallback_none() { + let metadata = MemoryTrackMetadata::new(); + assert_eq!(metadata.get_cover_url_with_fallback().await.unwrap(), None); + } + + #[tokio::test] + async fn test_get_cover_url_with_fallback_empty_strings() { + let mut metadata = MemoryTrackMetadata::new(); + metadata.set_cover_pk(Some("".to_string())).await.unwrap(); + metadata + .set_cover_url(Some("https://example.com/cover.jpg".to_string())) + .await + .unwrap(); + + // Empty cover_pk should fallback to cover_url + assert_eq!( + metadata.get_cover_url_with_fallback().await.unwrap(), + Some("https://example.com/cover.jpg".to_string()) + ); + } + + #[tokio::test] + async fn test_get_cover_url_or_default_with_cover_pk() { + let mut metadata = MemoryTrackMetadata::new(); + metadata + .set_cover_pk(Some("abc123".to_string())) + .await + .unwrap(); + + assert_eq!(metadata.get_cover_url_or_default().await.unwrap(), "abc123"); + } + + #[tokio::test] + async fn test_get_cover_url_or_default_with_cover_url() { + let mut metadata = MemoryTrackMetadata::new(); + metadata + .set_cover_url(Some("https://example.com/cover.jpg".to_string())) + .await + .unwrap(); + + assert_eq!( + metadata.get_cover_url_or_default().await.unwrap(), + "https://example.com/cover.jpg" + ); + } + + #[tokio::test] + async fn test_get_cover_url_or_default_no_cover() { + let metadata = MemoryTrackMetadata::new(); + let url = metadata.get_cover_url_or_default().await.unwrap(); + + // Should return the default SVG data URL + assert!(url.starts_with("data:image/svg+xml")); + assert!(url.contains("= now()` +3. Télécharge chaque chanson individuellement via son `gapless_url` +4. Stocke les métadonnées (titre, artiste, album, cover) dans le cache audio +5. Push les PKs dans une playlist avec TTL calculé = `sched_end - now()` +6. La playlist est consommée par `PlaylistSource` qui produit le flux audio + +### Avantages + +- **Simplicité** : Pas de calcul de bornes, pas de découpe manuelle +- **Précision** : Chaque fichier FLAC = une chanson exactement +- **Réutilisabilité** : Utilise l'infrastructure existante (pmoplaylist, pmoaudiocache, PlaylistSource) +- **TTL automatique** : Les chansons expirées sont automatiquement retirées de la playlist + +### Exemple d'utilisation + +```rust +use pmoparadise::{RadioParadiseClient, RadioParadisePlaylistFeeder}; +use pmoaudiocache::cache::new_cache; +use pmocovers::cache::new_cache as new_covers_cache; +use pmoaudio_ext::PlaylistSource; +use std::sync::Arc; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Créer les caches + let audio_cache = Arc::new(new_cache("./cache/audio", 500)?); + let covers_cache = Arc::new(new_covers_cache("./cache/covers", 500)?); + + // Créer le client Radio Paradise + let client = RadioParadiseClient::new().await?; + + // Créer le feeder (retourne feeder + read_handle) + let (feeder, read_handle) = RadioParadisePlaylistFeeder::new( + client.clone(), + audio_cache.clone(), + covers_cache.clone(), + "rp-live".to_string(), + Some("radio-paradise".to_string()), + ).await?; + + // Lancer le feeder dans une tâche + let feeder = Arc::new(feeder); + let feeder_clone = feeder.clone(); + tokio::spawn(async move { + if let Err(e) = feeder_clone.run().await { + tracing::error!("Feeder error: {}", e); + } + }); + + // Enqueue le bloc actuel + let now_playing = client.now_playing().await?; + feeder.push_block_id(now_playing.block.event); + + // Créer la source audio depuis la playlist + let playlist_source = PlaylistSource::new(read_handle, audio_cache); + + // Utiliser playlist_source dans un pipeline pmoaudio... + + Ok(()) +} +``` + +## Radio Paradise API - Référence des URLs + +### URLs d'artistes + +**Format** : `https://radioparadise.com/music/artist/{artist_id}` +**Format alternatif** : `https://radioparadise.com/music/artist/{artist_id}/{Artist_Name}` + +Le champ `artist_id` est disponible dans `song.song_credit_list[].artist_id`. + +**Exemples** : +- Sting (ID 4247) : https://radioparadise.com/music/artist/4247 +- Pink Martini (ID 3718) : https://radioparadise.com/music/artist/3718/Pink_Martini + +### URLs de chansons + +**Format** : `https://legacy.radioparadise.com/rp3.php?file=songinfo&name=Music&song_id={song_id}` + +Le champ `song_id` est disponible dans `song.song_id`. + +### URLs gapless (FLAC individuels) + +**Format** : Fourni directement par l'API dans `song.gapless_url` + +**Exemple** : `https://audio-geo.radioparadise.com/chan/1/x/1065/4/g/1065-3.flac` + +Ces URLs pointent vers des fichiers FLAC contenant **une seule chanson**, permettant un téléchargement et un traitement simplifiés. + +### Timestamps (`sched_time_millis`) + +Tous les timestamps de l'API Radio Paradise sont en **UTC** (Unix timestamp en millisecondes). + +**Exemple** : +```json +"sched_time_millis": 1763272707000 // 2025-11-16 06:16:09 UTC +``` + +Pour calculer la fin de diffusion d'une chanson : +```rust +let sched_end = song.sched_time_millis + song.duration; +let is_still_playing = sched_end >= now_ms; +``` + +## Notes d'implémentation future + +Ces URLs peuvent être utilisées pour : +- **Enrichir les métadonnées** avec les biographies d'artistes (scraping des pages artistes) +- **Récupérer les paroles** (via l'API ou scraping) +- **Afficher l'historique de diffusion** par chanson +- **Lier vers les pages communautaires** Radio Paradise pour ratings/commentaires +- **Intégration MusicBrainz/Discogs** : utiliser `asin` ou rechercher par artiste+titre+album + +## Structure des données + +### Block + +Un bloc Radio Paradise contient : +- `event` : ID de début du bloc +- `end_event` : ID de fin (= event du bloc suivant) +- `length` : Durée totale en millisecondes +- `url` : URL du bloc FLAC complet (legacy) +- `song` : Map des chansons indexées par position ("0", "1", "2", ...) + +### Song + +Chaque chanson contient : +- **Métadonnées** : `title`, `artist`, `album`, `year`, `rating` +- **Timing** : `elapsed` (position dans le bloc), `duration`, `sched_time_millis` +- **Identifiants** : `song_id`, `audio_id`, `event` +- **Covers** : `cover`, `cover_large`, `cover_medium`, `cover_small` +- **Streaming** : `gapless_url` (⭐ nouveau, recommandé) +- **Artiste** : `artist_id` (pour construire les URLs) + +### Filtrage des chansons + +Pour éviter de télécharger des chansons déjà terminées : + +```rust +let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH)? + .as_millis() as u64; + +for (idx, song) in block.songs_ordered() { + if song.is_still_playing(now_ms) { + // Télécharger et ajouter à la playlist + } +} +``` diff --git a/pmoparadise/Cargo.toml b/pmoparadise/Cargo.toml index 6267f96b..26a23d78 100644 --- a/pmoparadise/Cargo.toml +++ b/pmoparadise/Cargo.toml @@ -29,6 +29,7 @@ hex = "0.4" tokio-util = { version = "0.7", features = ["io"] } async-stream = "0.3" rusqlite = { version = "0.37", features = ["bundled"] } +once_cell = "1.20" # Gestion des erreurs thiserror = "2.0.17" @@ -52,7 +53,7 @@ symphonia = { version = "0.5", features = ["all"] } claxon = "0.4" # pmoaudio-ext with playlist support (optional for examples) -pmoaudio-ext = { path = "../pmoaudio-ext", optional = true, features = ["playlist", "http-stream"] } +pmoaudio-ext = { path = "../pmoaudio-ext", optional = true, features = ["playlist", "http-stream", "cache-sink"] } # Common music source traits pmosource = { path = "../pmosource" } @@ -77,7 +78,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 +87,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"] +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 @@ -111,3 +114,8 @@ path = "examples/now_playing.rs" name = "stream_block" path = "examples/stream_block.rs" required-features = ["full"] + +[[example]] +name = "single_channel_server" +path = "examples/single_channel_server.rs" +required-features = ["full"] diff --git a/pmoparadise/examples/download_block.rs b/pmoparadise/examples/download_block.rs index 47cdeda8..a5d5f3bb 100644 --- a/pmoparadise/examples/download_block.rs +++ b/pmoparadise/examples/download_block.rs @@ -84,10 +84,7 @@ async fn main() -> Result<(), Box> { println!("Block Information:"); println!(" Event ID: {}", block.event); println!(" Songs: {}", block.song_count()); - println!( - " Duration: {:.1} minutes", - block.length as f64 / 60000.0 - ); + println!(" Duration: {:.1} minutes", block.length as f64 / 60000.0); println!(); // Afficher la liste des pistes diff --git a/pmoparadise/examples/play_and_cache.rs b/pmoparadise/examples/play_and_cache.rs index 52fe6f5c..2a1ca627 100644 --- a/pmoparadise/examples/play_and_cache.rs +++ b/pmoparadise/examples/play_and_cache.rs @@ -28,9 +28,13 @@ use pmoaudio::{AudioPipelineNode, AudioSink, TimerNode}; use pmoaudio_ext::{FlacCacheSink, PlaylistSource}; -use pmoaudiocache::Cache as AudioCache; -use pmocovers::Cache as CoverCache; +use pmoaudiocache::{ + new_cache_with_consolidation as new_audio_cache, + register_audio_cache as register_global_audio_cache, +}; +use pmocovers::{new_cache_with_consolidation as new_cover_cache, register_cover_cache}; use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource}; +use pmoplaylist::register_audio_cache as register_playlist_audio_cache; use std::env; use std::sync::Arc; use tokio_util::sync::CancellationToken; @@ -46,7 +50,7 @@ async fn main() -> Result<(), Box> { .add_directive("pmoaudio_ext=debug".parse()?) .add_directive("pmoplaylist=debug".parse()?) .add_directive("pmoparadise=debug".parse()?) - .add_directive("pmoaudiocache=debug".parse()?) + .add_directive("pmoaudiocache=debug".parse()?), ) .init(); @@ -89,7 +93,8 @@ async fn main() -> Result<(), Box> { // Initialiser les caches et le gestionnaire de playlist // ═══════════════════════════════════════════════════════════════════════════ - let base_dir = std::env::var("PMO_CONFIG_DIR").unwrap_or_else(|_| "/tmp/pmomusic_test".to_string()); + let base_dir = + std::env::var("PMO_CONFIG_DIR").unwrap_or_else(|_| "/tmp/pmomusic_test".to_string()); std::fs::create_dir_all(&base_dir)?; tracing::info!("Initializing caches in: {}", base_dir); @@ -97,24 +102,20 @@ async fn main() -> Result<(), Box> { // Créer le cache audio let audio_cache_dir = format!("{}/audio_cache", base_dir); std::fs::create_dir_all(&audio_cache_dir)?; - let audio_cache = Arc::new(AudioCache::new( - &audio_cache_dir, - 1000, // 1000 MB limit - )?); + let audio_cache = new_audio_cache(&audio_cache_dir, 1000).await?; tracing::debug!("Audio cache initialized at: {}", audio_cache_dir); // Créer le cache de covers let cover_cache_dir = format!("{}/cover_cache", base_dir); std::fs::create_dir_all(&cover_cache_dir)?; - let cover_cache = Arc::new(CoverCache::new( - &cover_cache_dir, - 100, // 100 MB limit - )?); + let cover_cache = new_cover_cache(&cover_cache_dir, 100).await?; tracing::debug!("Cover cache initialized at: {}", cover_cache_dir); // Enregistrer le cache audio dans pmoplaylist // (requis par pmoplaylist pour valider les pks) - pmoplaylist::register_audio_cache(audio_cache.clone()); + register_global_audio_cache(audio_cache.clone()); + register_playlist_audio_cache(audio_cache.clone()); + register_cover_cache(cover_cache.clone()); tracing::debug!("Audio cache registered in pmoplaylist"); // Utiliser le gestionnaire de playlist singleton @@ -130,8 +131,12 @@ async fn main() -> Result<(), Box> { tracing::info!("Creating playlist: {}", playlist_id); // Créer une playlist éphémère (non persistante) pour cet exemple - let writer = playlist_manager.get_write_handle(playlist_id.clone()).await?; - writer.set_title(format!("Radio Paradise - Channel {}", channel_id)).await?; + let writer = playlist_manager + .get_write_handle(playlist_id.clone()) + .await?; + writer + .set_title(format!("Radio Paradise - Channel {}", channel_id)) + .await?; writer.flush().await?; // Vider la playlist si elle existait tracing::debug!("Playlist created and flushed"); @@ -178,7 +183,10 @@ async fn main() -> Result<(), Box> { // Créer la source Radio Paradise let mut download_source = RadioParadiseStreamSource::new(client); download_source.push_block_id(block.event); - tracing::debug!("RadioParadiseStreamSource created with block {}", block.event); + tracing::debug!( + "RadioParadiseStreamSource created with block {}", + block.event + ); // Créer le sink de cache FLAC let mut cache_sink = FlacCacheSink::new(audio_cache.clone(), cover_cache.clone()); diff --git a/pmoparadise/examples/serve_channels.rs b/pmoparadise/examples/serve_channels.rs new file mode 100644 index 00000000..da56c96a --- /dev/null +++ b/pmoparadise/examples/serve_channels.rs @@ -0,0 +1,280 @@ +//! Minimal HTTP server exposing all four Radio Paradise channels. +//! +//! Routes: +//! - `/radioparadise/stream//flac` +//! - `/radioparadise/stream//ogg` +//! - `/radioparadise/stream//icy` +//! - `/radioparadise/stream//historic//flac` +//! - `/radioparadise/stream//historic//ogg` +//! - `/radioparadise/metadata/` + +use std::{fs, sync::Arc}; + +use axum::{ + body::Body, + extract::{Path, State}, + http::{ + header::{ACCEPT_RANGES, CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, + StatusCode, + }, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use pmoaudiocache::{ + new_cache_with_consolidation as new_audio_cache, + register_audio_cache as register_global_audio_cache, +}; +use pmocovers::{new_cache_with_consolidation as new_cover_cache, register_cover_cache}; +use pmoparadise::{channels::ALL_CHANNELS, ParadiseChannelManager, ParadiseHistoryBuilder}; +use pmoplaylist::register_audio_cache as register_playlist_audio_cache; +use pmoserver::{init_logging, ServerBuilder}; +use tokio_util::io::ReaderStream; +use tracing::{error, info}; + +#[derive(Clone)] +struct AppState { + manager: Arc, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let _ = init_logging(); + + // Préparer les caches partagés + let cover_cache_dir = "./cache/rp_covers"; + let audio_cache_dir = "./cache/rp_audio"; + fs::create_dir_all(cover_cache_dir)?; + fs::create_dir_all(audio_cache_dir)?; + + let cover_cache = new_cover_cache(cover_cache_dir, 500).await?; + let audio_cache = new_audio_cache(audio_cache_dir, 1000).await?; + register_global_audio_cache(audio_cache.clone()); + register_playlist_audio_cache(audio_cache.clone()); + register_cover_cache(cover_cache.clone()); + let _playlist_manager = pmoplaylist::PlaylistManager(); + + let history_builder = ParadiseHistoryBuilder { + audio_cache: audio_cache.clone(), + cover_cache: cover_cache.clone(), + playlist_prefix: "radio-paradise-history".into(), + playlist_title_prefix: Some("Radio Paradise History".into()), + max_history_tracks: Some(500), + collection_prefix: Some("radioparadise".into()), + replay_max_lead_seconds: 1.0, + }; + + info!("Initializing Radio Paradise channels..."); + let server_base_url = format!("http://localhost:{}", 8080); + let manager = Arc::new( + ParadiseChannelManager::with_defaults_with_cover_cache( + Some(cover_cache), + Some(history_builder), + Some(server_base_url), + ) + .await?, + ); + let app_state = Arc::new(AppState { + manager: manager.clone(), + }); + + let mut server = ServerBuilder::new("RadioParadiseChannels", "http://localhost", 8080).build(); + + for descriptor in ALL_CHANNELS.iter() { + let slug = descriptor.slug; + let flac_path = format!("/radioparadise/stream/{}/flac", slug); + let ogg_path = format!("/radioparadise/stream/{}/ogg", slug); + let icy_path = format!("/radioparadise/stream/{}/icy", slug); + let history_path = format!("/radioparadise/stream/{}/historic", slug); + let meta_path = format!("/radioparadise/metadata/{}", slug); + let channel_id = descriptor.id; + + server + .add_handler_with_state( + &flac_path, + move |State(state): State>| { + let manager = state.manager.clone(); + async move { stream_flac(manager, channel_id).await } + }, + app_state.clone(), + ) + .await; + + server + .add_handler_with_state( + &ogg_path, + move |State(state): State>| { + let manager = state.manager.clone(); + async move { stream_ogg(manager, channel_id).await } + }, + app_state.clone(), + ) + .await; + + server + .add_handler_with_state( + &icy_path, + move |State(state): State>| { + let manager = state.manager.clone(); + async move { stream_icy(manager, channel_id).await } + }, + app_state.clone(), + ) + .await; + + let history_router = Router::new() + .route( + "/{client_id}/flac", + get({ + let manager = manager.clone(); + move |Path(client_id): Path| { + let manager = manager.clone(); + async move { stream_history_flac(manager, channel_id, client_id).await } + } + }), + ) + .route( + "/{client_id}/ogg", + get({ + let manager = manager.clone(); + move |Path(client_id): Path| { + let manager = manager.clone(); + async move { stream_history_ogg(manager, channel_id, client_id).await } + } + }), + ); + + server.add_router(&history_path, history_router).await; + + server + .add_handler_with_state( + &meta_path, + move |State(state): State>| { + let manager = state.manager.clone(); + async move { get_metadata(manager, channel_id).await } + }, + app_state.clone(), + ) + .await; + } + + info!("========================================"); + info!("Radio Paradise streaming server running on http://localhost:8080"); + info!("Available channels:"); + for descriptor in ALL_CHANNELS.iter() { + info!( + " {}: /radioparadise/stream/{}/flac (also /ogg, /icy, metadata, /historic//(flac|ogg))", + descriptor.display_name, descriptor.slug + ); + } + info!("Press Ctrl+C to stop."); + info!("========================================"); + + server.start().await; + server.wait().await; + Ok(()) +} + +async fn stream_flac( + manager: Arc, + channel_id: u8, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.subscribe_flac(); + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "audio/flac") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} + +async fn stream_ogg( + manager: Arc, + channel_id: u8, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.subscribe_ogg(); + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/ogg") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} + +async fn stream_icy( + manager: Arc, + channel_id: u8, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.subscribe_icy(); + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "audio/flac") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .header("icy-metaint", "16000") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} + +async fn get_metadata( + manager: Arc, + channel_id: u8, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let metadata = channel.metadata().await; + Ok(Json(metadata)) +} + +async fn stream_history_flac( + manager: Arc, + channel_id: u8, + client_id: String, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.stream_history_flac(&client_id).await.map_err(|e| { + error!( + "Failed to start historical FLAC stream for channel {} (client_id={}): {}", + channel_id, client_id, e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "audio/flac") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} + +async fn stream_history_ogg( + manager: Arc, + channel_id: u8, + client_id: String, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.stream_history_ogg(&client_id).await.map_err(|e| { + error!( + "Failed to start historical OGG stream for channel {} (client_id={}): {}", + channel_id, client_id, e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/ogg") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} diff --git a/pmoparadise/examples/single_channel_server.rs b/pmoparadise/examples/single_channel_server.rs new file mode 100644 index 00000000..c83b3475 --- /dev/null +++ b/pmoparadise/examples/single_channel_server.rs @@ -0,0 +1,241 @@ +//! Simple web server that exposes one Radio Paradise channel over HTTP. +//! +//! Usage: +//! ```bash +//! cargo run --example single_channel_server --features full -- main +//! ``` +//! Valid arguments are either the slug (`main`, `mellow`, `rock`, `eclectic`) or +//! the numeric channel id (`0`..`3`). When no argument is provided, the example +//! defaults to the “main” mix. + +use axum::{ + body::Body, + extract::{Path, Request, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use pmoaudio_ext::StreamingSinkOptions; +use pmoaudiocache::{ + new_cache_with_consolidation as new_audio_cache, + register_audio_cache as register_global_audio_cache, +}; +use pmocovers::{ + new_cache_with_consolidation as new_cover_cache, register_cover_cache, Cache as CoverCache, +}; +use pmoparadise::{ + channels::{ChannelDescriptor, ALL_CHANNELS}, + ParadiseHistoryBuilder, ParadiseStreamChannel, ParadiseStreamChannelConfig, +}; +use pmoplaylist::register_audio_cache as register_playlist_audio_cache; +use std::{fs, net::SocketAddr, sync::Arc}; +use tokio::net::TcpListener; +use tokio_util::io::ReaderStream; +use tracing::info; + +#[derive(Clone)] +struct AppState { + channel: Arc, + descriptor: ChannelDescriptor, + cover_cache: Arc, +} + +#[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(env_filter).init(); + + let descriptor = pick_descriptor(std::env::args().nth(1))?; + info!( + "Selected Radio Paradise channel: {} ({})", + descriptor.display_name, descriptor.slug + ); + + // Prepare caches under ./cache/single-channel + let cache_root = "./cache/single-channel"; + let audio_cache_dir = format!("{}/audio", cache_root); + let cover_cache_dir = format!("{}/covers", cache_root); + fs::create_dir_all(&audio_cache_dir)?; + fs::create_dir_all(&cover_cache_dir)?; + + let audio_cache = new_audio_cache(&audio_cache_dir, 1000).await?; + let cover_cache = new_cover_cache(&cover_cache_dir, 200).await?; + register_global_audio_cache(audio_cache.clone()); + register_playlist_audio_cache(audio_cache.clone()); + register_cover_cache(cover_cache.clone()); + + let mut history_builder = ParadiseHistoryBuilder::new(audio_cache.clone(), cover_cache.clone()); + history_builder.playlist_prefix = format!("single-channel-history-{}", descriptor.slug); + history_builder.collection_prefix = Some(format!("single-channel-{}", descriptor.slug)); + let history_opts = history_builder.build_for_channel(&descriptor).await?; + + let mut channel_config = ParadiseStreamChannelConfig::default(); + // Base URL for cover images in stream metadata + let server_base_url = "http://localhost:8080".to_string(); + + // Configuration commune pour FLAC et OGG + let common_options = StreamingSinkOptions::flac_defaults() + .with_default_artist(Some("Radio Paradise".to_string())) + .with_default_title(descriptor.display_name.to_string()) + .with_server_base_url(Some(server_base_url.clone())); + + channel_config.flac_options = common_options.clone(); + channel_config.ogg_options = StreamingSinkOptions::ogg_defaults() + .with_default_artist(Some("Radio Paradise".to_string())) + .with_default_title(descriptor.display_name.to_string()) + .with_server_base_url(Some(server_base_url)); + + let channel = Arc::new( + ParadiseStreamChannel::new( + descriptor, + channel_config, + Some(cover_cache.clone()), + Some(history_opts), + ) + .await?, + ); + + let state = AppState { + channel, + descriptor, + cover_cache, + }; + + let app = Router::new() + .route("/stream/flac", get(stream_flac)) + .route("/stream/ogg", get(stream_ogg)) + .route("/metadata", get(get_metadata)) + .route("/covers/image/{pk}", get(get_cover)) + .with_state(state); + + let addr: SocketAddr = ([0, 0, 0, 0], 8080).into(); + info!("========================================"); + info!("HTTP server listening on http://{addr}"); + info!("Available endpoints:"); + info!(" - /stream/flac : FLAC audio stream"); + info!(" - /stream/ogg : OGG-FLAC audio stream"); + info!(" - /metadata : Current track metadata (JSON)"); + info!(" - /covers/image/{{pk}} : Album cover images (WebP)"); + info!("========================================"); + info!("Connect with a FLAC player: ffplay http://localhost:8080/stream/flac"); + info!("Connect with an OGG-FLAC player: ffplay http://localhost:8080/stream/ogg"); + + let listener = TcpListener::bind(addr).await?; + axum::serve(listener, app.into_make_service()).await?; + + Ok(()) +} + +async fn stream_flac(State(state): State) -> Result { + let stream = state.channel.subscribe_flac(); + let body = Body::from_stream(ReaderStream::new(stream)); + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "audio/flac") + .header( + "X-PMO-Channel", + format!( + "{} ({})", + state.descriptor.display_name, state.descriptor.slug + ), + ) + .body(body) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +async fn stream_ogg(State(state): State) -> Result { + let stream = state.channel.subscribe_ogg(); + let body = Body::from_stream(ReaderStream::new(stream)); + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "audio/ogg") + .header( + "X-PMO-Channel", + format!( + "{} ({})", + state.descriptor.display_name, state.descriptor.slug + ), + ) + .body(body) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +async fn get_metadata( + State(state): State, + request: Request, +) -> Result { + let mut metadata = state.channel.metadata().await; + + // Si cover_pk est disponible, construire l'URL complète depuis les headers + // Format: /covers/image/{pk} (correspond à la structure du cache pmocovers) + if let Some(ref pk) = metadata.cover_pk { + let base_url = extract_base_url(&request); + metadata.cover_url = Some(format!("{}/covers/image/{}", base_url, pk)); + } + + Ok(Json(metadata)) +} + +/// Extrait l'URL de base depuis les headers HTTP de la requête +/// Supporte les proxies avec X-Forwarded-Host et X-Forwarded-Proto +fn extract_base_url(request: &Request) -> String { + let headers = request.headers(); + + // Déterminer le schéma (http ou https) + let scheme = headers + .get("x-forwarded-proto") + .and_then(|h| h.to_str().ok()) + .unwrap_or("http"); + + // Déterminer le host + let host = headers + .get("x-forwarded-host") + .or_else(|| headers.get("host")) + .and_then(|h| h.to_str().ok()) + .unwrap_or("localhost:8080"); + + format!("{}://{}", scheme, host) +} + +async fn get_cover( + State(state): State, + Path(pk): Path, +) -> Result { + // Récupérer le chemin de la cover depuis le cache + // Le cache retourne un PathBuf pointant vers le fichier .webp + let cover_path = state.cover_cache.get(&pk).await.map_err(|e| { + tracing::error!("Failed to get cover path for {}: {}", pk, e); + StatusCode::NOT_FOUND + })?; + + // Lire le fichier + let cover_data = tokio::fs::read(&cover_path).await.map_err(|e| { + tracing::error!("Failed to read cover file {:?}: {}", cover_path, e); + StatusCode::NOT_FOUND + })?; + + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "image/webp") + .header("Cache-Control", "public, max-age=86400") + .body(Body::from(cover_data)) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +fn pick_descriptor(arg: Option) -> anyhow::Result { + if let Some(token) = arg { + if let Some(desc) = ALL_CHANNELS.iter().find(|c| c.slug == token) { + return Ok(*desc); + } + if let Ok(id) = token.parse::() { + if let Some(desc) = ALL_CHANNELS.iter().find(|c| c.id == id) { + return Ok(*desc); + } + } + anyhow::bail!("Unknown channel identifier: {token}"); + } + Ok(ALL_CHANNELS[0]) +} diff --git a/pmoparadise/examples/stream_block.rs b/pmoparadise/examples/stream_block.rs index 5447ff80..d7849d63 100644 --- a/pmoparadise/examples/stream_block.rs +++ b/pmoparadise/examples/stream_block.rs @@ -9,13 +9,13 @@ //! //! Architecture: //! ```text -//! RadioParadiseStreamSource → TimerNode → StreamingFlacSink -//! ↓ -//! StreamHandle -//! ↓ -//! pmoserver (Axum) -//! ↓ -//! VLC / Media Player Client +//! RadioParadiseStreamSource → TimerBufferNode → StreamingFlacSink +//! ↓ +//! StreamHandle +//! ↓ +//! pmoserver (Axum) +//! ↓ +//! VLC / Media Player Client //! ``` //! //! Usage: @@ -38,11 +38,11 @@ use axum::{ http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; -use pmoaudio::{AudioPipelineNode, TimerNode}; +use pmoaudio::{AudioPipelineNode, TimerBufferNode}; use pmoaudio_ext::{StreamingFlacSink, StreamingOggFlacSink}; use pmoflac::EncoderOptions; use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource, END_OF_BLOCKS_SIGNAL}; -use pmoserver::{ServerBuilder, init_logging}; +use pmoserver::{init_logging, ServerBuilder}; use std::env; use std::sync::Arc; use tokio_util::io::ReaderStream; @@ -204,51 +204,51 @@ async fn main() -> Result<(), Box> { }; // ───────────────────────────────────────────────────────────────────────── - // Pipeline 1: FLAC streaming + // Unique pipeline feeding both FLAC and OGG sinks // ───────────────────────────────────────────────────────────────────────── - let mut source_flac = RadioParadiseStreamSource::new(client.clone()); - source_flac.push_block_id(block.event); - source_flac.push_block_id(END_OF_BLOCKS_SIGNAL); // Signal: no more blocks after this one - tracing::debug!("RadioParadiseStreamSource (FLAC) created with block {} + END signal", block.event); + let mut source = RadioParadiseStreamSource::new(client); + source.push_block_id(block.event); + source.push_block_id(END_OF_BLOCKS_SIGNAL); // Signal: no more blocks after this one + tracing::debug!( + "RadioParadiseStreamSource created with block {} + END signal", + block.event + ); - // Use SMALL channel size to make backpressure more reactive - // Instead of trying to buffer 3s of audio (60 chunks), use a much smaller buffer - // This forces tighter backpressure control - let max_lead_time = 3.0; - let channel_size = 8; // Small buffer for reactive backpressure - tracing::debug!("Using channel size: {} chunks ({:.1}s buffer at 50ms/chunk)", channel_size, channel_size as f64 * 0.05); + // Use SMALL channel size to make backpressure plus fan-out manageable. + let buffer_sec = 0.1; + let max_lead_time = buffer_sec; + let channel_size = 512; + tracing::debug!( + "Using channel size: {} chunks ({:.1}s buffer à 50ms/chunk)", + channel_size, + channel_size as f64 * 0.05 + ); - let mut timer_flac = TimerNode::with_channel_size(max_lead_time, channel_size); - tracing::debug!("TimerNode (FLAC) created with {:.1}s max lead time, {} chunk buffer", max_lead_time, channel_size); + let mut timer_node = TimerBufferNode::with_channel_size(buffer_sec, channel_size); + tracing::debug!( + "TimerBufferNode created with {:.1}s buffer, {} chunk queue", + buffer_sec, + channel_size + ); - // StreamingFlacSink doesn't take channel_size - it uses bits_per_sample (16, 24, or 32) - let (streaming_sink, stream_handle) = StreamingFlacSink::new(encoder_options.clone(), 16); + // Streaming sinks + let (streaming_sink, stream_handle) = + StreamingFlacSink::with_max_broadcast_lead(encoder_options.clone(), 16, max_lead_time); tracing::debug!("StreamingFlacSink created"); - timer_flac.register(Box::new(streaming_sink)); - source_flac.register(Box::new(timer_flac)); - tracing::info!("Pipeline 1 connected: RadioParadiseStreamSource → TimerNode → StreamingFlacSink"); - - // ───────────────────────────────────────────────────────────────────────── - // Pipeline 2: OGG-FLAC streaming - // ───────────────────────────────────────────────────────────────────────── - - let mut source_ogg = RadioParadiseStreamSource::new(client); - source_ogg.push_block_id(block.event); - source_ogg.push_block_id(END_OF_BLOCKS_SIGNAL); // Signal: no more blocks after this one - tracing::debug!("RadioParadiseStreamSource (OGG) created with block {} + END signal", block.event); - - let mut timer_ogg = TimerNode::with_channel_size(max_lead_time, channel_size); - tracing::debug!("TimerNode (OGG) created with {:.1}s max lead time, {} chunk buffer", max_lead_time, channel_size); - - // StreamingOggFlacSink doesn't take channel_size - it uses bits_per_sample (16, 24, or 32) - let (ogg_sink, ogg_handle) = StreamingOggFlacSink::new(encoder_options, 16); + let (ogg_sink, ogg_handle) = + StreamingOggFlacSink::with_max_broadcast_lead(encoder_options, 16, max_lead_time); tracing::debug!("StreamingOggFlacSink created"); - timer_ogg.register(Box::new(ogg_sink)); - source_ogg.register(Box::new(timer_ogg)); - tracing::info!("Pipeline 2 connected: RadioParadiseStreamSource → TimerNode → StreamingOggFlacSink"); + // timer_node.register(Box::new(streaming_sink)); + // timer_node.register(Box::new(ogg_sink)); + // source.register(Box::new(timer_node)); + + source.register(Box::new(streaming_sink)); + source.register(Box::new(ogg_sink)); + + tracing::info!("Pipeline connected: StreamSource → TimerBufferNode → {{FLAC, OGG}} sinks"); // ═══════════════════════════════════════════════════════════════════════════ // Setup pmoserver with streaming routes @@ -256,8 +256,8 @@ async fn main() -> Result<(), Box> { tracing::info!("Setting up pmoserver..."); - let mut server = ServerBuilder::new("RadioParadiseStreamTest", "http://localhost", 8080) - .build(); + let mut server = + ServerBuilder::new("RadioParadiseStreamTest", "http://localhost", 8080).build(); let app_state = Arc::new(AppState { stream_handle, @@ -265,12 +265,37 @@ async fn main() -> Result<(), Box> { }); // Add streaming routes - server.add_handler_with_state("/test/stream", stream_handler, app_state.clone()).await; - server.add_handler_with_state("/test/stream-icy", stream_icy_handler, app_state.clone()).await; - server.add_handler_with_state("/test/stream-ogg", stream_ogg_handler, app_state.clone()).await; + let base = "/radioparadise/test"; + server + .add_handler_with_state( + &format!("{}/stream", base), + stream_handler, + app_state.clone(), + ) + .await; + server + .add_handler_with_state( + &format!("{}/stream-icy", base), + stream_icy_handler, + app_state.clone(), + ) + .await; + server + .add_handler_with_state( + &format!("{}/stream-ogg", base), + stream_ogg_handler, + app_state.clone(), + ) + .await; // Add metadata route - server.add_handler_with_state("/test/metadata", metadata_handler, app_state.clone()).await; + server + .add_handler_with_state( + &format!("{}/metadata", base), + metadata_handler, + app_state.clone(), + ) + .await; // Add health check server.add_handler("/test/health", health_handler).await; @@ -280,16 +305,16 @@ async fn main() -> Result<(), Box> { tracing::info!("Ready to stream!"); tracing::info!(""); tracing::info!("Pure FLAC stream (for VLC, standard players):"); - tracing::info!(" vlc http://localhost:8080/test/stream"); + tracing::info!(" vlc http://localhost:8080{}/stream", base); tracing::info!(""); tracing::info!("OGG-FLAC stream (streaming container with metadata support):"); - tracing::info!(" vlc http://localhost:8080/test/stream-ogg"); + tracing::info!(" vlc http://localhost:8080{}/stream-ogg", base); tracing::info!(""); tracing::info!("FLAC + ICY metadata stream (for ICY-aware clients):"); - tracing::info!(" http://localhost:8080/test/stream-icy"); + tracing::info!(" http://localhost:8080{}/stream-icy", base); tracing::info!(""); tracing::info!("Metadata endpoint (JSON):"); - tracing::info!(" curl http://localhost:8080/test/metadata"); + tracing::info!(" curl http://localhost:8080{}/metadata", base); tracing::info!("========================================"); tracing::info!(""); @@ -298,27 +323,15 @@ async fn main() -> Result<(), Box> { // ═══════════════════════════════════════════════════════════════════════════ let stop_token = CancellationToken::new(); - let stop_token_flac = stop_token.clone(); - let stop_token_ogg = stop_token.clone(); + let pipeline_stop = stop_token.clone(); - // Start FLAC pipeline in background - let pipeline_flac_handle = tokio::spawn(async move { - tracing::info!("[PIPELINE-FLAC] Starting..."); - let result = Box::new(source_flac).run(stop_token_flac).await; + // Start shared pipeline in background + let pipeline_handle = tokio::spawn(async move { + tracing::info!("[PIPELINE] Starting..."); + let result = Box::new(source).run(pipeline_stop).await; match &result { - Ok(()) => tracing::info!("[PIPELINE-FLAC] Completed successfully"), - Err(e) => tracing::error!("[PIPELINE-FLAC] Error: {}", e), - } - result - }); - - // Start OGG-FLAC pipeline in background - let pipeline_ogg_handle = tokio::spawn(async move { - tracing::info!("[PIPELINE-OGG] Starting..."); - let result = Box::new(source_ogg).run(stop_token_ogg).await; - match &result { - Ok(()) => tracing::info!("[PIPELINE-OGG] Completed successfully"), - Err(e) => tracing::error!("[PIPELINE-OGG] Error: {}", e), + Ok(()) => tracing::info!("[PIPELINE] Completed successfully"), + Err(e) => tracing::error!("[PIPELINE] Error: {}", e), } result }); @@ -332,17 +345,11 @@ async fn main() -> Result<(), Box> { tracing::info!("Server stopped, canceling pipelines..."); stop_token.cancel(); - // Wait for both pipelines to finish - match pipeline_flac_handle.await { - Ok(Ok(())) => tracing::info!("FLAC pipeline completed successfully"), - Ok(Err(e)) => tracing::error!("FLAC pipeline error: {}", e), - Err(e) => tracing::error!("FLAC pipeline task error: {}", e), - } - - match pipeline_ogg_handle.await { - Ok(Ok(())) => tracing::info!("OGG-FLAC pipeline completed successfully"), - Ok(Err(e)) => tracing::error!("OGG-FLAC pipeline error: {}", e), - Err(e) => tracing::error!("OGG-FLAC pipeline task error: {}", e), + // Wait for pipeline to finish + match pipeline_handle.await { + Ok(Ok(())) => tracing::info!("Pipeline completed successfully"), + Ok(Err(e)) => tracing::error!("Pipeline error: {}", e), + Err(e) => tracing::error!("Pipeline task error: {}", e), } tracing::info!("Shutdown complete"); diff --git a/pmoparadise/src/client.rs b/pmoparadise/src/client.rs index 6c9725e1..a2aef0a2 100644 --- a/pmoparadise/src/client.rs +++ b/pmoparadise/src/client.rs @@ -140,7 +140,8 @@ impl RadioParadiseClient { url.query_pairs_mut() .append_pair("bitrate", "4") // FLAC lossless .append_pair("info", "true") - .append_pair("channel", &self.channel.to_string()); + // RP API expects `chan` rather than `channel` for channel selection. + .append_pair("chan", &self.channel.to_string()); if let Some(event_id) = event { url.query_pairs_mut() diff --git a/pmoparadise/src/lib.rs b/pmoparadise/src/lib.rs index 376d97d9..bca8da94 100644 --- a/pmoparadise/src/lib.rs +++ b/pmoparadise/src/lib.rs @@ -203,7 +203,7 @@ //! - `pmoserver`: Enable REST API extension for pmoserver integration (adds `utoipa`, `axum`) //! - `pmoaudio`: Enable RadioParadiseStreamSource for pmoaudio integration //! - `pmoconfig`: Enable configuration integration with pmoconfig -//! - `server`: Enable RadioParadiseSource stub for backward compatibility (deprecated) +//! - `server`: Enable RadioParadiseSource for UPnP ContentDirectory integration //! //! ## See Also //! @@ -228,6 +228,12 @@ pub mod config_ext; #[cfg(feature = "pmoaudio")] pub mod radio_paradise_stream_source; +#[cfg(feature = "pmoaudio")] +pub mod stream_channel; + +#[cfg(feature = "pmoaudio")] +pub mod playlist_feeder; + // Re-exports for convenience pub use client::{ClientBuilder, RadioParadiseClient}; pub use error::{Error, Result}; @@ -235,7 +241,17 @@ pub use models::{Block, DurationMs, EventId, NowPlaying, Song}; pub use source::RadioParadiseSource; #[cfg(feature = "pmoaudio")] -pub use radio_paradise_stream_source::{RadioParadiseStreamSource, END_OF_BLOCKS_SIGNAL}; +pub use radio_paradise_stream_source::RadioParadiseStreamSource; + +#[cfg(feature = "pmoaudio")] +pub use playlist_feeder::{RadioParadisePlaylistFeeder, END_OF_BLOCKS_SIGNAL}; + +#[cfg(feature = "pmoaudio")] +pub use stream_channel::{ + HistoryFlacStream, HistoryOggStream, HistoryStreamError, ParadiseChannelManager, + ParadiseHistoryBuilder, ParadiseHistoryOptions, ParadiseStreamChannel, + ParadiseStreamChannelConfig, +}; #[cfg(feature = "pmoserver")] pub use pmoserver_ext::{ diff --git a/pmoparadise/src/models.rs b/pmoparadise/src/models.rs index cfad4bc8..8afd6386 100644 --- a/pmoparadise/src/models.rs +++ b/pmoparadise/src/models.rs @@ -161,6 +161,27 @@ pub struct Song { #[serde(default, deserialize_with = "deserialize_optional_string_or_f32")] pub rating: Option, + /// Gapless URL for individual song FLAC + /// This URL points to a FLAC file containing only this song + #[serde(default)] + pub gapless_url: Option, + + /// Scheduled playback time on Radio Paradise (Unix timestamp in milliseconds, UTC) + #[serde(default)] + pub sched_time_millis: Option, + + /// Radio Paradise song ID (unique identifier) + #[serde(default)] + pub song_id: Option, + + /// Radio Paradise artist ID (for building artist URLs) + #[serde(default)] + pub artist_id: Option, + + /// Large cover image path (best quality) + #[serde(default)] + pub cover_large: Option, + /// Additional metadata #[serde(flatten)] pub extra: HashMap, @@ -176,6 +197,18 @@ impl Song { pub fn contains_timestamp(&self, timestamp_ms: DurationMs) -> bool { timestamp_ms >= self.elapsed && timestamp_ms < self.end_time_ms() } + + /// Calcule le timestamp de fin de diffusion (sched_time + duration) + pub fn sched_end_time_ms(&self) -> Option { + self.sched_time_millis.map(|start| start + self.duration) + } + + /// Vérifie si la chanson est encore en lecture ou à venir + pub fn is_still_playing(&self, now_ms: u64) -> bool { + self.sched_end_time_ms() + .map(|end| end >= now_ms) + .unwrap_or(false) + } } /// Image information @@ -214,6 +247,10 @@ pub struct Block { #[serde(default)] pub image_base: Option, + /// Scheduled start time for this block (Unix timestamp in milliseconds, UTC) + #[serde(default)] + pub sched_time_millis: Option, + /// Map of song index (as string) to Song metadata /// Keys are "0", "1", "2", etc. #[serde(default)] @@ -225,6 +262,16 @@ pub struct Block { } impl Block { + /// Scheduled start time in milliseconds if available. + pub fn start_time_millis(&self) -> Option { + if let Some(ts) = self.sched_time_millis { + return Some(ts); + } + self.songs_ordered() + .into_iter() + .find_map(|(_, song)| song.sched_time_millis) + } + /// Get songs in order by index pub fn songs_ordered(&self) -> Vec<(usize, &Song)> { let mut songs: Vec<_> = self @@ -329,6 +376,11 @@ mod tests { cover: None, rating: None, extra: HashMap::new(), + gapless_url: Some("http://example.com/song.flac".into()), + sched_time_millis: Some(1_700_000_000_000), + song_id: Some("song-id".into()), + artist_id: Some("artist-id".into()), + cover_large: Some("cover-large.jpg".into()), }; assert_eq!(song.end_time_ms(), 6000); diff --git a/pmoparadise/src/node_stats.rs b/pmoparadise/src/node_stats.rs index 2bc0b27b..758985a7 100644 --- a/pmoparadise/src/node_stats.rs +++ b/pmoparadise/src/node_stats.rs @@ -91,13 +91,15 @@ impl NodeStats { /// Enregistre l'envoi d'un segment pub fn record_segment_sent(&self, bytes: usize) { self.segments_sent.fetch_add(1, Ordering::Relaxed); - self.bytes_processed.fetch_add(bytes as u64, Ordering::Relaxed); + self.bytes_processed + .fetch_add(bytes as u64, Ordering::Relaxed); } /// Enregistre un événement de backpressure pub fn record_backpressure(&self, duration_ms: u64) { self.backpressure_blocks.fetch_add(1, Ordering::Relaxed); - self.backpressure_time_ms.fetch_add(duration_ms, Ordering::Relaxed); + self.backpressure_time_ms + .fetch_add(duration_ms, Ordering::Relaxed); } /// Retourne un rapport formaté des statistiques @@ -112,7 +114,11 @@ impl NodeStats { let first_ts = self.first_segment_timestamp.load(Ordering::Relaxed); let last_ts = self.last_segment_timestamp.load(Ordering::Relaxed); - let first_ts_sec = if first_ts == u64::MAX { 0.0 } else { first_ts as f64 / 1000.0 }; + let first_ts_sec = if first_ts == u64::MAX { + 0.0 + } else { + first_ts as f64 / 1000.0 + }; let last_ts_sec = last_ts as f64 / 1000.0; let audio_duration = last_ts_sec - first_ts_sec; @@ -126,12 +132,27 @@ impl NodeStats { Audio: {:.1}s (first: {:.1}s, last: {:.1}s) | Real-time ratio: {:.1}%\n\ Backpressure: {} blocks, {:.2}s total ({:.1}% of time)", self.name, - elapsed, received, sent, received.saturating_sub(sent), - mb, throughput_mbps, - audio_duration, first_ts_sec, last_ts_sec, - if audio_duration > 0.0 { (elapsed / audio_duration) * 100.0 } else { 0.0 }, - bp_blocks, bp_time_ms as f64 / 1000.0, - if elapsed > 0.0 { (bp_time_ms as f64 / 1000.0 / elapsed) * 100.0 } else { 0.0 } + elapsed, + received, + sent, + received.saturating_sub(sent), + mb, + throughput_mbps, + audio_duration, + first_ts_sec, + last_ts_sec, + if audio_duration > 0.0 { + (elapsed / audio_duration) * 100.0 + } else { + 0.0 + }, + bp_blocks, + bp_time_ms as f64 / 1000.0, + if elapsed > 0.0 { + (bp_time_ms as f64 / 1000.0 / elapsed) * 100.0 + } else { + 0.0 + } ) } } diff --git a/pmoparadise/src/playlist_feeder.rs b/pmoparadise/src/playlist_feeder.rs new file mode 100644 index 00000000..4594743c --- /dev/null +++ b/pmoparadise/src/playlist_feeder.rs @@ -0,0 +1,341 @@ +//! RadioParadisePlaylistFeeder - Télécharge et alimente une playlist à partir des blocs RP +//! +//! Architecture simplifiée utilisant les URLs gapless individuelles au lieu du bloc FLAC entier. + +use crate::{client::RadioParadiseClient, models::EventId}; +use anyhow::Result; +use pmoaudiocache::Cache as AudioCache; +use pmocovers::Cache as CoversCache; +use pmoplaylist::{PlaylistManager, ReadHandle, WriteHandle}; +use std::{ + collections::{HashMap, VecDeque}, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::Notify; + +/// Signal de fin de blocs +pub const END_OF_BLOCKS_SIGNAL: EventId = EventId::MAX; +const RECENT_BLOCKS_CACHE_SIZE: usize = 10; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BlockStatus { + Pending, + InProgress, + Done, +} + +struct RecentBlocks { + states: HashMap, + order: VecDeque, + capacity: usize, +} + +impl RecentBlocks { + fn new(capacity: usize) -> Self { + Self { + states: HashMap::new(), + order: VecDeque::new(), + capacity, + } + } + + fn try_enqueue(&mut self, event_id: EventId) -> bool { + match self.states.get(&event_id) { + Some(_) => false, + None => { + self.order.push_back(event_id); + self.states.insert(event_id, BlockStatus::Pending); + self.evict_old_done(); + true + } + } + } + + fn mark_in_progress(&mut self, event_id: EventId) { + if let Some(state) = self.states.get_mut(&event_id) { + *state = BlockStatus::InProgress; + } else { + self.order.push_back(event_id); + self.states.insert(event_id, BlockStatus::InProgress); + } + self.evict_old_done(); + } + + fn mark_done(&mut self, event_id: EventId) { + if let Some(state) = self.states.get_mut(&event_id) { + *state = BlockStatus::Done; + } else { + self.order.push_back(event_id); + self.states.insert(event_id, BlockStatus::Done); + } + self.evict_old_done(); + } + + fn purge(&mut self, event_id: EventId) { + self.states.remove(&event_id); + } + + fn evict_old_done(&mut self) { + while self.order.len() > self.capacity { + let Some(front) = self.order.front().copied() else { + break; + }; + match self.states.get(&front) { + Some(BlockStatus::Done) | None => { + self.order.pop_front(); + self.states.remove(&front); + } + Some(_) => break, + } + } + } +} + +/// Feeder qui télécharge les blocs RP et alimente une playlist +pub struct RadioParadisePlaylistFeeder { + client: RadioParadiseClient, + audio_cache: Arc, + covers_cache: Arc, + playlist_handle: Arc, + block_queue: Arc>>, + notify: Arc, + collection: Option, + recent_blocks: tokio::sync::Mutex, +} + +impl RadioParadisePlaylistFeeder { + /// Crée un nouveau feeder et retourne (feeder, read_handle) + pub async fn new( + client: RadioParadiseClient, + audio_cache: Arc, + covers_cache: Arc, + playlist_id: String, + collection: Option, + ) -> Result<(Self, ReadHandle)> { + let manager = PlaylistManager::get(); + let write_handle = manager + .create_persistent_playlist(playlist_id.clone()) + .await?; + let read_handle = manager.get_read_handle(&playlist_id).await?; + + Ok(( + Self { + client, + audio_cache, + covers_cache, + playlist_handle: Arc::new(write_handle), + block_queue: Arc::new(tokio::sync::Mutex::new(VecDeque::new())), + notify: Arc::new(Notify::new()), + collection, + recent_blocks: tokio::sync::Mutex::new(RecentBlocks::new(RECENT_BLOCKS_CACHE_SIZE)), + }, + read_handle, + )) + } + + /// Enqueue un bloc pour traitement + pub async fn push_block_id(&self, event_id: EventId) { + { + let mut recent = self.recent_blocks.lock().await; + if !recent.try_enqueue(event_id) { + tracing::debug!( + "RadioParadisePlaylistFeeder: Ignoring duplicate enqueue for block {}", + event_id + ); + return; + } + } + + { + let mut queue = self.block_queue.lock().await; + queue.push_back(event_id); + } + self.notify.notify_one(); + } + + async fn mark_in_progress(&self, event_id: EventId) { + let mut recent = self.recent_blocks.lock().await; + recent.mark_in_progress(event_id); + } + + async fn mark_done(&self, event_id: EventId) { + let mut recent = self.recent_blocks.lock().await; + recent.mark_done(event_id); + } + + async fn purge_block_state(&self, event_id: EventId) { + let mut recent = self.recent_blocks.lock().await; + recent.purge(event_id); + } + + pub(crate) async fn retry_block(&self, event_id: EventId) { + self.purge_block_state(event_id).await; + self.push_block_id(event_id).await; + } + + /// Boucle principale de traitement (à exécuter dans une tâche tokio) + pub async fn run(self: Arc) -> Result<()> { + loop { + // Attendre un bloc + let event_id = loop { + { + let mut queue = self.block_queue.lock().await; + if let Some(id) = queue.pop_front() { + if id == END_OF_BLOCKS_SIGNAL { + tracing::info!( + "RadioParadisePlaylistFeeder: END_OF_BLOCKS_SIGNAL received" + ); + return Ok(()); + } + break id; + } + } + self.notify.notified().await; + }; + + self.mark_in_progress(event_id).await; + + // Traiter le bloc + if let Err(e) = self.process_block(event_id).await { + tracing::error!( + "RadioParadisePlaylistFeeder: Failed to process block {}: {}", + event_id, + e + ); + self.purge_block_state(event_id).await; + tracing::debug!( + "RadioParadisePlaylistFeeder: Cleared block {} state after error", + event_id + ); + } else { + self.mark_done(event_id).await; + } + } + } + + /// Traite un bloc : fetch, filtre, download, push playlist + async fn process_block(&self, event_id: EventId) -> Result<()> { + tracing::info!("RadioParadisePlaylistFeeder: Processing block {}", event_id); + + // 1. Fetch le bloc + let block = self.client.get_block(Some(event_id)).await?; + + // 2. Timestamp actuel + let now_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64; + + // 3. Filtrer les chansons encore en lecture ou à venir + let songs = block.songs_ordered(); + let mut processed = 0; + + for (idx, song) in songs { + if !song.is_still_playing(now_ms) { + tracing::debug!( + "RadioParadisePlaylistFeeder: Skipping finished song {} - {} (ended at {})", + idx, + song.title, + song.sched_end_time_ms().unwrap_or(0) + ); + continue; + } + + // 4. Télécharger la chanson + let gapless_url = song + .gapless_url + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Missing gapless_url for song {}", idx))?; + + tracing::info!( + "RadioParadisePlaylistFeeder: Downloading song {} - {} by {}", + idx, + song.title, + song.artist + ); + + let pk = self + .audio_cache + .add_from_url(gapless_url, self.collection.as_deref()) + .await?; + + // 5. Sauvegarder les métadonnées + self.save_metadata(&pk, song, &block).await?; + + // 6. Calculer le TTL + let sched_end = song + .sched_end_time_ms() + .ok_or_else(|| anyhow::anyhow!("Cannot calculate TTL without sched_time_millis"))?; + let ttl_ms = sched_end.saturating_sub(now_ms); + let ttl = Duration::from_millis(ttl_ms); + + // 7. Push dans la playlist avec TTL + self.playlist_handle.push_with_ttl(pk.clone(), ttl).await?; + + tracing::info!( + "RadioParadisePlaylistFeeder: Added {} to playlist (pk={}, ttl={}s)", + song.title, + pk, + ttl.as_secs() + ); + + processed += 1; + } + + tracing::info!( + "RadioParadisePlaylistFeeder: Processed block {} - added {} songs to playlist", + event_id, + processed + ); + + Ok(()) + } + + /// Sauvegarde les métadonnées dans le cache audio + async fn save_metadata( + &self, + pk: &str, + song: &crate::models::Song, + block: &crate::models::Block, + ) -> Result<()> { + use pmoaudiocache::AudioTrackMetadataExt; + + let metadata = self.audio_cache.track_metadata(pk); + let mut meta = metadata.write().await; + + // Métadonnées de base + meta.set_title(Some(song.title.clone())).await?; + meta.set_artist(Some(song.artist.clone())).await?; + if let Some(ref album) = song.album { + meta.set_album(Some(album.clone())).await?; + } + if let Some(year) = song.year { + meta.set_year(Some(year)).await?; + } + + // Cover + if let Some(ref cover_large) = song.cover_large { + if let Some(cover_url) = block.cover_url(cover_large) { + meta.set_cover_url(Some(cover_url.clone())).await?; + + // Télécharger la cover + match self + .covers_cache + .add_from_url(&cover_url, self.collection.as_deref()) + .await + { + Ok(cover_pk) => { + meta.set_cover_pk(Some(cover_pk)).await?; + tracing::debug!( + "RadioParadisePlaylistFeeder: Cached cover for {}", + song.title + ); + } + Err(e) => { + tracing::warn!("RadioParadisePlaylistFeeder: Failed to cache cover: {}", e); + } + } + } + } + + Ok(()) + } +} diff --git a/pmoparadise/src/pmoserver_ext.rs b/pmoparadise/src/pmoserver_ext.rs index 5c19d5ad..4b95acb5 100644 --- a/pmoparadise/src/pmoserver_ext.rs +++ b/pmoparadise/src/pmoserver_ext.rs @@ -141,6 +141,37 @@ pub struct BlockResponse { pub songs: Vec, } +/// Réponse pour l'URL de streaming +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct StreamUrlResponse { + /// Event ID du block + #[schema(example = 1234567)] + pub event: u64, + /// URL de streaming FLAC + #[schema(example = "https://apps.radioparadise.com/blocks/chan/0/4/1234567-1234580.flac")] + pub stream_url: String, + /// Durée totale (ms) + #[schema(example = 900000)] + pub length_ms: u64, +} + +/// Réponse pour l'URL de pochette +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct CoverUrlResponse { + /// Event ID du block + #[schema(example = 1234567)] + pub event: u64, + /// Index du morceau + #[schema(example = 0)] + pub song_index: usize, + /// URL de la pochette (résolution complète) + #[schema(example = "https://img.radioparadise.com/covers/l/B00000I0JF.jpg")] + pub cover_url: Option, + /// Type de pochette: "cover" (petite) ou "cover_large" (grande) + #[schema(example = "cover_large")] + pub cover_type: String, +} + impl From for BlockResponse { fn from(block: Block) -> Self { let songs = block @@ -313,25 +344,219 @@ async fn get_channels() -> Json> { Json(channels) } +/// GET /block/{event_id}/song/{index} - Récupère un morceau spécifique d'un block +#[utoipa::path( + get, + path = "/block/{event_id}/song/{index}", + params( + ("event_id" = u64, Path, description = "Event ID du block"), + ("index" = usize, Path, description = "Index du morceau (0-based)"), + ("channel" = Option, Query, description = "Channel ID (0-3)") + ), + responses( + (status = 200, description = "Morceau demandé", body = SongInfo), + (status = 404, description = "Morceau non trouvé"), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_song_by_index( + State(state): State, + Path((event_id, index)): Path<(u64, usize)>, + Query(params): Query, +) -> Result, StatusCode> { + let client = state.client_for_params(¶ms).await?; + let block = client.get_block(Some(event_id)).await.map_err(|e| { + tracing::error!( + "Failed to fetch block {} from Radio Paradise: {}", + event_id, + e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let song = block.get_song(index).ok_or_else(|| { + tracing::warn!("Song index {} not found in block {}", index, event_id); + StatusCode::NOT_FOUND + })?; + + let song_info = SongInfo { + index, + artist: song.artist.clone(), + title: song.title.clone(), + album: song.album.clone().unwrap_or_default(), + year: song.year, + elapsed_ms: song.elapsed, + duration_ms: song.duration, + cover_url: song.cover.as_ref().and_then(|c| block.cover_url(c)), + rating: song.rating, + }; + + Ok(Json(song_info)) +} + +/// GET /cover-url/{event_id}/{song_index} - Récupère l'URL de la pochette d'un morceau +/// +/// Utilise automatiquement cover_large si disponible, sinon cover en fallback +#[utoipa::path( + get, + path = "/cover-url/{event_id}/{song_index}", + params( + ("event_id" = u64, Path, description = "Event ID du block"), + ("song_index" = usize, Path, description = "Index du morceau (0-based)"), + ("channel" = Option, Query, description = "Channel ID (0-3)") + ), + responses( + (status = 200, description = "URL de la pochette avec fallback automatique", body = CoverUrlResponse), + (status = 404, description = "Morceau non trouvé"), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_cover_url( + State(state): State, + Path((event_id, song_index)): Path<(u64, usize)>, + Query(params): Query, +) -> Result, StatusCode> { + let client = state.client_for_params(¶ms).await?; + let block = client.get_block(Some(event_id)).await.map_err(|e| { + tracing::error!( + "Failed to fetch block {} from Radio Paradise: {}", + event_id, + e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let song = block.get_song(song_index).ok_or_else(|| { + tracing::warn!("Song index {} not found in block {}", song_index, event_id); + StatusCode::NOT_FOUND + })?; + + // Fallback: cover_large → cover → none + let (cover_url, cover_type) = if let Some(ref cover_large) = song.cover_large { + (block.cover_url(cover_large), "cover_large") + } else if let Some(ref cover) = song.cover { + (block.cover_url(cover), "cover") + } else { + (None, "none") + }; + + Ok(Json(CoverUrlResponse { + event: event_id, + song_index, + cover_url, + cover_type: cover_type.to_string(), + })) +} + +/// GET /stream-url/{event_id} - Récupère l'URL de streaming direct d'un block +#[utoipa::path( + get, + path = "/stream-url/{event_id}", + params( + ("event_id" = u64, Path, description = "Event ID du block (None pour le block actuel)"), + ("channel" = Option, Query, description = "Channel ID (0-3)") + ), + responses( + (status = 200, description = "URL de streaming", body = StreamUrlResponse), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_stream_url( + State(state): State, + Path(event_id): Path, + Query(params): Query, +) -> Result, StatusCode> { + let client = state.client_for_params(¶ms).await?; + let block = client.get_block(Some(event_id)).await.map_err(|e| { + tracing::error!( + "Failed to fetch block {} from Radio Paradise: {}", + event_id, + e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(StreamUrlResponse { + event: block.event, + stream_url: block.url, + length_ms: block.length, + })) +} + /// Documentation OpenAPI pour l'API Radio Paradise #[derive(OpenApi)] #[openapi( info( title = "Radio Paradise API", version = "1.0.0", - description = "API REST pour accéder aux métadonnées de Radio Paradise" + description = r#" +# API REST pour Radio Paradise + +Cette API permet d'accéder aux métadonnées et flux de Radio Paradise. + +## Fonctionnalités + +- **Métadonnées en temps réel** : Récupération du morceau en cours et des blocks +- **Multi-canaux** : Support des 4 canaux Radio Paradise (Main, Mellow, Rock, Eclectic) +- **Streaming FLAC** : Accès direct aux URLs de streaming haute qualité +- **Pochettes d'albums** : URLs complètes des couvertures (petite et grande taille) +- **Historique** : Accès aux blocks passés via event_id + +## Canaux disponibles + +- **0: Main Mix** - Eclectic mix of rock, world, electronica, and more +- **1: Mellow Mix** - Mellower, less aggressive music +- **2: Rock Mix** - Heavier, more guitar-driven music +- **3: Eclectic Mix** - Curated worldwide selection + +## Format des données + +### Blocks +Les blocks sont des fichiers FLAC continus contenant plusieurs morceaux. +Chaque block a un `event` (ID de début) et `end_event` (ID du prochain block). + +### Timing +- Tous les temps sont en millisecondes (ms) +- `elapsed_ms` : temps écoulé depuis le début du block +- `duration_ms` : durée du morceau + +## Exemples d'utilisation + +### Récupérer le morceau en cours +``` +GET /api/radioparadise/now-playing?channel=0 +``` + +### Récupérer un block spécifique +``` +GET /api/radioparadise/block/1234567?channel=0 +``` + +### Récupérer la pochette d'un morceau (avec fallback automatique) +``` +GET /api/radioparadise/cover-url/1234567/0?channel=0 +``` + "# ), paths( get_now_playing, get_current_block, get_block_by_id, - get_channels + get_channels, + get_song_by_index, + get_cover_url, + get_stream_url ), components(schemas( NowPlayingResponse, BlockResponse, SongInfo, - ChannelInfo + ChannelInfo, + StreamUrlResponse, + CoverUrlResponse )), tags( (name = "Radio Paradise", description = "Endpoints pour Radio Paradise") @@ -345,6 +570,9 @@ pub fn create_api_router(state: RadioParadiseState) -> Router { .route("/now-playing", get(get_now_playing)) .route("/block/current", get(get_current_block)) .route("/block/{event_id}", get(get_block_by_id)) + .route("/block/{event_id}/song/{index}", get(get_song_by_index)) + .route("/cover-url/{event_id}/{song_index}", get(get_cover_url)) + .route("/stream-url/{event_id}", get(get_stream_url)) .route("/channels", get(get_channels)) .with_state(state) } diff --git a/pmoparadise/src/radio_paradise_stream_source.rs b/pmoparadise/src/radio_paradise_stream_source.rs index 0f72bd7f..b046b41f 100644 --- a/pmoparadise/src/radio_paradise_stream_source.rs +++ b/pmoparadise/src/radio_paradise_stream_source.rs @@ -11,7 +11,7 @@ use crate::{ use futures_util::StreamExt; use pmoaudio::{ nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS}, - pipeline::{Node, NodeLogic}, + pipeline::{send_to_children, send_to_children_with_timing, Node, NodeLogic}, type_constraints::TypeRequirement, AudioPipelineNode, AudioSegment, SyncMarker, I24, }; @@ -19,11 +19,11 @@ use pmoflac::decode_audio_stream; use pmometadata::{MemoryTrackMetadata, TrackMetadata}; use std::{ collections::VecDeque, - sync::Arc, + sync::{Arc, Mutex}, time::{Duration, Instant}, }; use tokio::io::AsyncReadExt; -use tokio::sync::{mpsc, RwLock}; +use tokio::sync::{mpsc, Notify, RwLock}; use tokio_util::{io::StreamReader, sync::CancellationToken}; /// Signal spécial pour indiquer qu'il n'y aura plus de blocs @@ -34,6 +34,58 @@ pub const END_OF_BLOCKS_SIGNAL: EventId = EventId::MAX; /// Nombre de blocs récents à mémoriser pour éviter les re-téléchargements const RECENT_BLOCKS_CACHE_SIZE: usize = 10; +/// Handle pour alimenter la queue de blocs pendant que la source tourne. +#[derive(Clone, Default)] +pub struct BlockQueueHandle { + queue: Arc>>, + notify: Arc, +} + +impl BlockQueueHandle { + fn new() -> Self { + Self { + queue: Arc::new(Mutex::new(VecDeque::new())), + notify: Arc::new(Notify::new()), + } + } + + /// Enfile un block pour traitement. + pub fn enqueue(&self, event_id: EventId) { + { + let mut queue = self.queue.lock().expect("block queue poisoned"); + queue.push_back(event_id); + } + self.notify.notify_one(); + } + + /// Retire le prochain block s'il existe. + fn pop(&self) -> Option { + let mut queue = self.queue.lock().expect("block queue poisoned"); + queue.pop_front() + } + + /// Nombre d'éléments en attente. + pub fn len(&self) -> usize { + let queue = self.queue.lock().expect("block queue poisoned"); + queue.len() + } + + fn snapshot(&self) -> Vec { + let queue = self.queue.lock().expect("block queue poisoned"); + queue.iter().copied().collect() + } + + fn front(&self) -> Option { + let queue = self.queue.lock().expect("block queue poisoned"); + queue.front().copied() + } + + fn back(&self) -> Option { + let queue = self.queue.lock().expect("block queue poisoned"); + queue.back().copied() + } +} + // ═══════════════════════════════════════════════════════════════════════════ // RadioParadiseStreamSourceLogic - Logique métier pure // ═══════════════════════════════════════════════════════════════════════════ @@ -43,12 +95,21 @@ pub struct RadioParadiseStreamSourceLogic { client: RadioParadiseClient, chunk_frames: usize, recent_blocks: VecDeque, - block_queue: VecDeque, + block_queue: BlockQueueHandle, stats: Arc, } impl RadioParadiseStreamSourceLogic { pub fn new(client: RadioParadiseClient, chunk_duration_ms: u32) -> Self { + let handle = BlockQueueHandle::new(); + Self::with_queue(client, chunk_duration_ms, handle) + } + + fn with_queue( + client: RadioParadiseClient, + chunk_duration_ms: u32, + block_queue: BlockQueueHandle, + ) -> Self { // Calculer chunk_frames pour la durée cible (on suppose 44.1kHz) let chunk_frames = ((chunk_duration_ms as f64 / 1000.0) * 44100.0) as usize; @@ -56,14 +117,14 @@ impl RadioParadiseStreamSourceLogic { client, chunk_frames, recent_blocks: VecDeque::with_capacity(RECENT_BLOCKS_CACHE_SIZE), - block_queue: VecDeque::new(), + block_queue, stats: NodeStats::new("RadioParadiseStreamSource"), } } /// Ajoute un block ID à la file d'attente - pub fn push_block_id(&mut self, event_id: EventId) { - self.block_queue.push_back(event_id); + pub fn push_block_id(&self, event_id: EventId) { + self.block_queue.enqueue(event_id); } /// Vérifie si un bloc a été téléchargé récemment @@ -97,7 +158,9 @@ impl RadioParadiseStreamSourceLogic { block.length as f64 / 60000.0, block.url ); - let response = self.client.client + let response = self + .client + .client .get(&block.url) .timeout(self.client.block_timeout) .send() @@ -125,9 +188,9 @@ impl RadioParadiseStreamSourceLogic { // Créer un stream reader tracing::debug!("Creating byte stream reader"); - let byte_stream = response.bytes_stream().map(|result| { - result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) - }); + let byte_stream = response + .bytes_stream() + .map(|result| result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))); let stream_reader = StreamReader::new(byte_stream); tracing::debug!("Stream reader created"); @@ -140,7 +203,11 @@ impl RadioParadiseStreamSourceLogic { let stream_info = decoder.info().clone(); let sample_rate = stream_info.sample_rate; let bits_per_sample = stream_info.bits_per_sample; - tracing::debug!("FLAC decoder initialized: {}Hz, {} bits/sample", sample_rate, bits_per_sample); + tracing::debug!( + "FLAC decoder initialized: {}Hz, {} bits/sample", + sample_rate, + bits_per_sample + ); // Préparer les songs ordonnées pour tracking let songs = block.songs_ordered(); @@ -165,13 +232,16 @@ impl RadioParadiseStreamSourceLogic { // Envoyer TrackBoundary pour la première song AVANT le premier chunk audio // Même si son elapsed > 0, cela garantit que FlacCacheSink a des métadonnées // dès le début (sinon il attendrait indéfiniment un TrackBoundary) - let mut next_song: Option<(usize, &Song)> = if let Some((idx, song)) = songs.get(0).copied() { - tracing::debug!("Sending TrackBoundary for first song (idx={}, elapsed={}ms) at timestamp 0", - idx, song.elapsed); + let mut next_song: Option<(usize, &Song)> = if let Some((idx, song)) = songs.get(0).copied() + { + tracing::debug!( + "Sending TrackBoundary for first song (idx={}, elapsed={}ms) at timestamp 0", + idx, + song.elapsed + ); let metadata = song_to_metadata(song, block).await; let track_boundary = AudioSegment::new_track_boundary( - *order, - 0.0, // timestamp = 0 au début du stream + *order, 0.0, // timestamp = 0 au début du stream metadata, ); self.send_to_children(output, track_boundary).await?; @@ -183,7 +253,6 @@ impl RadioParadiseStreamSourceLogic { }; tracing::debug!("Starting audio chunk loop"); - // Buffer pour lecture let bytes_per_sample = (bits_per_sample / 8) as usize; let frame_bytes = bytes_per_sample * 2; // stereo @@ -196,6 +265,7 @@ impl RadioParadiseStreamSourceLogic { let mut chunk_count = 0; let mut total_bytes_decoded = 0u64; let expected_duration_sec = block.length as f64 / 1000.0; + let mut stats_last_log = Instant::now(); loop { // Vérifier stop_token @@ -213,7 +283,9 @@ impl RadioParadiseStreamSourceLogic { // Remplir le buffer if pending.len() < chunk_byte_len { - let read = decoder.read(&mut read_buf).await + let read = decoder + .read(&mut read_buf) + .await .map_err(|e| AudioError::ProcessingError(format!("Read error: {}", e)))?; if read == 0 { @@ -263,22 +335,35 @@ impl RadioParadiseStreamSourceLogic { ); let metadata = song_to_metadata(song, block).await; let timestamp_sec = total_samples as f64 / sample_rate as f64; - let track_boundary = AudioSegment::new_track_boundary( - *order, - timestamp_sec, - metadata, - ); + let track_boundary = + AudioSegment::new_track_boundary(*order, timestamp_sec, metadata); self.send_to_children(output, track_boundary).await?; // Passer à la song suivante song_index += 1; next_song = songs.get(song_index).copied(); - tracing::debug!("Moved to next song, song_index={}, next_song present={}", song_index, next_song.is_some()); + tracing::debug!( + "Moved to next song, song_index={}, next_song present={}", + song_index, + next_song.is_some() + ); } } // Envoyer le chunk audio let timestamp_sec = total_samples as f64 / sample_rate as f64; + if stats_last_log.elapsed() >= Duration::from_secs(1) { + let real_elapsed = start_instant.elapsed().as_secs_f64(); + tracing::debug!( + "RP timing: chunk={} ts={:.3}s real_elapsed={:.3}s delta={:.3}s chunk_len={} frames", + chunk_count, + timestamp_sec, + real_elapsed, + timestamp_sec - real_elapsed, + chunk_len + ); + stats_last_log = Instant::now(); + } let audio_segment = pcm_to_audio_segment( &pcm_data, *order, @@ -295,7 +380,11 @@ impl RadioParadiseStreamSourceLogic { // Retourner le timestamp du dernier chunk (durée totale du bloc) et l'instant de début let final_timestamp = total_samples as f64 / sample_rate as f64; - tracing::debug!("Block decode complete: {} samples, {:.2}s duration", total_samples, final_timestamp); + tracing::debug!( + "Block decode complete: {} samples, {:.2}s duration", + total_samples, + final_timestamp + ); Ok((final_timestamp, start_instant)) } @@ -306,40 +395,42 @@ impl RadioParadiseStreamSourceLogic { output: &[mpsc::Sender>], segment: Arc, ) -> Result<(), AudioError> { - self.stats.record_segment_received(segment.timestamp_sec); + let segment_ts = segment.timestamp_sec; + self.stats.record_segment_received(segment_ts); - for (i, tx) in output.iter().enumerate() { - let capacity_before = tx.capacity(); - tracing::trace!( - "send_to_children: Sending to child {} (channel capacity={}, timestamp={:.3}s)", - i, capacity_before, segment.timestamp_sec - ); + let segment_bytes = match &segment.segment { + pmoaudio::_AudioSegment::Chunk(chunk) => chunk.len() * 2 * 4, + _ => 0, + }; - let send_start = std::time::Instant::now(); - tx.send(segment.clone()) - .await - .map_err(|_| AudioError::ChildDied)?; - let send_duration = send_start.elapsed(); - - if send_duration.as_millis() > 10 { - let duration_ms = send_duration.as_millis() as u64; - self.stats.record_backpressure(duration_ms); - tracing::debug!( - "send_to_children: Send to child {} BLOCKED for {:.3}s (backpressure triggered, timestamp={:.3}s)", - i, send_duration.as_secs_f64(), segment.timestamp_sec + send_to_children_with_timing( + std::any::type_name::(), + output, + segment, + |i, send_duration, capacity_before| { + tracing::trace!( + "send_to_children: Sending to child {} (channel capacity={}, timestamp={:.3}s)", + i, + capacity_before, + segment_ts ); - } - // Estimer la taille du segment pour les stats (frames * 2 channels * bytes_per_sample) - let segment_bytes = match &segment.segment { - pmoaudio::_AudioSegment::Chunk(chunk) => { - // Approximation: frames * 2 (stereo) * 4 bytes (i32/f32) - chunk.len() * 2 * 4 + if send_duration.as_millis() > 10 { + let duration_ms = send_duration.as_millis() as u64; + self.stats.record_backpressure(duration_ms); + tracing::trace!( + "send_to_children: Send to child {} BLOCKED for {:.3}s (channel capacity before send={}, timestamp={:.3}s)", + i, + send_duration.as_secs_f64(), + capacity_before, + segment_ts + ); } - _ => 0, - }; - self.stats.record_segment_sent(segment_bytes); - } + + self.stats.record_segment_sent(segment_bytes); + }, + ) + .await?; Ok(()) } } @@ -519,8 +610,11 @@ impl NodeLogic for RadioParadiseStreamSourceLogic { output: Vec>>, stop_token: CancellationToken, ) -> Result<(), AudioError> { - tracing::debug!("RadioParadiseStreamSource::process() started, block_queue has {} items", self.block_queue.len()); - for (i, event_id) in self.block_queue.iter().enumerate() { + tracing::debug!( + "RadioParadiseStreamSource::process() started, block_queue has {} items", + self.block_queue.len() + ); + for (i, event_id) in self.block_queue.snapshot().iter().enumerate() { tracing::debug!(" block_queue[{}] = {}", i, event_id); } @@ -539,21 +633,26 @@ impl NodeLogic for RadioParadiseStreamSourceLogic { } // Essayer de pop un event_id - if let Some(id) = self.block_queue.pop_front() { + if let Some(id) = self.block_queue.pop() { tracing::debug!("Got event_id {} from queue", id); // Vérifier si c'est le signal de fin if id == END_OF_BLOCKS_SIGNAL { - tracing::info!("Received END_OF_BLOCKS_SIGNAL, finishing after current block"); + tracing::info!( + "Received END_OF_BLOCKS_SIGNAL, finishing after current block" + ); break None; } break Some(id); } - // Queue vide, attendre un peu et réessayer - tracing::trace!("block_queue is empty, sleeping 100ms..."); - tokio::time::sleep(Duration::from_millis(100)).await; + tracing::trace!("block_queue is empty, waiting for new events..."); + tokio::select! { + _ = stop_token.cancelled() => break None, + _ = self.block_queue.notify.notified() => {}, + _ = tokio::time::sleep(Duration::from_millis(100)) => {} + }; }; // Si on n'a pas d'event_id, on termine @@ -573,10 +672,10 @@ impl NodeLogic for RadioParadiseStreamSourceLogic { // Récupérer les métadonnées du bloc tracing::debug!("Fetching block metadata for event_id {}...", event_id); - let block = self.client - .get_block(Some(event_id)) - .await - .map_err(|e| AudioError::ProcessingError(format!("Failed to get block: {}", e)))?; + let block = + self.client.get_block(Some(event_id)).await.map_err(|e| { + AudioError::ProcessingError(format!("Failed to get block: {}", e)) + })?; tracing::debug!("Block metadata received: url={}", block.url); // Marquer comme téléchargé @@ -584,21 +683,26 @@ impl NodeLogic for RadioParadiseStreamSourceLogic { // Télécharger et décoder le bloc tracing::info!("Starting download and decode for block {}...", event_id); - let (block_duration, start_instant) = self.download_and_decode_block(&block, &output, &stop_token, &mut order) + let (block_duration, start_instant) = self + .download_and_decode_block(&block, &output, &stop_token, &mut order) .await?; last_timestamp = block_duration; last_start_instant = Some(start_instant); - tracing::info!("Finished download and decode for block {} (duration: {:.2}s)", event_id, block_duration); + tracing::info!( + "Finished download and decode for block {} (duration: {:.2}s)", + event_id, + block_duration + ); } // Envoyer EndOfStream avec le timestamp du dernier chunk - tracing::info!("Sending EndOfStream with timestamp {:.2}s to {} outputs", last_timestamp, output.len()); + tracing::info!( + "Sending EndOfStream with timestamp {:.2}s to {} outputs", + last_timestamp, + output.len() + ); let eos = AudioSegment::new_end_of_stream(order, last_timestamp); - for tx in &output { - tx.send(eos.clone()) - .await - .map_err(|_| AudioError::ChildDied)?; - } + send_to_children(std::any::type_name::(), &output, eos).await?; // IMPORTANT: Attendre que tous les channels soient fermés par les enfants // Cela garantit que tous les chunks (y compris ceux en attente dans les buffers MPSC) @@ -632,6 +736,7 @@ impl NodeLogic for RadioParadiseStreamSourceLogic { pub struct RadioParadiseStreamSource { inner: Node, + block_handle: BlockQueueHandle, } impl RadioParadiseStreamSource { @@ -642,15 +747,23 @@ impl RadioParadiseStreamSource { /// Crée une nouvelle source avec durée de chunk personnalisée pub fn with_chunk_duration(client: RadioParadiseClient, chunk_duration_ms: u32) -> Self { - let logic = RadioParadiseStreamSourceLogic::new(client, chunk_duration_ms); + let handle = BlockQueueHandle::new(); + let logic = + RadioParadiseStreamSourceLogic::with_queue(client, chunk_duration_ms, handle.clone()); Self { inner: Node::new_source(logic), + block_handle: handle, } } /// Ajoute un block ID à la file d'attente de téléchargement - pub fn push_block_id(&mut self, event_id: EventId) { - self.inner.logic_mut().push_block_id(event_id); + pub fn push_block_id(&self, event_id: EventId) { + self.block_handle.enqueue(event_id); + } + + /// Retourne un handle permettant d'enfiler des blocks dynamiquement. + pub fn block_handle(&self) -> BlockQueueHandle { + self.block_handle.clone() } } @@ -692,7 +805,8 @@ mod tests { #[test] fn test_cache_fifo_basic() { let client = create_test_client(); - let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); // Ajouter 5 blocs for i in 1..=5 { @@ -709,7 +823,8 @@ mod tests { #[test] fn test_cache_fifo_exactly_10_elements() { let client = create_test_client(); - let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); // Ajouter exactement 10 blocs for i in 1..=10 { @@ -717,7 +832,11 @@ mod tests { } // Vérifier qu'on a exactement 10 éléments - assert_eq!(logic.recent_blocks.len(), 10, "Cache should have exactly 10 elements"); + assert_eq!( + logic.recent_blocks.len(), + 10, + "Cache should have exactly 10 elements" + ); // Tous devraient être dans le cache for i in 1..=10 { @@ -728,7 +847,8 @@ mod tests { #[test] fn test_cache_fifo_eviction_oldest() { let client = create_test_client(); - let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); // Remplir le cache avec 10 éléments (1..=10) for i in 1..=10 { @@ -739,10 +859,17 @@ mod tests { logic.mark_block_downloaded(11); // Le cache doit toujours avoir 10 éléments - assert_eq!(logic.recent_blocks.len(), 10, "Cache should still have 10 elements"); + assert_eq!( + logic.recent_blocks.len(), + 10, + "Cache should still have 10 elements" + ); // Le premier (plus ancien) doit avoir été évincé - assert!(!logic.is_recent_block(1), "Oldest block (1) should be evicted"); + assert!( + !logic.is_recent_block(1), + "Oldest block (1) should be evicted" + ); // Les éléments 2..=11 doivent être présents for i in 2..=11 { @@ -753,7 +880,8 @@ mod tests { #[test] fn test_cache_fifo_multiple_evictions() { let client = create_test_client(); - let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); // Remplir avec 10 éléments for i in 1..=10 { @@ -766,7 +894,11 @@ mod tests { } // Toujours 10 éléments - assert_eq!(logic.recent_blocks.len(), 10, "Cache should have 10 elements"); + assert_eq!( + logic.recent_blocks.len(), + 10, + "Cache should have 10 elements" + ); // Les 5 premiers doivent avoir été évincés for i in 1..=5 { @@ -782,7 +914,8 @@ mod tests { #[test] fn test_cache_never_exceeds_capacity() { let client = create_test_client(); - let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); // Vérifier la capacité pré-allouée assert_eq!(logic.recent_blocks.capacity(), RECENT_BLOCKS_CACHE_SIZE); @@ -812,7 +945,8 @@ mod tests { #[test] fn test_cache_fifo_order_preserved() { let client = create_test_client(); - let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); // Ajouter 10 éléments for i in 1..=10 { @@ -830,7 +964,8 @@ mod tests { #[test] fn test_block_queue_push() { let client = create_test_client(); - let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); // Tester push_block_id logic.push_block_id(100); @@ -838,7 +973,7 @@ mod tests { logic.push_block_id(300); assert_eq!(logic.block_queue.len(), 3); - assert_eq!(logic.block_queue.front(), Some(&100)); - assert_eq!(logic.block_queue.back(), Some(&300)); + assert_eq!(logic.block_queue.front(), Some(100)); + assert_eq!(logic.block_queue.back(), Some(300)); } } diff --git a/pmoparadise/src/source.rs b/pmoparadise/src/source.rs index 946af700..459998dd 100644 --- a/pmoparadise/src/source.rs +++ b/pmoparadise/src/source.rs @@ -1,114 +1,631 @@ -//! DEPRECATED: Stub implementation of RadioParadiseSource +//! RadioParadiseSource - Implementation of MusicSource for Radio Paradise //! -//! **⚠️ This module is deprecated and will be removed in a future version.** -//! -//! The orchestration-based RadioParadiseSource has been replaced by -//! `RadioParadiseStreamSource`, which integrates directly with the pmoaudio -//! pipeline for streaming and decoding. -//! -//! ## Migration Guide -//! -//! **Old approach** (deprecated): -//! ```rust,ignore -//! use pmoparadise::RadioParadiseSource; -//! let source = RadioParadiseSource::from_registry(client)?; -//! ``` -//! -//! **New approach** (recommended): -//! ```rust,ignore -//! use pmoparadise::RadioParadiseStreamSource; -//! use pmoaudio::pipeline::Node; -//! -//! let stream_source = RadioParadiseStreamSource::new(client, None).await?; -//! let node = Node::from_logic(stream_source); -//! // Use node in pmoaudio pipeline -//! ``` -//! -//! This stub implementation is provided only for backward compatibility with -//! existing code (e.g., pmomediaserver) until it can be updated to use -//! RadioParadiseStreamSource. +//! This module provides a UPnP ContentDirectory source for Radio Paradise, +//! exposing live streams and historical playlists for all 4 channels. -use crate::client::RadioParadiseClient; -use pmosource::pmodidl::{Container, Item}; -use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result}; -use std::time::SystemTime; +use crate::channels::{ChannelDescriptor, ALL_CHANNELS}; +use pmosource::pmodidl::{Container, Item, Resource}; +use pmosource::{ + async_trait, AudioFormat, BrowseResult, MusicSource, MusicSourceError, Result, + SourceCapabilities, +}; +use std::fmt; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime}; +use tokio::sync::RwLock; /// Default Radio Paradise image (embedded in binary) const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); -/// DEPRECATED: Stub implementation of RadioParadiseSource +#[cfg(feature = "playlist")] +const LIVE_PLAYLIST_MIN_READY_ITEMS: usize = 5; +#[cfg(feature = "playlist")] +const LIVE_PLAYLIST_READY_TIMEOUT: Duration = Duration::from_secs(10); +#[cfg(feature = "playlist")] +const LIVE_PLAYLIST_READY_POLL: Duration = Duration::from_millis(200); + +/// RadioParadiseSource - UPnP ContentDirectory source for Radio Paradise /// -/// This is a minimal stub that implements the MusicSource trait with no-op -/// implementations. It exists only to maintain API compatibility during the -/// migration to RadioParadiseStreamSource. +/// Provides access to: +/// - Live FLAC streams for all 4 channels (Main, Mellow, Rock, Eclectic) +/// - Historical playlists (FIFO) for each channel /// -/// **Do not use this in new code.** Use `RadioParadiseStreamSource` instead. -#[derive(Clone, Debug)] +/// # Object ID Schema +/// +/// - Root: `radio-paradise` +/// - Channel container: `radio-paradise:channel:{slug}` +/// - Live stream item: `radio-paradise:channel:{slug}:live` +/// - Live playlist container: `radio-paradise:channel:{slug}:liveplaylist` +/// - Live playlist track: `radio-paradise:channel:{slug}:liveplaylist:track:{pk}` +/// - History container: `radio-paradise:channel:{slug}:history` +/// - History track: `radio-paradise:channel:{slug}:history:track:{pk}` +#[derive(Clone)] pub struct RadioParadiseSource { - _client: RadioParadiseClient, + /// Base URL for streaming server (e.g., "http://localhost:8080") + base_url: String, + /// Update counter for change notifications + update_counter: Arc>, + /// Last change timestamp + last_change: Arc>, + /// Tokens des callbacks enregistrés auprès du PlaylistManager + callback_tokens: Arc>>, + /// Notifier optionnel pour signaler les mises à jour de conteneurs au ContentDirectory + container_notifier: Option>, +} + +impl fmt::Debug for RadioParadiseSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RadioParadiseSource") + .field("base_url", &self.base_url) + .finish_non_exhaustive() + } } impl RadioParadiseSource { - /// DEPRECATED: Create a new RadioParadiseSource from registry + /// Create a new RadioParadiseSource /// - /// This method is deprecated and will always return an error indicating - /// that the orchestration-based source is no longer supported. + /// # Arguments /// - /// Use `RadioParadiseStreamSource` instead for audio streaming. - #[cfg(feature = "server")] - pub fn from_registry(_client: RadioParadiseClient) -> Result { - Err(MusicSourceError::SourceUnavailable( - "RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead." - .to_string(), - )) + /// * `base_url` - Base URL for streaming server (e.g., "http://localhost:8080") + /// + /// # Note + /// + /// With the "playlist" feature enabled, this source will use the global PlaylistManager + /// singleton to access history playlists. + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + update_counter: Arc::new(RwLock::new(0)), + last_change: Arc::new(RwLock::new(SystemTime::now())), + callback_tokens: Arc::new(std::sync::Mutex::new(Vec::new())), + container_notifier: None, + } } - /// DEPRECATED: Create a new RadioParadiseSource from registry with defaults - /// - /// This method creates a stub instance that will log deprecation warnings - /// but allows existing code to compile. - /// - /// Use `RadioParadiseStreamSource` instead for audio streaming. - #[cfg(feature = "server")] - pub fn from_registry_default(client: RadioParadiseClient) -> Self { - tracing::warn!( - "RadioParadiseSource::from_registry_default is deprecated. \ - Use RadioParadiseStreamSource for audio streaming." - ); - Self { _client: client } + /// Injecte un notifier pour propager les changements de playlists vers le ContentDirectory + pub fn with_container_notifier( + mut self, + notifier: Arc, + ) -> Self { + self.container_notifier = Some(notifier); + self } - /// DEPRECATED: Create a new RadioParadiseSource with default settings - /// - /// This method is deprecated and only exists for API compatibility. - pub fn new_default(client: RadioParadiseClient) -> Self { - tracing::warn!( - "RadioParadiseSource::new_default is deprecated. \ - Use RadioParadiseStreamSource for audio streaming." - ); - Self { _client: client } + /// Build a live stream URL for a channel + fn build_live_url(&self, slug: &str) -> String { + format!("{}/radioparadise/stream/{}/flac", self.base_url, slug) } - /// DEPRECATED: Create a new RadioParadiseSource with cache - /// - /// This method is deprecated and only exists for API compatibility. - pub fn new_with_cache(client: RadioParadiseClient, _cache_size: usize) -> Self { - tracing::warn!( - "RadioParadiseSource::new_with_cache is deprecated. \ - Use RadioParadiseStreamSource for audio streaming." - ); - Self { _client: client } + /// Build an OGG-FLAC live stream URL for clients that support it + fn build_live_ogg_url(&self, slug: &str) -> String { + format!("{}/radioparadise/stream/{}/ogg", self.base_url, slug) } + + /// Incrémente l'update_counter et met à jour last_change + async fn bump_update_counter(&self) { + { + let mut c = self.update_counter.write().await; + *c = c.wrapping_add(1).max(1); + } + let mut lc = self.last_change.write().await; + *lc = SystemTime::now(); + } + + /// Enregistre des callbacks sur les playlists live/historique pour notifier les changements + pub fn attach_playlist_callbacks(self: &Arc) { + use pmoplaylist::PlaylistManager; + + // Préparer les IDs de playlists à surveiller (live + history pour chaque canal) + let ids: Vec = ALL_CHANNELS + .iter() + .flat_map(|ch| { + vec![ + Self::live_playlist_id(ch.slug), + Self::history_playlist_id(ch.slug), + ] + }) + .collect(); + + let mgr = PlaylistManager(); + let mut tokens = self.callback_tokens.lock().unwrap(); + + for pid in ids { + let weak = Arc::downgrade(self); + let pid_clone = pid.clone(); + let token = mgr.register_callback(move |event| { + let pid = pid_clone.clone(); + if event.playlist_id == pid { + // On ne réagit qu'aux mises à jour structurelles (ajout/suppression) + if !matches!(event.kind, pmoplaylist::PlaylistEventKind::Updated) { + return; + } + if let Some(strong) = weak.upgrade() { + tokio::spawn(async move { + strong.bump_update_counter().await; + // Notifier ContentDirectory des conteneurs concernés + let containers: Vec = if pid.contains("history") { + // history playlist -> container history + ALL_CHANNELS + .iter() + .find(|ch| pid.ends_with(ch.slug)) + .map(|ch| { + vec![format!("radio-paradise:channel:{}:history", ch.slug)] + }) + .unwrap_or_default() + } else { + // live playlist -> container liveplaylist + ALL_CHANNELS + .iter() + .find(|ch| pid.ends_with(ch.slug)) + .map(|ch| { + vec![format!( + "radio-paradise:channel:{}:liveplaylist", + ch.slug + )] + }) + .unwrap_or_default() + }; + + if !containers.is_empty() { + if let Some(notifier) = strong.container_notifier.as_ref() { + notifier(&containers); + } + } + }); + } + } + }); + tokens.push(token); + } + } + + /// URL de fallback pour l'image par défaut de la source + fn default_cover_url(&self) -> String { + format!("{}/api/sources/{}/image", self.base_url, self.id()) + } + + /// Fetch current metadata from the live stream + async fn fetch_live_metadata(&self, slug: &str) -> Result> { + let metadata_url = format!("{}/radioparadise/metadata/{}", self.base_url, slug); + + // Try to fetch metadata via HTTP + match reqwest::get(&metadata_url).await { + Ok(response) if response.status().is_success() => { + match response.json::().await { + Ok(json) => { + // Parse metadata from JSON and create an Item + let title = json["title"] + .as_str() + .unwrap_or("Unknown Title") + .to_string(); + let artist = json["artist"].as_str().map(|s| s.to_string()); + let album = json["album"].as_str().map(|s| s.to_string()); + let year = json["year"].as_u64().map(|y| y as u32); + // Préférer l'URL de cache si cover_pk est fourni par le pipeline + let cover_pk = json["cover_pk"].as_str().map(|s| s.to_string()); + let cover_url = cover_pk + .as_ref() + .map(|pk| format!("{}/covers/jpeg/{}", self.base_url, pk)) + .or_else(|| json["cover_url"].as_str().map(|s| s.to_string())) + .or_else(|| Some(self.default_cover_url())); + + // Parse duration from JSON (in seconds as a float) + let duration = json["duration"] + .as_object() + .and_then(|d| d.get("secs")) + .and_then(|s| s.as_f64()) + .or_else(|| json["duration"].as_f64()) + .map(|secs| { + let total_secs = secs as u64; + format!( + "{}:{:02}:{:02}", + total_secs / 3600, + (total_secs % 3600) / 60, + total_secs % 60 + ) + }); + + // Create the item with current metadata + let item = Item { + id: format!("radio-paradise:channel:{}:live", slug), + parent_id: format!("radio-paradise:channel:{}", slug), + restricted: Some("1".to_string()), + title, + creator: artist.clone(), + class: "object.item.audioItem.audioBroadcast".to_string(), + artist, + album, + genre: Some("Radio".to_string()), + album_art: cover_url, + album_art_pk: cover_pk, + date: year.map(|y| y.to_string()), + original_track_number: None, + resources: vec![Resource { + protocol_info: "http-get:*:audio/flac:*".to_string(), + bits_per_sample: None, + sample_frequency: None, + nr_audio_channels: Some("2".to_string()), + duration, + url: self.build_live_url(slug), + }], + descriptions: vec![], + }; + + Ok(Some(item)) + } + Err(_) => Ok(None), + } + } + _ => Ok(None), + } + } + + /// Get the playlist ID for a channel's history + #[cfg(feature = "playlist")] + fn history_playlist_id(slug: &str) -> String { + // Must match the prefix used in ParadiseHistoryBuilder + format!("radio-paradise-history-{}", slug) + } + + /// Live playlist id for a channel + fn live_playlist_id(slug: &str) -> String { + format!("radio-paradise-live-{}", slug) + } + + #[cfg(feature = "playlist")] + async fn wait_for_live_playlist_ready(&self, slug: &str) -> Result<()> { + let playlist_id = Self::live_playlist_id(slug); + let manager = pmoplaylist::PlaylistManager(); + let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { + MusicSourceError::BrowseError(format!( + "Failed to get live playlist {}: {}", + playlist_id, e + )) + })?; + let start = Instant::now(); + loop { + match reader.remaining().await { + Ok(count) if count >= LIVE_PLAYLIST_MIN_READY_ITEMS => return Ok(()), + Ok(_) => {} + Err(e) => { + return Err(MusicSourceError::BrowseError(format!( + "Failed to inspect live playlist {}: {}", + playlist_id, e + ))); + } + } + + if start.elapsed() >= LIVE_PLAYLIST_READY_TIMEOUT { + tracing::warn!( + "Timeout waiting for live playlist {} to reach {} items", + playlist_id, + LIVE_PLAYLIST_MIN_READY_ITEMS + ); + return Ok(()); + } + + tokio::time::sleep(LIVE_PLAYLIST_READY_POLL).await; + } + } + + /// Get channel descriptor by slug + fn get_channel_by_slug(slug: &str) -> Option<&'static ChannelDescriptor> { + ALL_CHANNELS.iter().find(|ch| ch.slug == slug) + } + + /// Parse an object ID into its components + fn parse_object_id(id: &str) -> ObjectIdType { + let parts: Vec<&str> = id.split(':').collect(); + match parts.as_slice() { + ["radio-paradise"] => ObjectIdType::Root, + ["radio-paradise", "channel", slug] => ObjectIdType::Channel { + slug: (*slug).to_string(), + }, + ["radio-paradise", "channel", slug, "live"] => ObjectIdType::LiveStream { + slug: (*slug).to_string(), + }, + ["radio-paradise", "channel", slug, "liveplaylist"] => ObjectIdType::LivePlaylist { + slug: (*slug).to_string(), + }, + ["radio-paradise", "channel", slug, "liveplaylist", "track", pk] => { + ObjectIdType::LivePlaylistTrack { + slug: (*slug).to_string(), + pk: (*pk).to_string(), + } + } + ["radio-paradise", "channel", slug, "history"] => ObjectIdType::History { + slug: (*slug).to_string(), + }, + ["radio-paradise", "channel", slug, "history", "track", pk] => { + ObjectIdType::HistoryTrack { + slug: (*slug).to_string(), + pk: (*pk).to_string(), + } + } + _ => ObjectIdType::Unknown, + } + } + + /// Build a channel container + fn build_channel_container(&self, descriptor: &ChannelDescriptor) -> Container { + Container { + id: format!("radio-paradise:channel:{}", descriptor.slug), + parent_id: "radio-paradise".to_string(), + restricted: Some("1".to_string()), + child_count: None, + searchable: Some("1".to_string()), + title: descriptor.display_name.to_string(), + class: "object.container".to_string(), + containers: vec![], + items: vec![], + } + } + + /// Build the live playlist container for a channel + fn build_live_playlist_container(&self, descriptor: &ChannelDescriptor) -> Container { + Container { + id: format!("radio-paradise:channel:{}:liveplaylist", descriptor.slug), + parent_id: format!("radio-paradise:channel:{}", descriptor.slug), + restricted: Some("1".to_string()), + child_count: None, + searchable: Some("0".to_string()), + title: format!("{} - Live Playlist", descriptor.display_name), + class: "object.container.playlistContainer".to_string(), + containers: vec![], + items: vec![], + } + } + + /// Build a live stream item for a channel + fn build_live_stream_item(&self, descriptor: &ChannelDescriptor) -> Item { + let stream_url = self.build_live_url(descriptor.slug); + + Item { + id: format!("radio-paradise:channel:{}:live", descriptor.slug), + parent_id: format!("radio-paradise:channel:{}", descriptor.slug), + restricted: Some("1".to_string()), + title: format!("{} - Live Stream", descriptor.display_name), + creator: Some("Radio Paradise".to_string()), + class: "object.item.audioItem.audioBroadcast".to_string(), + artist: Some("Radio Paradise".to_string()), + album: Some(descriptor.display_name.to_string()), + genre: Some("Radio".to_string()), + album_art: Some(self.default_cover_url()), + album_art_pk: None, + date: None, + original_track_number: None, + resources: vec![ + Resource { + protocol_info: "http-get:*:audio/flac:*".to_string(), + bits_per_sample: Some("16".to_string()), + sample_frequency: Some("44100".to_string()), + nr_audio_channels: Some("2".to_string()), + duration: None, + url: stream_url.clone(), + }, + Resource { + protocol_info: "http-get:*:audio/ogg:*".to_string(), + bits_per_sample: Some("16".to_string()), + sample_frequency: Some("44100".to_string()), + nr_audio_channels: Some("2".to_string()), + duration: None, + url: self.build_live_ogg_url(descriptor.slug), + }, + ], + descriptions: vec![], + } + } + + /// Build a history container for a channel + fn build_history_container(&self, descriptor: &ChannelDescriptor) -> Container { + Container { + id: format!("radio-paradise:channel:{}:history", descriptor.slug), + parent_id: format!("radio-paradise:channel:{}", descriptor.slug), + restricted: Some("1".to_string()), + child_count: None, + searchable: Some("1".to_string()), + title: format!("{} - History", descriptor.display_name), + // Expose l'historique comme une playlist jouable + class: "object.container.playlistContainer".to_string(), + containers: vec![], + items: vec![], + } + } + + /// Build a history container with accurate child count from playlist + #[cfg(feature = "playlist")] + async fn build_history_container_with_count( + &self, + descriptor: &ChannelDescriptor, + ) -> Container { + let mut container = self.build_history_container(descriptor); + + // Try to get actual count from playlist + let playlist_id = Self::history_playlist_id(descriptor.slug); + let manager = pmoplaylist::PlaylistManager(); + + if let Ok(reader) = manager.get_read_handle(&playlist_id).await { + if let Ok(count) = reader.remaining().await { + container.child_count = Some(count.to_string()); + } + } + + container + } + + /// Get items from history playlist + #[cfg(feature = "playlist")] + async fn get_history_items( + &self, + slug: &str, + _offset: usize, + count: usize, + ) -> Result> { + let playlist_id = Self::history_playlist_id(slug); + + // Get read handle for the playlist from the singleton + let manager = pmoplaylist::PlaylistManager(); + let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { + MusicSourceError::BrowseError(format!("Failed to get playlist {}: {}", playlist_id, e)) + })?; + + // Get items from playlist (to_items starts from cursor position) + let mut items = reader.to_items(count).await.map_err(|e| { + MusicSourceError::BrowseError(format!("Failed to read playlist entries: {}", e)) + })?; + + // Transform item IDs, parent_ids, and resource URLs to match Radio Paradise schema + // Expected: radio-paradise:channel:{slug}:history:track:{pk} + // Parent: radio-paradise:channel:{slug}:history + for item in items.iter_mut() { + // Extract cache_pk from the resource URL (last segment) + if let Some(resource) = item.resources.first_mut() { + if let Some(pk) = resource.url.split('/').last() { + // Update item ID and parent ID + item.id = format!("radio-paradise:channel:{}:history:track:{}", slug, pk); + item.parent_id = format!("radio-paradise:channel:{}:history", slug); + + // Convert relative URL to absolute URL + // From: /audio/flac/pk + // To: http://base_url/audio/flac/pk + if resource.url.starts_with('/') { + resource.url = format!("{}{}", self.base_url, resource.url); + } + } + } + + // Fix: Ajouter un genre par défaut si absent + // Certains clients UPnP (comme gupnp-av-cp) requièrent le champ + // pour parser correctement les items de classe musicTrack, même si ce champ + // est optionnel selon la spec UPnP ContentDirectory. + if item.genre.is_none() { + item.genre = Some("Radio Paradise".to_string()); + } + + // Normaliser l'albumArtURI : rendre absolu si chemin relatif, sinon fallback par défaut + if let Some(art) = item.album_art.as_mut() { + if art.starts_with('/') { + *art = format!("{}{}", self.base_url, art); + } + } else { + item.album_art = Some(self.default_cover_url()); + } + } + + Ok(items) + } + + /// Get items from live playlist (current stream queue) + #[cfg(feature = "playlist")] + async fn get_live_playlist_items( + &self, + slug: &str, + _offset: usize, + count: usize, + ) -> Result> { + #[cfg(all(feature = "playlist", feature = "pmoaudio"))] + if let Some(descriptor) = Self::get_channel_by_slug(slug) { + if let Some(manager) = crate::stream_channel::get_global_channel_manager() { + if let Err(e) = manager.prefetch_until_horizon(descriptor.id).await { + tracing::warn!( + "Failed to prefetch live playlist for {}: {}", + descriptor.slug, + e + ); + } + } + } + + #[cfg(feature = "playlist")] + if let Err(e) = self.wait_for_live_playlist_ready(slug).await { + tracing::warn!( + "Failed to wait for live playlist readiness on {}: {}", + slug, + e + ); + } + + let playlist_id = Self::live_playlist_id(slug); + + let manager = pmoplaylist::PlaylistManager(); + let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { + MusicSourceError::BrowseError(format!( + "Failed to get live playlist {}: {}", + playlist_id, e + )) + })?; + + let mut items = reader.to_items(count).await.map_err(|e| { + MusicSourceError::BrowseError(format!("Failed to read live playlist entries: {}", e)) + })?; + + for item in items.iter_mut() { + // Ajuster id/parent/url pour coller au schéma Radio Paradise + if let Some(resource) = item.resources.first_mut() { + if let Some(pk) = resource.url.split('/').last() { + item.id = format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk); + item.parent_id = format!("radio-paradise:channel:{}:liveplaylist", slug); + + if resource.url.starts_with('/') { + resource.url = format!("{}{}", self.base_url, resource.url); + } + } + } + + if item.genre.is_none() { + item.genre = Some("Radio Paradise".to_string()); + } + + if let Some(art) = item.album_art.as_mut() { + if art.starts_with('/') { + *art = format!("{}{}", self.base_url, art); + } + } else { + item.album_art = Some(self.default_cover_url()); + } + } + + Ok(items) + } + + /// Get a single item from the live playlist by pk + #[cfg(feature = "playlist")] + async fn get_live_playlist_item(&self, slug: &str, pk: &str) -> Result { + let items = self.get_live_playlist_items(slug, 0, 1000).await?; + let expected_id = format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk); + for item in items { + if item.id == expected_id { + return Ok(item); + } + } + Err(MusicSourceError::ObjectNotFound(format!( + "Track with pk {} not found in live playlist", + pk + ))) + } +} + +/// Types of object IDs in the Radio Paradise source +#[derive(Debug, Clone, PartialEq)] +enum ObjectIdType { + Root, + Channel { slug: String }, + LiveStream { slug: String }, + LivePlaylist { slug: String }, + LivePlaylistTrack { slug: String, pk: String }, + History { slug: String }, + HistoryTrack { slug: String, pk: String }, + Unknown, } #[async_trait] impl MusicSource for RadioParadiseSource { fn name(&self) -> &str { - "Radio Paradise (DEPRECATED)" + "Radio Paradise" } fn id(&self) -> &str { - "radio-paradise-deprecated" + "radio-paradise" } fn default_image(&self) -> &[u8] { @@ -117,57 +634,410 @@ impl MusicSource for RadioParadiseSource { async fn root_container(&self) -> Result { Ok(Container { - id: "radio-paradise-deprecated".to_string(), + id: "radio-paradise".to_string(), parent_id: "0".to_string(), restricted: Some("1".to_string()), - child_count: Some("0".to_string()), - searchable: Some("0".to_string()), - title: "Radio Paradise (DEPRECATED)".to_string(), + // childCount retiré pour éviter les soucis de compatibilité côté CP + child_count: None, + searchable: Some("1".to_string()), + title: "Radio Paradise".to_string(), class: "object.container".to_string(), containers: vec![], items: vec![], }) } - async fn browse(&self, _object_id: &str) -> Result { - tracing::warn!("RadioParadiseSource::browse called but source is deprecated"); - Ok(BrowseResult::Mixed { - containers: vec![], - items: vec![], - }) + async fn browse(&self, object_id: &str) -> Result { + match Self::parse_object_id(object_id) { + ObjectIdType::Root => { + // Return the 4 channel containers + let containers: Vec = ALL_CHANNELS + .iter() + .map(|ch| self.build_channel_container(ch)) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + ObjectIdType::Channel { slug } => { + // Return live stream item + history container + let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { + MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) + })?; + + let live_item = self.build_live_stream_item(descriptor); + let live_playlist_container = self.build_live_playlist_container(descriptor); + + #[cfg(feature = "playlist")] + let history_container = self.build_history_container_with_count(descriptor).await; + #[cfg(not(feature = "playlist"))] + let history_container = self.build_history_container(descriptor); + + Ok(BrowseResult::Mixed { + containers: vec![live_playlist_container, history_container], + items: vec![live_item], + }) + } + + ObjectIdType::History { slug } => { + // Return history container (for BrowseMetadata) and items (for BrowseDirectChildren) + // The content_handler will filter out the container when browsing direct children + let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { + MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) + })?; + + #[cfg(feature = "playlist")] + { + let history_container = + self.build_history_container_with_count(descriptor).await; + let items = self.get_history_items(&slug, 0, 100).await?; + Ok(BrowseResult::Mixed { + containers: vec![history_container], + items, + }) + } + + #[cfg(not(feature = "playlist"))] + { + // If playlist feature is disabled, return just the container + let history_container = self.build_history_container(descriptor); + Ok(BrowseResult::Containers(vec![history_container])) + } + } + + 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::LivePlaylist { slug } => { + // Playlist du live : container + items + let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { + MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) + })?; + + #[cfg(feature = "playlist")] + { + let container = self.build_live_playlist_container(descriptor); + let items = self.get_live_playlist_items(&slug, 0, 100).await?; + Ok(BrowseResult::Mixed { + containers: vec![container], + items, + }) + } + + #[cfg(not(feature = "playlist"))] + { + let container = self.build_live_playlist_container(descriptor); + Ok(BrowseResult::Containers(vec![container])) + } + } + + 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::LivePlaylistTrack { slug, pk } => { + // Détails d'un titre du live (playlist live) + #[cfg(feature = "playlist")] + { + let item = self.get_live_playlist_item(&slug, &pk).await?; + Ok(BrowseResult::Items(vec![item])) + } + + #[cfg(not(feature = "playlist"))] + { + let _ = (slug, pk); + Err(MusicSourceError::NotSupported( + "Playlist feature not enabled".to_string(), + )) + } + } + + ObjectIdType::Unknown => Err(MusicSourceError::ObjectNotFound(format!( + "Unknown object ID: {}", + object_id + ))), + } } - async fn resolve_uri(&self, _object_id: &str) -> Result { - Err(MusicSourceError::SourceUnavailable( - "RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead." - .to_string(), - )) + async fn resolve_uri(&self, object_id: &str) -> Result { + match Self::parse_object_id(object_id) { + ObjectIdType::LiveStream { slug } => { + // Return live stream URL + Ok(self.build_live_url(&slug)) + } + + ObjectIdType::HistoryTrack { pk, .. } => { + // Return cached audio URL + Ok(format!("{}/cache/audio/{}", self.base_url, pk)) + } + + ObjectIdType::LivePlaylistTrack { pk, .. } => { + // Return cached audio URL + Ok(format!("{}/cache/audio/{}", self.base_url, pk)) + } + + _ => Err(MusicSourceError::ObjectNotFound(format!( + "Cannot resolve URI for object: {}", + object_id + ))), + } + } + + fn capabilities(&self) -> SourceCapabilities { + SourceCapabilities { + supports_fifo: self.supports_fifo(), + supports_search: false, + supports_favorites: false, + supports_playlists: false, + supports_user_content: false, + supports_high_res_audio: true, + max_sample_rate: Some(44100), + supports_multiple_formats: true, + supports_advanced_search: false, + supports_pagination: false, + } + } + + async fn get_available_formats(&self, object_id: &str) -> Result> { + match Self::parse_object_id(object_id) { + ObjectIdType::LiveStream { .. } => Ok(vec![ + AudioFormat { + format_id: "flac".to_string(), + mime_type: "audio/flac".to_string(), + sample_rate: Some(44100), + bit_depth: Some(16), + bitrate: None, + channels: Some(2), + }, + AudioFormat { + format_id: "ogg-flac".to_string(), + mime_type: "audio/ogg".to_string(), + sample_rate: Some(44100), + bit_depth: Some(16), + bitrate: None, + channels: Some(2), + }, + ]), + ObjectIdType::HistoryTrack { .. } => Ok(vec![AudioFormat { + format_id: "flac".to_string(), + mime_type: "audio/flac".to_string(), + sample_rate: Some(44100), + bit_depth: Some(16), + bitrate: None, + channels: Some(2), + }]), + ObjectIdType::LivePlaylistTrack { .. } => Ok(vec![AudioFormat { + format_id: "flac".to_string(), + mime_type: "audio/flac".to_string(), + sample_rate: Some(44100), + bit_depth: Some(16), + bitrate: None, + channels: Some(2), + }]), + _ => Err(MusicSourceError::ObjectNotFound(format!( + "Cannot list formats for object: {}", + object_id + ))), + } + } + + async fn get_item(&self, object_id: &str) -> Result { + match Self::parse_object_id(object_id) { + ObjectIdType::LiveStream { slug } => { + // Try to fetch current metadata from live stream + if let Ok(Some(item)) = self.fetch_live_metadata(&slug).await { + return Ok(item); + } + + // Fallback to static item if metadata fetch fails + let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { + MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) + })?; + Ok(self.build_live_stream_item(descriptor)) + } + + ObjectIdType::HistoryTrack { slug, pk } => { + // Get from history playlist + #[cfg(feature = "playlist")] + { + let playlist_id = Self::history_playlist_id(&slug); + let manager = pmoplaylist::PlaylistManager(); + let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { + MusicSourceError::BrowseError(format!( + "Failed to get playlist {}: {}", + playlist_id, 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 + )) + })?; + + // Ajuster les IDs/parent_id/URL pour coller au schéma Radio Paradise, + // comme dans get_history_items. + let mut adjusted = Vec::new(); + for mut item in items { + if let Some(resource) = item.resources.first_mut() { + if let Some(pk2) = resource.url.split('/').last() { + item.id = format!( + "radio-paradise:channel:{}:history:track:{}", + slug, pk2 + ); + item.parent_id = format!("radio-paradise:channel:{}:history", slug); + + if resource.url.starts_with('/') { + resource.url = format!("{}{}", self.base_url, resource.url); + } + } + } + if item.genre.is_none() { + item.genre = Some("Radio Paradise".to_string()); + } + adjusted.push(item); + } + + // Find the item matching this pk in the item ID + let expected_id = + format!("radio-paradise:channel:{}:history:track:{}", slug, pk); + for item in adjusted { + if item.id == expected_id { + return Ok(item); + } + } + + Err(MusicSourceError::ObjectNotFound(format!( + "Track with pk {} not found in history", + pk + ))) + } + + #[cfg(not(feature = "playlist"))] + { + let _ = (slug, pk); + Err(MusicSourceError::NotSupported( + "Playlist feature not enabled".to_string(), + )) + } + } + + ObjectIdType::LivePlaylistTrack { slug, pk } => { + #[cfg(feature = "playlist")] + { + let playlist_id = Self::live_playlist_id(&slug); + let manager = pmoplaylist::PlaylistManager(); + let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { + MusicSourceError::BrowseError(format!( + "Failed to get live playlist {}: {}", + playlist_id, e + )) + })?; + + let items = reader.to_items(1000).await.map_err(|e| { + MusicSourceError::BrowseError(format!( + "Failed to read live playlist entries: {}", + e + )) + })?; + + for mut item in items { + if let Some(resource) = item.resources.first_mut() { + if let Some(pk2) = resource.url.split('/').last() { + item.id = format!( + "radio-paradise:channel:{}:liveplaylist:track:{}", + slug, pk2 + ); + item.parent_id = + format!("radio-paradise:channel:{}:liveplaylist", slug); + + if resource.url.starts_with('/') { + resource.url = format!("{}{}", self.base_url, resource.url); + } + } + } + + if item.genre.is_none() { + item.genre = Some("Radio Paradise".to_string()); + } + + if let Some(art) = item.album_art.as_mut() { + if art.starts_with('/') { + *art = format!("{}{}", self.base_url, art); + } + } else { + item.album_art = Some(self.default_cover_url()); + } + + let expected_id = + format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk); + if item.id == expected_id { + return Ok(item); + } + } + + Err(MusicSourceError::ObjectNotFound(format!( + "Track with pk {} not found in live playlist", + pk + ))) + } + + #[cfg(not(feature = "playlist"))] + { + let _ = (slug, pk); + Err(MusicSourceError::NotSupported( + "Playlist feature not enabled".to_string(), + )) + } + } + + _ => Err(MusicSourceError::ObjectNotFound(format!( + "Cannot get item for object: {}", + object_id + ))), + } } fn supports_fifo(&self) -> bool { - false + // History playlists are FIFO + cfg!(feature = "playlist") } async fn append_track(&self, _track: Item) -> Result<()> { - Err(MusicSourceError::SourceUnavailable( - "RadioParadiseSource is deprecated and does not support FIFO operations." - .to_string(), + // Tracks are added automatically by FlacCacheSink + Err(MusicSourceError::NotSupported( + "Tracks are automatically added to history by the streaming system".to_string(), )) } async fn remove_oldest(&self) -> Result> { + // Managed automatically by playlist FIFO Ok(None) } async fn update_id(&self) -> u32 { - 0 + *self.update_counter.read().await } async fn last_change(&self) -> Option { - None + Some(*self.last_change.read().await) } - async fn get_items(&self, _offset: usize, _count: usize) -> Result> { + async fn get_items(&self, offset: usize, count: usize) -> Result> { + // For Radio Paradise, we don't have a global FIFO + // Each channel has its own history + // Return empty for now - clients should browse specific channel histories + let _ = (offset, count); Ok(vec![]) } } diff --git a/pmoparadise/src/stream_channel.rs b/pmoparadise/src/stream_channel.rs new file mode 100644 index 00000000..4bd90ae4 --- /dev/null +++ b/pmoparadise/src/stream_channel.rs @@ -0,0 +1,1011 @@ +//! Version simplifiée de stream_channel.rs utilisant RadioParadisePlaylistFeeder + PlaylistSource +//! +//! Cette version remplace l'architecture complexe RadioParadiseStreamSource par : +//! - RadioParadisePlaylistFeeder : télécharge les URLs gapless et alimente une playlist +//! - PlaylistSource::with_history() : lit la playlist et gère l'historique automatiquement + +use std::{ + collections::HashMap, + pin::Pin, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + task::{Context, Poll}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use crate::{ + channels::{ChannelDescriptor, ParadiseChannelKind, ALL_CHANNELS}, + client::RadioParadiseClient, + models::{Block, EventId}, + playlist_feeder::RadioParadisePlaylistFeeder, +}; +use anyhow::{anyhow, Context as AnyhowContext, Result}; +use once_cell::sync::OnceCell; +use pmoaudio::{AudioError, AudioPipelineNode}; +use pmoaudio_ext::{ + FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, OggFlacStreamHandle, + PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink, StreamingSinkOptions, + TrackBoundaryCoverNode, +}; +use pmoaudiocache::{get_audio_cache, Cache as AudioCache}; +use pmocovers::{get_cover_cache, Cache as CoverCache}; +use pmoflac::EncoderOptions; +use pmoplaylist::PlaylistManager; +use thiserror::Error; +use tokio::io::{AsyncRead, ReadBuf}; +use tokio::sync::{Mutex, Notify}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; + +/// Configuration pour un canal Radio Paradise. +#[derive(Clone, Debug)] +pub struct ParadiseStreamChannelConfig { + /// Durée maximale (en secondes) d'avance acceptée par le broadcast. + pub max_lead_seconds: f64, + /// Options pour le flux FLAC pur. + pub flac_options: StreamingSinkOptions, + /// Options pour le flux OGG-FLAC. + pub ogg_options: StreamingSinkOptions, + /// URL de base du serveur (pour les métadonnées, covers...) + pub server_base_url: Option, +} + +impl Default for ParadiseStreamChannelConfig { + fn default() -> Self { + Self { + max_lead_seconds: 3.0, // Compromis live/fluidité : assez pour absorber les transitions + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } +} + +/// Options pour activer l'archivage/historique d'un canal. +pub struct ParadiseHistoryOptions { + pub audio_cache: Arc, + pub cover_cache: Arc, + pub playlist_id: String, + pub collection: Option, + pub replay_max_lead_seconds: f64, + pub max_history_tracks: Option, +} + +/// Builder pratique pour configurer automatiquement les playlists historiques. +#[derive(Clone)] +pub struct ParadiseHistoryBuilder { + pub audio_cache: Arc, + pub cover_cache: Arc, + pub playlist_prefix: String, + pub playlist_title_prefix: Option, + pub max_history_tracks: Option, + pub collection_prefix: Option, + pub replay_max_lead_seconds: f64, +} + +impl ParadiseHistoryBuilder { + pub fn new(audio_cache: Arc, cover_cache: Arc) -> Self { + Self { + audio_cache, + cover_cache, + playlist_prefix: "radio-paradise-history".into(), + playlist_title_prefix: Some("Radio Paradise History".into()), + max_history_tracks: Some(500), + collection_prefix: Some("radio-paradise".into()), + replay_max_lead_seconds: 3.0, // Aligné avec le live + } + } + + pub async fn build_for_channel( + &self, + descriptor: &ChannelDescriptor, + ) -> Result { + let playlist_id = format!("{}-{}", self.playlist_prefix, descriptor.slug); + + let collection = self + .collection_prefix + .as_ref() + .map(|prefix| format!("{}-{}", prefix, descriptor.slug)); + + Ok(ParadiseHistoryOptions { + audio_cache: self.audio_cache.clone(), + cover_cache: self.cover_cache.clone(), + playlist_id, + collection, + replay_max_lead_seconds: self.replay_max_lead_seconds, + max_history_tracks: self.max_history_tracks, + }) + } +} + +impl Default for ParadiseHistoryBuilder { + fn default() -> Self { + let audio_cache = get_audio_cache() + .expect("pmoaudiocache::register_audio_cache must be called before using ParadiseHistoryBuilder::default()"); + let cover_cache = get_cover_cache() + .expect("pmocovers::register_cover_cache must be called before using ParadiseHistoryBuilder::default()"); + Self::new(audio_cache, cover_cache) + } +} + +#[cfg(feature = "pmoconfig")] +impl ParadiseStreamChannelConfig { + pub fn from_config(cfg: &pmoconfig::Config, channel: ParadiseChannelKind) -> Self { + use serde_yaml::Value; + let path = [ + "sources", + "radio_paradise", + "channels", + channel.slug(), + "max_lead_seconds", + ]; + match cfg.get_value(&path) { + Ok(Value::Number(num)) => { + if let Some(v) = num.as_f64() { + Self { + max_lead_seconds: v.max(0.1), + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } else { + let default = Self::default(); + let _ = + cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + Ok(Value::String(s)) => { + if let Ok(v) = s.parse::() { + Self { + max_lead_seconds: v.max(0.1), + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } else { + let default = Self::default(); + let _ = + cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + _ => { + let default = Self::default(); + let _ = cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + } +} + +/// Stream complet (FLAC pur + OGG-FLAC) pour un canal Radio Paradise. +/// +/// Version simplifiée utilisant RadioParadisePlaylistFeeder + PlaylistSource +pub struct ParadiseStreamChannel { + descriptor: ChannelDescriptor, + state: Arc, + pipeline_handle: JoinHandle<()>, + feeder_handle: JoinHandle<()>, +} + +impl ParadiseStreamChannel { + /// Crée un canal avec client déjà configuré. + pub async fn with_client( + descriptor: ChannelDescriptor, + client: RadioParadiseClient, + config: ParadiseStreamChannelConfig, + cover_cache: Option>, + history: Option, + ) -> Result { + // Propager server_base_url dans les options pour que les encoders injectent les covers du cache + let mut config = config; + if let Some(ref base) = config.server_base_url { + config.flac_options = config + .flac_options + .clone() + .with_server_base_url(Some(base.clone())); + config.ogg_options = config + .ogg_options + .clone() + .with_server_base_url(Some(base.clone())); + } + let cover_cache = cover_cache + .or_else(|| history.as_ref().map(|opts| opts.cover_cache.clone())) + .or_else(|| get_cover_cache()); + let manager = PlaylistManager::get(); + + // 1. Créer la playlist live pour ce canal + let live_playlist_id = format!("radio-paradise-live-{}", descriptor.slug); + let (feeder, live_read) = if let Some(ref history_opts) = history { + RadioParadisePlaylistFeeder::new( + client.clone(), + history_opts.audio_cache.clone(), + history_opts.cover_cache.clone(), + live_playlist_id.clone(), + history_opts.collection.clone(), + ) + .await? + } else { + // Pas d'historique, on a besoin quand même d'un cache audio basique + return Err(anyhow!( + "History options required for now (audio cache needed)" + )); + }; + + let feeder = Arc::new(feeder); + + // 2. Créer/récupérer la playlist historique si activée + let history_write = if let Some(ref history_opts) = history { + let write = manager + .get_persistent_write_handle(history_opts.playlist_id.clone()) + .await?; + + // Configurer la capacité + if let Some(capacity) = history_opts.max_history_tracks { + write.set_capacity(Some(capacity)).await?; + } + + // Configurer le titre + let title = format!("Radio Paradise History - {}", descriptor.display_name); + write.set_title(title).await?; + + Some(Arc::new(write)) + } else { + None + }; + + // 3. Créer la source playlist avec historique + let audio_cache = history.as_ref().unwrap().audio_cache.clone(); + let mut source = if let Some(history_write) = history_write.clone() { + PlaylistSource::with_history(live_read, audio_cache.clone(), history_write) + } else { + PlaylistSource::new(live_read, audio_cache.clone()) + }; + + // 4. Créer les sinks de broadcast (FLAC + OGG) + let (flac_sink, stream_handle) = StreamingFlacSink::with_options( + EncoderOptions::default(), + 16, + config.max_lead_seconds, + config.flac_options.clone(), + ); + let (ogg_sink, ogg_handle) = StreamingOggFlacSink::with_options( + EncoderOptions::default(), + 16, + config.max_lead_seconds, + config.ogg_options.clone(), + ); + + let mut downstream_children: Vec> = Vec::new(); + downstream_children.push(Box::new(flac_sink)); + downstream_children.push(Box::new(ogg_sink)); + + // 5. Optionnel : ajouter le nœud de cache de covers + if let Some(cache) = cover_cache { + let mut cover_node = TrackBoundaryCoverNode::new(cache); + for child in downstream_children { + cover_node.register(child); + } + source.register(Box::new(cover_node)); + } else { + for child in downstream_children { + source.register(child); + } + } + + stream_handle.set_auto_stop(false); + ogg_handle.set_auto_stop(false); + + // 6. Lancer le pipeline audio + let stop_token = CancellationToken::new(); + let pipeline_stop = stop_token.clone(); + let channel_display_name = descriptor.display_name; + + let state = Arc::new(ChannelState { + descriptor, + config, + client, + feeder: feeder.clone(), + stream_handle, + ogg_handle, + history_playlist_id: history.map(|h| h.playlist_id), + history_audio_cache: history_write.map(|_| audio_cache), + active_clients: AtomicUsize::new(0), + activity_notify: Notify::new(), + stop_token, + current_block: Mutex::new(None), + prefetch_lock: Mutex::new(()), + }); + + let pipeline_state = state.clone(); + let pipeline_handle = tokio::spawn(async move { + info!( + "RadioParadise stream pipeline started for channel {}", + channel_display_name + ); + if let Err(e) = Box::new(source).run(pipeline_stop).await { + error!("Pipeline error for channel {}: {}", channel_display_name, e); + pipeline_state.handle_pipeline_error(&e).await; + } + }); + + // 7. Lancer le feeder qui traite les blocs + let feeder_runner = feeder.clone(); + tokio::spawn(async move { + if let Err(e) = feeder_runner.run().await { + error!("RadioParadisePlaylistFeeder error: {}", e); + } + }); + + // 8. Lancer le scheduler qui enqueue les blocs + let feeder_state = state.clone(); + let feeder_handle = tokio::spawn(async move { + feeder_state.run_scheduler().await; + }); + + Ok(Self { + descriptor, + state, + pipeline_handle, + feeder_handle, + }) + } + + /// Crée un canal en construisant automatiquement le client pour ce descriptor. + pub async fn new( + descriptor: ChannelDescriptor, + config: ParadiseStreamChannelConfig, + cover_cache: Option>, + history: Option, + ) -> Result { + let client = RadioParadiseClient::builder() + .channel(descriptor.id) + .build() + .await?; + Self::with_client(descriptor, client, config, cover_cache, history).await + } + + /// S'abonne au flux FLAC pur. + pub fn subscribe_flac(&self) -> ChannelFlacStream { + self.state.on_client_added(); + let inner = self.state.stream_handle.subscribe_flac(); + ChannelFlacStream::new(inner, self.state.clone()) + } + + /// S'abonne au flux FLAC + ICY metadata. + pub fn subscribe_icy(&self) -> ChannelIcyStream { + self.state.on_client_added(); + let inner = self.state.stream_handle.subscribe_icy(); + ChannelIcyStream::new(inner, self.state.clone()) + } + + /// S'abonne au flux OGG-FLAC. + pub fn subscribe_ogg(&self) -> ChannelOggStream { + self.state.on_client_added(); + let inner = self.state.ogg_handle.subscribe(); + ChannelOggStream::new(inner, self.state.clone()) + } + + /// Snapshot des métadonnées actuelles. + pub async fn metadata(&self) -> MetadataSnapshot { + self.state.stream_handle.get_metadata().await + } + + /// Nombre de clients actifs. + pub fn active_clients(&self) -> usize { + self.state.active_clients.load(Ordering::SeqCst) + } + + pub fn descriptor(&self) -> ChannelDescriptor { + self.descriptor + } + + /// Lance un pipeline dédié pour rejouer l'historique (FLAC pur) pour un client. + pub async fn stream_history_flac( + &self, + client_id: &str, + ) -> Result { + let history_id = self + .state + .history_playlist_id + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + + let audio_cache = self + .state + .history_audio_cache + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + + tracing::info!( + "Starting historical FLAC replay for channel {} (client_id={})", + self.descriptor.display_name, + client_id + ); + + let reader = pmoplaylist::PlaylistManager::get() + .get_read_handle(history_id) + .await + .map_err(|e| HistoryStreamError::Playlist(e.to_string()))?; + + let mut source = PlaylistSource::new(reader, audio_cache.clone()); + let (flac_sink, handle) = StreamingFlacSink::with_max_broadcast_lead( + EncoderOptions::default(), + 16, + self.state.config.max_lead_seconds, + ); + source.register(Box::new(flac_sink)); + let stop_token = CancellationToken::new(); + let stop_clone = stop_token.clone(); + let pipeline = tokio::spawn(async move { + let _ = Box::new(source).run(stop_clone).await; + }); + let stream = handle.subscribe_flac(); + Ok(HistoryFlacStream::new(stream, stop_token, pipeline)) + } + + /// Lance un pipeline dédié pour rejouer l'historique (OGG-FLAC) pour un client. + pub async fn stream_history_ogg( + &self, + client_id: &str, + ) -> Result { + let history_id = self + .state + .history_playlist_id + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + + let audio_cache = self + .state + .history_audio_cache + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + + tracing::info!( + "Starting historical OGG replay for channel {} (client_id={})", + self.descriptor.display_name, + client_id + ); + + let reader = pmoplaylist::PlaylistManager::get() + .get_read_handle(history_id) + .await + .map_err(|e| HistoryStreamError::Playlist(e.to_string()))?; + + let mut source = PlaylistSource::new(reader, audio_cache.clone()); + let (ogg_sink, handle) = StreamingOggFlacSink::with_max_broadcast_lead( + EncoderOptions::default(), + 16, + self.state.config.max_lead_seconds, + ); + source.register(Box::new(ogg_sink)); + let stop_token = CancellationToken::new(); + let stop_clone = stop_token.clone(); + let pipeline = tokio::spawn(async move { + let _ = Box::new(source).run(stop_clone).await; + }); + let stream = handle.subscribe(); + Ok(HistoryOggStream::new(stream, stop_token, pipeline)) + } +} + +impl Drop for ParadiseStreamChannel { + fn drop(&mut self) { + self.state.stop_token.cancel(); + self.pipeline_handle.abort(); + self.feeder_handle.abort(); + } +} + +const MAX_BLOCK_LEAD: Duration = Duration::from_secs(3600); +const BLOCK_LEAD_CHECK_CHUNK: Duration = Duration::from_secs(300); +const LIVE_PREFETCH_MIN_TRACKS: usize = 5; +const LIVE_PREFETCH_TIMEOUT: Duration = Duration::from_secs(10); +const LIVE_PREFETCH_POLL_INTERVAL: Duration = Duration::from_millis(200); +const LIVE_PREFETCH_MAX_BLOCKS: usize = 4; + +static GLOBAL_CHANNEL_MANAGER: OnceCell> = OnceCell::new(); + +struct ChannelState { + descriptor: ChannelDescriptor, + config: ParadiseStreamChannelConfig, + client: RadioParadiseClient, + feeder: Arc, + stream_handle: StreamHandle, + ogg_handle: OggFlacStreamHandle, + history_playlist_id: Option, + history_audio_cache: Option>, + active_clients: AtomicUsize, + activity_notify: Notify, + stop_token: CancellationToken, + current_block: Mutex>, + prefetch_lock: Mutex<()>, +} + +impl ChannelState { + fn current_unix_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) + } + + fn block_lead_delay(&self, block: &Block) -> Option { + let start = block.start_time_millis()?; + let now = Self::current_unix_millis(); + let max_lead_ms = MAX_BLOCK_LEAD.as_millis() as u64; + if start <= now + max_lead_ms { + None + } else { + Some(Duration::from_millis(start - now - max_lead_ms)) + } + } + + fn on_client_added(&self) { + if self.active_clients.fetch_add(1, Ordering::SeqCst) == 0 { + self.activity_notify.notify_one(); + } + } + + fn on_client_removed(&self) { + self.active_clients.fetch_sub(1, Ordering::SeqCst); + } + + async fn wait_for_clients(&self) -> bool { + while self.active_clients.load(Ordering::SeqCst) == 0 { + tokio::select! { + _ = self.stop_token.cancelled() => return false, + _ = self.activity_notify.notified() => {}, + } + } + true + } + + async fn wait_until_block_ready(&self, block: &Block) -> BlockReadiness { + loop { + if self.stop_token.is_cancelled() { + return BlockReadiness::Stopped; + } + if self.active_clients.load(Ordering::SeqCst) == 0 { + return BlockReadiness::NoClients; + } + + if let Some(delay) = self.block_lead_delay(block) { + let sleep_for = delay.min(BLOCK_LEAD_CHECK_CHUNK); + let lead_secs = delay.as_secs_f64(); + info!( + "Block {} scheduled too far in the future ({:.1} min). Sleeping {:?} before retrying.", + block.event, + lead_secs / 60.0, + sleep_for + ); + tokio::select! { + _ = self.stop_token.cancelled() => return BlockReadiness::Stopped, + _ = tokio::time::sleep(sleep_for) => {}, + } + continue; + } + + return BlockReadiness::Ready; + } + } + + fn live_playlist_id(&self) -> String { + format!("radio-paradise-live-{}", self.descriptor.slug) + } + + async fn prefetch_until_horizon(&self) -> Result<()> { + let _guard = self.prefetch_lock.lock().await; + let playlist_id = self.live_playlist_id(); + let manager = PlaylistManager::get(); + let reader = manager + .get_read_handle(&playlist_id) + .await + .with_context(|| format!("Failed to get live playlist {}", playlist_id))?; + let start = Instant::now(); + let mut next_event: Option = None; + let mut attempts = 0usize; + + loop { + let available = reader + .remaining() + .await + .with_context(|| format!("Failed to inspect playlist {}", playlist_id))?; + if available >= LIVE_PREFETCH_MIN_TRACKS { + return Ok(()); + } + + if start.elapsed() >= LIVE_PREFETCH_TIMEOUT { + warn!( + "Prefetch timeout for channel {} ({} tracks available)", + self.descriptor.display_name, available + ); + return Ok(()); + } + + if attempts >= LIVE_PREFETCH_MAX_BLOCKS { + warn!( + "Prefetch block limit reached for channel {} ({} tracks available)", + self.descriptor.display_name, available + ); + return Ok(()); + } + + match self.client.get_block(next_event).await { + Ok(block) => { + attempts += 1; + next_event = Some(block.end_event); + self.feeder.push_block_id(block.event).await; + } + Err(e) => { + warn!( + "Failed to fetch block during prefetch for channel {}: {}", + self.descriptor.display_name, e + ); + return Ok(()); + } + } + + tokio::time::sleep(LIVE_PREFETCH_POLL_INTERVAL).await; + } + } + + async fn set_current_block(&self, event_id: EventId) { + let mut guard = self.current_block.lock().await; + *guard = Some(event_id); + } + + async fn take_current_block(&self) -> Option { + self.current_block.lock().await.take() + } + + async fn handle_pipeline_error(&self, err: &AudioError) { + if let Some(event_id) = self.take_current_block().await { + warn!( + "Pipeline error while streaming block {} on channel {}: {}. Rescheduling block.", + event_id, self.descriptor.display_name, err + ); + self.feeder.retry_block(event_id).await; + } else { + warn!( + "Pipeline error for channel {} but no tracked block: {}", + self.descriptor.display_name, err + ); + } + } + + async fn run_scheduler(self: Arc) { + let mut backoff = Duration::from_secs(5); + 'scheduler: loop { + if self.stop_token.is_cancelled() { + break; + } + + if !self.wait_for_clients().await { + break; + } + + match self.client.get_block(None).await { + Ok(block) => { + match self.wait_until_block_ready(&block).await { + BlockReadiness::Ready => {} + BlockReadiness::NoClients => continue, + BlockReadiness::Stopped => break, + } + info!( + "Channel {} streaming block {}", + self.descriptor.display_name, block.event + ); + self.set_current_block(block.event).await; + self.feeder.push_block_id(block.event).await; + let mut next_event = block.end_event; + + loop { + if self.stop_token.is_cancelled() { + return; + } + + if self.active_clients.load(Ordering::SeqCst) == 0 { + break; + } + + match self.client.get_block(Some(next_event)).await { + Ok(next_block) => { + match self.wait_until_block_ready(&next_block).await { + BlockReadiness::Ready => {} + BlockReadiness::NoClients => break, + BlockReadiness::Stopped => break 'scheduler, + } + self.set_current_block(next_block.event).await; + self.feeder.push_block_id(next_block.event).await; + next_event = next_block.end_event; + backoff = Duration::from_secs(5); + } + Err(e) => { + warn!( + "Failed to fetch next block for channel {}: {}", + self.descriptor.display_name, e + ); + tokio::select! { + _ = self.stop_token.cancelled() => return, + _ = tokio::time::sleep(backoff) => {}, + } + backoff = (backoff * 2).min(Duration::from_secs(60)); + } + } + } + } + Err(e) => { + warn!( + "Failed to fetch current block for channel {}: {}", + self.descriptor.display_name, e + ); + tokio::select! { + _ = self.stop_token.cancelled() => break, + _ = tokio::time::sleep(backoff) => {}, + } + backoff = (backoff * 2).min(Duration::from_secs(60)); + } + } + } + } +} + +enum BlockReadiness { + Ready, + NoClients, + Stopped, +} + +macro_rules! wrap_stream { + ($name:ident, $inner:ty) => { + pub struct $name { + inner: $inner, + state: Arc, + } + + impl $name { + fn new(inner: $inner, state: Arc) -> Self { + Self { inner, state } + } + } + + impl AsyncRead for $name { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } + } + + impl Drop for $name { + fn drop(&mut self) { + self.state.on_client_removed(); + } + } + }; +} + +wrap_stream!(ChannelFlacStream, FlacClientStream); +wrap_stream!(ChannelIcyStream, IcyClientStream); +wrap_stream!(ChannelOggStream, OggFlacClientStream); + +#[derive(Debug, Error)] +pub enum HistoryStreamError { + #[error("history replay not enabled for this channel")] + HistoryDisabled, + #[error("playlist error: {0}")] + Playlist(String), +} + +pub struct HistoryFlacStream { + inner: FlacClientStream, + stop_token: CancellationToken, + pipeline: Option>, +} + +impl HistoryFlacStream { + fn new( + inner: FlacClientStream, + stop_token: CancellationToken, + pipeline: JoinHandle<()>, + ) -> Self { + Self { + inner, + stop_token, + pipeline: Some(pipeline), + } + } +} + +impl AsyncRead for HistoryFlacStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl Unpin for HistoryFlacStream {} + +impl Drop for HistoryFlacStream { + fn drop(&mut self) { + self.stop_token.cancel(); + if let Some(handle) = self.pipeline.take() { + handle.abort(); + } + } +} + +pub struct HistoryOggStream { + inner: OggFlacClientStream, + stop_token: CancellationToken, + pipeline: Option>, +} + +impl HistoryOggStream { + fn new( + inner: OggFlacClientStream, + stop_token: CancellationToken, + pipeline: JoinHandle<()>, + ) -> Self { + Self { + inner, + stop_token, + pipeline: Some(pipeline), + } + } +} + +impl AsyncRead for HistoryOggStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl Unpin for HistoryOggStream {} + +impl Drop for HistoryOggStream { + fn drop(&mut self) { + self.stop_token.cancel(); + if let Some(handle) = self.pipeline.take() { + handle.abort(); + } + } +} + +/// Gestionnaire multi-canaux. +pub struct ParadiseChannelManager { + channels: HashMap>, +} + +impl ParadiseChannelManager { + pub fn new(channels: HashMap>) -> Self { + Self { channels } + } + + pub async fn with_defaults_with_cover_cache( + cover_cache: Option>, + history_builder: Option, + server_base_url: Option, + ) -> Result { + tracing::info!( + "➡️ Entering with_defaults_with_cover_cache ({} channels, base_url={:?})", + ALL_CHANNELS.len(), + server_base_url + ); + let mut map = HashMap::new(); + for descriptor in ALL_CHANNELS.iter().copied() { + let mut config = ParadiseStreamChannelConfig::default(); + config.server_base_url = server_base_url.clone(); + + let start = Instant::now(); + tracing::info!( + "⏳ Initializing Radio Paradise channel {} ({})...", + descriptor.display_name, + descriptor.slug + ); + + let history_opts = if let Some(builder) = &history_builder { + tracing::debug!( + " ⏳ Building history options for channel {} ({})", + descriptor.display_name, + descriptor.slug + ); + Some( + builder + .build_for_channel(&descriptor) + .await + .map_err(|e| anyhow!("Failed to init history playlist: {}", e))?, + ) + } else { + None + }; + tracing::debug!( + " ⏩ History options ready for channel {} ({})", + descriptor.display_name, + descriptor.slug + ); + let channel = match tokio::time::timeout( + Duration::from_secs(20), + ParadiseStreamChannel::new(descriptor, config, cover_cache.clone(), history_opts), + ) + .await + { + Ok(Ok(ch)) => { + tracing::info!( + "✅ Channel {} ({}) initialized in {:?}", + descriptor.display_name, + descriptor.slug, + start.elapsed() + ); + ch + } + Ok(Err(e)) => { + tracing::error!( + "⚠️ Failed to initialize channel {} ({}): {}", + descriptor.display_name, + descriptor.slug, + e + ); + continue; + } + Err(_) => { + tracing::error!( + "⚠️ Timeout initializing channel {} ({}) after 20s, skipping", + descriptor.display_name, + descriptor.slug + ); + continue; + } + }; + map.insert(descriptor.id, Arc::new(channel)); + } + Ok(Self { channels: map }) + } + + pub async fn with_defaults() -> Result { + Self::with_defaults_with_cover_cache(None, None, None).await + } + + pub fn get(&self, id: u8) -> Option> { + self.channels.get(&id).cloned() + } + + pub fn iter(&self) -> impl Iterator> { + self.channels.values() + } + + pub async fn prefetch_until_horizon(&self, channel_id: u8) -> Result<()> { + let channel = self + .get(channel_id) + .ok_or_else(|| anyhow!("Unknown channel id {}", channel_id))?; + channel.prefetch_until_horizon().await + } +} + +pub fn register_global_channel_manager(manager: Arc) { + let _ = GLOBAL_CHANNEL_MANAGER.set(Arc::downgrade(&manager)); +} + +pub fn get_global_channel_manager() -> Option> { + GLOBAL_CHANNEL_MANAGER.get().and_then(|weak| weak.upgrade()) +} + +impl ParadiseStreamChannel { + pub async fn prefetch_until_horizon(&self) -> Result<()> { + self.state.prefetch_until_horizon().await + } +} diff --git a/pmoparadise/src/stream_channel_old.rs b/pmoparadise/src/stream_channel_old.rs new file mode 100644 index 00000000..a42f6fde --- /dev/null +++ b/pmoparadise/src/stream_channel_old.rs @@ -0,0 +1,717 @@ +use std::{ + collections::HashMap, + pin::Pin, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + task::{Context, Poll}, + time::Duration, +}; + +use crate::{ + channels::{ChannelDescriptor, ParadiseChannelKind, ALL_CHANNELS}, + client::RadioParadiseClient, + radio_paradise_stream_source::RadioParadiseStreamSource, +}; +use anyhow::{anyhow, Result}; +use pmoaudio::{nodes::DEFAULT_CHANNEL_SIZE, AudioPipelineNode}; +use pmoaudio_ext::{ + FlacCacheSink, FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, + OggFlacStreamHandle, PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink, + TrackBoundaryCoverNode, StreamingSinkOptions, +}; +use pmoaudiocache::Cache as AudioCache; +use pmocovers::Cache as CoverCache; +use pmoflac::EncoderOptions; +use pmoplaylist::WriteHandle; +use thiserror::Error; +use tokio::io::{AsyncRead, ReadBuf}; +use tokio::sync::Notify; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; + +/// Configuration pour un canal Radio Paradise. +#[derive(Clone, Debug)] +pub struct ParadiseStreamChannelConfig { + /// Durée maximale (en secondes) d'avance acceptée par le broadcast. + pub max_lead_seconds: f64, + pub flac_options: StreamingSinkOptions, + pub ogg_options: StreamingSinkOptions, + pub server_base_url: Option, +} + +impl Default for ParadiseStreamChannelConfig { + fn default() -> Self { + Self { + max_lead_seconds: 1.0, + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } +} + +/// Options pour activer l'archivage/historique d'un canal. +pub struct ParadiseHistoryOptions { + pub audio_cache: Arc, + pub cover_cache: Arc, + pub playlist_id: String, + pub playlist_writer: WriteHandle, + pub collection: Option, + pub replay_max_lead_seconds: f64, +} + +/// Builder pratique pour configurer automatiquement les playlists historiques. +#[derive(Clone)] +pub struct ParadiseHistoryBuilder { + pub audio_cache: Arc, + pub cover_cache: Arc, + pub playlist_prefix: String, + pub playlist_title_prefix: Option, + pub max_history_tracks: Option, + pub collection_prefix: Option, + pub replay_max_lead_seconds: f64, +} + +impl ParadiseHistoryBuilder { + pub fn new(audio_cache: Arc, cover_cache: Arc) -> Self { + Self { + audio_cache, + cover_cache, + playlist_prefix: "radio-paradise-history".into(), + playlist_title_prefix: Some("Radio Paradise History".into()), + max_history_tracks: Some(500), + collection_prefix: Some("radio-paradise".into()), + replay_max_lead_seconds: 1.0, + } + } + + pub async fn build_for_channel( + &self, + descriptor: &ChannelDescriptor, + ) -> Result { + let playlist_id = format!("{}-{}", self.playlist_prefix, descriptor.slug); + let manager = pmoplaylist::PlaylistManager(); + let writer = manager + .get_persistent_write_handle(playlist_id.clone()) + .await?; + + if let Some(prefix) = &self.playlist_title_prefix { + let title = format!("{} - {}", prefix, descriptor.display_name); + writer.set_title(title).await?; + } + + if let Some(capacity) = self.max_history_tracks { + writer.set_capacity(Some(capacity)).await?; + } + + let collection = self + .collection_prefix + .as_ref() + .map(|prefix| format!("{}-{}", prefix, descriptor.slug)); + + Ok(ParadiseHistoryOptions { + audio_cache: self.audio_cache.clone(), + cover_cache: self.cover_cache.clone(), + playlist_id, + playlist_writer: writer, + collection, + replay_max_lead_seconds: self.replay_max_lead_seconds, + }) + } +} + +struct HistoryState { + playlist_id: String, + audio_cache: Arc, + replay_max_lead_seconds: f64, +} + +#[cfg(feature = "pmoconfig")] +impl ParadiseStreamChannelConfig { + pub fn from_config(cfg: &pmoconfig::Config, channel: ParadiseChannelKind) -> Self { + use serde_yaml::Value; + let path = [ + "sources", + "radio_paradise", + "channels", + channel.slug(), + "max_lead_seconds", + ]; + match cfg.get_value(&path) { + Ok(Value::Number(num)) => { + if let Some(v) = num.as_f64() { + Self { + max_lead_seconds: v.max(0.1), + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } else { + let default = Self::default(); + let _ = + cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + Ok(Value::String(s)) => { + if let Ok(v) = s.parse::() { + Self { + max_lead_seconds: v.max(0.1), + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } else { + let default = Self::default(); + let _ = + cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + _ => { + let default = Self::default(); + let _ = cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + } +} + +/// Stream complet (FLAC pur + OGG-FLAC) pour un canal Radio Paradise. +pub struct ParadiseStreamChannel { + descriptor: ChannelDescriptor, + state: Arc, + pipeline_handle: JoinHandle<()>, + feeder_handle: JoinHandle<()>, + history: Option, +} + +impl ParadiseStreamChannel { + /// Crée un canal avec client déjà configuré. + pub fn with_client( + descriptor: ChannelDescriptor, + client: RadioParadiseClient, + config: ParadiseStreamChannelConfig, + cover_cache: Option>, + history: Option, + ) -> Self { + let mut source = RadioParadiseStreamSource::new(client.clone()); + let block_handle = source.block_handle(); + + let (flac_sink, stream_handle) = StreamingFlacSink::with_options( + EncoderOptions::default(), + 16, + config.max_lead_seconds, + config.flac_options.clone(), + ); + let (ogg_sink, ogg_handle) = StreamingOggFlacSink::with_options( + EncoderOptions::default(), + 16, + config.max_lead_seconds, + config.ogg_options.clone(), + ); + + let mut downstream_children: Vec> = Vec::new(); + downstream_children.push(Box::new(flac_sink)); + downstream_children.push(Box::new(ogg_sink)); + + let mut history_state = None; + + if let Some(history_opts) = history { + let ParadiseHistoryOptions { + audio_cache, + cover_cache, + playlist_id, + playlist_writer, + collection, + replay_max_lead_seconds, + } = history_opts; + let mut cache_sink = FlacCacheSink::with_config( + audio_cache.clone(), + cover_cache, + DEFAULT_CHANNEL_SIZE, + EncoderOptions::default(), + collection, + ); + cache_sink.register_playlist(playlist_writer); + downstream_children.push(Box::new(cache_sink)); + history_state = Some(HistoryState { + playlist_id, + audio_cache, + replay_max_lead_seconds, + }); + } + + if let Some(cache) = cover_cache { + let mut cover_node = TrackBoundaryCoverNode::new(cache); + for child in downstream_children { + cover_node.register(child); + } + source.register(Box::new(cover_node)); + } else { + for child in downstream_children { + source.register(child); + } + } + stream_handle.set_auto_stop(false); + ogg_handle.set_auto_stop(false); + + let stop_token = CancellationToken::new(); + let pipeline_stop = stop_token.clone(); + let pipeline_handle = tokio::spawn(async move { + info!( + "RadioParadise stream pipeline started for channel {}", + descriptor.display_name + ); + if let Err(e) = Box::new(source).run(pipeline_stop).await { + error!( + "Pipeline error for channel {}: {}", + descriptor.display_name, e + ); + } + }); + + let state = Arc::new(ChannelState { + descriptor, + config, + client, + block_handle, + stream_handle, + ogg_handle, + active_clients: AtomicUsize::new(0), + activity_notify: Notify::new(), + stop_token, + }); + + let feeder_state = state.clone(); + let feeder_handle = tokio::spawn(async move { + feeder_state.run_scheduler().await; + }); + + Self { + descriptor, + state, + pipeline_handle, + feeder_handle, + history: history_state, + } + } + + /// Crée un canal en construisant automatiquement le client pour ce descriptor. + pub async fn new( + descriptor: ChannelDescriptor, + config: ParadiseStreamChannelConfig, + cover_cache: Option>, + history: Option, + ) -> Result { + let client = RadioParadiseClient::builder() + .channel(descriptor.id) + .build() + .await?; + Ok(Self::with_client( + descriptor, + client, + config, + cover_cache, + history, + )) + } + + /// S'abonne au flux FLAC pur. + pub fn subscribe_flac(&self) -> ChannelFlacStream { + self.state.on_client_added(); + let inner = self.state.stream_handle.subscribe_flac(); + ChannelFlacStream::new(inner, self.state.clone()) + } + + /// S'abonne au flux FLAC + ICY metadata. + pub fn subscribe_icy(&self) -> ChannelIcyStream { + self.state.on_client_added(); + let inner = self.state.stream_handle.subscribe_icy(); + ChannelIcyStream::new(inner, self.state.clone()) + } + + /// S'abonne au flux OGG-FLAC. + pub fn subscribe_ogg(&self) -> ChannelOggStream { + self.state.on_client_added(); + let inner = self.state.ogg_handle.subscribe(); + ChannelOggStream::new(inner, self.state.clone()) + } + + /// Snapshot des métadonnées actuelles. + pub async fn metadata(&self) -> MetadataSnapshot { + self.state.stream_handle.get_metadata().await + } + + /// Nombre de clients actifs. + pub fn active_clients(&self) -> usize { + self.state.active_clients.load(Ordering::SeqCst) + } + + pub fn descriptor(&self) -> ChannelDescriptor { + self.descriptor + } + + /// Lance un pipeline dédié pour rejouer l'historique (FLAC pur) pour un client. + pub async fn stream_history_flac( + &self, + client_id: &str, + ) -> Result { + let history = self + .history + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + tracing::info!( + "Starting historical FLAC replay for channel {} (client_id={})", + self.descriptor.display_name, + client_id + ); + + let reader = pmoplaylist::PlaylistManager() + .get_read_handle(&history.playlist_id) + .await + .map_err(|e| HistoryStreamError::Playlist(e.to_string()))?; + let mut source = PlaylistSource::new(reader, history.audio_cache.clone()); + let (flac_sink, handle) = StreamingFlacSink::with_options( + EncoderOptions::default(), + 16, + history.replay_max_lead_seconds, + self.state.config.flac_options.clone(), + ); + source.register(Box::new(flac_sink)); + let stop_token = CancellationToken::new(); + let mut pipeline_source = source; + let stop_clone = stop_token.clone(); + let pipeline = tokio::spawn(async move { + let _ = Box::new(pipeline_source).run(stop_clone).await; + }); + let stream = handle.subscribe_flac(); + Ok(HistoryFlacStream::new(stream, stop_token, pipeline)) + } + + /// Lance un pipeline dédié pour rejouer l'historique (OGG-FLAC) pour un client. + pub async fn stream_history_ogg( + &self, + client_id: &str, + ) -> Result { + let history = self + .history + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + tracing::info!( + "Starting historical OGG replay for channel {} (client_id={})", + self.descriptor.display_name, + client_id + ); + + let reader = pmoplaylist::PlaylistManager() + .get_read_handle(&history.playlist_id) + .await + .map_err(|e| HistoryStreamError::Playlist(e.to_string()))?; + let mut source = PlaylistSource::new(reader, history.audio_cache.clone()); + let (ogg_sink, handle) = StreamingOggFlacSink::with_options( + EncoderOptions::default(), + 16, + history.replay_max_lead_seconds, + self.state.config.ogg_options.clone(), + ); + source.register(Box::new(ogg_sink)); + let stop_token = CancellationToken::new(); + let mut pipeline_source = source; + let stop_clone = stop_token.clone(); + let pipeline = tokio::spawn(async move { + let _ = Box::new(pipeline_source).run(stop_clone).await; + }); + let stream = handle.subscribe(); + Ok(HistoryOggStream::new(stream, stop_token, pipeline)) + } +} + +impl Drop for ParadiseStreamChannel { + fn drop(&mut self) { + self.state.stop_token.cancel(); + self.pipeline_handle.abort(); + self.feeder_handle.abort(); + } +} + +struct ChannelState { + descriptor: ChannelDescriptor, + config: ParadiseStreamChannelConfig, + client: RadioParadiseClient, + block_handle: crate::radio_paradise_stream_source::BlockQueueHandle, + stream_handle: StreamHandle, + ogg_handle: OggFlacStreamHandle, + active_clients: AtomicUsize, + activity_notify: Notify, + stop_token: CancellationToken, +} + +impl ChannelState { + fn on_client_added(&self) { + if self.active_clients.fetch_add(1, Ordering::SeqCst) == 0 { + self.activity_notify.notify_one(); + } + } + + fn on_client_removed(&self) { + self.active_clients.fetch_sub(1, Ordering::SeqCst); + } + + async fn wait_for_clients(&self) -> bool { + while self.active_clients.load(Ordering::SeqCst) == 0 { + tokio::select! { + _ = self.stop_token.cancelled() => return false, + _ = self.activity_notify.notified() => {}, + } + } + true + } + + async fn run_scheduler(self: Arc) { + let mut backoff = Duration::from_secs(5); + loop { + if self.stop_token.is_cancelled() { + break; + } + + if !self.wait_for_clients().await { + break; + } + + match self.client.get_block(None).await { + Ok(block) => { + info!( + "Channel {} streaming block {}", + self.descriptor.display_name, block.event + ); + self.block_handle.enqueue(block.event); + let mut next_event = block.end_event; + + loop { + if self.stop_token.is_cancelled() { + return; + } + + if self.active_clients.load(Ordering::SeqCst) == 0 { + break; + } + + match self.client.get_block(Some(next_event)).await { + Ok(next_block) => { + self.block_handle.enqueue(next_block.event); + next_event = next_block.end_event; + backoff = Duration::from_secs(5); + } + Err(e) => { + warn!( + "Failed to fetch next block for channel {}: {}", + self.descriptor.display_name, e + ); + tokio::select! { + _ = self.stop_token.cancelled() => return, + _ = tokio::time::sleep(backoff) => {}, + } + backoff = (backoff * 2).min(Duration::from_secs(60)); + } + } + } + } + Err(e) => { + warn!( + "Failed to fetch current block for channel {}: {}", + self.descriptor.display_name, e + ); + tokio::select! { + _ = self.stop_token.cancelled() => break, + _ = tokio::time::sleep(backoff) => {}, + } + backoff = (backoff * 2).min(Duration::from_secs(60)); + } + } + } + } +} + +macro_rules! wrap_stream { + ($name:ident, $inner:ty) => { + pub struct $name { + inner: $inner, + state: Arc, + } + + impl $name { + fn new(inner: $inner, state: Arc) -> Self { + Self { inner, state } + } + } + + impl AsyncRead for $name { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } + } + + impl Drop for $name { + fn drop(&mut self) { + self.state.on_client_removed(); + } + } + }; +} + +wrap_stream!(ChannelFlacStream, FlacClientStream); +wrap_stream!(ChannelIcyStream, IcyClientStream); +wrap_stream!(ChannelOggStream, OggFlacClientStream); + +#[derive(Debug, Error)] +pub enum HistoryStreamError { + #[error("history replay not enabled for this channel")] + HistoryDisabled, + #[error("playlist error: {0}")] + Playlist(String), +} + +pub struct HistoryFlacStream { + inner: FlacClientStream, + stop_token: CancellationToken, + pipeline: Option>, +} + +impl HistoryFlacStream { + fn new( + inner: FlacClientStream, + stop_token: CancellationToken, + pipeline: JoinHandle<()>, + ) -> Self { + Self { + inner, + stop_token, + pipeline: Some(pipeline), + } + } +} + +impl AsyncRead for HistoryFlacStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl Unpin for HistoryFlacStream {} + +impl Drop for HistoryFlacStream { + fn drop(&mut self) { + self.stop_token.cancel(); + if let Some(handle) = self.pipeline.take() { + handle.abort(); + } + } +} + +pub struct HistoryOggStream { + inner: OggFlacClientStream, + stop_token: CancellationToken, + pipeline: Option>, +} + +impl HistoryOggStream { + fn new( + inner: OggFlacClientStream, + stop_token: CancellationToken, + pipeline: JoinHandle<()>, + ) -> Self { + Self { + inner, + stop_token, + pipeline: Some(pipeline), + } + } +} + +impl AsyncRead for HistoryOggStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl Unpin for HistoryOggStream {} + +impl Drop for HistoryOggStream { + fn drop(&mut self) { + self.stop_token.cancel(); + if let Some(handle) = self.pipeline.take() { + handle.abort(); + } + } +} + +/// Gestionnaire multi-canaux. +pub struct ParadiseChannelManager { + channels: HashMap>, +} + +impl ParadiseChannelManager { + pub fn new(channels: HashMap>) -> Self { + Self { channels } + } + + pub async fn with_defaults_with_cover_cache( + cover_cache: Option>, + history_builder: Option, + server_base_url: Option, + ) -> Result { + let mut map = HashMap::new(); + for descriptor in ALL_CHANNELS.iter().copied() { + let mut config = ParadiseStreamChannelConfig::default(); + config.server_base_url = server_base_url.clone(); + + let history_opts = if let Some(builder) = &history_builder { + Some( + builder + .build_for_channel(&descriptor) + .await + .map_err(|e| anyhow!("Failed to init history playlist: {}", e))?, + ) + } else { + None + }; + let channel = ParadiseStreamChannel::new( + descriptor, + config, + cover_cache.clone(), + history_opts, + ) + .await?; + map.insert(descriptor.id, Arc::new(channel)); + } + Ok(Self { channels: map }) + } + + pub async fn with_defaults() -> Result { + Self::with_defaults_with_cover_cache(None, None, None).await + } + + pub fn get(&self, id: u8) -> Option> { + self.channels.get(&id).cloned() + } + + pub fn iter(&self) -> impl Iterator> { + self.channels.values() + } +} diff --git a/pmoparadise/tests/integration_tests.rs b/pmoparadise/tests/integration_tests.rs index b14577ea..5173cc97 100644 --- a/pmoparadise/tests/integration_tests.rs +++ b/pmoparadise/tests/integration_tests.rs @@ -48,6 +48,7 @@ async fn test_get_current_block() { .and(path("/api/get_block")) .and(query_param("bitrate", "4")) .and(query_param("info", "true")) + .and(query_param("chan", "0")) .respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(1234, 5678))) .mount(&mock_server) .await; @@ -82,6 +83,7 @@ async fn test_get_specific_block() { .and(path("/api/get_block")) .and(query_param("bitrate", "4")) .and(query_param("info", "true")) + .and(query_param("chan", "0")) .and(query_param("event", "5678")) .respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(5678, 9012))) .mount(&mock_server) @@ -99,12 +101,38 @@ async fn test_get_specific_block() { assert_eq!(block.end_event, 9012); } +#[tokio::test] +async fn test_get_block_respects_channel() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/api/get_block")) + .and(query_param("bitrate", "4")) + .and(query_param("info", "true")) + .and(query_param("chan", "2")) + .respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(2222, 3333))) + .mount(&mock_server) + .await; + + let client = RadioParadiseClient::builder() + .api_base(format!("{}/api", mock_server.uri())) + .channel(2) + .build() + .await + .unwrap(); + + let block = client.get_block(None).await.unwrap(); + assert_eq!(block.event, 2222); + assert_eq!(block.end_event, 3333); +} + #[tokio::test] async fn test_now_playing() { let mock_server = MockServer::start().await; Mock::given(method("GET")) .and(path("/api/get_block")) + .and(query_param("chan", "0")) .respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(1234, 5678))) .mount(&mock_server) .await; @@ -133,6 +161,8 @@ async fn test_prefetch_next() { // First block Mock::given(method("GET")) + .and(path("/api/get_block")) + .and(query_param("chan", "0")) .and(query_param("event", "1234")) .respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(1234, 5678))) .mount(&mock_server) @@ -140,6 +170,8 @@ async fn test_prefetch_next() { // Next block Mock::given(method("GET")) + .and(path("/api/get_block")) + .and(query_param("chan", "0")) .and(query_param("event", "5678")) .respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(5678, 9012))) .mount(&mock_server) diff --git a/pmoparadise_011.txt b/pmoparadise_011.txt new file mode 100644 index 00000000..6c10f232 --- /dev/null +++ b/pmoparadise_011.txt @@ -0,0 +1,7970 @@ +------------ pmoparadise/src/channels.rs ---------- +//! Radio Paradise channel definitions +//! +//! This module defines the available Radio Paradise channels and their metadata. + +use std::str::FromStr; + +/// Logical identifier for a Radio Paradise channel. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ParadiseChannelKind { + Main, + Mellow, + Rock, + Eclectic, +} + +impl ParadiseChannelKind { + pub const fn id(self) -> u8 { + match self { + Self::Main => 0, + Self::Mellow => 1, + Self::Rock => 2, + Self::Eclectic => 3, + } + } + + pub const fn slug(self) -> &'static str { + match self { + Self::Main => "main", + Self::Mellow => "mellow", + Self::Rock => "rock", + Self::Eclectic => "eclectic", + } + } + + pub const fn display_name(self) -> &'static str { + match self { + Self::Main => "Main Mix", + Self::Mellow => "Mellow Mix", + Self::Rock => "Rock Mix", + Self::Eclectic => "Eclectic Mix", + } + } + + pub const fn description(self) -> &'static str { + match self { + Self::Main => "Eclectic mix of rock, world, electronica, and more", + Self::Mellow => "Mellower, less aggressive music", + Self::Rock => "Heavier, more guitar-driven music", + Self::Eclectic => "Curated worldwide selection", + } + } +} + +impl FromStr for ParadiseChannelKind { + type Err = anyhow::Error; + + fn from_str(s: &str) -> std::result::Result { + match s.to_ascii_lowercase().as_str() { + "main" | "0" => Ok(Self::Main), + "mellow" | "1" => Ok(Self::Mellow), + "rock" | "2" => Ok(Self::Rock), + "eclectic" | "3" => Ok(Self::Eclectic), + other => Err(anyhow::anyhow!("Unknown Radio Paradise channel: {}", other)), + } + } +} + +/// Metadata descriptor for a channel. +#[derive(Debug, Clone, Copy)] +pub struct ChannelDescriptor { + pub kind: ParadiseChannelKind, + pub id: u8, + pub slug: &'static str, + pub display_name: &'static str, + pub description: &'static str, +} + +impl ChannelDescriptor { + pub const fn new(kind: ParadiseChannelKind) -> Self { + Self { + id: kind.id(), + slug: kind.slug(), + display_name: kind.display_name(), + description: kind.description(), + kind, + } + } +} + +/// All available Radio Paradise channels +pub const ALL_CHANNELS: [ChannelDescriptor; 4] = [ + ChannelDescriptor::new(ParadiseChannelKind::Main), + ChannelDescriptor::new(ParadiseChannelKind::Mellow), + ChannelDescriptor::new(ParadiseChannelKind::Rock), + ChannelDescriptor::new(ParadiseChannelKind::Eclectic), +]; + +/// Returns the maximum valid channel ID +pub const fn max_channel_id() -> u8 { + (ALL_CHANNELS.len() - 1) as u8 +} + +/// Default maximum number of tracks to keep in history +/// +/// This is used as the default if not configured via pmoconfig. +/// Value: 100 tracks - represents ~5-8 hours of playback history +pub const HISTORY_DEFAULT_MAX_TRACKS: usize = 100; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_channel_ids() { + assert_eq!(ParadiseChannelKind::Main.id(), 0); + assert_eq!(ParadiseChannelKind::Mellow.id(), 1); + assert_eq!(ParadiseChannelKind::Rock.id(), 2); + assert_eq!(ParadiseChannelKind::Eclectic.id(), 3); + } + + #[test] + fn test_max_channel_id() { + assert_eq!(max_channel_id(), 3); + } + + #[test] + fn test_all_channels_length() { + assert_eq!(ALL_CHANNELS.len(), 4); + } + + #[test] + fn test_channel_from_str() { + assert!(matches!( + "main".parse::(), + Ok(ParadiseChannelKind::Main) + )); + assert!(matches!( + "0".parse::(), + Ok(ParadiseChannelKind::Main) + )); + assert!("invalid".parse::().is_err()); + } +} +-------End of pmoparadise/src/channels.rs --------- + +------------ pmoparadise/src/client.rs ---------- +//! HTTP client for Radio Paradise API + +use crate::error::{Error, Result}; +use crate::models::{Block, EventId, NowPlaying}; +use reqwest::Client; +use std::time::Duration; +use url::Url; + +/// Default Radio Paradise API base URL +pub const DEFAULT_API_BASE: &str = "https://api.radioparadise.com/api"; + +/// Default block base URL (channel is appended) +pub const DEFAULT_BLOCK_BASE: &str = "https://apps.radioparadise.com/blocks/chan"; + +/// Default image base URL +pub const DEFAULT_IMAGE_BASE: &str = "https://img.radioparadise.com/"; + +/// Default timeout for metadata HTTP requests +pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; + +/// Default timeout for large block downloads/streams +/// IMPORTANT: Radio Paradise blocks can be ~20 minutes long, and with backpressure +/// from the audio pipeline, the HTTP stream must stay open for the entire duration. +/// Setting this to 2 hours to safely handle even the longest blocks. +pub const DEFAULT_BLOCK_TIMEOUT_SECS: u64 = 7200; // 2 hours + +/// Default User-Agent +pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0"; + +/// Default channel (0 = main mix) +pub const DEFAULT_CHANNEL: u8 = 0; + +/// Radio Paradise HTTP client +/// +/// This client provides access to Radio Paradise's streaming API, +/// including metadata retrieval and block streaming. +/// +/// # Example +/// +/// ```no_run +/// use pmoparadise::RadioParadiseClient; +/// +/// #[tokio::main] +/// async fn main() -> Result<(), Box> { +/// let client = RadioParadiseClient::new().await?; +/// let now_playing = client.now_playing().await?; +/// println!("Now playing: {} - {}", +/// now_playing.current_song.as_ref().unwrap().artist, +/// now_playing.current_song.as_ref().unwrap().title); +/// Ok(()) +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct RadioParadiseClient { + pub(crate) client: Client, + api_base: String, + channel: u8, + pub(crate) request_timeout: Duration, + pub(crate) block_timeout: Duration, + next_block_url: Option, +} + +impl RadioParadiseClient { + /// Create a new client with default settings + /// + /// Uses FLAC quality and channel 0 (main mix) + pub async fn new() -> Result { + Self::builder().build().await + } + + /// Create a builder for configuring the client + pub fn builder() -> ClientBuilder { + ClientBuilder::default() + } + + /// Create a client with a custom reqwest::Client + /// + /// Useful for sharing HTTP connection pools or custom proxy settings + /// + /// Note: Uses default settings (channel 0, default timeouts). + /// For more control, use `ClientBuilder::default().client(client).build()`. + pub fn with_client(client: Client) -> Self { + Self { + client, + api_base: DEFAULT_API_BASE.to_string(), + channel: DEFAULT_CHANNEL, + request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), + block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS), + next_block_url: None, + } + } + + /// Get the current channel (0 = main mix) + pub fn channel(&self) -> u8 { + self.channel + } + + /// Get the block base URL for this client's channel + pub fn block_base(&self) -> String { + format!("{}/{}", DEFAULT_BLOCK_BASE, self.channel) + } + + /// Clone the client with a different channel while preserving other settings. + pub fn clone_with_channel(&self, channel: u8) -> Self { + let mut cloned = self.clone(); + cloned.channel = channel; + cloned.next_block_url = None; + cloned + } + + /// Get a block by event ID + /// + /// If `event` is None, returns the current block. + /// + /// # Arguments + /// + /// * `event` - Optional event ID to fetch a specific block + /// + /// # Example + /// + /// ```no_run + /// # use pmoparadise::RadioParadiseClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let client = RadioParadiseClient::new().await?; + /// + /// // Get current block + /// let current = client.get_block(None).await?; + /// println!("Current block: {} songs", current.song_count()); + /// + /// // Get next block + /// let next = client.get_block(Some(current.end_event)).await?; + /// println!("Next block: {} songs", next.song_count()); + /// # Ok(()) + /// # } + /// ``` + pub async fn get_block(&self, event: Option) -> Result { + let mut url = Url::parse(&format!("{}/get_block", self.api_base))?; + + url.query_pairs_mut() + .append_pair("bitrate", "4") // FLAC lossless + .append_pair("info", "true") + // RP API expects `chan` rather than `channel` for channel selection. + .append_pair("chan", &self.channel.to_string()); + + if let Some(event_id) = event { + url.query_pairs_mut() + .append_pair("event", &event_id.to_string()); + } + + #[cfg(feature = "logging")] + tracing::debug!("Fetching block: {}", url); + + let response = self + .client + .get(url) + .timeout(self.request_timeout) + .send() + .await?; + + if !response.status().is_success() { + return Err(Error::other(format!( + "API returned error status: {}", + response.status() + ))); + } + + let mut block: Block = response.json().await?; + + // Normalize protocol-relative URLs from API (//img.radioparadise.com/) + if let Some(ref base) = block.image_base { + if base.starts_with("//") { + block.image_base = Some(format!("https:{}", base)); + } + } else { + // Fallback if API doesn't provide image_base (should never happen) + block.image_base = Some(DEFAULT_IMAGE_BASE.to_string()); + } + + #[cfg(feature = "logging")] + tracing::debug!( + "Received block: event={}, songs={}", + block.event, + block.song_count() + ); + + Ok(block) + } + + /// Get the currently playing block and song + /// + /// Returns a `NowPlaying` struct with the current block and + /// an estimate of which song is currently playing (first song). + /// + /// Note: Without real-time synchronization, we assume playback + /// starts from the beginning of the block. + pub async fn now_playing(&self) -> Result { + let block = self.get_block(None).await?; + Ok(NowPlaying::from_block(block)) + } + + /// Prefetch metadata for the next block + /// + /// Stores the next block URL internally for seamless transitions. + /// Call this before the current block finishes playing. + /// + /// # Arguments + /// + /// * `current` - The currently playing block + pub async fn prefetch_next(&mut self, current: &Block) -> Result<()> { + let next_block = self.get_block(Some(current.end_event)).await?; + self.next_block_url = Some(next_block.url.clone()); + + #[cfg(feature = "logging")] + tracing::debug!( + "Prefetched next block: {} -> {}", + current.end_event, + next_block.event + ); + + Ok(()) + } + + /// Get the prefetched next block URL + pub fn next_block_url(&self) -> Option<&str> { + self.next_block_url.as_deref() + } + + /// Clear the prefetched next block URL + pub fn clear_next_block(&mut self) { + self.next_block_url = None; + } + + /// Get the internal HTTP client + pub fn http_client(&self) -> &Client { + &self.client + } +} + +/// Builder for configuring a RadioParadiseClient +#[derive(Debug)] +pub struct ClientBuilder { + client: Option, + api_base: String, + channel: u8, + request_timeout: Duration, + block_timeout: Duration, + user_agent: String, + proxy: Option, +} + +impl Default for ClientBuilder { + fn default() -> Self { + Self { + client: None, + api_base: DEFAULT_API_BASE.to_string(), + channel: DEFAULT_CHANNEL, + request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), + block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS), + user_agent: DEFAULT_USER_AGENT.to_string(), + proxy: None, + } + } +} + +impl ClientBuilder { + /// Create a new builder with default settings + pub fn new() -> Self { + Self::default() + } + + /// Set a custom HTTP client + pub fn client(mut self, client: Client) -> Self { + self.client = Some(client); + self + } + + /// Set the API base URL + pub fn api_base(mut self, url: impl Into) -> Self { + self.api_base = url.into(); + self + } + + /// Set the channel (0 = main mix, 1 = mellow, 2 = rock, 3 = world/etc) + pub fn channel(mut self, channel: u8) -> Self { + self.channel = channel; + self + } + + /// Set the request timeout + pub fn timeout(mut self, timeout: Duration) -> Self { + self.request_timeout = timeout; + self + } + + /// Set the timeout specifically for block downloads/streams + pub fn block_timeout(mut self, timeout: Duration) -> Self { + self.block_timeout = timeout; + self + } + + /// Set a custom User-Agent header + pub fn user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = user_agent.into(); + self + } + + /// Set a proxy URL + pub fn proxy(mut self, proxy: impl Into) -> Self { + self.proxy = Some(proxy.into()); + self + } + + /// Build the client + pub async fn build(self) -> Result { + let client = if let Some(client) = self.client { + client + } else { + let mut builder = Client::builder() + .user_agent(&self.user_agent) + .timeout(self.request_timeout); + + if let Some(proxy_url) = &self.proxy { + let proxy = reqwest::Proxy::all(proxy_url) + .map_err(|e| Error::other(format!("Invalid proxy: {}", e)))?; + builder = builder.proxy(proxy); + } + + builder.build()? + }; + + Ok(RadioParadiseClient { + client, + api_base: self.api_base, + channel: self.channel, + request_timeout: self.request_timeout, + block_timeout: self.block_timeout, + next_block_url: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_builder_defaults() { + let builder = ClientBuilder::default(); + assert_eq!(builder.api_base, DEFAULT_API_BASE); + assert_eq!(builder.channel, DEFAULT_CHANNEL); + } +} +-------End of pmoparadise/src/client.rs --------- + +------------ pmoparadise/src/config_ext.rs ---------- +//! Extension pour intégrer Radio Paradise dans pmoconfig +//! +//! Ce module fournit le trait `RadioParadiseConfigExt` qui permet d'ajouter facilement +//! des méthodes de gestion de la configuration Radio Paradise à pmoconfig::Config. +//! +//! La configuration est minimale - seulement ce qui doit vraiment être configurable : +//! - Activation/désactivation de la source +//! +//! # Exemple +//! +//! ```rust,ignore +//! use pmoconfig::get_config; +//! use pmoparadise::RadioParadiseConfigExt; +//! +//! let config = get_config(); +//! +//! // Check if enabled +//! if !config.get_paradise_enabled()? { +//! println!("Radio Paradise is disabled"); +//! return Ok(()); +//! } +//! ``` + +use crate::{channels::ParadiseChannelKind, client::DEFAULT_CHANNEL}; +use anyhow::Result; +use pmoconfig::Config; +use serde_yaml::Value; + +/// Trait d'extension pour gérer la configuration Radio Paradise dans pmoconfig +/// +/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques +/// à la configuration minimale de Radio Paradise. +/// +/// # Auto-persist des valeurs par défaut +/// +/// Le getter persiste automatiquement la valeur par défaut dans la +/// configuration si elle n'existe pas encore. Cela permet à l'utilisateur de +/// voir la configuration effective dans le fichier YAML et de la modifier facilement. +/// +/// # Exemple +/// +/// ```rust,ignore +/// use pmoconfig::get_config; +/// use pmoparadise::RadioParadiseConfigExt; +/// +/// let config = get_config(); +/// +/// // Premier appel : persiste "enabled: true" dans la config et retourne true +/// let enabled = config.get_paradise_enabled()?; +/// +/// // L'utilisateur peut maintenant éditer cette valeur dans le fichier YAML +/// ``` +pub trait RadioParadiseConfigExt { + /// Vérifie si Radio Paradise est activé + /// + /// # Returns + /// + /// `true` si la source est activée (default), `false` sinon. + /// + /// Si la valeur n'existe pas dans la configuration, elle est automatiquement + /// définie à `true` (activé par défaut) et persistée. + /// + /// # Exemple + /// + /// ```rust,ignore + /// if config.get_paradise_enabled()? { + /// // Initialize Radio Paradise... + /// } + /// ``` + fn get_paradise_enabled(&self) -> Result; + + /// Active ou désactive Radio Paradise + /// + /// # Arguments + /// + /// * `enabled` - `true` pour activer, `false` pour désactiver + /// + /// # Exemple + /// + /// ```rust,ignore + /// // Disable Radio Paradise + /// config.set_paradise_enabled(false)?; + /// ``` + fn set_paradise_enabled(&self, enabled: bool) -> Result<()>; + + /// Récupère le channel par défaut + /// + /// # Returns + /// + /// Le channel par défaut (0 = Main Mix par défaut). + /// + /// Si la valeur n'existe pas dans la configuration, elle est automatiquement + /// définie à "main" et persistée. + /// + /// # Channels disponibles + /// + /// Peut être configuré comme chaîne de caractères ou nombre : + /// - "main" ou 0 = Main Mix (eclectic, diverse mix) + /// - "mellow" ou 1 = Mellow Mix (smooth, chilled music) + /// - "rock" ou 2 = Rock Mix (classic & modern rock) + /// - "eclectic" ou 3 = Eclectic Mix (global sounds) + /// + /// # Exemple de configuration YAML + /// + /// ```yaml + /// sources: + /// radio_paradise: + /// default_channel: mellow # or 1 + /// ``` + /// + /// # Exemple d'utilisation + /// + /// ```rust,ignore + /// let channel = config.get_paradise_default_channel()?; + /// let client = RadioParadiseClient::builder().channel(channel).build().await?; + /// ``` + fn get_paradise_default_channel(&self) -> Result; + + /// Définit le channel par défaut + /// + /// # Arguments + /// + /// * `channel` - Le channel (0-3) + /// + /// La valeur est stockée sous forme de nom convivial ("main", "mellow", etc.) + /// dans le fichier de configuration. + /// + /// # Exemple + /// + /// ```rust,ignore + /// use pmoparadise::channels::ParadiseChannelKind; + /// + /// // Use Mellow Mix by default + /// config.set_paradise_default_channel(ParadiseChannelKind::Mellow.id())?; + /// // Or simply: + /// config.set_paradise_default_channel(1)?; + /// ``` + fn set_paradise_default_channel(&self, channel: u8) -> Result<()>; +} + +impl RadioParadiseConfigExt for Config { + fn get_paradise_enabled(&self) -> Result { + match self.get_value(&["sources", "radio_paradise", "enabled"]) { + Ok(Value::Bool(b)) => Ok(b), + _ => { + // Use default (enabled) and persist it + self.set_paradise_enabled(true)?; + Ok(true) + } + } + } + + fn set_paradise_enabled(&self, enabled: bool) -> Result<()> { + self.set_value( + &["sources", "radio_paradise", "enabled"], + Value::Bool(enabled), + ) + } + + fn get_paradise_default_channel(&self) -> Result { + match self.get_value(&["sources", "radio_paradise", "default_channel"]) { + Ok(Value::String(s)) => { + // Try to parse as channel name (e.g., "main", "mellow", etc.) + match s.parse::() { + Ok(kind) => Ok(kind.id()), + Err(_) => { + // Invalid channel name, use default + self.set_paradise_default_channel(DEFAULT_CHANNEL)?; + Ok(DEFAULT_CHANNEL) + } + } + } + Ok(Value::Number(n)) => { + // Accept numeric channel ID (0-3) + if let Some(ch) = n.as_u64() { + if ch <= 3 { + Ok(ch as u8) + } else { + // Invalid channel number, use default + self.set_paradise_default_channel(DEFAULT_CHANNEL)?; + Ok(DEFAULT_CHANNEL) + } + } else { + // Not a valid number, use default + self.set_paradise_default_channel(DEFAULT_CHANNEL)?; + Ok(DEFAULT_CHANNEL) + } + } + _ => { + // Use default and persist it as "main" (user-friendly) + self.set_value( + &["sources", "radio_paradise", "default_channel"], + Value::String("main".to_string()), + )?; + Ok(DEFAULT_CHANNEL) + } + } + } + + fn set_paradise_default_channel(&self, channel: u8) -> Result<()> { + // Convert channel ID to user-friendly string name + let channel_name = match channel { + 0 => "main", + 1 => "mellow", + 2 => "rock", + 3 => "eclectic", + _ => return Err(anyhow::anyhow!("Invalid channel ID: {}", channel)), + }; + + self.set_value( + &["sources", "radio_paradise", "default_channel"], + Value::String(channel_name.to_string()), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_trait_exists() { + // Simple test to ensure the trait compiles + } +} +-------End of pmoparadise/src/config_ext.rs --------- + +------------ pmoparadise/src/error.rs ---------- +//! Error types for the Radio Paradise client + +/// Result type alias for Radio Paradise operations +pub type Result = std::result::Result; + +/// Errors that can occur when using the Radio Paradise client +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// HTTP request failed + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + + /// JSON parsing failed + #[error("JSON parsing failed: {0}")] + Json(#[from] serde_json::Error), + + /// Invalid URL + #[error("Invalid URL: {0}")] + InvalidUrl(#[from] url::ParseError), + + /// IO error + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + /// Invalid track index + #[error("Invalid track index: {0} (block has {1} tracks)")] + InvalidIndex(usize, usize), + + /// Invalid bitrate + #[error("Invalid bitrate value: {0} (must be 0-4)")] + InvalidBitrate(u8), + + /// Invalid event ID + #[error("Invalid event ID: {0}")] + InvalidEvent(String), + + /// Track not found in block + #[error("Track not found at index {0}")] + TrackNotFound(usize), + + /// Invalid elapsed time + #[error("Invalid elapsed time: {0}ms (exceeds block length)")] + InvalidElapsed(u64), + + /// Timeout error + #[error("Request timeout")] + Timeout, + + /// Generic error + #[error("{0}")] + Other(String), +} + +impl Error { + /// Create a generic error from a string + pub fn other(msg: impl Into) -> Self { + Self::Other(msg.into()) + } +} +-------End of pmoparadise/src/error.rs --------- + +------------ pmoparadise/src/lib.rs ---------- +//! # pmoparadise - Radio Paradise Client for Rust +//! +//! `pmoparadise` is an idiomatic Rust client library for accessing Radio Paradise's +//! streaming API. It provides metadata retrieval, block streaming, and optional +//! per-track extraction from FLAC blocks. +//! +//! ## Features +//! +//! - **Metadata Access**: Get current and historical block metadata with song information +//! - **Block Streaming**: Stream continuous FLAC blocks with automatic prefetching +//! - **FLAC Quality**: Lossless CD quality or better +//! - **Per-Track Extraction** (optional): Extract individual tracks from FLAC blocks +//! - **Async/Await**: Built on tokio for efficient async I/O +//! - **Type-Safe**: Strongly typed API with comprehensive error handling +//! +//! ## Quick Start +//! +//! ```no_run +//! use pmoparadise::RadioParadiseClient; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! // Create a client +//! let client = RadioParadiseClient::new().await?; +//! +//! // Get what's currently playing +//! let now_playing = client.now_playing().await?; +//! +//! if let Some(song) = &now_playing.current_song { +//! println!("Now Playing: {} - {}", song.artist, song.title); +//! if let Some(album) = &song.album { +//! println!("Album: {}", album); +//! } +//! } +//! +//! // Get all songs in the current block +//! for (index, song) in now_playing.block.songs_ordered() { +//! println!(" {}. {} - {} ({}s)", +//! index, +//! song.artist, +//! song.title, +//! song.duration / 1000); +//! } +//! +//! Ok(()) +//! } +//! ``` +//! +//! ## Streaming Blocks +//! +//! Radio Paradise broadcasts music in continuous "blocks" - each block is a single +//! FLAC file containing multiple songs with metadata indicating timing offsets. +//! +//! ```no_run +//! use pmoparadise::RadioParadiseClient; +//! use futures::StreamExt; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let client = RadioParadiseClient::new().await?; +//! let block = client.get_block(None).await?; +//! +//! // Stream the block +//! let mut stream = client.stream_block_from_metadata(&block).await?; +//! +//! while let Some(chunk) = stream.next().await { +//! let bytes = chunk?; +//! // Feed to audio player, write to file, etc. +//! } +//! +//! Ok(()) +//! } +//! ``` +//! +//! ## Per-Track Extraction (Feature: `per-track`) +//! +//! **Important**: This is an advanced feature with significant tradeoffs. +//! See the [`track`] module documentation for details. +//! +//! Most applications should stream blocks and use player-based seeking instead. +//! +//! ```no_run +//! # #[cfg(feature = "per-track")] +//! # { +//! use pmoparadise::RadioParadiseClient; +//! use std::path::Path; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let client = RadioParadiseClient::new().await?; +//! let block = client.get_block(None).await?; +//! +//! // Extract first track to WAV +//! let mut track = client.open_track_stream(&block, 0).await?; +//! track.export_wav(Path::new("track.wav"))?; +//! +//! // Or get position for player-based seeking (recommended) +//! let (start, duration) = client.track_position_seconds(&block, 0)?; +//! println!("Play with: mpv --start={} --length={} {}", start, duration, block.url); +//! +//! Ok(()) +//! } +//! # } +//! ``` +//! +//! ## Architecture +//! +//! The API is organized into several modules: +//! +//! - [`client`]: Main HTTP client for API access +//! - [`models`]: Data structures for blocks, songs, and metadata +//! - [`stream`]: Block streaming functionality +//! - [`track`]: Per-track extraction (feature-gated) +//! - [`error`]: Error types and result aliases +//! +//! ## Radio Paradise Block Format +//! +//! Radio Paradise streams use a block-based format: +//! +//! - Each block is a single FLAC audio file +//! - Blocks contain multiple songs (typically 10-15 minutes total) +//! - Metadata includes timing offsets (`song[i].elapsed` in ms) for each song +//! - Block URLs follow the pattern: `https://apps.radioparadise.com/blocks/chan/0/4/-.flac` +//! - The `end_event` of one block is the `event` of the next, enabling seamless transitions +//! +//! ## Best Practices +//! +//! ### For Continuous Playback +//! +//! 1. Get current block with `get_block(None)` +//! 2. Stream block with `stream_block_from_metadata()` +//! 3. Use `prefetch_next()` to prepare the next block +//! 4. When current block ends, stream the next block seamlessly +//! +//! ### For Per-Song Seeking +//! +//! **Recommended approach** (efficient): +//! ```bash +//! # Use your audio player's seek capability +//! mpv --start=123.5 --length=234.0 +//! ``` +//! +//! **Alternative** (resource-intensive, requires `per-track` feature): +//! - Download and decode block +//! - Extract specific track to PCM/WAV +//! +//! ## Error Handling +//! +//! All operations return `Result` with detailed error types: +//! +//! ```no_run +//! use pmoparadise::{RadioParadiseClient, Error}; +//! +//! #[tokio::main] +//! async fn main() { +//! let client = RadioParadiseClient::new().await.unwrap(); +//! +//! match client.get_block(Some(99999999)).await { +//! Ok(block) => println!("Got block: {}", block.event), +//! Err(Error::Http(e)) => eprintln!("Network error: {}", e), +//! Err(Error::Json(e)) => eprintln!("Parse error: {}", e), +//! Err(e) => eprintln!("Other error: {}", e), +//! } +//! } +//! ``` +//! +//! ## Audio Streaming (Feature: `pmoaudio`) +//! +//! For direct audio streaming and integration with pmoaudio pipelines, +//! use `RadioParadiseStreamSource`: +//! +//! ```no_run +//! # #[cfg(feature = "pmoaudio")] +//! # { +//! use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource}; +//! use pmoaudio::pipeline::Node; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let client = RadioParadiseClient::new().await?; +//! let stream_source = RadioParadiseStreamSource::new(client, None).await?; +//! +//! // Create audio node from stream source +//! let node = Node::from_logic(stream_source); +//! +//! // Use in pmoaudio pipeline... +//! +//! Ok(()) +//! } +//! # } +//! ``` +//! +//! **RadioParadiseStreamSource**: +//! - Downloads and decodes FLAC blocks in real-time +//! - Automatically detects bit depth (16/24/32-bit) +//! - Inserts track boundaries with metadata +//! - Integrates seamlessly with pmoaudio pipelines +//! +//! ## Cargo Features +//! +//! - `default`: Standard metadata and streaming (no FLAC decoding) +//! - `per-track`: Enable FLAC decoding and per-track extraction (adds `claxon`, `hound`, `tempfile`) +//! - `pmoserver`: Enable REST API extension for pmoserver integration (adds `utoipa`, `axum`) +//! - `pmoaudio`: Enable RadioParadiseStreamSource for pmoaudio integration +//! - `pmoconfig`: Enable configuration integration with pmoconfig +//! - `server`: Enable RadioParadiseSource for UPnP ContentDirectory integration +//! +//! ## See Also +//! +//! - [Radio Paradise](https://radioparadise.com) - Official website +//! - [Radio Paradise API](https://api.radioparadise.com) - API documentation + +pub mod channels; +pub mod client; +pub mod error; +pub mod models; +pub mod source; + +#[cfg(feature = "pmoaudio")] +pub mod node_stats; + +#[cfg(feature = "pmoserver")] +pub mod pmoserver_ext; + +#[cfg(feature = "pmoconfig")] +pub mod config_ext; + +#[cfg(feature = "pmoaudio")] +pub mod radio_paradise_stream_source; + +#[cfg(feature = "pmoaudio")] +pub mod stream_channel; + +#[cfg(feature = "pmoaudio")] +pub mod playlist_feeder; + +// Re-exports for convenience +pub use client::{ClientBuilder, RadioParadiseClient}; +pub use error::{Error, Result}; +pub use models::{Block, DurationMs, EventId, NowPlaying, Song}; +pub use source::RadioParadiseSource; + +#[cfg(feature = "pmoaudio")] +pub use radio_paradise_stream_source::RadioParadiseStreamSource; + +#[cfg(feature = "pmoaudio")] +pub use playlist_feeder::{RadioParadisePlaylistFeeder, END_OF_BLOCKS_SIGNAL}; + +#[cfg(feature = "pmoaudio")] +pub use stream_channel::{ + HistoryFlacStream, HistoryOggStream, HistoryStreamError, ParadiseChannelManager, + ParadiseHistoryBuilder, ParadiseHistoryOptions, ParadiseStreamChannel, + ParadiseStreamChannelConfig, +}; + +#[cfg(feature = "pmoserver")] +pub use pmoserver_ext::{ + create_api_router, RadioParadiseApiDoc, RadioParadiseExt, RadioParadiseState, +}; + +#[cfg(feature = "pmoconfig")] +pub use config_ext::RadioParadiseConfigExt; + +// Version information +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version() { + assert!(!VERSION.is_empty()); + } +} +-------End of pmoparadise/src/lib.rs --------- + +------------ pmoparadise/src/models.rs ---------- +//! Data models for Radio Paradise API responses + +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Number; +use std::collections::HashMap; +use url::Url; + +/// Deserialize a string or number into a u64 +fn deserialize_string_or_u64<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + use serde::de::Error; + + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrU64 { + String(String), + Number(u64), + } + + match StringOrU64::deserialize(deserializer)? { + StringOrU64::String(s) => s.parse::().map_err(D::Error::custom), + StringOrU64::Number(n) => Ok(n), + } +} + +/// Deserialize a string or number into a f64, then convert to u64 milliseconds +fn deserialize_length<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + use serde::de::Error; + + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrNumber { + String(String), + Number(Number), + } + + fn to_milliseconds(value: f64) -> u64 { + if value >= 100_000.0 { + value.round() as u64 + } else { + (value * 1000.0).round() as u64 + } + } + + match StringOrNumber::deserialize(deserializer)? { + StringOrNumber::String(s) => { + let value = s.parse::().map_err(D::Error::custom)?; + Ok(to_milliseconds(value)) + } + StringOrNumber::Number(n) => { + if let Some(int_value) = n.as_u64() { + Ok(to_milliseconds(int_value as f64)) + } else if let Some(float_value) = n.as_f64() { + Ok(to_milliseconds(float_value)) + } else { + Err(D::Error::custom("Invalid number for block length")) + } + } + } +} + +/// Deserialize an optional string or number into Option +fn deserialize_optional_string_or_u32<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::Error; + + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrU32 { + String(String), + Number(u32), + } + + let opt = Option::::deserialize(deserializer)?; + match opt { + None => Ok(None), + Some(StringOrU32::String(s)) => { + if s.is_empty() { + Ok(None) + } else { + s.parse::().map(Some).map_err(D::Error::custom) + } + } + Some(StringOrU32::Number(n)) => Ok(Some(n)), + } +} + +/// Deserialize an optional string or number into Option +fn deserialize_optional_string_or_f32<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::Error; + + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrF32 { + String(String), + Float(f32), + Int(i32), + } + + let opt = Option::::deserialize(deserializer)?; + match opt { + None => Ok(None), + Some(StringOrF32::String(s)) => { + if s.is_empty() { + Ok(None) + } else { + s.parse::().map(Some).map_err(D::Error::custom) + } + } + Some(StringOrF32::Float(f)) => Ok(Some(f)), + Some(StringOrF32::Int(i)) => Ok(Some(i as f32)), + } +} + +/// Duration in milliseconds +pub type DurationMs = u64; + +/// Event ID for block identification +pub type EventId = u64; + +/// Information about a song/track within a block +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Song { + /// Artist name + pub artist: String, + + /// Song title + pub title: String, + + /// Album name (may be missing for promos/announcements) + #[serde(default)] + pub album: Option, + + /// Year of release + /// Note: API returns this as a string, we deserialize to u32 + #[serde(default, deserialize_with = "deserialize_optional_string_or_u32")] + pub year: Option, + + /// Elapsed time from start of block in milliseconds + pub elapsed: DurationMs, + + /// Duration of the track in milliseconds + pub duration: DurationMs, + + /// Cover image filename/path + #[serde(default)] + pub cover: Option, + + /// Rating (0-10) + /// Note: API returns this as a string, we deserialize to f32 + #[serde(default, deserialize_with = "deserialize_optional_string_or_f32")] + pub rating: Option, + + /// Gapless URL for individual song FLAC + /// This URL points to a FLAC file containing only this song + #[serde(default)] + pub gapless_url: Option, + + /// Scheduled playback time on Radio Paradise (Unix timestamp in milliseconds, UTC) + #[serde(default)] + pub sched_time_millis: Option, + + /// Radio Paradise song ID (unique identifier) + #[serde(default)] + pub song_id: Option, + + /// Radio Paradise artist ID (for building artist URLs) + #[serde(default)] + pub artist_id: Option, + + /// Large cover image path (best quality) + #[serde(default)] + pub cover_large: Option, + + /// Additional metadata + #[serde(flatten)] + pub extra: HashMap, +} + +impl Song { + /// Get the end time of this song in the block (elapsed + duration) + pub fn end_time_ms(&self) -> DurationMs { + self.elapsed + self.duration + } + + /// Check if a given timestamp (ms) falls within this song + pub fn contains_timestamp(&self, timestamp_ms: DurationMs) -> bool { + timestamp_ms >= self.elapsed && timestamp_ms < self.end_time_ms() + } + + /// Calcule le timestamp de fin de diffusion (sched_time + duration) + pub fn sched_end_time_ms(&self) -> Option { + self.sched_time_millis.map(|start| start + self.duration) + } + + /// Vérifie si la chanson est encore en lecture ou à venir + pub fn is_still_playing(&self, now_ms: u64) -> bool { + self.sched_end_time_ms() + .map(|end| end >= now_ms) + .unwrap_or(false) + } +} + +/// Image information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageInfo { + /// Base URL for images + pub base: String, +} + +/// A block of songs from Radio Paradise +/// +/// Radio Paradise streams music in "blocks" - continuous FLAC files +/// containing multiple songs. Each block contains metadata about all +/// songs within it and timing information for seeking. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Block { + /// Event ID for this block (start event) + /// Note: API returns this as a string, we deserialize to u64 + #[serde(deserialize_with = "deserialize_string_or_u64")] + pub event: EventId, + + /// Event ID for the next block (end event) + /// Note: API returns this as a string, we deserialize to u64 + #[serde(deserialize_with = "deserialize_string_or_u64")] + pub end_event: EventId, + + /// Total length of the block in milliseconds + /// Note: API returns this as a string in seconds (e.g., "1715.54"), we convert to ms + #[serde(deserialize_with = "deserialize_length")] + pub length: DurationMs, + + /// URL to stream this block + pub url: String, + + /// Base URL for cover images + #[serde(default)] + pub image_base: Option, + + /// Scheduled start time for this block (Unix timestamp in milliseconds, UTC) + #[serde(default)] + pub sched_time_millis: Option, + + /// Map of song index (as string) to Song metadata + /// Keys are "0", "1", "2", etc. + #[serde(default)] + pub song: HashMap, + + /// Additional metadata + #[serde(flatten)] + pub extra: HashMap, +} + +impl Block { + /// Scheduled start time in milliseconds if available. + pub fn start_time_millis(&self) -> Option { + if let Some(ts) = self.sched_time_millis { + return Some(ts); + } + self.songs_ordered() + .into_iter() + .find_map(|(_, song)| song.sched_time_millis) + } + + /// Get songs in order by index + pub fn songs_ordered(&self) -> Vec<(usize, &Song)> { + let mut songs: Vec<_> = self + .song + .iter() + .filter_map(|(k, v)| k.parse::().ok().map(|idx| (idx, v))) + .collect(); + songs.sort_by_key(|(idx, _)| *idx); + songs + } + + /// Get a song by index + pub fn get_song(&self, index: usize) -> Option<&Song> { + self.song.get(&index.to_string()) + } + + /// Get the number of songs in this block + pub fn song_count(&self) -> usize { + self.song.len() + } + + /// Get the full URL for a cover image + pub fn cover_url(&self, cover_path: &str) -> Option { + let base = self.image_base.as_ref()?; + let base_url = Url::parse(base).ok()?; + base_url.join(cover_path).ok().map(|url| url.to_string()) + } + + /// Find which song is playing at a given timestamp (ms from block start) + pub fn song_at_timestamp(&self, timestamp_ms: DurationMs) -> Option<(usize, &Song)> { + self.songs_ordered() + .into_iter() + .find(|(_, song)| song.contains_timestamp(timestamp_ms)) + } + + /// Parse the block URL to get start and end event IDs + /// + /// Block URLs follow the pattern: + /// `https://apps.radioparadise.com/blocks/chan/0/4/-.flac` + pub fn parse_url_events(&self) -> Option<(EventId, EventId)> { + let url_path = self.url.split('/').last()?; + let filename = url_path.strip_suffix(".flac")?; + let mut parts = filename.split('-'); + let start = parts.next()?.parse::().ok()?; + let end = parts.next()?.parse::().ok()?; + Some((start, end)) + } +} + +/// Currently playing information +#[derive(Debug, Clone)] +pub struct NowPlaying { + /// The current block + pub block: Block, + + /// Current song index (if determinable) + pub current_song_index: Option, + + /// Current song + pub current_song: Option, + + /// Approximate elapsed time in current block (ms) + /// Note: This is estimated and may not be perfectly accurate + pub block_elapsed_ms: Option, +} + +impl NowPlaying { + /// Create from a block (assumes starting from beginning) + pub fn from_block(block: Block) -> Self { + let (current_song_index, current_song) = block + .get_song(0) + .map(|s| (Some(0), Some(s.clone()))) + .unwrap_or((None, None)); + + Self { + block, + current_song_index, + current_song, + block_elapsed_ms: Some(0), + } + } + + /// Get URL for the current block stream + pub fn stream_url(&self) -> &str { + &self.block.url + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_song_timing() { + let song = Song { + artist: "Test Artist".to_string(), + title: "Test Song".to_string(), + album: Some("Test Album".to_string()), + year: Some(2024), + elapsed: 1000, + duration: 5000, + cover: None, + rating: None, + extra: HashMap::new(), + gapless_url: Some("http://example.com/song.flac".into()), + sched_time_millis: Some(1_700_000_000_000), + song_id: Some("song-id".into()), + artist_id: Some("artist-id".into()), + cover_large: Some("cover-large.jpg".into()), + }; + + assert_eq!(song.end_time_ms(), 6000); + assert!(song.contains_timestamp(3000)); + assert!(!song.contains_timestamp(7000)); + assert!(!song.contains_timestamp(500)); + } + + #[test] + fn test_block_parse() { + let json = r#"{ + "event": 1234, + "end_event": 5678, + "length": 900000, + "url": "https://apps.radioparadise.com/blocks/chan/0/4/1234-5678.flac", + "image_base": "https://img.radioparadise.com/covers/l/", + "song": { + "0": { + "artist": "Miles Davis", + "title": "So What", + "album": "Kind of Blue", + "year": 1959, + "elapsed": 0, + "duration": 540000, + "cover": "B00000I0JF.jpg" + }, + "1": { + "artist": "John Coltrane", + "title": "Giant Steps", + "album": "Giant Steps", + "year": 1960, + "elapsed": 540000, + "duration": 360000, + "cover": "B000002I4U.jpg" + } + } + }"#; + + let block: Block = serde_json::from_str(json).unwrap(); + assert_eq!(block.event, 1234); + assert_eq!(block.end_event, 5678); + assert_eq!(block.song_count(), 2); + + let songs = block.songs_ordered(); + assert_eq!(songs.len(), 2); + assert_eq!(songs[0].1.title, "So What"); + assert_eq!(songs[1].1.title, "Giant Steps"); + + let (start, end) = block.parse_url_events().unwrap(); + assert_eq!(start, 1234); + assert_eq!(end, 5678); + + let (idx, song) = block.song_at_timestamp(600000).unwrap(); + assert_eq!(idx, 1); + assert_eq!(song.title, "Giant Steps"); + } + + #[test] + fn test_block_length_from_seconds_string() { + let json = serde_json::json!({ + "event": 1, + "end_event": 2, + "length": "1715.54", + "url": "https://example.com/block.flac", + "song": {} + }); + + let block: Block = serde_json::from_value(json).unwrap(); + assert_eq!(block.length, 1_715_540); + } + + #[test] + fn test_block_length_from_seconds_integer() { + let json = serde_json::json!({ + "event": 1, + "end_event": 2, + "length": 1800, + "url": "https://example.com/block.flac", + "song": {} + }); + + let block: Block = serde_json::from_value(json).unwrap(); + assert_eq!(block.length, 1_800_000); + } + + #[test] + fn test_block_length_from_milliseconds_integer() { + let json = serde_json::json!({ + "event": 1, + "end_event": 2, + "length": 900_000, + "url": "https://example.com/block.flac", + "song": {} + }); + + let block: Block = serde_json::from_value(json).unwrap(); + assert_eq!(block.length, 900_000); + } + + #[test] + fn test_block_length_from_milliseconds_float() { + let json = serde_json::json!({ + "event": 1, + "end_event": 2, + "length": 900_000.0, + "url": "https://example.com/block.flac", + "song": {} + }); + + let block: Block = serde_json::from_value(json).unwrap(); + assert_eq!(block.length, 900_000); + } +} +-------End of pmoparadise/src/models.rs --------- + +------------ pmoparadise/src/node_stats.rs ---------- +//! Node statistics tracking +//! +//! Provides detailed statistics for pipeline nodes to understand +//! data flow, backpressure behavior, and timing. + +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Instant; + +/// Statistics pour un node audio +#[derive(Debug)] +pub struct NodeStats { + /// Nom du node pour identification + pub name: String, + + /// Instant de démarrage du node + pub start_time: Instant, + + /// Nombre total de segments reçus + pub segments_received: AtomicUsize, + + /// Nombre total de segments envoyés + pub segments_sent: AtomicUsize, + + /// Nombre total de bytes traités + pub bytes_processed: AtomicU64, + + /// Nombre de fois où l'envoi a été bloqué (backpressure) + pub backpressure_blocks: AtomicUsize, + + /// Temps total passé bloqué en millisecondes + pub backpressure_time_ms: AtomicU64, + + /// Timestamp du premier segment (secondes) + pub first_segment_timestamp: AtomicU64, // Stocké comme u64 * 1000 pour précision + + /// Timestamp du dernier segment (secondes) + pub last_segment_timestamp: AtomicU64, // Stocké comme u64 * 1000 pour précision +} + +impl NodeStats { + pub fn new(name: impl Into) -> Arc { + Arc::new(Self { + name: name.into(), + start_time: Instant::now(), + segments_received: AtomicUsize::new(0), + segments_sent: AtomicUsize::new(0), + bytes_processed: AtomicU64::new(0), + backpressure_blocks: AtomicUsize::new(0), + backpressure_time_ms: AtomicU64::new(0), + first_segment_timestamp: AtomicU64::new(u64::MAX), + last_segment_timestamp: AtomicU64::new(0), + }) + } + + /// Enregistre la réception d'un segment + pub fn record_segment_received(&self, timestamp_sec: f64) { + self.segments_received.fetch_add(1, Ordering::Relaxed); + + let ts_millis = (timestamp_sec * 1000.0) as u64; + + // Update first timestamp (atomic min) + let mut current = self.first_segment_timestamp.load(Ordering::Relaxed); + while current > ts_millis { + match self.first_segment_timestamp.compare_exchange_weak( + current, + ts_millis, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(x) => current = x, + } + } + + // Update last timestamp (atomic max) + let mut current = self.last_segment_timestamp.load(Ordering::Relaxed); + while current < ts_millis { + match self.last_segment_timestamp.compare_exchange_weak( + current, + ts_millis, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(x) => current = x, + } + } + } + + /// Enregistre l'envoi d'un segment + pub fn record_segment_sent(&self, bytes: usize) { + self.segments_sent.fetch_add(1, Ordering::Relaxed); + self.bytes_processed + .fetch_add(bytes as u64, Ordering::Relaxed); + } + + /// Enregistre un événement de backpressure + pub fn record_backpressure(&self, duration_ms: u64) { + self.backpressure_blocks.fetch_add(1, Ordering::Relaxed); + self.backpressure_time_ms + .fetch_add(duration_ms, Ordering::Relaxed); + } + + /// Retourne un rapport formaté des statistiques + pub fn report(&self) -> String { + let elapsed = self.start_time.elapsed().as_secs_f64(); + let received = self.segments_received.load(Ordering::Relaxed); + let sent = self.segments_sent.load(Ordering::Relaxed); + let bytes = self.bytes_processed.load(Ordering::Relaxed); + let bp_blocks = self.backpressure_blocks.load(Ordering::Relaxed); + let bp_time_ms = self.backpressure_time_ms.load(Ordering::Relaxed); + + let first_ts = self.first_segment_timestamp.load(Ordering::Relaxed); + let last_ts = self.last_segment_timestamp.load(Ordering::Relaxed); + + let first_ts_sec = if first_ts == u64::MAX { + 0.0 + } else { + first_ts as f64 / 1000.0 + }; + let last_ts_sec = last_ts as f64 / 1000.0; + let audio_duration = last_ts_sec - first_ts_sec; + + let mb = bytes as f64 / 1_048_576.0; + let throughput_mbps = if elapsed > 0.0 { mb / elapsed } else { 0.0 }; + + format!( + "[{}]\n\ + Elapsed: {:.1}s | Received: {} | Sent: {} | Lost: {}\n\ + Data: {:.1} MB | Throughput: {:.2} MB/s\n\ + Audio: {:.1}s (first: {:.1}s, last: {:.1}s) | Real-time ratio: {:.1}%\n\ + Backpressure: {} blocks, {:.2}s total ({:.1}% of time)", + self.name, + elapsed, + received, + sent, + received.saturating_sub(sent), + mb, + throughput_mbps, + audio_duration, + first_ts_sec, + last_ts_sec, + if audio_duration > 0.0 { + (elapsed / audio_duration) * 100.0 + } else { + 0.0 + }, + bp_blocks, + bp_time_ms as f64 / 1000.0, + if elapsed > 0.0 { + (bp_time_ms as f64 / 1000.0 / elapsed) * 100.0 + } else { + 0.0 + } + ) + } +} +-------End of pmoparadise/src/node_stats.rs --------- + +------------ pmoparadise/src/playlist_feeder.rs ---------- +//! RadioParadisePlaylistFeeder - Télécharge et alimente une playlist à partir des blocs RP +//! +//! Architecture simplifiée utilisant les URLs gapless individuelles au lieu du bloc FLAC entier. + +use crate::{client::RadioParadiseClient, models::EventId}; +use anyhow::Result; +use pmoaudiocache::Cache as AudioCache; +use pmocovers::Cache as CoversCache; +use pmoplaylist::{PlaylistManager, ReadHandle, WriteHandle}; +use std::{ + collections::{HashMap, VecDeque}, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::Notify; + +/// Signal de fin de blocs +pub const END_OF_BLOCKS_SIGNAL: EventId = EventId::MAX; +const RECENT_BLOCKS_CACHE_SIZE: usize = 10; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BlockStatus { + Pending, + InProgress, + Done, +} + +struct RecentBlocks { + states: HashMap, + order: VecDeque, + capacity: usize, +} + +impl RecentBlocks { + fn new(capacity: usize) -> Self { + Self { + states: HashMap::new(), + order: VecDeque::new(), + capacity, + } + } + + fn try_enqueue(&mut self, event_id: EventId) -> bool { + match self.states.get(&event_id) { + Some(_) => false, + None => { + self.order.push_back(event_id); + self.states.insert(event_id, BlockStatus::Pending); + self.evict_old_done(); + true + } + } + } + + fn mark_in_progress(&mut self, event_id: EventId) { + if let Some(state) = self.states.get_mut(&event_id) { + *state = BlockStatus::InProgress; + } else { + self.order.push_back(event_id); + self.states.insert(event_id, BlockStatus::InProgress); + } + self.evict_old_done(); + } + + fn mark_done(&mut self, event_id: EventId) { + if let Some(state) = self.states.get_mut(&event_id) { + *state = BlockStatus::Done; + } else { + self.order.push_back(event_id); + self.states.insert(event_id, BlockStatus::Done); + } + self.evict_old_done(); + } + + fn purge(&mut self, event_id: EventId) { + self.states.remove(&event_id); + } + + fn evict_old_done(&mut self) { + while self.order.len() > self.capacity { + let Some(front) = self.order.front().copied() else { + break; + }; + match self.states.get(&front) { + Some(BlockStatus::Done) | None => { + self.order.pop_front(); + self.states.remove(&front); + } + Some(_) => break, + } + } + } +} + +/// Feeder qui télécharge les blocs RP et alimente une playlist +pub struct RadioParadisePlaylistFeeder { + client: RadioParadiseClient, + audio_cache: Arc, + covers_cache: Arc, + playlist_handle: Arc, + block_queue: Arc>>, + notify: Arc, + collection: Option, + recent_blocks: tokio::sync::Mutex, +} + +impl RadioParadisePlaylistFeeder { + /// Crée un nouveau feeder et retourne (feeder, read_handle) + pub async fn new( + client: RadioParadiseClient, + audio_cache: Arc, + covers_cache: Arc, + playlist_id: String, + collection: Option, + ) -> Result<(Self, ReadHandle)> { + let manager = PlaylistManager::get(); + let write_handle = manager + .create_persistent_playlist(playlist_id.clone()) + .await?; + let read_handle = manager.get_read_handle(&playlist_id).await?; + + Ok(( + Self { + client, + audio_cache, + covers_cache, + playlist_handle: Arc::new(write_handle), + block_queue: Arc::new(tokio::sync::Mutex::new(VecDeque::new())), + notify: Arc::new(Notify::new()), + collection, + recent_blocks: tokio::sync::Mutex::new(RecentBlocks::new(RECENT_BLOCKS_CACHE_SIZE)), + }, + read_handle, + )) + } + + /// Enqueue un bloc pour traitement + pub async fn push_block_id(&self, event_id: EventId) { + { + let mut recent = self.recent_blocks.lock().await; + if !recent.try_enqueue(event_id) { + tracing::debug!( + "RadioParadisePlaylistFeeder: Ignoring duplicate enqueue for block {}", + event_id + ); + return; + } + } + + { + let mut queue = self.block_queue.lock().await; + queue.push_back(event_id); + } + self.notify.notify_one(); + } + + async fn mark_in_progress(&self, event_id: EventId) { + let mut recent = self.recent_blocks.lock().await; + recent.mark_in_progress(event_id); + } + + async fn mark_done(&self, event_id: EventId) { + let mut recent = self.recent_blocks.lock().await; + recent.mark_done(event_id); + } + + async fn purge_block_state(&self, event_id: EventId) { + let mut recent = self.recent_blocks.lock().await; + recent.purge(event_id); + } + + pub(crate) async fn retry_block(&self, event_id: EventId) { + self.purge_block_state(event_id).await; + self.push_block_id(event_id).await; + } + + /// Boucle principale de traitement (à exécuter dans une tâche tokio) + pub async fn run(self: Arc) -> Result<()> { + loop { + // Attendre un bloc + let event_id = loop { + { + let mut queue = self.block_queue.lock().await; + if let Some(id) = queue.pop_front() { + if id == END_OF_BLOCKS_SIGNAL { + tracing::info!( + "RadioParadisePlaylistFeeder: END_OF_BLOCKS_SIGNAL received" + ); + return Ok(()); + } + break id; + } + } + self.notify.notified().await; + }; + + self.mark_in_progress(event_id).await; + + // Traiter le bloc + if let Err(e) = self.process_block(event_id).await { + tracing::error!( + "RadioParadisePlaylistFeeder: Failed to process block {}: {}", + event_id, + e + ); + self.purge_block_state(event_id).await; + tracing::debug!( + "RadioParadisePlaylistFeeder: Cleared block {} state after error", + event_id + ); + } else { + self.mark_done(event_id).await; + } + } + } + + /// Traite un bloc : fetch, filtre, download, push playlist + async fn process_block(&self, event_id: EventId) -> Result<()> { + tracing::info!("RadioParadisePlaylistFeeder: Processing block {}", event_id); + + // 1. Fetch le bloc + let block = self.client.get_block(Some(event_id)).await?; + + // 2. Timestamp actuel + let now_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64; + + // 3. Filtrer les chansons encore en lecture ou à venir + let songs = block.songs_ordered(); + let mut processed = 0; + + for (idx, song) in songs { + if !song.is_still_playing(now_ms) { + tracing::debug!( + "RadioParadisePlaylistFeeder: Skipping finished song {} - {} (ended at {})", + idx, + song.title, + song.sched_end_time_ms().unwrap_or(0) + ); + continue; + } + + // 4. Télécharger la chanson + let gapless_url = song + .gapless_url + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Missing gapless_url for song {}", idx))?; + + tracing::info!( + "RadioParadisePlaylistFeeder: Downloading song {} - {} by {}", + idx, + song.title, + song.artist + ); + + let pk = self + .audio_cache + .add_from_url(gapless_url, self.collection.as_deref()) + .await?; + + // 5. Sauvegarder les métadonnées + self.save_metadata(&pk, song, &block).await?; + + // 6. Calculer le TTL + let sched_end = song + .sched_end_time_ms() + .ok_or_else(|| anyhow::anyhow!("Cannot calculate TTL without sched_time_millis"))?; + let ttl_ms = sched_end.saturating_sub(now_ms); + let ttl = Duration::from_millis(ttl_ms); + + // 7. Push dans la playlist avec TTL + self.playlist_handle.push_with_ttl(pk.clone(), ttl).await?; + + tracing::info!( + "RadioParadisePlaylistFeeder: Added {} to playlist (pk={}, ttl={}s)", + song.title, + pk, + ttl.as_secs() + ); + + processed += 1; + } + + tracing::info!( + "RadioParadisePlaylistFeeder: Processed block {} - added {} songs to playlist", + event_id, + processed + ); + + Ok(()) + } + + /// Sauvegarde les métadonnées dans le cache audio + async fn save_metadata( + &self, + pk: &str, + song: &crate::models::Song, + block: &crate::models::Block, + ) -> Result<()> { + use pmoaudiocache::AudioTrackMetadataExt; + + let metadata = self.audio_cache.track_metadata(pk); + let mut meta = metadata.write().await; + + // Métadonnées de base + meta.set_title(Some(song.title.clone())).await?; + meta.set_artist(Some(song.artist.clone())).await?; + if let Some(ref album) = song.album { + meta.set_album(Some(album.clone())).await?; + } + if let Some(year) = song.year { + meta.set_year(Some(year)).await?; + } + + // Cover + if let Some(ref cover_large) = song.cover_large { + if let Some(cover_url) = block.cover_url(cover_large) { + meta.set_cover_url(Some(cover_url.clone())).await?; + + // Télécharger la cover + match self + .covers_cache + .add_from_url(&cover_url, self.collection.as_deref()) + .await + { + Ok(cover_pk) => { + meta.set_cover_pk(Some(cover_pk)).await?; + tracing::debug!( + "RadioParadisePlaylistFeeder: Cached cover for {}", + song.title + ); + } + Err(e) => { + tracing::warn!("RadioParadisePlaylistFeeder: Failed to cache cover: {}", e); + } + } + } + } + + Ok(()) + } +} +-------End of pmoparadise/src/playlist_feeder.rs --------- + +------------ pmoparadise/src/pmoserver_ext.rs ---------- +//! Extension pmoserver pour Radio Paradise +//! +//! Ce module fournit un trait d'extension pour ajouter facilement l'API Radio Paradise +//! à un serveur pmoserver. + +use crate::channels::{max_channel_id, ChannelDescriptor, ALL_CHANNELS}; +use crate::{Block, NowPlaying, RadioParadiseClient}; +use async_trait::async_trait; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::get, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::RwLock; +use utoipa::{OpenApi, ToSchema}; + +/// État partagé pour l'API Radio Paradise +#[derive(Clone)] +pub struct RadioParadiseState { + client: Arc>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct ParadiseQuery { + channel: Option, +} + +impl RadioParadiseState { + pub async fn new() -> anyhow::Result { + let client = RadioParadiseClient::new() + .await + .map_err(|e| anyhow::anyhow!("Failed to create RadioParadise client: {}", e))?; + + Ok(Self { + client: Arc::new(RwLock::new(client)), + }) + } + + async fn client_for_params( + &self, + params: &ParadiseQuery, + ) -> Result { + let base_client = { + let client_guard = self.client.read().await; + client_guard.clone() + }; + + let mut client = base_client; + + if let Some(channel) = params.channel { + if channel > max_channel_id() { + tracing::warn!("Invalid Radio Paradise channel requested: {}", channel); + return Err(StatusCode::BAD_REQUEST); + } + client = client.clone_with_channel(channel); + } + + Ok(client) + } +} + +/// Information sur un canal Radio Paradise +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ChannelInfo { + /// ID du canal (0-3) + pub id: u8, + /// Nom du canal + pub name: String, + /// Description + pub description: String, +} + +impl From<&ChannelDescriptor> for ChannelInfo { + fn from(descriptor: &ChannelDescriptor) -> Self { + Self { + id: descriptor.id, + name: descriptor.display_name.to_string(), + description: descriptor.description.to_string(), + } + } +} + +/// Réponse avec informations étendues sur le morceau en cours +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct NowPlayingResponse { + /// Event ID du block actuel + pub event: u64, + /// Event ID du prochain block + pub end_event: u64, + /// URL de streaming du block + pub stream_url: String, + /// Durée totale du block en ms + pub block_length_ms: u64, + /// Index du morceau actuel + pub current_song_index: Option, + /// Morceau actuel + pub current_song: Option, + /// Tous les morceaux du block + pub songs: Vec, +} + +/// Information sur un morceau +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SongInfo { + /// Index dans le block + pub index: usize, + /// Artiste + pub artist: String, + /// Titre + pub title: String, + /// Album + pub album: String, + /// Année + pub year: Option, + /// Temps écoulé depuis le début du block (ms) + pub elapsed_ms: u64, + /// Durée du morceau (ms) + pub duration_ms: u64, + /// URL de la pochette + pub cover_url: Option, + /// Note (0-10) + pub rating: Option, +} + +/// Réponse pour un block +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct BlockResponse { + /// Event ID du block + pub event: u64, + /// Event ID du prochain block + pub end_event: u64, + /// URL de streaming + pub url: String, + /// Durée totale (ms) + pub length_ms: u64, + /// Morceaux du block + pub songs: Vec, +} + +/// Réponse pour l'URL de streaming +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct StreamUrlResponse { + /// Event ID du block + #[schema(example = 1234567)] + pub event: u64, + /// URL de streaming FLAC + #[schema(example = "https://apps.radioparadise.com/blocks/chan/0/4/1234567-1234580.flac")] + pub stream_url: String, + /// Durée totale (ms) + #[schema(example = 900000)] + pub length_ms: u64, +} + +/// Réponse pour l'URL de pochette +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct CoverUrlResponse { + /// Event ID du block + #[schema(example = 1234567)] + pub event: u64, + /// Index du morceau + #[schema(example = 0)] + pub song_index: usize, + /// URL de la pochette (résolution complète) + #[schema(example = "https://img.radioparadise.com/covers/l/B00000I0JF.jpg")] + pub cover_url: Option, + /// Type de pochette: "cover" (petite) ou "cover_large" (grande) + #[schema(example = "cover_large")] + pub cover_type: String, +} + +impl From for BlockResponse { + fn from(block: Block) -> Self { + let songs = block + .songs_ordered() + .into_iter() + .map(|(index, song)| SongInfo { + index, + artist: song.artist.clone(), + title: song.title.clone(), + album: song.album.clone().unwrap_or_default(), + year: song.year, + elapsed_ms: song.elapsed, + duration_ms: song.duration, + cover_url: song.cover.as_ref().and_then(|c| block.cover_url(c)), + rating: song.rating, + }) + .collect(); + + Self { + event: block.event, + end_event: block.end_event, + url: block.url, + length_ms: block.length, + songs, + } + } +} + +impl From for NowPlayingResponse { + fn from(np: NowPlaying) -> Self { + let songs: Vec = np + .block + .songs_ordered() + .into_iter() + .map(|(index, song)| SongInfo { + index, + artist: song.artist.clone(), + title: song.title.clone(), + album: song.album.clone().unwrap_or_default(), + year: song.year, + elapsed_ms: song.elapsed, + duration_ms: song.duration, + cover_url: song.cover.as_ref().and_then(|c| np.block.cover_url(c)), + rating: song.rating, + }) + .collect(); + + let current_song = np.current_song.as_ref().and_then(|song| { + let index = np.current_song_index?; + Some(SongInfo { + index, + artist: song.artist.clone(), + title: song.title.clone(), + album: song.album.clone().unwrap_or_default(), + year: song.year, + elapsed_ms: song.elapsed, + duration_ms: song.duration, + cover_url: song.cover.as_ref().and_then(|c| np.block.cover_url(c)), + rating: song.rating, + }) + }); + + Self { + event: np.block.event, + end_event: np.block.end_event, + stream_url: np.block.url, + block_length_ms: np.block.length, + current_song_index: np.current_song_index, + current_song, + songs, + } + } +} + +/// GET /now-playing - Récupère le morceau en cours +#[utoipa::path( + get, + path = "/now-playing", + params( + ("channel" = Option, Query, description = "Channel ID (0-3)") + ), + responses( + (status = 200, description = "Morceau en cours", body = NowPlayingResponse), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_now_playing( + State(state): State, + Query(params): Query, +) -> Result, StatusCode> { + let client = state.client_for_params(¶ms).await?; + let now_playing = client.now_playing().await.map_err(|e| { + tracing::error!("Failed to fetch now playing from Radio Paradise: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(now_playing.into())) +} + +/// GET /block/current - Récupère le block actuel +#[utoipa::path( + get, + path = "/block/current", + params( + ("channel" = Option, Query, description = "Channel ID (0-3)") + ), + responses( + (status = 200, description = "Block actuel", body = BlockResponse), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_current_block( + State(state): State, + Query(params): Query, +) -> Result, StatusCode> { + let client = state.client_for_params(¶ms).await?; + let block = client.get_block(None).await.map_err(|e| { + tracing::error!("Failed to fetch current block from Radio Paradise: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(block.into())) +} + +/// GET /block/{event_id} - Récupère un block spécifique +#[utoipa::path( + get, + path = "/block/{event_id}", + params( + ("event_id" = u64, Path, description = "Event ID du block"), + ("channel" = Option, Query, description = "Channel ID (0-3)") + ), + responses( + (status = 200, description = "Block demandé", body = BlockResponse), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_block_by_id( + State(state): State, + Path(event_id): Path, + Query(params): Query, +) -> Result, StatusCode> { + let client = state.client_for_params(¶ms).await?; + let block = client.get_block(Some(event_id)).await.map_err(|e| { + tracing::error!( + "Failed to fetch block {} from Radio Paradise: {}", + event_id, + e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(block.into())) +} + +/// GET /channels - Liste les canaux disponibles +#[utoipa::path( + get, + path = "/channels", + responses( + (status = 200, description = "Liste des canaux", body = Vec) + ), + tag = "Radio Paradise" +)] +async fn get_channels() -> Json> { + let channels: Vec = ALL_CHANNELS.iter().map(Into::into).collect(); + Json(channels) +} + +/// GET /block/{event_id}/song/{index} - Récupère un morceau spécifique d'un block +#[utoipa::path( + get, + path = "/block/{event_id}/song/{index}", + params( + ("event_id" = u64, Path, description = "Event ID du block"), + ("index" = usize, Path, description = "Index du morceau (0-based)"), + ("channel" = Option, Query, description = "Channel ID (0-3)") + ), + responses( + (status = 200, description = "Morceau demandé", body = SongInfo), + (status = 404, description = "Morceau non trouvé"), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_song_by_index( + State(state): State, + Path((event_id, index)): Path<(u64, usize)>, + Query(params): Query, +) -> Result, StatusCode> { + let client = state.client_for_params(¶ms).await?; + let block = client.get_block(Some(event_id)).await.map_err(|e| { + tracing::error!( + "Failed to fetch block {} from Radio Paradise: {}", + event_id, + e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let song = block.get_song(index).ok_or_else(|| { + tracing::warn!("Song index {} not found in block {}", index, event_id); + StatusCode::NOT_FOUND + })?; + + let song_info = SongInfo { + index, + artist: song.artist.clone(), + title: song.title.clone(), + album: song.album.clone().unwrap_or_default(), + year: song.year, + elapsed_ms: song.elapsed, + duration_ms: song.duration, + cover_url: song.cover.as_ref().and_then(|c| block.cover_url(c)), + rating: song.rating, + }; + + Ok(Json(song_info)) +} + +/// GET /cover-url/{event_id}/{song_index} - Récupère l'URL de la pochette d'un morceau +/// +/// Utilise automatiquement cover_large si disponible, sinon cover en fallback +#[utoipa::path( + get, + path = "/cover-url/{event_id}/{song_index}", + params( + ("event_id" = u64, Path, description = "Event ID du block"), + ("song_index" = usize, Path, description = "Index du morceau (0-based)"), + ("channel" = Option, Query, description = "Channel ID (0-3)") + ), + responses( + (status = 200, description = "URL de la pochette avec fallback automatique", body = CoverUrlResponse), + (status = 404, description = "Morceau non trouvé"), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_cover_url( + State(state): State, + Path((event_id, song_index)): Path<(u64, usize)>, + Query(params): Query, +) -> Result, StatusCode> { + let client = state.client_for_params(¶ms).await?; + let block = client.get_block(Some(event_id)).await.map_err(|e| { + tracing::error!( + "Failed to fetch block {} from Radio Paradise: {}", + event_id, + e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let song = block.get_song(song_index).ok_or_else(|| { + tracing::warn!("Song index {} not found in block {}", song_index, event_id); + StatusCode::NOT_FOUND + })?; + + // Fallback: cover_large → cover → none + let (cover_url, cover_type) = if let Some(ref cover_large) = song.cover_large { + (block.cover_url(cover_large), "cover_large") + } else if let Some(ref cover) = song.cover { + (block.cover_url(cover), "cover") + } else { + (None, "none") + }; + + Ok(Json(CoverUrlResponse { + event: event_id, + song_index, + cover_url, + cover_type: cover_type.to_string(), + })) +} + +/// GET /stream-url/{event_id} - Récupère l'URL de streaming direct d'un block +#[utoipa::path( + get, + path = "/stream-url/{event_id}", + params( + ("event_id" = u64, Path, description = "Event ID du block (None pour le block actuel)"), + ("channel" = Option, Query, description = "Channel ID (0-3)") + ), + responses( + (status = 200, description = "URL de streaming", body = StreamUrlResponse), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_stream_url( + State(state): State, + Path(event_id): Path, + Query(params): Query, +) -> Result, StatusCode> { + let client = state.client_for_params(¶ms).await?; + let block = client.get_block(Some(event_id)).await.map_err(|e| { + tracing::error!( + "Failed to fetch block {} from Radio Paradise: {}", + event_id, + e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(StreamUrlResponse { + event: block.event, + stream_url: block.url, + length_ms: block.length, + })) +} + +/// Documentation OpenAPI pour l'API Radio Paradise +#[derive(OpenApi)] +#[openapi( + info( + title = "Radio Paradise API", + version = "1.0.0", + description = r#" +# API REST pour Radio Paradise + +Cette API permet d'accéder aux métadonnées et flux de Radio Paradise. + +## Fonctionnalités + +- **Métadonnées en temps réel** : Récupération du morceau en cours et des blocks +- **Multi-canaux** : Support des 4 canaux Radio Paradise (Main, Mellow, Rock, Eclectic) +- **Streaming FLAC** : Accès direct aux URLs de streaming haute qualité +- **Pochettes d'albums** : URLs complètes des couvertures (petite et grande taille) +- **Historique** : Accès aux blocks passés via event_id + +## Canaux disponibles + +- **0: Main Mix** - Eclectic mix of rock, world, electronica, and more +- **1: Mellow Mix** - Mellower, less aggressive music +- **2: Rock Mix** - Heavier, more guitar-driven music +- **3: Eclectic Mix** - Curated worldwide selection + +## Format des données + +### Blocks +Les blocks sont des fichiers FLAC continus contenant plusieurs morceaux. +Chaque block a un `event` (ID de début) et `end_event` (ID du prochain block). + +### Timing +- Tous les temps sont en millisecondes (ms) +- `elapsed_ms` : temps écoulé depuis le début du block +- `duration_ms` : durée du morceau + +## Exemples d'utilisation + +### Récupérer le morceau en cours +``` +GET /api/radioparadise/now-playing?channel=0 +``` + +### Récupérer un block spécifique +``` +GET /api/radioparadise/block/1234567?channel=0 +``` + +### Récupérer la pochette d'un morceau (avec fallback automatique) +``` +GET /api/radioparadise/cover-url/1234567/0?channel=0 +``` + "# + ), + paths( + get_now_playing, + get_current_block, + get_block_by_id, + get_channels, + get_song_by_index, + get_cover_url, + get_stream_url + ), + components(schemas( + NowPlayingResponse, + BlockResponse, + SongInfo, + ChannelInfo, + StreamUrlResponse, + CoverUrlResponse + )), + tags( + (name = "Radio Paradise", description = "Endpoints pour Radio Paradise") + ) +)] +pub struct RadioParadiseApiDoc; + +/// Crée le router pour l'API Radio Paradise +pub fn create_api_router(state: RadioParadiseState) -> Router { + Router::new() + .route("/now-playing", get(get_now_playing)) + .route("/block/current", get(get_current_block)) + .route("/block/{event_id}", get(get_block_by_id)) + .route("/block/{event_id}/song/{index}", get(get_song_by_index)) + .route("/cover-url/{event_id}/{song_index}", get(get_cover_url)) + .route("/stream-url/{event_id}", get(get_stream_url)) + .route("/channels", get(get_channels)) + .with_state(state) +} + +/// Trait d'extension pour pmoserver::Server +/// +/// Permet d'initialiser Radio Paradise avec routes HTTP complètes +#[cfg(feature = "pmoserver")] +#[async_trait] +pub trait RadioParadiseExt { + /// Initialise l'API Radio Paradise + /// + /// # Routes créées + /// + /// - API: `/api/radioparadise/*` + /// - `/now-playing` + /// - `/block/*` + /// - `/channels` + /// - Swagger: `/swagger-ui/radioparadise` + async fn init_radioparadise(&mut self) -> anyhow::Result; +} + +#[cfg(feature = "pmoserver")] +#[async_trait] +impl RadioParadiseExt for pmoserver::Server { + async fn init_radioparadise(&mut self) -> anyhow::Result { + let state = RadioParadiseState::new().await?; + + // Créer le router API + let api_router = create_api_router(state.clone()); + + // L'enregistrer avec OpenAPI + self.add_openapi(api_router, RadioParadiseApiDoc::openapi(), "radioparadise") + .await; + + Ok(state) + } +} +-------End of pmoparadise/src/pmoserver_ext.rs --------- + +------------ pmoparadise/src/radio_paradise_stream_source.rs ---------- +//! RadioParadiseStreamSource - Node audio pmoaudio pour Radio Paradise +//! +//! Ce node télécharge et décode les blocs FLAC de Radio Paradise en streaming, +//! avec insertion automatique des TrackBoundary au bon timing. + +use crate::{ + client::RadioParadiseClient, + models::{Block, EventId, Song}, + node_stats::NodeStats, +}; +use futures_util::StreamExt; +use pmoaudio::{ + nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS}, + pipeline::{send_to_children, send_to_children_with_timing, Node, NodeLogic}, + type_constraints::TypeRequirement, + AudioPipelineNode, AudioSegment, SyncMarker, I24, +}; +use pmoflac::decode_audio_stream; +use pmometadata::{MemoryTrackMetadata, TrackMetadata}; +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +use tokio::io::AsyncReadExt; +use tokio::sync::{mpsc, Notify, RwLock}; +use tokio_util::{io::StreamReader, sync::CancellationToken}; + +/// Signal spécial pour indiquer qu'il n'y aura plus de blocs +/// Quand ce blockid est poussé dans la queue, le source termine proprement +/// après avoir fini de traiter le bloc en cours +pub const END_OF_BLOCKS_SIGNAL: EventId = EventId::MAX; + +/// Nombre de blocs récents à mémoriser pour éviter les re-téléchargements +const RECENT_BLOCKS_CACHE_SIZE: usize = 10; + +/// Handle pour alimenter la queue de blocs pendant que la source tourne. +#[derive(Clone, Default)] +pub struct BlockQueueHandle { + queue: Arc>>, + notify: Arc, +} + +impl BlockQueueHandle { + fn new() -> Self { + Self { + queue: Arc::new(Mutex::new(VecDeque::new())), + notify: Arc::new(Notify::new()), + } + } + + /// Enfile un block pour traitement. + pub fn enqueue(&self, event_id: EventId) { + { + let mut queue = self.queue.lock().expect("block queue poisoned"); + queue.push_back(event_id); + } + self.notify.notify_one(); + } + + /// Retire le prochain block s'il existe. + fn pop(&self) -> Option { + let mut queue = self.queue.lock().expect("block queue poisoned"); + queue.pop_front() + } + + /// Nombre d'éléments en attente. + pub fn len(&self) -> usize { + let queue = self.queue.lock().expect("block queue poisoned"); + queue.len() + } + + fn snapshot(&self) -> Vec { + let queue = self.queue.lock().expect("block queue poisoned"); + queue.iter().copied().collect() + } + + fn front(&self) -> Option { + let queue = self.queue.lock().expect("block queue poisoned"); + queue.front().copied() + } + + fn back(&self) -> Option { + let queue = self.queue.lock().expect("block queue poisoned"); + queue.back().copied() + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// RadioParadiseStreamSourceLogic - Logique métier pure +// ═══════════════════════════════════════════════════════════════════════════ + +/// Logique pure de téléchargement et décodage des blocs Radio Paradise +pub struct RadioParadiseStreamSourceLogic { + client: RadioParadiseClient, + chunk_frames: usize, + recent_blocks: VecDeque, + block_queue: BlockQueueHandle, + stats: Arc, +} + +impl RadioParadiseStreamSourceLogic { + pub fn new(client: RadioParadiseClient, chunk_duration_ms: u32) -> Self { + let handle = BlockQueueHandle::new(); + Self::with_queue(client, chunk_duration_ms, handle) + } + + fn with_queue( + client: RadioParadiseClient, + chunk_duration_ms: u32, + block_queue: BlockQueueHandle, + ) -> Self { + // Calculer chunk_frames pour la durée cible (on suppose 44.1kHz) + let chunk_frames = ((chunk_duration_ms as f64 / 1000.0) * 44100.0) as usize; + + Self { + client, + chunk_frames, + recent_blocks: VecDeque::with_capacity(RECENT_BLOCKS_CACHE_SIZE), + block_queue, + stats: NodeStats::new("RadioParadiseStreamSource"), + } + } + + /// Ajoute un block ID à la file d'attente + pub fn push_block_id(&self, event_id: EventId) { + self.block_queue.enqueue(event_id); + } + + /// Vérifie si un bloc a été téléchargé récemment + fn is_recent_block(&self, event_id: EventId) -> bool { + self.recent_blocks.contains(&event_id) + } + + /// Marque un bloc comme récemment téléchargé (FIFO) + fn mark_block_downloaded(&mut self, event_id: EventId) { + // Retirer tous les éléments excédentaires (garantit <= CACHE_SIZE) + while self.recent_blocks.len() >= RECENT_BLOCKS_CACHE_SIZE { + self.recent_blocks.pop_front(); + } + + // Puis ajouter le nouveau bloc + self.recent_blocks.push_back(event_id); + } + + /// Télécharge et décode un bloc FLAC + /// Retourne (timestamp_final, instant_debut) pour permettre le timing correct + async fn download_and_decode_block( + &mut self, + block: &Block, + output: &[mpsc::Sender>], + stop_token: &CancellationToken, + order: &mut u64, + ) -> Result<(f64, Instant), AudioError> { + // Télécharger le FLAC + tracing::info!( + "Sending HTTP GET request for block FLAC (expected duration: {:.1}min, url: {})", + block.length as f64 / 60000.0, + block.url + ); + let response = self + .client + .client + .get(&block.url) + .timeout(self.client.block_timeout) + .send() + .await + .map_err(|e| AudioError::ProcessingError(format!("Block download failed: {}", e)))?; + + tracing::debug!("HTTP response received, status={}", response.status()); + if !response.status().is_success() { + return Err(AudioError::ProcessingError(format!( + "Block download returned status {}", + response.status() + ))); + } + + // Vérifier la taille du contenu si disponible + if let Some(content_length) = response.content_length() { + tracing::info!( + "HTTP Content-Length: {} bytes ({:.1} MB)", + content_length, + content_length as f64 / 1_048_576.0 + ); + } else { + tracing::warn!("HTTP response has no Content-Length header"); + } + + // Créer un stream reader + tracing::debug!("Creating byte stream reader"); + let byte_stream = response + .bytes_stream() + .map(|result| result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))); + let stream_reader = StreamReader::new(byte_stream); + tracing::debug!("Stream reader created"); + + // Décoder le FLAC + tracing::debug!("Decoding FLAC stream..."); + let mut decoder = decode_audio_stream(stream_reader) + .await + .map_err(|e| AudioError::ProcessingError(format!("FLAC decode failed: {}", e)))?; + + let stream_info = decoder.info().clone(); + let sample_rate = stream_info.sample_rate; + let bits_per_sample = stream_info.bits_per_sample; + tracing::debug!( + "FLAC decoder initialized: {}Hz, {} bits/sample", + sample_rate, + bits_per_sample + ); + + // Préparer les songs ordonnées pour tracking + let songs = block.songs_ordered(); + let mut song_index = 0; + let mut total_samples = 0u64; + tracing::debug!("Block has {} songs", songs.len()); + + // Noter l'instant de début AVANT d'envoyer TopZeroSync + // Ceci permet de synchroniser la durée réelle du bloc + let start_instant = Instant::now(); + + // Envoyer TopZeroSync au début du bloc + tracing::debug!("Sending TopZeroSync to {} outputs", output.len()); + let top_zero = Arc::new(AudioSegment { + order: *order, + timestamp_sec: 0.0, + segment: pmoaudio::_AudioSegment::Sync(Arc::new(SyncMarker::TopZeroSync)), + }); + self.send_to_children(output, top_zero).await?; + tracing::debug!("TopZeroSync sent"); + + // Envoyer TrackBoundary pour la première song AVANT le premier chunk audio + // Même si son elapsed > 0, cela garantit que FlacCacheSink a des métadonnées + // dès le début (sinon il attendrait indéfiniment un TrackBoundary) + let mut next_song: Option<(usize, &Song)> = if let Some((idx, song)) = songs.get(0).copied() + { + tracing::debug!( + "Sending TrackBoundary for first song (idx={}, elapsed={}ms) at timestamp 0", + idx, + song.elapsed + ); + let metadata = song_to_metadata(song, block).await; + let track_boundary = AudioSegment::new_track_boundary( + *order, 0.0, // timestamp = 0 au début du stream + metadata, + ); + self.send_to_children(output, track_boundary).await?; + song_index = 1; + // Le prochain TrackBoundary sera pour la deuxième song quand elapsed_ms >= song.elapsed + songs.get(1).copied() + } else { + None + }; + tracing::debug!("Starting audio chunk loop"); + + // Buffer pour lecture + let bytes_per_sample = (bits_per_sample / 8) as usize; + let frame_bytes = bytes_per_sample * 2; // stereo + let chunk_frames = self.chunk_frames; + let chunk_byte_len = chunk_frames * frame_bytes; + let mut read_buf = vec![0u8; chunk_byte_len * 2]; + let mut pending: Vec = Vec::with_capacity(chunk_byte_len * 2); + + // Traiter les chunks audio + let mut chunk_count = 0; + let mut total_bytes_decoded = 0u64; + let expected_duration_sec = block.length as f64 / 1000.0; + let mut stats_last_log = Instant::now(); + + loop { + // Vérifier stop_token + if stop_token.is_cancelled() { + // Retourner le timestamp actuel et start_instant si on est interrompu + let current_timestamp = total_samples as f64 / sample_rate as f64; + tracing::warn!( + "Block decode CANCELLED: sent {} chunks, {:.2}s duration ({:.1}% of expected {:.2}s), decoded {} bytes", + chunk_count, current_timestamp, + (current_timestamp / expected_duration_sec) * 100.0, + expected_duration_sec, total_bytes_decoded + ); + return Ok((current_timestamp, start_instant)); + } + + // Remplir le buffer + if pending.len() < chunk_byte_len { + let read = decoder + .read(&mut read_buf) + .await + .map_err(|e| AudioError::ProcessingError(format!("Read error: {}", e)))?; + + if read == 0 { + let actual_duration = total_samples as f64 / sample_rate as f64; + let percentage = (actual_duration / expected_duration_sec) * 100.0; + + if percentage < 95.0 { + tracing::error!( + "FLAC decode EOF PREMATURE: sent {} chunks, {:.2}s actual vs {:.2}s expected ({:.1}%), decoded {} bytes", + chunk_count, actual_duration, expected_duration_sec, percentage, total_bytes_decoded + ); + } else { + tracing::info!( + "FLAC decode EOF reached: sent {} chunks, {:.2}s duration ({:.1}% of expected), decoded {} bytes", + chunk_count, actual_duration, percentage, total_bytes_decoded + ); + } + break; // EOF + } + total_bytes_decoded += read as u64; + pending.extend_from_slice(&read_buf[..read]); + } + + if pending.is_empty() { + break; + } + + // Extraire un chunk + let frames_in_pending = pending.len() / frame_bytes; + let frames_to_emit = frames_in_pending.min(chunk_frames); + let take_bytes = frames_to_emit * frame_bytes; + let pcm_data = pending.drain(..take_bytes).collect::>(); + + // Calculer le nombre de frames (samples par canal) + let bytes_per_sample = (bits_per_sample / 8) as usize; + let chunk_len = (pcm_data.len() / (bytes_per_sample * 2)) as u64; // 2 = stereo + + // Vérifier si on doit insérer un TrackBoundary avant ce chunk + if let Some((idx, song)) = next_song { + let elapsed_ms = (total_samples * 1000) / sample_rate as u64; + + if elapsed_ms >= song.elapsed { + // Envoyer TrackBoundary AVANT le chunk (avec le même order) + tracing::debug!( + "Sending TrackBoundary for song {} at elapsed_ms={} (song.elapsed={}, timestamp_sec={:.2})", + idx, elapsed_ms, song.elapsed, (total_samples as f64 / sample_rate as f64) + ); + let metadata = song_to_metadata(song, block).await; + let timestamp_sec = total_samples as f64 / sample_rate as f64; + let track_boundary = + AudioSegment::new_track_boundary(*order, timestamp_sec, metadata); + self.send_to_children(output, track_boundary).await?; + + // Passer à la song suivante + song_index += 1; + next_song = songs.get(song_index).copied(); + tracing::debug!( + "Moved to next song, song_index={}, next_song present={}", + song_index, + next_song.is_some() + ); + } + } + + // Envoyer le chunk audio + let timestamp_sec = total_samples as f64 / sample_rate as f64; + if stats_last_log.elapsed() >= Duration::from_secs(1) { + let real_elapsed = start_instant.elapsed().as_secs_f64(); + tracing::debug!( + "RP timing: chunk={} ts={:.3}s real_elapsed={:.3}s delta={:.3}s chunk_len={} frames", + chunk_count, + timestamp_sec, + real_elapsed, + timestamp_sec - real_elapsed, + chunk_len + ); + stats_last_log = Instant::now(); + } + let audio_segment = pcm_to_audio_segment( + &pcm_data, + *order, + timestamp_sec, + sample_rate, + bits_per_sample, + )?; + self.send_to_children(output, audio_segment).await?; + + *order += 1; + total_samples += chunk_len; + chunk_count += 1; + } + + // Retourner le timestamp du dernier chunk (durée totale du bloc) et l'instant de début + let final_timestamp = total_samples as f64 / sample_rate as f64; + tracing::debug!( + "Block decode complete: {} samples, {:.2}s duration", + total_samples, + final_timestamp + ); + + Ok((final_timestamp, start_instant)) + } + + /// Envoie un segment à tous les enfants + async fn send_to_children( + &self, + output: &[mpsc::Sender>], + segment: Arc, + ) -> Result<(), AudioError> { + let segment_ts = segment.timestamp_sec; + self.stats.record_segment_received(segment_ts); + + let segment_bytes = match &segment.segment { + pmoaudio::_AudioSegment::Chunk(chunk) => chunk.len() * 2 * 4, + _ => 0, + }; + + send_to_children_with_timing( + std::any::type_name::(), + output, + segment, + |i, send_duration, capacity_before| { + tracing::trace!( + "send_to_children: Sending to child {} (channel capacity={}, timestamp={:.3}s)", + i, + capacity_before, + segment_ts + ); + + if send_duration.as_millis() > 10 { + let duration_ms = send_duration.as_millis() as u64; + self.stats.record_backpressure(duration_ms); + tracing::trace!( + "send_to_children: Send to child {} BLOCKED for {:.3}s (channel capacity before send={}, timestamp={:.3}s)", + i, + send_duration.as_secs_f64(), + capacity_before, + segment_ts + ); + } + + self.stats.record_segment_sent(segment_bytes); + }, + ) + .await?; + Ok(()) + } +} + +/// Convertit PCM bytes en AudioSegment +fn pcm_to_audio_segment( + pcm_data: &[u8], + order: u64, + timestamp_sec: f64, + sample_rate: u32, + bits_per_sample: u8, +) -> Result, AudioError> { + use pmoaudio::{AudioChunk, AudioChunkData, _AudioSegment}; + + let bytes_per_sample = (bits_per_sample / 8) as usize; + let channels = 2; // Stereo + let frame_bytes = bytes_per_sample * channels; + let frames = pcm_data.len() / frame_bytes; + + // Valider que la taille des données est correcte + if pcm_data.len() % frame_bytes != 0 { + return Err(AudioError::ProcessingError(format!( + "Invalid PCM data size: {} bytes is not a multiple of frame size {} ({}bit, {} channels)", + pcm_data.len(), + frame_bytes, + bits_per_sample, + channels + ))); + } + + let chunk = match bits_per_sample { + 16 => { + // Type I16 + let mut stereo = Vec::with_capacity(frames); + for frame_idx in 0..frames { + let base = frame_idx * frame_bytes; + let left = i16::from_le_bytes([pcm_data[base], pcm_data[base + 1]]); + let right = i16::from_le_bytes([pcm_data[base + 2], pcm_data[base + 3]]); + stereo.push([left, right]); + } + let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0); + AudioChunk::I16(chunk_data) + } + 24 => { + // Type I24 avec sign extension correcte + let mut stereo = Vec::with_capacity(frames); + for frame_idx in 0..frames { + let base = frame_idx * frame_bytes; + + // Left channel (bytes 0,1,2) avec sign extension + let left_i32 = { + let mut buf = [0u8; 4]; + buf[..3].copy_from_slice(&pcm_data[base..base + 3]); + // Sign extend si négatif + if pcm_data[base + 2] & 0x80 != 0 { + buf[3] = 0xFF; + } + i32::from_le_bytes(buf) + }; + let left = I24::new(left_i32).ok_or_else(|| { + AudioError::ProcessingError(format!("Invalid I24 value: {}", left_i32)) + })?; + + // Right channel (bytes 3,4,5) avec sign extension + let right_i32 = { + let mut buf = [0u8; 4]; + buf[..3].copy_from_slice(&pcm_data[base + 3..base + 6]); + // Sign extend si négatif + if pcm_data[base + 5] & 0x80 != 0 { + buf[3] = 0xFF; + } + i32::from_le_bytes(buf) + }; + let right = I24::new(right_i32).ok_or_else(|| { + AudioError::ProcessingError(format!("Invalid I24 value: {}", right_i32)) + })?; + + stereo.push([left, right]); + } + let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0); + AudioChunk::I24(chunk_data) + } + 32 => { + // Type I32 + let mut stereo = Vec::with_capacity(frames); + for frame_idx in 0..frames { + let base = frame_idx * frame_bytes; + let left = i32::from_le_bytes([ + pcm_data[base], + pcm_data[base + 1], + pcm_data[base + 2], + pcm_data[base + 3], + ]); + let right = i32::from_le_bytes([ + pcm_data[base + 4], + pcm_data[base + 5], + pcm_data[base + 6], + pcm_data[base + 7], + ]); + stereo.push([left, right]); + } + let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0); + AudioChunk::I32(chunk_data) + } + _ => { + return Err(AudioError::ProcessingError(format!( + "Unsupported bit depth: {}", + bits_per_sample + ))) + } + }; + + Ok(Arc::new(AudioSegment { + order, + timestamp_sec, + segment: _AudioSegment::Chunk(Arc::new(chunk)), + })) +} + +/// Convertit Song en TrackMetadata +/// +/// Configure toutes les métadonnées de manière asynchrone et attend que la configuration +/// soit terminée avant de retourner, garantissant que les métadonnées (y compris cover_url) +/// sont disponibles immédiatement pour les nodes suivants +async fn song_to_metadata(song: &Song, block: &Block) -> Arc> { + let metadata = MemoryTrackMetadata::new(); + let metadata_arc = Arc::new(RwLock::new(metadata)) as Arc>; + + // Cloner les données + let title = song.title.clone(); + let artist = song.artist.clone(); + let album = song.album.clone(); + let year = song.year; + let cover_url = song.cover.as_ref().and_then(|cover| block.cover_url(cover)); + + // Configurer les métadonnées de manière synchrone (mais async await) + { + let mut meta = metadata_arc.write().await; + + // Ces méthodes peuvent échouer (retournent Result), donc on log les erreurs + if let Err(e) = meta.set_title(Some(title)).await { + tracing::warn!("Failed to set title: {}", e); + } + if let Err(e) = meta.set_artist(Some(artist)).await { + tracing::warn!("Failed to set artist: {}", e); + } + if let Some(album) = album { + if let Err(e) = meta.set_album(Some(album)).await { + tracing::warn!("Failed to set album: {}", e); + } + } + if let Some(year) = year { + if let Err(e) = meta.set_year(Some(year)).await { + tracing::warn!("Failed to set year: {}", e); + } + } + if let Some(ref url) = cover_url { + tracing::debug!("RadioParadiseStreamSource: Setting cover_url to: {}", url); + if let Err(e) = meta.set_cover_url(Some(url.clone())).await { + tracing::warn!("Failed to set cover_url: {}", e); + } else { + tracing::debug!("RadioParadiseStreamSource: Successfully set cover_url"); + } + } else { + tracing::debug!("RadioParadiseStreamSource: No cover URL available for song"); + } + } + + metadata_arc +} + +#[async_trait::async_trait] +impl NodeLogic for RadioParadiseStreamSourceLogic { + async fn process( + &mut self, + _input: Option>>, + output: Vec>>, + stop_token: CancellationToken, + ) -> Result<(), AudioError> { + tracing::debug!( + "RadioParadiseStreamSource::process() started, block_queue has {} items", + self.block_queue.len() + ); + for (i, event_id) in self.block_queue.snapshot().iter().enumerate() { + tracing::debug!(" block_queue[{}] = {}", i, event_id); + } + + let mut order = 0u64; + let mut last_timestamp = 0.0; + let mut last_start_instant: Option = None; + + loop { + // Attendre un block ID depuis la queue (pas de timeout - mode idle) + tracing::debug!("Waiting for block_id from queue (idle mode, no timeout)..."); + let event_id = loop { + // Vérifier d'abord le stop_token + if stop_token.is_cancelled() { + tracing::info!("Stop token cancelled while waiting for block_id"); + break None; + } + + // Essayer de pop un event_id + if let Some(id) = self.block_queue.pop() { + tracing::debug!("Got event_id {} from queue", id); + + // Vérifier si c'est le signal de fin + if id == END_OF_BLOCKS_SIGNAL { + tracing::info!( + "Received END_OF_BLOCKS_SIGNAL, finishing after current block" + ); + break None; + } + + break Some(id); + } + + tracing::trace!("block_queue is empty, waiting for new events..."); + tokio::select! { + _ = stop_token.cancelled() => break None, + _ = self.block_queue.notify.notified() => {}, + _ = tokio::time::sleep(Duration::from_millis(100)) => {} + }; + }; + + // Si on n'a pas d'event_id, on termine + let event_id = match event_id { + Some(id) => id, + None => { + tracing::info!("No more blocks to process, exiting loop"); + break; + } + }; + + // Vérifier si déjà téléchargé récemment + if self.is_recent_block(event_id) { + tracing::debug!("Block {} was recently downloaded, skipping", event_id); + continue; + } + + // Récupérer les métadonnées du bloc + tracing::debug!("Fetching block metadata for event_id {}...", event_id); + let block = + self.client.get_block(Some(event_id)).await.map_err(|e| { + AudioError::ProcessingError(format!("Failed to get block: {}", e)) + })?; + tracing::debug!("Block metadata received: url={}", block.url); + + // Marquer comme téléchargé + self.mark_block_downloaded(event_id); + + // Télécharger et décoder le bloc + tracing::info!("Starting download and decode for block {}...", event_id); + let (block_duration, start_instant) = self + .download_and_decode_block(&block, &output, &stop_token, &mut order) + .await?; + last_timestamp = block_duration; + last_start_instant = Some(start_instant); + tracing::info!( + "Finished download and decode for block {} (duration: {:.2}s)", + event_id, + block_duration + ); + } + + // Envoyer EndOfStream avec le timestamp du dernier chunk + tracing::info!( + "Sending EndOfStream with timestamp {:.2}s to {} outputs", + last_timestamp, + output.len() + ); + let eos = AudioSegment::new_end_of_stream(order, last_timestamp); + send_to_children(std::any::type_name::(), &output, eos).await?; + + // IMPORTANT: Attendre que tous les channels soient fermés par les enfants + // Cela garantit que tous les chunks (y compris ceux en attente dans les buffers MPSC) + // ont été traités avant que nous ne fermions notre bout + tracing::info!("Waiting for all child nodes to close their channels..."); + for (i, tx) in output.iter().enumerate() { + tracing::debug!("Waiting for child {} to close channel...", i); + tx.closed().await; + tracing::debug!("Child {} channel closed", i); + } + tracing::info!("All child channels closed, pipeline complete"); + + if let Some(start_instant) = last_start_instant { + let total_elapsed = start_instant.elapsed().as_secs_f64(); + tracing::info!( + "Block processing complete: duration={:.2}s, total_elapsed={:.2}s ({:.1}% of real-time)", + last_timestamp, total_elapsed, (total_elapsed / last_timestamp) * 100.0 + ); + } + + // Log des statistiques finales + tracing::info!("\n{}", self.stats.report()); + + Ok(()) + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// RadioParadiseStreamSource - Wrapper utilisant Node +// ═══════════════════════════════════════════════════════════════════════════ + +pub struct RadioParadiseStreamSource { + inner: Node, + block_handle: BlockQueueHandle, +} + +impl RadioParadiseStreamSource { + /// Crée une nouvelle source Radio Paradise avec durée de chunk par défaut + pub fn new(client: RadioParadiseClient) -> Self { + Self::with_chunk_duration(client, DEFAULT_CHUNK_DURATION_MS as u32) + } + + /// Crée une nouvelle source avec durée de chunk personnalisée + pub fn with_chunk_duration(client: RadioParadiseClient, chunk_duration_ms: u32) -> Self { + let handle = BlockQueueHandle::new(); + let logic = + RadioParadiseStreamSourceLogic::with_queue(client, chunk_duration_ms, handle.clone()); + Self { + inner: Node::new_source(logic), + block_handle: handle, + } + } + + /// Ajoute un block ID à la file d'attente de téléchargement + pub fn push_block_id(&self, event_id: EventId) { + self.block_handle.enqueue(event_id); + } + + /// Retourne un handle permettant d'enfiler des blocks dynamiquement. + pub fn block_handle(&self) -> BlockQueueHandle { + self.block_handle.clone() + } +} + +#[async_trait::async_trait] +impl AudioPipelineNode for RadioParadiseStreamSource { + fn get_tx(&self) -> Option>> { + self.inner.get_tx() + } + + fn register(&mut self, child: Box) { + self.inner.register(child); + } + + async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { + Box::new(self.inner).run(stop_token).await + } +} + +impl TypedAudioNode for RadioParadiseStreamSource { + fn input_type(&self) -> Option { + None // Source node + } + + fn output_type(&self) -> Option { + // Radio Paradise FLAC peut être 16-bit, 24-bit, ou 32-bit + // La profondeur est détectée automatiquement depuis le header FLAC + Some(TypeRequirement::any_integer()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_client() -> RadioParadiseClient { + RadioParadiseClient::with_client(reqwest::Client::new()) + } + + #[test] + fn test_cache_fifo_basic() { + let client = create_test_client(); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + + // Ajouter 5 blocs + for i in 1..=5 { + logic.mark_block_downloaded(i); + } + + // Vérifier que tous sont dans le cache + for i in 1..=5 { + assert!(logic.is_recent_block(i), "Block {} should be in cache", i); + } + assert_eq!(logic.recent_blocks.len(), 5); + } + + #[test] + fn test_cache_fifo_exactly_10_elements() { + let client = create_test_client(); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + + // Ajouter exactement 10 blocs + for i in 1..=10 { + logic.mark_block_downloaded(i); + } + + // Vérifier qu'on a exactement 10 éléments + assert_eq!( + logic.recent_blocks.len(), + 10, + "Cache should have exactly 10 elements" + ); + + // Tous devraient être dans le cache + for i in 1..=10 { + assert!(logic.is_recent_block(i), "Block {} should be in cache", i); + } + } + + #[test] + fn test_cache_fifo_eviction_oldest() { + let client = create_test_client(); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + + // Remplir le cache avec 10 éléments (1..=10) + for i in 1..=10 { + logic.mark_block_downloaded(i); + } + + // Ajouter un 11ème élément + logic.mark_block_downloaded(11); + + // Le cache doit toujours avoir 10 éléments + assert_eq!( + logic.recent_blocks.len(), + 10, + "Cache should still have 10 elements" + ); + + // Le premier (plus ancien) doit avoir été évincé + assert!( + !logic.is_recent_block(1), + "Oldest block (1) should be evicted" + ); + + // Les éléments 2..=11 doivent être présents + for i in 2..=11 { + assert!(logic.is_recent_block(i), "Block {} should be in cache", i); + } + } + + #[test] + fn test_cache_fifo_multiple_evictions() { + let client = create_test_client(); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + + // Remplir avec 10 éléments + for i in 1..=10 { + logic.mark_block_downloaded(i); + } + + // Ajouter 5 éléments supplémentaires + for i in 11..=15 { + logic.mark_block_downloaded(i); + } + + // Toujours 10 éléments + assert_eq!( + logic.recent_blocks.len(), + 10, + "Cache should have 10 elements" + ); + + // Les 5 premiers doivent avoir été évincés + for i in 1..=5 { + assert!(!logic.is_recent_block(i), "Block {} should be evicted", i); + } + + // Les éléments 6..=15 doivent être présents + for i in 6..=15 { + assert!(logic.is_recent_block(i), "Block {} should be in cache", i); + } + } + + #[test] + fn test_cache_never_exceeds_capacity() { + let client = create_test_client(); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + + // Vérifier la capacité pré-allouée + assert_eq!(logic.recent_blocks.capacity(), RECENT_BLOCKS_CACHE_SIZE); + + // Ajouter beaucoup d'éléments + for i in 1..=100 { + logic.mark_block_downloaded(i); + + // À chaque itération, vérifier qu'on ne dépasse jamais 10 + assert!( + logic.recent_blocks.len() <= RECENT_BLOCKS_CACHE_SIZE, + "Cache size {} exceeded max {}", + logic.recent_blocks.len(), + RECENT_BLOCKS_CACHE_SIZE + ); + } + + // Finalement, on doit avoir exactement 10 éléments + assert_eq!(logic.recent_blocks.len(), 10); + + // Ce doivent être les 10 derniers (91..=100) + for i in 91..=100 { + assert!(logic.is_recent_block(i), "Block {} should be in cache", i); + } + } + + #[test] + fn test_cache_fifo_order_preserved() { + let client = create_test_client(); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + + // Ajouter 10 éléments + for i in 1..=10 { + logic.mark_block_downloaded(i); + } + + // Vérifier l'ordre dans la VecDeque (le front devrait être le plus ancien) + let front = logic.recent_blocks.front().copied(); + assert_eq!(front, Some(1), "Front should be the oldest element"); + + let back = logic.recent_blocks.back().copied(); + assert_eq!(back, Some(10), "Back should be the newest element"); + } + + #[test] + fn test_block_queue_push() { + let client = create_test_client(); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + + // Tester push_block_id + logic.push_block_id(100); + logic.push_block_id(200); + logic.push_block_id(300); + + assert_eq!(logic.block_queue.len(), 3); + assert_eq!(logic.block_queue.front(), Some(100)); + assert_eq!(logic.block_queue.back(), Some(300)); + } +} +-------End of pmoparadise/src/radio_paradise_stream_source.rs --------- + +------------ pmoparadise/src/source.rs ---------- +//! RadioParadiseSource - Implementation of MusicSource for Radio Paradise +//! +//! This module provides a UPnP ContentDirectory source for Radio Paradise, +//! exposing live streams and historical playlists for all 4 channels. + +use crate::channels::{ChannelDescriptor, ALL_CHANNELS}; +use pmosource::pmodidl::{Container, Item, Resource}; +use pmosource::{ + async_trait, AudioFormat, BrowseResult, MusicSource, MusicSourceError, Result, + SourceCapabilities, +}; +use std::fmt; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime}; +use tokio::sync::RwLock; + +/// Default Radio Paradise image (embedded in binary) +const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); + +#[cfg(feature = "playlist")] +const LIVE_PLAYLIST_MIN_READY_ITEMS: usize = 5; +#[cfg(feature = "playlist")] +const LIVE_PLAYLIST_READY_TIMEOUT: Duration = Duration::from_secs(10); +#[cfg(feature = "playlist")] +const LIVE_PLAYLIST_READY_POLL: Duration = Duration::from_millis(200); + +/// RadioParadiseSource - UPnP ContentDirectory source for Radio Paradise +/// +/// Provides access to: +/// - Live FLAC streams for all 4 channels (Main, Mellow, Rock, Eclectic) +/// - Historical playlists (FIFO) for each channel +/// +/// # Object ID Schema +/// +/// - Root: `radio-paradise` +/// - Channel container: `radio-paradise:channel:{slug}` +/// - Live stream item: `radio-paradise:channel:{slug}:live` +/// - Live playlist container: `radio-paradise:channel:{slug}:liveplaylist` +/// - Live playlist track: `radio-paradise:channel:{slug}:liveplaylist:track:{pk}` +/// - History container: `radio-paradise:channel:{slug}:history` +/// - History track: `radio-paradise:channel:{slug}:history:track:{pk}` +#[derive(Clone)] +pub struct RadioParadiseSource { + /// Base URL for streaming server (e.g., "http://localhost:8080") + base_url: String, + /// Update counter for change notifications + update_counter: Arc>, + /// Last change timestamp + last_change: Arc>, + /// Tokens des callbacks enregistrés auprès du PlaylistManager + callback_tokens: Arc>>, + /// Notifier optionnel pour signaler les mises à jour de conteneurs au ContentDirectory + container_notifier: Option>, +} + +impl fmt::Debug for RadioParadiseSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RadioParadiseSource") + .field("base_url", &self.base_url) + .finish_non_exhaustive() + } +} + +impl RadioParadiseSource { + /// Create a new RadioParadiseSource + /// + /// # Arguments + /// + /// * `base_url` - Base URL for streaming server (e.g., "http://localhost:8080") + /// + /// # Note + /// + /// With the "playlist" feature enabled, this source will use the global PlaylistManager + /// singleton to access history playlists. + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + update_counter: Arc::new(RwLock::new(0)), + last_change: Arc::new(RwLock::new(SystemTime::now())), + callback_tokens: Arc::new(std::sync::Mutex::new(Vec::new())), + container_notifier: None, + } + } + + /// Injecte un notifier pour propager les changements de playlists vers le ContentDirectory + pub fn with_container_notifier( + mut self, + notifier: Arc, + ) -> Self { + self.container_notifier = Some(notifier); + self + } + + /// Build a live stream URL for a channel + fn build_live_url(&self, slug: &str) -> String { + format!("{}/radioparadise/stream/{}/flac", self.base_url, slug) + } + + /// Build an OGG-FLAC live stream URL for clients that support it + fn build_live_ogg_url(&self, slug: &str) -> String { + format!("{}/radioparadise/stream/{}/ogg", self.base_url, slug) + } + + /// Incrémente l'update_counter et met à jour last_change + async fn bump_update_counter(&self) { + { + let mut c = self.update_counter.write().await; + *c = c.wrapping_add(1).max(1); + } + let mut lc = self.last_change.write().await; + *lc = SystemTime::now(); + } + + /// Enregistre des callbacks sur les playlists live/historique pour notifier les changements + pub fn attach_playlist_callbacks(self: &Arc) { + use pmoplaylist::PlaylistManager; + + // Préparer les IDs de playlists à surveiller (live + history pour chaque canal) + let ids: Vec = ALL_CHANNELS + .iter() + .flat_map(|ch| { + vec![ + Self::live_playlist_id(ch.slug), + Self::history_playlist_id(ch.slug), + ] + }) + .collect(); + + let mgr = PlaylistManager(); + let mut tokens = self.callback_tokens.lock().unwrap(); + + for pid in ids { + let weak = Arc::downgrade(self); + let pid_clone = pid.clone(); + let token = mgr.register_callback(move |event| { + let pid = pid_clone.clone(); + if event.playlist_id == pid { + // On ne réagit qu'aux mises à jour structurelles (ajout/suppression) + if !matches!(event.kind, pmoplaylist::PlaylistEventKind::Updated) { + return; + } + if let Some(strong) = weak.upgrade() { + tokio::spawn(async move { + strong.bump_update_counter().await; + // Notifier ContentDirectory des conteneurs concernés + let containers: Vec = if pid.contains("history") { + // history playlist -> container history + ALL_CHANNELS + .iter() + .find(|ch| pid.ends_with(ch.slug)) + .map(|ch| { + vec![format!("radio-paradise:channel:{}:history", ch.slug)] + }) + .unwrap_or_default() + } else { + // live playlist -> container liveplaylist + ALL_CHANNELS + .iter() + .find(|ch| pid.ends_with(ch.slug)) + .map(|ch| { + vec![format!( + "radio-paradise:channel:{}:liveplaylist", + ch.slug + )] + }) + .unwrap_or_default() + }; + + if !containers.is_empty() { + if let Some(notifier) = strong.container_notifier.as_ref() { + notifier(&containers); + } + } + }); + } + } + }); + tokens.push(token); + } + } + + /// URL de fallback pour l'image par défaut de la source + fn default_cover_url(&self) -> String { + format!("{}/api/sources/{}/image", self.base_url, self.id()) + } + + /// Fetch current metadata from the live stream + async fn fetch_live_metadata(&self, slug: &str) -> Result> { + let metadata_url = format!("{}/radioparadise/metadata/{}", self.base_url, slug); + + // Try to fetch metadata via HTTP + match reqwest::get(&metadata_url).await { + Ok(response) if response.status().is_success() => { + match response.json::().await { + Ok(json) => { + // Parse metadata from JSON and create an Item + let title = json["title"] + .as_str() + .unwrap_or("Unknown Title") + .to_string(); + let artist = json["artist"].as_str().map(|s| s.to_string()); + let album = json["album"].as_str().map(|s| s.to_string()); + let year = json["year"].as_u64().map(|y| y as u32); + // Préférer l'URL de cache si cover_pk est fourni par le pipeline + let cover_pk = json["cover_pk"].as_str().map(|s| s.to_string()); + let cover_url = cover_pk + .as_ref() + .map(|pk| format!("{}/covers/jpeg/{}", self.base_url, pk)) + .or_else(|| json["cover_url"].as_str().map(|s| s.to_string())) + .or_else(|| Some(self.default_cover_url())); + + // Parse duration from JSON (in seconds as a float) + let duration = json["duration"] + .as_object() + .and_then(|d| d.get("secs")) + .and_then(|s| s.as_f64()) + .or_else(|| json["duration"].as_f64()) + .map(|secs| { + let total_secs = secs as u64; + format!( + "{}:{:02}:{:02}", + total_secs / 3600, + (total_secs % 3600) / 60, + total_secs % 60 + ) + }); + + // Create the item with current metadata + let item = Item { + id: format!("radio-paradise:channel:{}:live", slug), + parent_id: format!("radio-paradise:channel:{}", slug), + restricted: Some("1".to_string()), + title, + creator: artist.clone(), + class: "object.item.audioItem.audioBroadcast".to_string(), + artist, + album, + genre: Some("Radio".to_string()), + album_art: cover_url, + album_art_pk: cover_pk, + date: year.map(|y| y.to_string()), + original_track_number: None, + resources: vec![Resource { + protocol_info: "http-get:*:audio/flac:*".to_string(), + bits_per_sample: None, + sample_frequency: None, + nr_audio_channels: Some("2".to_string()), + duration, + url: self.build_live_url(slug), + }], + descriptions: vec![], + }; + + Ok(Some(item)) + } + Err(_) => Ok(None), + } + } + _ => Ok(None), + } + } + + /// Get the playlist ID for a channel's history + #[cfg(feature = "playlist")] + fn history_playlist_id(slug: &str) -> String { + // Must match the prefix used in ParadiseHistoryBuilder + format!("radio-paradise-history-{}", slug) + } + + /// Live playlist id for a channel + fn live_playlist_id(slug: &str) -> String { + format!("radio-paradise-live-{}", slug) + } + + #[cfg(feature = "playlist")] + async fn wait_for_live_playlist_ready(&self, slug: &str) -> Result<()> { + let playlist_id = Self::live_playlist_id(slug); + let manager = pmoplaylist::PlaylistManager(); + let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { + MusicSourceError::BrowseError(format!( + "Failed to get live playlist {}: {}", + playlist_id, e + )) + })?; + let start = Instant::now(); + loop { + match reader.remaining().await { + Ok(count) if count >= LIVE_PLAYLIST_MIN_READY_ITEMS => return Ok(()), + Ok(_) => {} + Err(e) => { + return Err(MusicSourceError::BrowseError(format!( + "Failed to inspect live playlist {}: {}", + playlist_id, e + ))); + } + } + + if start.elapsed() >= LIVE_PLAYLIST_READY_TIMEOUT { + tracing::warn!( + "Timeout waiting for live playlist {} to reach {} items", + playlist_id, + LIVE_PLAYLIST_MIN_READY_ITEMS + ); + return Ok(()); + } + + tokio::time::sleep(LIVE_PLAYLIST_READY_POLL).await; + } + } + + /// Get channel descriptor by slug + fn get_channel_by_slug(slug: &str) -> Option<&'static ChannelDescriptor> { + ALL_CHANNELS.iter().find(|ch| ch.slug == slug) + } + + /// Parse an object ID into its components + fn parse_object_id(id: &str) -> ObjectIdType { + let parts: Vec<&str> = id.split(':').collect(); + match parts.as_slice() { + ["radio-paradise"] => ObjectIdType::Root, + ["radio-paradise", "channel", slug] => ObjectIdType::Channel { + slug: (*slug).to_string(), + }, + ["radio-paradise", "channel", slug, "live"] => ObjectIdType::LiveStream { + slug: (*slug).to_string(), + }, + ["radio-paradise", "channel", slug, "liveplaylist"] => ObjectIdType::LivePlaylist { + slug: (*slug).to_string(), + }, + ["radio-paradise", "channel", slug, "liveplaylist", "track", pk] => { + ObjectIdType::LivePlaylistTrack { + slug: (*slug).to_string(), + pk: (*pk).to_string(), + } + } + ["radio-paradise", "channel", slug, "history"] => ObjectIdType::History { + slug: (*slug).to_string(), + }, + ["radio-paradise", "channel", slug, "history", "track", pk] => { + ObjectIdType::HistoryTrack { + slug: (*slug).to_string(), + pk: (*pk).to_string(), + } + } + _ => ObjectIdType::Unknown, + } + } + + /// Build a channel container + fn build_channel_container(&self, descriptor: &ChannelDescriptor) -> Container { + Container { + id: format!("radio-paradise:channel:{}", descriptor.slug), + parent_id: "radio-paradise".to_string(), + restricted: Some("1".to_string()), + child_count: None, + searchable: Some("1".to_string()), + title: descriptor.display_name.to_string(), + class: "object.container".to_string(), + containers: vec![], + items: vec![], + } + } + + /// Build the live playlist container for a channel + fn build_live_playlist_container(&self, descriptor: &ChannelDescriptor) -> Container { + Container { + id: format!("radio-paradise:channel:{}:liveplaylist", descriptor.slug), + parent_id: format!("radio-paradise:channel:{}", descriptor.slug), + restricted: Some("1".to_string()), + child_count: None, + searchable: Some("0".to_string()), + title: format!("{} - Live Playlist", descriptor.display_name), + class: "object.container.playlistContainer".to_string(), + containers: vec![], + items: vec![], + } + } + + /// Build a live stream item for a channel + fn build_live_stream_item(&self, descriptor: &ChannelDescriptor) -> Item { + let stream_url = self.build_live_url(descriptor.slug); + + Item { + id: format!("radio-paradise:channel:{}:live", descriptor.slug), + parent_id: format!("radio-paradise:channel:{}", descriptor.slug), + restricted: Some("1".to_string()), + title: format!("{} - Live Stream", descriptor.display_name), + creator: Some("Radio Paradise".to_string()), + class: "object.item.audioItem.audioBroadcast".to_string(), + artist: Some("Radio Paradise".to_string()), + album: Some(descriptor.display_name.to_string()), + genre: Some("Radio".to_string()), + album_art: Some(self.default_cover_url()), + album_art_pk: None, + date: None, + original_track_number: None, + resources: vec![ + Resource { + protocol_info: "http-get:*:audio/flac:*".to_string(), + bits_per_sample: Some("16".to_string()), + sample_frequency: Some("44100".to_string()), + nr_audio_channels: Some("2".to_string()), + duration: None, + url: stream_url.clone(), + }, + Resource { + protocol_info: "http-get:*:audio/ogg:*".to_string(), + bits_per_sample: Some("16".to_string()), + sample_frequency: Some("44100".to_string()), + nr_audio_channels: Some("2".to_string()), + duration: None, + url: self.build_live_ogg_url(descriptor.slug), + }, + ], + descriptions: vec![], + } + } + + /// Build a history container for a channel + fn build_history_container(&self, descriptor: &ChannelDescriptor) -> Container { + Container { + id: format!("radio-paradise:channel:{}:history", descriptor.slug), + parent_id: format!("radio-paradise:channel:{}", descriptor.slug), + restricted: Some("1".to_string()), + child_count: None, + searchable: Some("1".to_string()), + title: format!("{} - History", descriptor.display_name), + // Expose l'historique comme une playlist jouable + class: "object.container.playlistContainer".to_string(), + containers: vec![], + items: vec![], + } + } + + /// Build a history container with accurate child count from playlist + #[cfg(feature = "playlist")] + async fn build_history_container_with_count( + &self, + descriptor: &ChannelDescriptor, + ) -> Container { + let mut container = self.build_history_container(descriptor); + + // Try to get actual count from playlist + let playlist_id = Self::history_playlist_id(descriptor.slug); + let manager = pmoplaylist::PlaylistManager(); + + if let Ok(reader) = manager.get_read_handle(&playlist_id).await { + if let Ok(count) = reader.remaining().await { + container.child_count = Some(count.to_string()); + } + } + + container + } + + /// Get items from history playlist + #[cfg(feature = "playlist")] + async fn get_history_items( + &self, + slug: &str, + _offset: usize, + count: usize, + ) -> Result> { + let playlist_id = Self::history_playlist_id(slug); + + // Get read handle for the playlist from the singleton + let manager = pmoplaylist::PlaylistManager(); + let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { + MusicSourceError::BrowseError(format!("Failed to get playlist {}: {}", playlist_id, e)) + })?; + + // Get items from playlist (to_items starts from cursor position) + let mut items = reader.to_items(count).await.map_err(|e| { + MusicSourceError::BrowseError(format!("Failed to read playlist entries: {}", e)) + })?; + + // Transform item IDs, parent_ids, and resource URLs to match Radio Paradise schema + // Expected: radio-paradise:channel:{slug}:history:track:{pk} + // Parent: radio-paradise:channel:{slug}:history + for item in items.iter_mut() { + // Extract cache_pk from the resource URL (last segment) + if let Some(resource) = item.resources.first_mut() { + if let Some(pk) = resource.url.split('/').last() { + // Update item ID and parent ID + item.id = format!("radio-paradise:channel:{}:history:track:{}", slug, pk); + item.parent_id = format!("radio-paradise:channel:{}:history", slug); + + // Convert relative URL to absolute URL + // From: /audio/flac/pk + // To: http://base_url/audio/flac/pk + if resource.url.starts_with('/') { + resource.url = format!("{}{}", self.base_url, resource.url); + } + } + } + + // Fix: Ajouter un genre par défaut si absent + // Certains clients UPnP (comme gupnp-av-cp) requièrent le champ + // pour parser correctement les items de classe musicTrack, même si ce champ + // est optionnel selon la spec UPnP ContentDirectory. + if item.genre.is_none() { + item.genre = Some("Radio Paradise".to_string()); + } + + // Normaliser l'albumArtURI : rendre absolu si chemin relatif, sinon fallback par défaut + if let Some(art) = item.album_art.as_mut() { + if art.starts_with('/') { + *art = format!("{}{}", self.base_url, art); + } + } else { + item.album_art = Some(self.default_cover_url()); + } + } + + Ok(items) + } + + /// Get items from live playlist (current stream queue) + #[cfg(feature = "playlist")] + async fn get_live_playlist_items( + &self, + slug: &str, + _offset: usize, + count: usize, + ) -> Result> { + #[cfg(all(feature = "playlist", feature = "pmoaudio"))] + if let Some(descriptor) = Self::get_channel_by_slug(slug) { + if let Some(manager) = crate::stream_channel::get_global_channel_manager() { + if let Err(e) = manager.prefetch_until_horizon(descriptor.id).await { + tracing::warn!( + "Failed to prefetch live playlist for {}: {}", + descriptor.slug, + e + ); + } + } + } + + #[cfg(feature = "playlist")] + if let Err(e) = self.wait_for_live_playlist_ready(slug).await { + tracing::warn!( + "Failed to wait for live playlist readiness on {}: {}", + slug, + e + ); + } + + let playlist_id = Self::live_playlist_id(slug); + + let manager = pmoplaylist::PlaylistManager(); + let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { + MusicSourceError::BrowseError(format!( + "Failed to get live playlist {}: {}", + playlist_id, e + )) + })?; + + let mut items = reader.to_items(count).await.map_err(|e| { + MusicSourceError::BrowseError(format!("Failed to read live playlist entries: {}", e)) + })?; + + for item in items.iter_mut() { + // Ajuster id/parent/url pour coller au schéma Radio Paradise + if let Some(resource) = item.resources.first_mut() { + if let Some(pk) = resource.url.split('/').last() { + item.id = format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk); + item.parent_id = format!("radio-paradise:channel:{}:liveplaylist", slug); + + if resource.url.starts_with('/') { + resource.url = format!("{}{}", self.base_url, resource.url); + } + } + } + + if item.genre.is_none() { + item.genre = Some("Radio Paradise".to_string()); + } + + if let Some(art) = item.album_art.as_mut() { + if art.starts_with('/') { + *art = format!("{}{}", self.base_url, art); + } + } else { + item.album_art = Some(self.default_cover_url()); + } + } + + Ok(items) + } + + /// Get a single item from the live playlist by pk + #[cfg(feature = "playlist")] + async fn get_live_playlist_item(&self, slug: &str, pk: &str) -> Result { + let items = self.get_live_playlist_items(slug, 0, 1000).await?; + let expected_id = format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk); + for item in items { + if item.id == expected_id { + return Ok(item); + } + } + Err(MusicSourceError::ObjectNotFound(format!( + "Track with pk {} not found in live playlist", + pk + ))) + } +} + +/// Types of object IDs in the Radio Paradise source +#[derive(Debug, Clone, PartialEq)] +enum ObjectIdType { + Root, + Channel { slug: String }, + LiveStream { slug: String }, + LivePlaylist { slug: String }, + LivePlaylistTrack { slug: String, pk: String }, + History { slug: String }, + HistoryTrack { slug: String, pk: String }, + Unknown, +} + +#[async_trait] +impl MusicSource for RadioParadiseSource { + fn name(&self) -> &str { + "Radio Paradise" + } + + fn id(&self) -> &str { + "radio-paradise" + } + + fn default_image(&self) -> &[u8] { + DEFAULT_IMAGE + } + + async fn root_container(&self) -> Result { + Ok(Container { + id: "radio-paradise".to_string(), + parent_id: "0".to_string(), + restricted: Some("1".to_string()), + // childCount retiré pour éviter les soucis de compatibilité côté CP + child_count: None, + searchable: Some("1".to_string()), + title: "Radio Paradise".to_string(), + class: "object.container".to_string(), + containers: vec![], + items: vec![], + }) + } + + async fn browse(&self, object_id: &str) -> Result { + match Self::parse_object_id(object_id) { + ObjectIdType::Root => { + // Return the 4 channel containers + let containers: Vec = ALL_CHANNELS + .iter() + .map(|ch| self.build_channel_container(ch)) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + ObjectIdType::Channel { slug } => { + // Return live stream item + history container + let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { + MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) + })?; + + let live_item = self.build_live_stream_item(descriptor); + let live_playlist_container = self.build_live_playlist_container(descriptor); + + #[cfg(feature = "playlist")] + let history_container = self.build_history_container_with_count(descriptor).await; + #[cfg(not(feature = "playlist"))] + let history_container = self.build_history_container(descriptor); + + Ok(BrowseResult::Mixed { + containers: vec![live_playlist_container, history_container], + items: vec![live_item], + }) + } + + ObjectIdType::History { slug } => { + // Return history container (for BrowseMetadata) and items (for BrowseDirectChildren) + // The content_handler will filter out the container when browsing direct children + let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { + MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) + })?; + + #[cfg(feature = "playlist")] + { + let history_container = + self.build_history_container_with_count(descriptor).await; + let items = self.get_history_items(&slug, 0, 100).await?; + Ok(BrowseResult::Mixed { + containers: vec![history_container], + items, + }) + } + + #[cfg(not(feature = "playlist"))] + { + // If playlist feature is disabled, return just the container + let history_container = self.build_history_container(descriptor); + Ok(BrowseResult::Containers(vec![history_container])) + } + } + + 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::LivePlaylist { slug } => { + // Playlist du live : container + items + let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { + MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) + })?; + + #[cfg(feature = "playlist")] + { + let container = self.build_live_playlist_container(descriptor); + let items = self.get_live_playlist_items(&slug, 0, 100).await?; + Ok(BrowseResult::Mixed { + containers: vec![container], + items, + }) + } + + #[cfg(not(feature = "playlist"))] + { + let container = self.build_live_playlist_container(descriptor); + Ok(BrowseResult::Containers(vec![container])) + } + } + + 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::LivePlaylistTrack { slug, pk } => { + // Détails d'un titre du live (playlist live) + #[cfg(feature = "playlist")] + { + let item = self.get_live_playlist_item(&slug, &pk).await?; + Ok(BrowseResult::Items(vec![item])) + } + + #[cfg(not(feature = "playlist"))] + { + let _ = (slug, pk); + Err(MusicSourceError::NotSupported( + "Playlist feature not enabled".to_string(), + )) + } + } + + ObjectIdType::Unknown => Err(MusicSourceError::ObjectNotFound(format!( + "Unknown object ID: {}", + object_id + ))), + } + } + + async fn resolve_uri(&self, object_id: &str) -> Result { + match Self::parse_object_id(object_id) { + ObjectIdType::LiveStream { slug } => { + // Return live stream URL + Ok(self.build_live_url(&slug)) + } + + ObjectIdType::HistoryTrack { pk, .. } => { + // Return cached audio URL + Ok(format!("{}/cache/audio/{}", self.base_url, pk)) + } + + ObjectIdType::LivePlaylistTrack { pk, .. } => { + // Return cached audio URL + Ok(format!("{}/cache/audio/{}", self.base_url, pk)) + } + + _ => Err(MusicSourceError::ObjectNotFound(format!( + "Cannot resolve URI for object: {}", + object_id + ))), + } + } + + fn capabilities(&self) -> SourceCapabilities { + SourceCapabilities { + supports_fifo: self.supports_fifo(), + supports_search: false, + supports_favorites: false, + supports_playlists: false, + supports_user_content: false, + supports_high_res_audio: true, + max_sample_rate: Some(44100), + supports_multiple_formats: true, + supports_advanced_search: false, + supports_pagination: false, + } + } + + async fn get_available_formats(&self, object_id: &str) -> Result> { + match Self::parse_object_id(object_id) { + ObjectIdType::LiveStream { .. } => Ok(vec![ + AudioFormat { + format_id: "flac".to_string(), + mime_type: "audio/flac".to_string(), + sample_rate: Some(44100), + bit_depth: Some(16), + bitrate: None, + channels: Some(2), + }, + AudioFormat { + format_id: "ogg-flac".to_string(), + mime_type: "audio/ogg".to_string(), + sample_rate: Some(44100), + bit_depth: Some(16), + bitrate: None, + channels: Some(2), + }, + ]), + ObjectIdType::HistoryTrack { .. } => Ok(vec![AudioFormat { + format_id: "flac".to_string(), + mime_type: "audio/flac".to_string(), + sample_rate: Some(44100), + bit_depth: Some(16), + bitrate: None, + channels: Some(2), + }]), + ObjectIdType::LivePlaylistTrack { .. } => Ok(vec![AudioFormat { + format_id: "flac".to_string(), + mime_type: "audio/flac".to_string(), + sample_rate: Some(44100), + bit_depth: Some(16), + bitrate: None, + channels: Some(2), + }]), + _ => Err(MusicSourceError::ObjectNotFound(format!( + "Cannot list formats for object: {}", + object_id + ))), + } + } + + async fn get_item(&self, object_id: &str) -> Result { + match Self::parse_object_id(object_id) { + ObjectIdType::LiveStream { slug } => { + // Try to fetch current metadata from live stream + if let Ok(Some(item)) = self.fetch_live_metadata(&slug).await { + return Ok(item); + } + + // Fallback to static item if metadata fetch fails + let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { + MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) + })?; + Ok(self.build_live_stream_item(descriptor)) + } + + ObjectIdType::HistoryTrack { slug, pk } => { + // Get from history playlist + #[cfg(feature = "playlist")] + { + let playlist_id = Self::history_playlist_id(&slug); + let manager = pmoplaylist::PlaylistManager(); + let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { + MusicSourceError::BrowseError(format!( + "Failed to get playlist {}: {}", + playlist_id, 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 + )) + })?; + + // Ajuster les IDs/parent_id/URL pour coller au schéma Radio Paradise, + // comme dans get_history_items. + let mut adjusted = Vec::new(); + for mut item in items { + if let Some(resource) = item.resources.first_mut() { + if let Some(pk2) = resource.url.split('/').last() { + item.id = format!( + "radio-paradise:channel:{}:history:track:{}", + slug, pk2 + ); + item.parent_id = format!("radio-paradise:channel:{}:history", slug); + + if resource.url.starts_with('/') { + resource.url = format!("{}{}", self.base_url, resource.url); + } + } + } + if item.genre.is_none() { + item.genre = Some("Radio Paradise".to_string()); + } + adjusted.push(item); + } + + // Find the item matching this pk in the item ID + let expected_id = + format!("radio-paradise:channel:{}:history:track:{}", slug, pk); + for item in adjusted { + if item.id == expected_id { + return Ok(item); + } + } + + Err(MusicSourceError::ObjectNotFound(format!( + "Track with pk {} not found in history", + pk + ))) + } + + #[cfg(not(feature = "playlist"))] + { + let _ = (slug, pk); + Err(MusicSourceError::NotSupported( + "Playlist feature not enabled".to_string(), + )) + } + } + + ObjectIdType::LivePlaylistTrack { slug, pk } => { + #[cfg(feature = "playlist")] + { + let playlist_id = Self::live_playlist_id(&slug); + let manager = pmoplaylist::PlaylistManager(); + let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { + MusicSourceError::BrowseError(format!( + "Failed to get live playlist {}: {}", + playlist_id, e + )) + })?; + + let items = reader.to_items(1000).await.map_err(|e| { + MusicSourceError::BrowseError(format!( + "Failed to read live playlist entries: {}", + e + )) + })?; + + for mut item in items { + if let Some(resource) = item.resources.first_mut() { + if let Some(pk2) = resource.url.split('/').last() { + item.id = format!( + "radio-paradise:channel:{}:liveplaylist:track:{}", + slug, pk2 + ); + item.parent_id = + format!("radio-paradise:channel:{}:liveplaylist", slug); + + if resource.url.starts_with('/') { + resource.url = format!("{}{}", self.base_url, resource.url); + } + } + } + + if item.genre.is_none() { + item.genre = Some("Radio Paradise".to_string()); + } + + if let Some(art) = item.album_art.as_mut() { + if art.starts_with('/') { + *art = format!("{}{}", self.base_url, art); + } + } else { + item.album_art = Some(self.default_cover_url()); + } + + let expected_id = + format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk); + if item.id == expected_id { + return Ok(item); + } + } + + Err(MusicSourceError::ObjectNotFound(format!( + "Track with pk {} not found in live playlist", + pk + ))) + } + + #[cfg(not(feature = "playlist"))] + { + let _ = (slug, pk); + Err(MusicSourceError::NotSupported( + "Playlist feature not enabled".to_string(), + )) + } + } + + _ => Err(MusicSourceError::ObjectNotFound(format!( + "Cannot get item for object: {}", + object_id + ))), + } + } + + fn supports_fifo(&self) -> bool { + // History playlists are FIFO + cfg!(feature = "playlist") + } + + async fn append_track(&self, _track: Item) -> Result<()> { + // Tracks are added automatically by FlacCacheSink + Err(MusicSourceError::NotSupported( + "Tracks are automatically added to history by the streaming system".to_string(), + )) + } + + async fn remove_oldest(&self) -> Result> { + // Managed automatically by playlist FIFO + Ok(None) + } + + async fn update_id(&self) -> u32 { + *self.update_counter.read().await + } + + async fn last_change(&self) -> Option { + Some(*self.last_change.read().await) + } + + async fn get_items(&self, offset: usize, count: usize) -> Result> { + // For Radio Paradise, we don't have a global FIFO + // Each channel has its own history + // Return empty for now - clients should browse specific channel histories + let _ = (offset, count); + Ok(vec![]) + } +} +-------End of pmoparadise/src/source.rs --------- + +------------ pmoparadise/src/stream_channel_old.rs ---------- +use std::{ + collections::HashMap, + pin::Pin, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + task::{Context, Poll}, + time::Duration, +}; + +use crate::{ + channels::{ChannelDescriptor, ParadiseChannelKind, ALL_CHANNELS}, + client::RadioParadiseClient, + radio_paradise_stream_source::RadioParadiseStreamSource, +}; +use anyhow::{anyhow, Result}; +use pmoaudio::{nodes::DEFAULT_CHANNEL_SIZE, AudioPipelineNode}; +use pmoaudio_ext::{ + FlacCacheSink, FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, + OggFlacStreamHandle, PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink, + TrackBoundaryCoverNode, StreamingSinkOptions, +}; +use pmoaudiocache::Cache as AudioCache; +use pmocovers::Cache as CoverCache; +use pmoflac::EncoderOptions; +use pmoplaylist::WriteHandle; +use thiserror::Error; +use tokio::io::{AsyncRead, ReadBuf}; +use tokio::sync::Notify; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; + +/// Configuration pour un canal Radio Paradise. +#[derive(Clone, Debug)] +pub struct ParadiseStreamChannelConfig { + /// Durée maximale (en secondes) d'avance acceptée par le broadcast. + pub max_lead_seconds: f64, + pub flac_options: StreamingSinkOptions, + pub ogg_options: StreamingSinkOptions, + pub server_base_url: Option, +} + +impl Default for ParadiseStreamChannelConfig { + fn default() -> Self { + Self { + max_lead_seconds: 1.0, + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } +} + +/// Options pour activer l'archivage/historique d'un canal. +pub struct ParadiseHistoryOptions { + pub audio_cache: Arc, + pub cover_cache: Arc, + pub playlist_id: String, + pub playlist_writer: WriteHandle, + pub collection: Option, + pub replay_max_lead_seconds: f64, +} + +/// Builder pratique pour configurer automatiquement les playlists historiques. +#[derive(Clone)] +pub struct ParadiseHistoryBuilder { + pub audio_cache: Arc, + pub cover_cache: Arc, + pub playlist_prefix: String, + pub playlist_title_prefix: Option, + pub max_history_tracks: Option, + pub collection_prefix: Option, + pub replay_max_lead_seconds: f64, +} + +impl ParadiseHistoryBuilder { + pub fn new(audio_cache: Arc, cover_cache: Arc) -> Self { + Self { + audio_cache, + cover_cache, + playlist_prefix: "radio-paradise-history".into(), + playlist_title_prefix: Some("Radio Paradise History".into()), + max_history_tracks: Some(500), + collection_prefix: Some("radio-paradise".into()), + replay_max_lead_seconds: 1.0, + } + } + + pub async fn build_for_channel( + &self, + descriptor: &ChannelDescriptor, + ) -> Result { + let playlist_id = format!("{}-{}", self.playlist_prefix, descriptor.slug); + let manager = pmoplaylist::PlaylistManager(); + let writer = manager + .get_persistent_write_handle(playlist_id.clone()) + .await?; + + if let Some(prefix) = &self.playlist_title_prefix { + let title = format!("{} - {}", prefix, descriptor.display_name); + writer.set_title(title).await?; + } + + if let Some(capacity) = self.max_history_tracks { + writer.set_capacity(Some(capacity)).await?; + } + + let collection = self + .collection_prefix + .as_ref() + .map(|prefix| format!("{}-{}", prefix, descriptor.slug)); + + Ok(ParadiseHistoryOptions { + audio_cache: self.audio_cache.clone(), + cover_cache: self.cover_cache.clone(), + playlist_id, + playlist_writer: writer, + collection, + replay_max_lead_seconds: self.replay_max_lead_seconds, + }) + } +} + +struct HistoryState { + playlist_id: String, + audio_cache: Arc, + replay_max_lead_seconds: f64, +} + +#[cfg(feature = "pmoconfig")] +impl ParadiseStreamChannelConfig { + pub fn from_config(cfg: &pmoconfig::Config, channel: ParadiseChannelKind) -> Self { + use serde_yaml::Value; + let path = [ + "sources", + "radio_paradise", + "channels", + channel.slug(), + "max_lead_seconds", + ]; + match cfg.get_value(&path) { + Ok(Value::Number(num)) => { + if let Some(v) = num.as_f64() { + Self { + max_lead_seconds: v.max(0.1), + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } else { + let default = Self::default(); + let _ = + cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + Ok(Value::String(s)) => { + if let Ok(v) = s.parse::() { + Self { + max_lead_seconds: v.max(0.1), + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } else { + let default = Self::default(); + let _ = + cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + _ => { + let default = Self::default(); + let _ = cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + } +} + +/// Stream complet (FLAC pur + OGG-FLAC) pour un canal Radio Paradise. +pub struct ParadiseStreamChannel { + descriptor: ChannelDescriptor, + state: Arc, + pipeline_handle: JoinHandle<()>, + feeder_handle: JoinHandle<()>, + history: Option, +} + +impl ParadiseStreamChannel { + /// Crée un canal avec client déjà configuré. + pub fn with_client( + descriptor: ChannelDescriptor, + client: RadioParadiseClient, + config: ParadiseStreamChannelConfig, + cover_cache: Option>, + history: Option, + ) -> Self { + let mut source = RadioParadiseStreamSource::new(client.clone()); + let block_handle = source.block_handle(); + + let (flac_sink, stream_handle) = StreamingFlacSink::with_options( + EncoderOptions::default(), + 16, + config.max_lead_seconds, + config.flac_options.clone(), + ); + let (ogg_sink, ogg_handle) = StreamingOggFlacSink::with_options( + EncoderOptions::default(), + 16, + config.max_lead_seconds, + config.ogg_options.clone(), + ); + + let mut downstream_children: Vec> = Vec::new(); + downstream_children.push(Box::new(flac_sink)); + downstream_children.push(Box::new(ogg_sink)); + + let mut history_state = None; + + if let Some(history_opts) = history { + let ParadiseHistoryOptions { + audio_cache, + cover_cache, + playlist_id, + playlist_writer, + collection, + replay_max_lead_seconds, + } = history_opts; + let mut cache_sink = FlacCacheSink::with_config( + audio_cache.clone(), + cover_cache, + DEFAULT_CHANNEL_SIZE, + EncoderOptions::default(), + collection, + ); + cache_sink.register_playlist(playlist_writer); + downstream_children.push(Box::new(cache_sink)); + history_state = Some(HistoryState { + playlist_id, + audio_cache, + replay_max_lead_seconds, + }); + } + + if let Some(cache) = cover_cache { + let mut cover_node = TrackBoundaryCoverNode::new(cache); + for child in downstream_children { + cover_node.register(child); + } + source.register(Box::new(cover_node)); + } else { + for child in downstream_children { + source.register(child); + } + } + stream_handle.set_auto_stop(false); + ogg_handle.set_auto_stop(false); + + let stop_token = CancellationToken::new(); + let pipeline_stop = stop_token.clone(); + let pipeline_handle = tokio::spawn(async move { + info!( + "RadioParadise stream pipeline started for channel {}", + descriptor.display_name + ); + if let Err(e) = Box::new(source).run(pipeline_stop).await { + error!( + "Pipeline error for channel {}: {}", + descriptor.display_name, e + ); + } + }); + + let state = Arc::new(ChannelState { + descriptor, + config, + client, + block_handle, + stream_handle, + ogg_handle, + active_clients: AtomicUsize::new(0), + activity_notify: Notify::new(), + stop_token, + }); + + let feeder_state = state.clone(); + let feeder_handle = tokio::spawn(async move { + feeder_state.run_scheduler().await; + }); + + Self { + descriptor, + state, + pipeline_handle, + feeder_handle, + history: history_state, + } + } + + /// Crée un canal en construisant automatiquement le client pour ce descriptor. + pub async fn new( + descriptor: ChannelDescriptor, + config: ParadiseStreamChannelConfig, + cover_cache: Option>, + history: Option, + ) -> Result { + let client = RadioParadiseClient::builder() + .channel(descriptor.id) + .build() + .await?; + Ok(Self::with_client( + descriptor, + client, + config, + cover_cache, + history, + )) + } + + /// S'abonne au flux FLAC pur. + pub fn subscribe_flac(&self) -> ChannelFlacStream { + self.state.on_client_added(); + let inner = self.state.stream_handle.subscribe_flac(); + ChannelFlacStream::new(inner, self.state.clone()) + } + + /// S'abonne au flux FLAC + ICY metadata. + pub fn subscribe_icy(&self) -> ChannelIcyStream { + self.state.on_client_added(); + let inner = self.state.stream_handle.subscribe_icy(); + ChannelIcyStream::new(inner, self.state.clone()) + } + + /// S'abonne au flux OGG-FLAC. + pub fn subscribe_ogg(&self) -> ChannelOggStream { + self.state.on_client_added(); + let inner = self.state.ogg_handle.subscribe(); + ChannelOggStream::new(inner, self.state.clone()) + } + + /// Snapshot des métadonnées actuelles. + pub async fn metadata(&self) -> MetadataSnapshot { + self.state.stream_handle.get_metadata().await + } + + /// Nombre de clients actifs. + pub fn active_clients(&self) -> usize { + self.state.active_clients.load(Ordering::SeqCst) + } + + pub fn descriptor(&self) -> ChannelDescriptor { + self.descriptor + } + + /// Lance un pipeline dédié pour rejouer l'historique (FLAC pur) pour un client. + pub async fn stream_history_flac( + &self, + client_id: &str, + ) -> Result { + let history = self + .history + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + tracing::info!( + "Starting historical FLAC replay for channel {} (client_id={})", + self.descriptor.display_name, + client_id + ); + + let reader = pmoplaylist::PlaylistManager() + .get_read_handle(&history.playlist_id) + .await + .map_err(|e| HistoryStreamError::Playlist(e.to_string()))?; + let mut source = PlaylistSource::new(reader, history.audio_cache.clone()); + let (flac_sink, handle) = StreamingFlacSink::with_options( + EncoderOptions::default(), + 16, + history.replay_max_lead_seconds, + self.state.config.flac_options.clone(), + ); + source.register(Box::new(flac_sink)); + let stop_token = CancellationToken::new(); + let mut pipeline_source = source; + let stop_clone = stop_token.clone(); + let pipeline = tokio::spawn(async move { + let _ = Box::new(pipeline_source).run(stop_clone).await; + }); + let stream = handle.subscribe_flac(); + Ok(HistoryFlacStream::new(stream, stop_token, pipeline)) + } + + /// Lance un pipeline dédié pour rejouer l'historique (OGG-FLAC) pour un client. + pub async fn stream_history_ogg( + &self, + client_id: &str, + ) -> Result { + let history = self + .history + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + tracing::info!( + "Starting historical OGG replay for channel {} (client_id={})", + self.descriptor.display_name, + client_id + ); + + let reader = pmoplaylist::PlaylistManager() + .get_read_handle(&history.playlist_id) + .await + .map_err(|e| HistoryStreamError::Playlist(e.to_string()))?; + let mut source = PlaylistSource::new(reader, history.audio_cache.clone()); + let (ogg_sink, handle) = StreamingOggFlacSink::with_options( + EncoderOptions::default(), + 16, + history.replay_max_lead_seconds, + self.state.config.ogg_options.clone(), + ); + source.register(Box::new(ogg_sink)); + let stop_token = CancellationToken::new(); + let mut pipeline_source = source; + let stop_clone = stop_token.clone(); + let pipeline = tokio::spawn(async move { + let _ = Box::new(pipeline_source).run(stop_clone).await; + }); + let stream = handle.subscribe(); + Ok(HistoryOggStream::new(stream, stop_token, pipeline)) + } +} + +impl Drop for ParadiseStreamChannel { + fn drop(&mut self) { + self.state.stop_token.cancel(); + self.pipeline_handle.abort(); + self.feeder_handle.abort(); + } +} + +struct ChannelState { + descriptor: ChannelDescriptor, + config: ParadiseStreamChannelConfig, + client: RadioParadiseClient, + block_handle: crate::radio_paradise_stream_source::BlockQueueHandle, + stream_handle: StreamHandle, + ogg_handle: OggFlacStreamHandle, + active_clients: AtomicUsize, + activity_notify: Notify, + stop_token: CancellationToken, +} + +impl ChannelState { + fn on_client_added(&self) { + if self.active_clients.fetch_add(1, Ordering::SeqCst) == 0 { + self.activity_notify.notify_one(); + } + } + + fn on_client_removed(&self) { + self.active_clients.fetch_sub(1, Ordering::SeqCst); + } + + async fn wait_for_clients(&self) -> bool { + while self.active_clients.load(Ordering::SeqCst) == 0 { + tokio::select! { + _ = self.stop_token.cancelled() => return false, + _ = self.activity_notify.notified() => {}, + } + } + true + } + + async fn run_scheduler(self: Arc) { + let mut backoff = Duration::from_secs(5); + loop { + if self.stop_token.is_cancelled() { + break; + } + + if !self.wait_for_clients().await { + break; + } + + match self.client.get_block(None).await { + Ok(block) => { + info!( + "Channel {} streaming block {}", + self.descriptor.display_name, block.event + ); + self.block_handle.enqueue(block.event); + let mut next_event = block.end_event; + + loop { + if self.stop_token.is_cancelled() { + return; + } + + if self.active_clients.load(Ordering::SeqCst) == 0 { + break; + } + + match self.client.get_block(Some(next_event)).await { + Ok(next_block) => { + self.block_handle.enqueue(next_block.event); + next_event = next_block.end_event; + backoff = Duration::from_secs(5); + } + Err(e) => { + warn!( + "Failed to fetch next block for channel {}: {}", + self.descriptor.display_name, e + ); + tokio::select! { + _ = self.stop_token.cancelled() => return, + _ = tokio::time::sleep(backoff) => {}, + } + backoff = (backoff * 2).min(Duration::from_secs(60)); + } + } + } + } + Err(e) => { + warn!( + "Failed to fetch current block for channel {}: {}", + self.descriptor.display_name, e + ); + tokio::select! { + _ = self.stop_token.cancelled() => break, + _ = tokio::time::sleep(backoff) => {}, + } + backoff = (backoff * 2).min(Duration::from_secs(60)); + } + } + } + } +} + +macro_rules! wrap_stream { + ($name:ident, $inner:ty) => { + pub struct $name { + inner: $inner, + state: Arc, + } + + impl $name { + fn new(inner: $inner, state: Arc) -> Self { + Self { inner, state } + } + } + + impl AsyncRead for $name { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } + } + + impl Drop for $name { + fn drop(&mut self) { + self.state.on_client_removed(); + } + } + }; +} + +wrap_stream!(ChannelFlacStream, FlacClientStream); +wrap_stream!(ChannelIcyStream, IcyClientStream); +wrap_stream!(ChannelOggStream, OggFlacClientStream); + +#[derive(Debug, Error)] +pub enum HistoryStreamError { + #[error("history replay not enabled for this channel")] + HistoryDisabled, + #[error("playlist error: {0}")] + Playlist(String), +} + +pub struct HistoryFlacStream { + inner: FlacClientStream, + stop_token: CancellationToken, + pipeline: Option>, +} + +impl HistoryFlacStream { + fn new( + inner: FlacClientStream, + stop_token: CancellationToken, + pipeline: JoinHandle<()>, + ) -> Self { + Self { + inner, + stop_token, + pipeline: Some(pipeline), + } + } +} + +impl AsyncRead for HistoryFlacStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl Unpin for HistoryFlacStream {} + +impl Drop for HistoryFlacStream { + fn drop(&mut self) { + self.stop_token.cancel(); + if let Some(handle) = self.pipeline.take() { + handle.abort(); + } + } +} + +pub struct HistoryOggStream { + inner: OggFlacClientStream, + stop_token: CancellationToken, + pipeline: Option>, +} + +impl HistoryOggStream { + fn new( + inner: OggFlacClientStream, + stop_token: CancellationToken, + pipeline: JoinHandle<()>, + ) -> Self { + Self { + inner, + stop_token, + pipeline: Some(pipeline), + } + } +} + +impl AsyncRead for HistoryOggStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl Unpin for HistoryOggStream {} + +impl Drop for HistoryOggStream { + fn drop(&mut self) { + self.stop_token.cancel(); + if let Some(handle) = self.pipeline.take() { + handle.abort(); + } + } +} + +/// Gestionnaire multi-canaux. +pub struct ParadiseChannelManager { + channels: HashMap>, +} + +impl ParadiseChannelManager { + pub fn new(channels: HashMap>) -> Self { + Self { channels } + } + + pub async fn with_defaults_with_cover_cache( + cover_cache: Option>, + history_builder: Option, + server_base_url: Option, + ) -> Result { + let mut map = HashMap::new(); + for descriptor in ALL_CHANNELS.iter().copied() { + let mut config = ParadiseStreamChannelConfig::default(); + config.server_base_url = server_base_url.clone(); + + let history_opts = if let Some(builder) = &history_builder { + Some( + builder + .build_for_channel(&descriptor) + .await + .map_err(|e| anyhow!("Failed to init history playlist: {}", e))?, + ) + } else { + None + }; + let channel = ParadiseStreamChannel::new( + descriptor, + config, + cover_cache.clone(), + history_opts, + ) + .await?; + map.insert(descriptor.id, Arc::new(channel)); + } + Ok(Self { channels: map }) + } + + pub async fn with_defaults() -> Result { + Self::with_defaults_with_cover_cache(None, None, None).await + } + + pub fn get(&self, id: u8) -> Option> { + self.channels.get(&id).cloned() + } + + pub fn iter(&self) -> impl Iterator> { + self.channels.values() + } +} +-------End of pmoparadise/src/stream_channel_old.rs --------- + +------------ pmoparadise/src/stream_channel.rs ---------- +//! Version simplifiée de stream_channel.rs utilisant RadioParadisePlaylistFeeder + PlaylistSource +//! +//! Cette version remplace l'architecture complexe RadioParadiseStreamSource par : +//! - RadioParadisePlaylistFeeder : télécharge les URLs gapless et alimente une playlist +//! - PlaylistSource::with_history() : lit la playlist et gère l'historique automatiquement + +use std::{ + collections::HashMap, + pin::Pin, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + task::{Context, Poll}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use crate::{ + channels::{ChannelDescriptor, ParadiseChannelKind, ALL_CHANNELS}, + client::RadioParadiseClient, + models::{Block, EventId}, + playlist_feeder::RadioParadisePlaylistFeeder, +}; +use anyhow::{anyhow, Context as AnyhowContext, Result}; +use once_cell::sync::OnceCell; +use pmoaudio::{AudioError, AudioPipelineNode}; +use pmoaudio_ext::{ + FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, OggFlacStreamHandle, + PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink, StreamingSinkOptions, + TrackBoundaryCoverNode, +}; +use pmoaudiocache::{get_audio_cache, Cache as AudioCache}; +use pmocovers::{get_cover_cache, Cache as CoverCache}; +use pmoflac::EncoderOptions; +use pmoplaylist::PlaylistManager; +use thiserror::Error; +use tokio::io::{AsyncRead, ReadBuf}; +use tokio::sync::{Mutex, Notify}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; + +/// Configuration pour un canal Radio Paradise. +#[derive(Clone, Debug)] +pub struct ParadiseStreamChannelConfig { + /// Durée maximale (en secondes) d'avance acceptée par le broadcast. + pub max_lead_seconds: f64, + /// Options pour le flux FLAC pur. + pub flac_options: StreamingSinkOptions, + /// Options pour le flux OGG-FLAC. + pub ogg_options: StreamingSinkOptions, + /// URL de base du serveur (pour les métadonnées, covers...) + pub server_base_url: Option, +} + +impl Default for ParadiseStreamChannelConfig { + fn default() -> Self { + Self { + max_lead_seconds: 3.0, // Compromis live/fluidité : assez pour absorber les transitions + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } +} + +/// Options pour activer l'archivage/historique d'un canal. +pub struct ParadiseHistoryOptions { + pub audio_cache: Arc, + pub cover_cache: Arc, + pub playlist_id: String, + pub collection: Option, + pub replay_max_lead_seconds: f64, + pub max_history_tracks: Option, +} + +/// Builder pratique pour configurer automatiquement les playlists historiques. +#[derive(Clone)] +pub struct ParadiseHistoryBuilder { + pub audio_cache: Arc, + pub cover_cache: Arc, + pub playlist_prefix: String, + pub playlist_title_prefix: Option, + pub max_history_tracks: Option, + pub collection_prefix: Option, + pub replay_max_lead_seconds: f64, +} + +impl ParadiseHistoryBuilder { + pub fn new(audio_cache: Arc, cover_cache: Arc) -> Self { + Self { + audio_cache, + cover_cache, + playlist_prefix: "radio-paradise-history".into(), + playlist_title_prefix: Some("Radio Paradise History".into()), + max_history_tracks: Some(500), + collection_prefix: Some("radio-paradise".into()), + replay_max_lead_seconds: 3.0, // Aligné avec le live + } + } + + pub async fn build_for_channel( + &self, + descriptor: &ChannelDescriptor, + ) -> Result { + let playlist_id = format!("{}-{}", self.playlist_prefix, descriptor.slug); + + let collection = self + .collection_prefix + .as_ref() + .map(|prefix| format!("{}-{}", prefix, descriptor.slug)); + + Ok(ParadiseHistoryOptions { + audio_cache: self.audio_cache.clone(), + cover_cache: self.cover_cache.clone(), + playlist_id, + collection, + replay_max_lead_seconds: self.replay_max_lead_seconds, + max_history_tracks: self.max_history_tracks, + }) + } +} + +impl Default for ParadiseHistoryBuilder { + fn default() -> Self { + let audio_cache = get_audio_cache() + .expect("pmoaudiocache::register_audio_cache must be called before using ParadiseHistoryBuilder::default()"); + let cover_cache = get_cover_cache() + .expect("pmocovers::register_cover_cache must be called before using ParadiseHistoryBuilder::default()"); + Self::new(audio_cache, cover_cache) + } +} + +#[cfg(feature = "pmoconfig")] +impl ParadiseStreamChannelConfig { + pub fn from_config(cfg: &pmoconfig::Config, channel: ParadiseChannelKind) -> Self { + use serde_yaml::Value; + let path = [ + "sources", + "radio_paradise", + "channels", + channel.slug(), + "max_lead_seconds", + ]; + match cfg.get_value(&path) { + Ok(Value::Number(num)) => { + if let Some(v) = num.as_f64() { + Self { + max_lead_seconds: v.max(0.1), + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } else { + let default = Self::default(); + let _ = + cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + Ok(Value::String(s)) => { + if let Ok(v) = s.parse::() { + Self { + max_lead_seconds: v.max(0.1), + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } else { + let default = Self::default(); + let _ = + cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + _ => { + let default = Self::default(); + let _ = cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + } +} + +/// Stream complet (FLAC pur + OGG-FLAC) pour un canal Radio Paradise. +/// +/// Version simplifiée utilisant RadioParadisePlaylistFeeder + PlaylistSource +pub struct ParadiseStreamChannel { + descriptor: ChannelDescriptor, + state: Arc, + pipeline_handle: JoinHandle<()>, + feeder_handle: JoinHandle<()>, +} + +impl ParadiseStreamChannel { + /// Crée un canal avec client déjà configuré. + pub async fn with_client( + descriptor: ChannelDescriptor, + client: RadioParadiseClient, + config: ParadiseStreamChannelConfig, + cover_cache: Option>, + history: Option, + ) -> Result { + // Propager server_base_url dans les options pour que les encoders injectent les covers du cache + let mut config = config; + if let Some(ref base) = config.server_base_url { + config.flac_options = config + .flac_options + .clone() + .with_server_base_url(Some(base.clone())); + config.ogg_options = config + .ogg_options + .clone() + .with_server_base_url(Some(base.clone())); + } + let cover_cache = cover_cache + .or_else(|| history.as_ref().map(|opts| opts.cover_cache.clone())) + .or_else(|| get_cover_cache()); + let manager = PlaylistManager::get(); + + // 1. Créer la playlist live pour ce canal + let live_playlist_id = format!("radio-paradise-live-{}", descriptor.slug); + let (feeder, live_read) = if let Some(ref history_opts) = history { + RadioParadisePlaylistFeeder::new( + client.clone(), + history_opts.audio_cache.clone(), + history_opts.cover_cache.clone(), + live_playlist_id.clone(), + history_opts.collection.clone(), + ) + .await? + } else { + // Pas d'historique, on a besoin quand même d'un cache audio basique + return Err(anyhow!( + "History options required for now (audio cache needed)" + )); + }; + + let feeder = Arc::new(feeder); + + // 2. Créer/récupérer la playlist historique si activée + let history_write = if let Some(ref history_opts) = history { + let write = manager + .get_persistent_write_handle(history_opts.playlist_id.clone()) + .await?; + + // Configurer la capacité + if let Some(capacity) = history_opts.max_history_tracks { + write.set_capacity(Some(capacity)).await?; + } + + // Configurer le titre + let title = format!("Radio Paradise History - {}", descriptor.display_name); + write.set_title(title).await?; + + Some(Arc::new(write)) + } else { + None + }; + + // 3. Créer la source playlist avec historique + let audio_cache = history.as_ref().unwrap().audio_cache.clone(); + let mut source = if let Some(history_write) = history_write.clone() { + PlaylistSource::with_history(live_read, audio_cache.clone(), history_write) + } else { + PlaylistSource::new(live_read, audio_cache.clone()) + }; + + // 4. Créer les sinks de broadcast (FLAC + OGG) + let (flac_sink, stream_handle) = StreamingFlacSink::with_options( + EncoderOptions::default(), + 16, + config.max_lead_seconds, + config.flac_options.clone(), + ); + let (ogg_sink, ogg_handle) = StreamingOggFlacSink::with_options( + EncoderOptions::default(), + 16, + config.max_lead_seconds, + config.ogg_options.clone(), + ); + + let mut downstream_children: Vec> = Vec::new(); + downstream_children.push(Box::new(flac_sink)); + downstream_children.push(Box::new(ogg_sink)); + + // 5. Optionnel : ajouter le nœud de cache de covers + if let Some(cache) = cover_cache { + let mut cover_node = TrackBoundaryCoverNode::new(cache); + for child in downstream_children { + cover_node.register(child); + } + source.register(Box::new(cover_node)); + } else { + for child in downstream_children { + source.register(child); + } + } + + stream_handle.set_auto_stop(false); + ogg_handle.set_auto_stop(false); + + // 6. Lancer le pipeline audio + let stop_token = CancellationToken::new(); + let pipeline_stop = stop_token.clone(); + let channel_display_name = descriptor.display_name; + + let state = Arc::new(ChannelState { + descriptor, + config, + client, + feeder: feeder.clone(), + stream_handle, + ogg_handle, + history_playlist_id: history.map(|h| h.playlist_id), + history_audio_cache: history_write.map(|_| audio_cache), + active_clients: AtomicUsize::new(0), + activity_notify: Notify::new(), + stop_token, + current_block: Mutex::new(None), + prefetch_lock: Mutex::new(()), + }); + + let pipeline_state = state.clone(); + let pipeline_handle = tokio::spawn(async move { + info!( + "RadioParadise stream pipeline started for channel {}", + channel_display_name + ); + if let Err(e) = Box::new(source).run(pipeline_stop).await { + error!("Pipeline error for channel {}: {}", channel_display_name, e); + pipeline_state.handle_pipeline_error(&e).await; + } + }); + + // 7. Lancer le feeder qui traite les blocs + let feeder_runner = feeder.clone(); + tokio::spawn(async move { + if let Err(e) = feeder_runner.run().await { + error!("RadioParadisePlaylistFeeder error: {}", e); + } + }); + + // 8. Lancer le scheduler qui enqueue les blocs + let feeder_state = state.clone(); + let feeder_handle = tokio::spawn(async move { + feeder_state.run_scheduler().await; + }); + + Ok(Self { + descriptor, + state, + pipeline_handle, + feeder_handle, + }) + } + + /// Crée un canal en construisant automatiquement le client pour ce descriptor. + pub async fn new( + descriptor: ChannelDescriptor, + config: ParadiseStreamChannelConfig, + cover_cache: Option>, + history: Option, + ) -> Result { + let client = RadioParadiseClient::builder() + .channel(descriptor.id) + .build() + .await?; + Self::with_client(descriptor, client, config, cover_cache, history).await + } + + /// S'abonne au flux FLAC pur. + pub fn subscribe_flac(&self) -> ChannelFlacStream { + self.state.on_client_added(); + let inner = self.state.stream_handle.subscribe_flac(); + ChannelFlacStream::new(inner, self.state.clone()) + } + + /// S'abonne au flux FLAC + ICY metadata. + pub fn subscribe_icy(&self) -> ChannelIcyStream { + self.state.on_client_added(); + let inner = self.state.stream_handle.subscribe_icy(); + ChannelIcyStream::new(inner, self.state.clone()) + } + + /// S'abonne au flux OGG-FLAC. + pub fn subscribe_ogg(&self) -> ChannelOggStream { + self.state.on_client_added(); + let inner = self.state.ogg_handle.subscribe(); + ChannelOggStream::new(inner, self.state.clone()) + } + + /// Snapshot des métadonnées actuelles. + pub async fn metadata(&self) -> MetadataSnapshot { + self.state.stream_handle.get_metadata().await + } + + /// Nombre de clients actifs. + pub fn active_clients(&self) -> usize { + self.state.active_clients.load(Ordering::SeqCst) + } + + pub fn descriptor(&self) -> ChannelDescriptor { + self.descriptor + } + + /// Lance un pipeline dédié pour rejouer l'historique (FLAC pur) pour un client. + pub async fn stream_history_flac( + &self, + client_id: &str, + ) -> Result { + let history_id = self + .state + .history_playlist_id + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + + let audio_cache = self + .state + .history_audio_cache + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + + tracing::info!( + "Starting historical FLAC replay for channel {} (client_id={})", + self.descriptor.display_name, + client_id + ); + + let reader = pmoplaylist::PlaylistManager::get() + .get_read_handle(history_id) + .await + .map_err(|e| HistoryStreamError::Playlist(e.to_string()))?; + + let mut source = PlaylistSource::new(reader, audio_cache.clone()); + let (flac_sink, handle) = StreamingFlacSink::with_max_broadcast_lead( + EncoderOptions::default(), + 16, + self.state.config.max_lead_seconds, + ); + source.register(Box::new(flac_sink)); + let stop_token = CancellationToken::new(); + let stop_clone = stop_token.clone(); + let pipeline = tokio::spawn(async move { + let _ = Box::new(source).run(stop_clone).await; + }); + let stream = handle.subscribe_flac(); + Ok(HistoryFlacStream::new(stream, stop_token, pipeline)) + } + + /// Lance un pipeline dédié pour rejouer l'historique (OGG-FLAC) pour un client. + pub async fn stream_history_ogg( + &self, + client_id: &str, + ) -> Result { + let history_id = self + .state + .history_playlist_id + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + + let audio_cache = self + .state + .history_audio_cache + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + + tracing::info!( + "Starting historical OGG replay for channel {} (client_id={})", + self.descriptor.display_name, + client_id + ); + + let reader = pmoplaylist::PlaylistManager::get() + .get_read_handle(history_id) + .await + .map_err(|e| HistoryStreamError::Playlist(e.to_string()))?; + + let mut source = PlaylistSource::new(reader, audio_cache.clone()); + let (ogg_sink, handle) = StreamingOggFlacSink::with_max_broadcast_lead( + EncoderOptions::default(), + 16, + self.state.config.max_lead_seconds, + ); + source.register(Box::new(ogg_sink)); + let stop_token = CancellationToken::new(); + let stop_clone = stop_token.clone(); + let pipeline = tokio::spawn(async move { + let _ = Box::new(source).run(stop_clone).await; + }); + let stream = handle.subscribe(); + Ok(HistoryOggStream::new(stream, stop_token, pipeline)) + } +} + +impl Drop for ParadiseStreamChannel { + fn drop(&mut self) { + self.state.stop_token.cancel(); + self.pipeline_handle.abort(); + self.feeder_handle.abort(); + } +} + +const MAX_BLOCK_LEAD: Duration = Duration::from_secs(3600); +const BLOCK_LEAD_CHECK_CHUNK: Duration = Duration::from_secs(300); +const LIVE_PREFETCH_MIN_TRACKS: usize = 5; +const LIVE_PREFETCH_TIMEOUT: Duration = Duration::from_secs(10); +const LIVE_PREFETCH_POLL_INTERVAL: Duration = Duration::from_millis(200); +const LIVE_PREFETCH_MAX_BLOCKS: usize = 4; + +static GLOBAL_CHANNEL_MANAGER: OnceCell> = OnceCell::new(); + +struct ChannelState { + descriptor: ChannelDescriptor, + config: ParadiseStreamChannelConfig, + client: RadioParadiseClient, + feeder: Arc, + stream_handle: StreamHandle, + ogg_handle: OggFlacStreamHandle, + history_playlist_id: Option, + history_audio_cache: Option>, + active_clients: AtomicUsize, + activity_notify: Notify, + stop_token: CancellationToken, + current_block: Mutex>, + prefetch_lock: Mutex<()>, +} + +impl ChannelState { + fn current_unix_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) + } + + fn block_lead_delay(&self, block: &Block) -> Option { + let start = block.start_time_millis()?; + let now = Self::current_unix_millis(); + let max_lead_ms = MAX_BLOCK_LEAD.as_millis() as u64; + if start <= now + max_lead_ms { + None + } else { + Some(Duration::from_millis(start - now - max_lead_ms)) + } + } + + fn on_client_added(&self) { + if self.active_clients.fetch_add(1, Ordering::SeqCst) == 0 { + self.activity_notify.notify_one(); + } + } + + fn on_client_removed(&self) { + self.active_clients.fetch_sub(1, Ordering::SeqCst); + } + + async fn wait_for_clients(&self) -> bool { + while self.active_clients.load(Ordering::SeqCst) == 0 { + tokio::select! { + _ = self.stop_token.cancelled() => return false, + _ = self.activity_notify.notified() => {}, + } + } + true + } + + async fn wait_until_block_ready(&self, block: &Block) -> BlockReadiness { + loop { + if self.stop_token.is_cancelled() { + return BlockReadiness::Stopped; + } + if self.active_clients.load(Ordering::SeqCst) == 0 { + return BlockReadiness::NoClients; + } + + if let Some(delay) = self.block_lead_delay(block) { + let sleep_for = delay.min(BLOCK_LEAD_CHECK_CHUNK); + let lead_secs = delay.as_secs_f64(); + info!( + "Block {} scheduled too far in the future ({:.1} min). Sleeping {:?} before retrying.", + block.event, + lead_secs / 60.0, + sleep_for + ); + tokio::select! { + _ = self.stop_token.cancelled() => return BlockReadiness::Stopped, + _ = tokio::time::sleep(sleep_for) => {}, + } + continue; + } + + return BlockReadiness::Ready; + } + } + + fn live_playlist_id(&self) -> String { + format!("radio-paradise-live-{}", self.descriptor.slug) + } + + async fn prefetch_until_horizon(&self) -> Result<()> { + let _guard = self.prefetch_lock.lock().await; + let playlist_id = self.live_playlist_id(); + let manager = PlaylistManager::get(); + let reader = manager + .get_read_handle(&playlist_id) + .await + .with_context(|| format!("Failed to get live playlist {}", playlist_id))?; + let start = Instant::now(); + let mut next_event: Option = None; + let mut attempts = 0usize; + + loop { + let available = reader + .remaining() + .await + .with_context(|| format!("Failed to inspect playlist {}", playlist_id))?; + if available >= LIVE_PREFETCH_MIN_TRACKS { + return Ok(()); + } + + if start.elapsed() >= LIVE_PREFETCH_TIMEOUT { + warn!( + "Prefetch timeout for channel {} ({} tracks available)", + self.descriptor.display_name, available + ); + return Ok(()); + } + + if attempts >= LIVE_PREFETCH_MAX_BLOCKS { + warn!( + "Prefetch block limit reached for channel {} ({} tracks available)", + self.descriptor.display_name, available + ); + return Ok(()); + } + + match self.client.get_block(next_event).await { + Ok(block) => { + attempts += 1; + next_event = Some(block.end_event); + self.feeder.push_block_id(block.event).await; + } + Err(e) => { + warn!( + "Failed to fetch block during prefetch for channel {}: {}", + self.descriptor.display_name, e + ); + return Ok(()); + } + } + + tokio::time::sleep(LIVE_PREFETCH_POLL_INTERVAL).await; + } + } + + async fn set_current_block(&self, event_id: EventId) { + let mut guard = self.current_block.lock().await; + *guard = Some(event_id); + } + + async fn take_current_block(&self) -> Option { + self.current_block.lock().await.take() + } + + async fn handle_pipeline_error(&self, err: &AudioError) { + if let Some(event_id) = self.take_current_block().await { + warn!( + "Pipeline error while streaming block {} on channel {}: {}. Rescheduling block.", + event_id, self.descriptor.display_name, err + ); + self.feeder.retry_block(event_id).await; + } else { + warn!( + "Pipeline error for channel {} but no tracked block: {}", + self.descriptor.display_name, err + ); + } + } + + async fn run_scheduler(self: Arc) { + let mut backoff = Duration::from_secs(5); + 'scheduler: loop { + if self.stop_token.is_cancelled() { + break; + } + + if !self.wait_for_clients().await { + break; + } + + match self.client.get_block(None).await { + Ok(block) => { + match self.wait_until_block_ready(&block).await { + BlockReadiness::Ready => {} + BlockReadiness::NoClients => continue, + BlockReadiness::Stopped => break, + } + info!( + "Channel {} streaming block {}", + self.descriptor.display_name, block.event + ); + self.set_current_block(block.event).await; + self.feeder.push_block_id(block.event).await; + let mut next_event = block.end_event; + + loop { + if self.stop_token.is_cancelled() { + return; + } + + if self.active_clients.load(Ordering::SeqCst) == 0 { + break; + } + + match self.client.get_block(Some(next_event)).await { + Ok(next_block) => { + match self.wait_until_block_ready(&next_block).await { + BlockReadiness::Ready => {} + BlockReadiness::NoClients => break, + BlockReadiness::Stopped => break 'scheduler, + } + self.set_current_block(next_block.event).await; + self.feeder.push_block_id(next_block.event).await; + next_event = next_block.end_event; + backoff = Duration::from_secs(5); + } + Err(e) => { + warn!( + "Failed to fetch next block for channel {}: {}", + self.descriptor.display_name, e + ); + tokio::select! { + _ = self.stop_token.cancelled() => return, + _ = tokio::time::sleep(backoff) => {}, + } + backoff = (backoff * 2).min(Duration::from_secs(60)); + } + } + } + } + Err(e) => { + warn!( + "Failed to fetch current block for channel {}: {}", + self.descriptor.display_name, e + ); + tokio::select! { + _ = self.stop_token.cancelled() => break, + _ = tokio::time::sleep(backoff) => {}, + } + backoff = (backoff * 2).min(Duration::from_secs(60)); + } + } + } + } +} + +enum BlockReadiness { + Ready, + NoClients, + Stopped, +} + +macro_rules! wrap_stream { + ($name:ident, $inner:ty) => { + pub struct $name { + inner: $inner, + state: Arc, + } + + impl $name { + fn new(inner: $inner, state: Arc) -> Self { + Self { inner, state } + } + } + + impl AsyncRead for $name { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } + } + + impl Drop for $name { + fn drop(&mut self) { + self.state.on_client_removed(); + } + } + }; +} + +wrap_stream!(ChannelFlacStream, FlacClientStream); +wrap_stream!(ChannelIcyStream, IcyClientStream); +wrap_stream!(ChannelOggStream, OggFlacClientStream); + +#[derive(Debug, Error)] +pub enum HistoryStreamError { + #[error("history replay not enabled for this channel")] + HistoryDisabled, + #[error("playlist error: {0}")] + Playlist(String), +} + +pub struct HistoryFlacStream { + inner: FlacClientStream, + stop_token: CancellationToken, + pipeline: Option>, +} + +impl HistoryFlacStream { + fn new( + inner: FlacClientStream, + stop_token: CancellationToken, + pipeline: JoinHandle<()>, + ) -> Self { + Self { + inner, + stop_token, + pipeline: Some(pipeline), + } + } +} + +impl AsyncRead for HistoryFlacStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl Unpin for HistoryFlacStream {} + +impl Drop for HistoryFlacStream { + fn drop(&mut self) { + self.stop_token.cancel(); + if let Some(handle) = self.pipeline.take() { + handle.abort(); + } + } +} + +pub struct HistoryOggStream { + inner: OggFlacClientStream, + stop_token: CancellationToken, + pipeline: Option>, +} + +impl HistoryOggStream { + fn new( + inner: OggFlacClientStream, + stop_token: CancellationToken, + pipeline: JoinHandle<()>, + ) -> Self { + Self { + inner, + stop_token, + pipeline: Some(pipeline), + } + } +} + +impl AsyncRead for HistoryOggStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl Unpin for HistoryOggStream {} + +impl Drop for HistoryOggStream { + fn drop(&mut self) { + self.stop_token.cancel(); + if let Some(handle) = self.pipeline.take() { + handle.abort(); + } + } +} + +/// Gestionnaire multi-canaux. +pub struct ParadiseChannelManager { + channels: HashMap>, +} + +impl ParadiseChannelManager { + pub fn new(channels: HashMap>) -> Self { + Self { channels } + } + + pub async fn with_defaults_with_cover_cache( + cover_cache: Option>, + history_builder: Option, + server_base_url: Option, + ) -> Result { + tracing::warn!( + "➡️ Entering with_defaults_with_cover_cache ({} channels, base_url={:?})", + ALL_CHANNELS.len(), + server_base_url + ); + let mut map = HashMap::new(); + for descriptor in ALL_CHANNELS.iter().copied() { + let mut config = ParadiseStreamChannelConfig::default(); + config.server_base_url = server_base_url.clone(); + + let start = Instant::now(); + tracing::warn!( + "⏳ Initializing Radio Paradise channel {} ({})...", + descriptor.display_name, + descriptor.slug + ); + + let history_opts = if let Some(builder) = &history_builder { + tracing::warn!( + " ⏳ Building history options for channel {} ({})", + descriptor.display_name, + descriptor.slug + ); + Some( + builder + .build_for_channel(&descriptor) + .await + .map_err(|e| anyhow!("Failed to init history playlist: {}", e))?, + ) + } else { + None + }; + tracing::warn!( + " ⏩ History options ready for channel {} ({})", + descriptor.display_name, + descriptor.slug + ); + let channel = match tokio::time::timeout( + Duration::from_secs(20), + ParadiseStreamChannel::new(descriptor, config, cover_cache.clone(), history_opts), + ) + .await + { + Ok(Ok(ch)) => { + tracing::warn!( + "✅ Channel {} ({}) initialized in {:?}", + descriptor.display_name, + descriptor.slug, + start.elapsed() + ); + ch + } + Ok(Err(e)) => { + tracing::error!( + "⚠️ Failed to initialize channel {} ({}): {}", + descriptor.display_name, + descriptor.slug, + e + ); + continue; + } + Err(_) => { + tracing::error!( + "⚠️ Timeout initializing channel {} ({}) after 20s, skipping", + descriptor.display_name, + descriptor.slug + ); + continue; + } + }; + map.insert(descriptor.id, Arc::new(channel)); + } + Ok(Self { channels: map }) + } + + pub async fn with_defaults() -> Result { + Self::with_defaults_with_cover_cache(None, None, None).await + } + + pub fn get(&self, id: u8) -> Option> { + self.channels.get(&id).cloned() + } + + pub fn iter(&self) -> impl Iterator> { + self.channels.values() + } + + pub async fn prefetch_until_horizon(&self, channel_id: u8) -> Result<()> { + let channel = self + .get(channel_id) + .ok_or_else(|| anyhow!("Unknown channel id {}", channel_id))?; + channel.prefetch_until_horizon().await + } +} + +pub fn register_global_channel_manager(manager: Arc) { + let _ = GLOBAL_CHANNEL_MANAGER.set(Arc::downgrade(&manager)); +} + +pub fn get_global_channel_manager() -> Option> { + GLOBAL_CHANNEL_MANAGER.get().and_then(|weak| weak.upgrade()) +} + +impl ParadiseStreamChannel { + pub async fn prefetch_until_horizon(&self) -> Result<()> { + self.state.prefetch_until_horizon().await + } +} +-------End of pmoparadise/src/stream_channel.rs --------- + +------------ pmoparadise/examples/download_block.rs ---------- +//! Télécharge un bloc complet de Radio Paradise et sauvegarde toutes les pistes en FLAC +//! +//! Ce programme démontre l'utilisation de la chaîne : +//! 1. RadioParadiseStreamSource - Télécharge et décode un bloc FLAC de Radio Paradise +//! 2. FlacFileSink - Sauvegarde automatiquement chaque piste dans un fichier FLAC séparé +//! +//! La nouvelle architecture AudioPipelineNode permet de : +//! - Télécharger et décoder automatiquement les blocs FLAC de Radio Paradise +//! - Détecter les limites de pistes (TrackBoundary) +//! - Sauvegarder automatiquement chaque piste dans un fichier séparé +//! - Gérer proprement l'arrêt du pipeline avec un CancellationToken +//! +//! Usage: +//! cargo run --example download_block -- +//! +//! Exemple: +//! cargo run --example download_block -- 0 # Main Mix +//! cargo run --example download_block -- 1 # Mellow Mix +//! cargo run --example download_block -- 2 # Rock Mix +//! cargo run --example download_block -- 3 # World/Etc Mix + +use pmoaudio::{AudioPipelineNode, FlacFileSink}; +use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource}; +use std::env; +use tokio_util::sync::CancellationToken; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialiser tracing pour le debug + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive(tracing::Level::INFO.into()), + ) + .init(); + + // Récupérer les arguments + let args: Vec = env::args().collect(); + if args.len() != 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!(); + eprintln!("Downloads a complete Radio Paradise block and saves all tracks as FLAC files."); + eprintln!(); + eprintln!("Channel IDs:"); + eprintln!(" 0 - Main Mix (eclectic, diverse mix)"); + eprintln!(" 1 - Mellow Mix (smooth, chilled music)"); + eprintln!(" 2 - Rock Mix (classic & modern rock)"); + eprintln!(" 3 - World/Etc Mix (global sounds)"); + eprintln!(); + eprintln!("Example:"); + eprintln!(" {} 0 # Download Main Mix", args[0]); + eprintln!(" {} 2 # Download Rock Mix", args[0]); + std::process::exit(1); + } + + let channel_id: u8 = match args[1].parse() { + Ok(id) => id, + Err(_) => { + eprintln!("Error: channel_id must be a number between 0 and 3"); + std::process::exit(1); + } + }; + + if channel_id > 3 { + eprintln!("Error: channel_id must be between 0 and 3"); + std::process::exit(1); + } + + println!("=== Radio Paradise Block Downloader ==="); + println!(); + println!("Channel ID: {}", channel_id); + println!(); + + // Créer le client Radio Paradise pour le channel spécifié + println!("Fetching current block metadata..."); + let client = RadioParadiseClient::builder() + .channel(channel_id) + .build() + .await?; + + // Récupérer le bloc actuel + let block = client.get_block(None).await?; + + println!("Block Information:"); + println!(" Event ID: {}", block.event); + println!(" Songs: {}", block.song_count()); + println!(" Duration: {:.1} minutes", block.length as f64 / 60000.0); + println!(); + + // Afficher la liste des pistes + println!("Tracklist:"); + for (index, song) in block.songs_ordered() { + println!( + " {:2}. {} - {} ({})", + index + 1, + song.artist, + song.title, + song.album.as_deref().unwrap_or("Unknown Album") + ); + } + println!(); + + // Créer le répertoire de sortie + let output_dir = format!("./rp_channel_{}block{}", channel_id, block.event); + std::fs::create_dir_all(&output_dir)?; + println!("Output directory: {}", output_dir); + println!(); + + // Créer le pipeline: RadioParadiseStreamSource → FlacFileSink + let mut source = RadioParadiseStreamSource::new(client); + + // Ajouter le bloc à télécharger + source.push_block_id(block.event); + + // Créer le sink qui sauvegarde chaque piste dans un fichier séparé + let base_path = format!("{}/track.flac", output_dir); + let sink = FlacFileSink::new(&base_path); + + // Construire la chaîne: source → sink + source.register(Box::new(sink)); + + // Créer un token d'arrêt + let stop_token = CancellationToken::new(); + + // Gérer Ctrl+C pour arrêt propre + let stop_token_clone = stop_token.clone(); + tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + println!("\n\nReceived Ctrl+C, stopping..."); + stop_token_clone.cancel(); + }); + + // Lancer tout le pipeline + println!("Downloading and processing block..."); + println!("Press Ctrl+C to stop."); + println!(); + let start = std::time::Instant::now(); + + let result = Box::new(source).run(stop_token).await; + + let elapsed = start.elapsed(); + + // Vérifier le résultat + match result { + Ok(()) => { + println!(); + println!( + "✓ Download completed successfully in {:.2}s", + elapsed.as_secs_f64() + ); + println!(" Output directory: {}", output_dir); + println!(); + + // Afficher les fichiers créés + let entries = std::fs::read_dir(&output_dir)?; + let mut files: Vec<_> = entries + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .and_then(|s| s.to_str()) + .map(|s| s == "flac") + .unwrap_or(false) + }) + .collect(); + files.sort_by_key(|e| e.path()); + + println!("Files created:"); + for (i, entry) in files.iter().enumerate() { + let path = entry.path(); + let metadata = std::fs::metadata(&path)?; + let size_mb = metadata.len() as f64 / (1024.0 * 1024.0); + println!( + " {:2}. {} ({:.2} MB)", + i + 1, + path.file_name().unwrap().to_string_lossy(), + size_mb + ); + } + println!(); + + // Calculer la taille totale + let total_size: u64 = files + .iter() + .filter_map(|e| std::fs::metadata(e.path()).ok()) + .map(|m| m.len()) + .sum(); + println!( + "Total size: {:.2} MB", + total_size as f64 / (1024.0 * 1024.0) + ); + } + Err(e) => { + eprintln!(); + eprintln!("✗ Download error: {}", e); + eprintln!(); + return Err(e.into()); + } + } + + Ok(()) +} +-------End of pmoparadise/examples/download_block.rs --------- + +------------ pmoparadise/examples/now_playing.rs ---------- +//! Example: Display currently playing song and block information +//! +//! This example demonstrates: +//! - Creating a Radio Paradise client +//! - Fetching the current block +//! - Displaying song metadata +//! - Generating cover image URLs +//! +//! Run with: cargo run --example now_playing + +use pmoparadise::{RadioParadiseClient, Result}; + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize logging (optional) + #[cfg(feature = "logging")] + tracing_subscriber::fmt::init(); + + println!("Radio Paradise - Now Playing"); + println!("=============================\n"); + + // Create client with default settings (FLAC quality, channel 0) + let client = RadioParadiseClient::new().await?; + + // Get what's currently playing + let now_playing = client.now_playing().await?; + let block = &now_playing.block; + + // Display block information + println!("Block Information:"); + println!(" Event ID: {}", block.event); + println!(" Next Event: {}", block.end_event); + println!(" Duration: {:.1} minutes", block.length as f64 / 60000.0); + println!(" Songs in block: {}", block.song_count()); + println!(" Stream URL: {}\n", block.url); + + // Display current song (if available) + if let Some(song) = &now_playing.current_song { + println!("Now Playing:"); + println!(" Title: {}", song.title); + println!(" Artist: {}", song.artist); + if let Some(ref album) = song.album { + println!(" Album: {}", album); + } + if let Some(year) = song.year { + println!(" Year: {}", year); + } + if let Some(rating) = song.rating { + println!(" Rating: {:.1}/10", rating); + } + println!( + " Duration: {}:{:02}", + song.duration / 60000, + (song.duration % 60000) / 1000 + ); + + // Display cover URL + if let Some(cover) = &song.cover { + if let Some(cover_url) = block.cover_url(cover) { + println!(" Cover: {}", cover_url); + } + } + println!(); + } + + // Display all songs in the block + println!("All Songs in This Block:"); + println!("------------------------"); + + for (index, song) in block.songs_ordered() { + let start_sec = song.elapsed / 1000; + let duration_sec = song.duration / 1000; + + println!( + "{}. [{:02}:{:02}] {} - {} ({:02}:{:02})", + index + 1, + start_sec / 60, + start_sec % 60, + song.artist, + song.title, + duration_sec / 60, + duration_sec % 60 + ); + if let Some(ref album) = song.album { + println!(" Album: {}", album); + } + + if let Some(year) = song.year { + print!(" Year: {}", year); + } + if let Some(rating) = song.rating { + print!(" Rating: {:.1}/10", rating); + } + println!("\n"); + } + + // Show how to get the next block + println!("Fetching Next Block..."); + let next_block = client.get_block(Some(block.end_event)).await?; + println!(" Next block event: {}", next_block.event); + println!(" Songs in next block: {}", next_block.song_count()); + + if let Some((_, first_song)) = next_block.songs_ordered().first() { + println!(" First song: {} - {}", first_song.artist, first_song.title); + } + + Ok(()) +} +-------End of pmoparadise/examples/now_playing.rs --------- + +------------ pmoparadise/examples/play_and_cache.rs ---------- +//! Télécharge un bloc Radio Paradise, le cache, et le joue en même temps +//! +//! Ce programme démontre l'utilisation complète de la chaîne : +//! 1. RadioParadiseStreamSource - Télécharge et décode un bloc FLAC +//! 2. FlacCacheSink - Cache chaque piste en FLAC et alimente une playlist +//! 3. PlaylistSource - Lit la playlist pendant le téléchargement +//! 4. TimerNode - Régule le débit pour éviter EOF prématurés (progressive cache) +//! 5. AudioSink - Joue l'audio sur la sortie standard +//! +//! Architecture : +//! ```text +//! Pipeline 1 (Download & Cache): +//! RadioParadiseStreamSource → FlacCacheSink (avec playlist abonnée) +//! +//! Pipeline 2 (Playback): +//! PlaylistSource → TimerNode (rate limiting) → AudioSink +//! ↓ +//! Prévention EOF +//! (3s max lead) +//! ``` +//! +//! Usage: +//! cargo run --example play_and_cache --features full -- +//! +//! Exemple: +//! cargo run --example play_and_cache --features full -- 0 # Main Mix +//! cargo run --example play_and_cache --features full -- 2 # Rock Mix + +use pmoaudio::{AudioPipelineNode, AudioSink, TimerNode}; +use pmoaudio_ext::{FlacCacheSink, PlaylistSource}; +use pmoaudiocache::{ + new_cache_with_consolidation as new_audio_cache, + register_audio_cache as register_global_audio_cache, +}; +use pmocovers::{new_cache_with_consolidation as new_cover_cache, register_cover_cache}; +use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource}; +use pmoplaylist::register_audio_cache as register_playlist_audio_cache; +use std::env; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialiser tracing avec beaucoup de logs + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive(tracing::Level::DEBUG.into()) + .add_directive("pmoaudio=debug".parse()?) + .add_directive("pmoaudio_ext=debug".parse()?) + .add_directive("pmoplaylist=debug".parse()?) + .add_directive("pmoparadise=debug".parse()?) + .add_directive("pmoaudiocache=debug".parse()?), + ) + .init(); + + tracing::info!("=== Radio Paradise Play & Cache ==="); + + // Récupérer les arguments + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} [--null-audio]", args[0]); + eprintln!(); + eprintln!("Downloads a Radio Paradise block, caches it, and plays it simultaneously."); + eprintln!(); + eprintln!("Channel IDs:"); + eprintln!(" 0 - Main Mix (eclectic, diverse mix)"); + eprintln!(" 1 - Mellow Mix (smooth, chilled music)"); + eprintln!(" 2 - Rock Mix (classic & modern rock)"); + eprintln!(" 3 - World/Etc Mix (global sounds)"); + eprintln!(); + eprintln!("Options:"); + eprintln!(" --null-audio Don't play audio (for testing without audio device)"); + std::process::exit(1); + } + + let channel_id: u8 = match args[1].parse() { + Ok(id) if id <= 3 => id, + _ => { + eprintln!("Error: channel_id must be a number between 0 and 3"); + std::process::exit(1); + } + }; + + let use_null_audio = args.len() > 2 && args[2] == "--null-audio"; + + tracing::info!("Channel ID: {}", channel_id); + if use_null_audio { + tracing::info!("Using null audio output (no playback)"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Initialiser les caches et le gestionnaire de playlist + // ═══════════════════════════════════════════════════════════════════════════ + + let base_dir = + std::env::var("PMO_CONFIG_DIR").unwrap_or_else(|_| "/tmp/pmomusic_test".to_string()); + std::fs::create_dir_all(&base_dir)?; + + tracing::info!("Initializing caches in: {}", base_dir); + + // Créer le cache audio + let audio_cache_dir = format!("{}/audio_cache", base_dir); + std::fs::create_dir_all(&audio_cache_dir)?; + let audio_cache = new_audio_cache(&audio_cache_dir, 1000).await?; + tracing::debug!("Audio cache initialized at: {}", audio_cache_dir); + + // Créer le cache de covers + let cover_cache_dir = format!("{}/cover_cache", base_dir); + std::fs::create_dir_all(&cover_cache_dir)?; + let cover_cache = new_cover_cache(&cover_cache_dir, 100).await?; + tracing::debug!("Cover cache initialized at: {}", cover_cache_dir); + + // Enregistrer le cache audio dans pmoplaylist + // (requis par pmoplaylist pour valider les pks) + register_global_audio_cache(audio_cache.clone()); + register_playlist_audio_cache(audio_cache.clone()); + register_cover_cache(cover_cache.clone()); + tracing::debug!("Audio cache registered in pmoplaylist"); + + // Utiliser le gestionnaire de playlist singleton + tracing::info!("Getting playlist manager..."); + let playlist_manager = pmoplaylist::PlaylistManager(); + tracing::debug!("Playlist manager obtained"); + + // ═══════════════════════════════════════════════════════════════════════════ + // Créer la playlist pour ce channel + // ═══════════════════════════════════════════════════════════════════════════ + + let playlist_id = format!("radio-paradise-ch{}", channel_id); + tracing::info!("Creating playlist: {}", playlist_id); + + // Créer une playlist éphémère (non persistante) pour cet exemple + let writer = playlist_manager + .get_write_handle(playlist_id.clone()) + .await?; + writer + .set_title(format!("Radio Paradise - Channel {}", channel_id)) + .await?; + writer.flush().await?; // Vider la playlist si elle existait + tracing::debug!("Playlist created and flushed"); + + // Créer le reader pour la lecture + let reader = playlist_manager.get_read_handle(&playlist_id).await?; + tracing::debug!("Read handle created"); + + // ═══════════════════════════════════════════════════════════════════════════ + // Récupérer les infos du bloc à télécharger + // ═══════════════════════════════════════════════════════════════════════════ + + tracing::info!("Fetching current block metadata..."); + let client = RadioParadiseClient::builder() + .channel(channel_id) + .build() + .await?; + + let block = client.get_block(None).await?; + + tracing::info!("Block Information:"); + tracing::info!(" Event ID: {}", block.event); + tracing::info!(" Songs: {}", block.song_count()); + tracing::info!(" Duration: {:.1} minutes", block.length as f64 / 60000.0); + tracing::info!(""); + + tracing::info!("Tracklist:"); + for (index, song) in block.songs_ordered() { + tracing::info!( + " {:2}. {} - {} ({})", + index + 1, + song.artist, + song.title, + song.album.as_deref().unwrap_or("Unknown Album") + ); + } + tracing::info!(""); + + // ═══════════════════════════════════════════════════════════════════════════ + // Pipeline 1: Téléchargement et cache + // ═══════════════════════════════════════════════════════════════════════════ + + tracing::info!("Creating download pipeline..."); + + // Créer la source Radio Paradise + let mut download_source = RadioParadiseStreamSource::new(client); + download_source.push_block_id(block.event); + tracing::debug!( + "RadioParadiseStreamSource created with block {}", + block.event + ); + + // Créer le sink de cache FLAC + let mut cache_sink = FlacCacheSink::new(audio_cache.clone(), cover_cache.clone()); + cache_sink.register_playlist(writer); + tracing::debug!("FlacCacheSink created and registered with playlist"); + + // Connecter source → sink + download_source.register(Box::new(cache_sink)); + tracing::info!("Download pipeline connected: RadioParadiseStreamSource → FlacCacheSink"); + + // ═══════════════════════════════════════════════════════════════════════════ + // Pipeline 2: Lecture depuis la playlist + // ═══════════════════════════════════════════════════════════════════════════ + + tracing::info!("Creating playback pipeline..."); + + // Créer la source playlist + let mut playlist_source = PlaylistSource::new(reader, audio_cache.clone()); + tracing::debug!("PlaylistSource created"); + + // Créer le timer node pour réguler le débit (empêche EOF prématurés) + // Tolère 3 secondes d'avance max pour permettre le buffering + let mut timer = TimerNode::new(3.0); + tracing::debug!("TimerNode created (max_lead_time=3.0s)"); + + // Créer le sink audio + let audio_sink = if use_null_audio { + AudioSink::with_null_output() + } else { + AudioSink::new() + }; + tracing::debug!("AudioSink created"); + + // Connecter timer → audio (AVANT de mettre timer dans une Box) + timer.register(Box::new(audio_sink)); + + // Connecter playlist → timer + playlist_source.register(Box::new(timer)); + tracing::info!("Playback pipeline connected: PlaylistSource → TimerNode → AudioSink"); + + // ═══════════════════════════════════════════════════════════════════════════ + // Lancer les deux pipelines en parallèle + // ═══════════════════════════════════════════════════════════════════════════ + + tracing::info!(""); + tracing::info!("========================================"); + tracing::info!("Starting both pipelines..."); + tracing::info!("Pipeline 1: Downloading and caching"); + tracing::info!("Pipeline 2: Playing from playlist"); + tracing::info!("========================================"); + tracing::info!(""); + + let stop_token = CancellationToken::new(); + let stop_token_download = stop_token.clone(); + let stop_token_playback = stop_token.clone(); + + // Gérer Ctrl+C + let stop_token_ctrl_c = stop_token.clone(); + tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + tracing::warn!("Received Ctrl+C, stopping..."); + stop_token_ctrl_c.cancel(); + }); + + let start = std::time::Instant::now(); + + // Lancer les deux pipelines en parallèle + let download_handle = tokio::spawn(async move { + tracing::info!("[DOWNLOAD] Pipeline starting..."); + let result = Box::new(download_source).run(stop_token_download).await; + match &result { + Ok(()) => tracing::info!("[DOWNLOAD] Pipeline completed successfully"), + Err(e) => tracing::error!("[DOWNLOAD] Pipeline error: {}", e), + } + result + }); + + let playback_handle = tokio::spawn(async move { + // Pas de sleep - le cache progressif permet de démarrer immédiatement + // dès que le prebuffer (512 KB) est atteint + tracing::info!("[PLAYBACK] Pipeline starting (will wait for prebuffer)..."); + let result = Box::new(playlist_source).run(stop_token_playback).await; + match &result { + Ok(()) => tracing::info!("[PLAYBACK] Pipeline completed successfully"), + Err(e) => tracing::error!("[PLAYBACK] Pipeline error: {}", e), + } + result + }); + + // Attendre les deux pipelines + let (download_result, playback_result) = tokio::join!(download_handle, playback_handle); + + let elapsed = start.elapsed(); + + // Vérifier les résultats + match (download_result, playback_result) { + (Ok(Ok(())), Ok(Ok(()))) => { + tracing::info!(""); + tracing::info!("========================================"); + tracing::info!("✓ Both pipelines completed successfully"); + tracing::info!(" Total time: {:.2}s", elapsed.as_secs_f64()); + tracing::info!("========================================"); + } + (download_res, playback_res) => { + tracing::error!(""); + tracing::error!("========================================"); + if let Err(e) = download_res { + tracing::error!("✗ Download pipeline error: {:?}", e); + } else if let Ok(Err(e)) = download_res { + tracing::error!("✗ Download pipeline error: {}", e); + } + if let Err(e) = playback_res { + tracing::error!("✗ Playback pipeline error: {:?}", e); + } else if let Ok(Err(e)) = playback_res { + tracing::error!("✗ Playback pipeline error: {}", e); + } + tracing::error!("========================================"); + return Err("Pipeline error".into()); + } + } + + Ok(()) +} +-------End of pmoparadise/examples/play_and_cache.rs --------- + +------------ pmoparadise/examples/serve_channels.rs ---------- +//! Minimal HTTP server exposing all four Radio Paradise channels. +//! +//! Routes: +//! - `/radioparadise/stream//flac` +//! - `/radioparadise/stream//ogg` +//! - `/radioparadise/stream//icy` +//! - `/radioparadise/stream//historic//flac` +//! - `/radioparadise/stream//historic//ogg` +//! - `/radioparadise/metadata/` + +use std::{fs, sync::Arc}; + +use axum::{ + body::Body, + extract::{Path, State}, + http::{ + header::{ACCEPT_RANGES, CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, + StatusCode, + }, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use pmoaudiocache::{ + new_cache_with_consolidation as new_audio_cache, + register_audio_cache as register_global_audio_cache, +}; +use pmocovers::{new_cache_with_consolidation as new_cover_cache, register_cover_cache}; +use pmoparadise::{channels::ALL_CHANNELS, ParadiseChannelManager, ParadiseHistoryBuilder}; +use pmoplaylist::register_audio_cache as register_playlist_audio_cache; +use pmoserver::{init_logging, ServerBuilder}; +use tokio_util::io::ReaderStream; +use tracing::{error, info}; + +#[derive(Clone)] +struct AppState { + manager: Arc, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let _ = init_logging(); + + // Préparer les caches partagés + let cover_cache_dir = "./cache/rp_covers"; + let audio_cache_dir = "./cache/rp_audio"; + fs::create_dir_all(cover_cache_dir)?; + fs::create_dir_all(audio_cache_dir)?; + + let cover_cache = new_cover_cache(cover_cache_dir, 500).await?; + let audio_cache = new_audio_cache(audio_cache_dir, 1000).await?; + register_global_audio_cache(audio_cache.clone()); + register_playlist_audio_cache(audio_cache.clone()); + register_cover_cache(cover_cache.clone()); + let _playlist_manager = pmoplaylist::PlaylistManager(); + + let history_builder = ParadiseHistoryBuilder { + audio_cache: audio_cache.clone(), + cover_cache: cover_cache.clone(), + playlist_prefix: "radio-paradise-history".into(), + playlist_title_prefix: Some("Radio Paradise History".into()), + max_history_tracks: Some(500), + collection_prefix: Some("radioparadise".into()), + replay_max_lead_seconds: 1.0, + }; + + info!("Initializing Radio Paradise channels..."); + let server_base_url = format!("http://localhost:{}", 8080); + let manager = Arc::new( + ParadiseChannelManager::with_defaults_with_cover_cache( + Some(cover_cache), + Some(history_builder), + Some(server_base_url), + ) + .await?, + ); + let app_state = Arc::new(AppState { + manager: manager.clone(), + }); + + let mut server = ServerBuilder::new("RadioParadiseChannels", "http://localhost", 8080).build(); + + for descriptor in ALL_CHANNELS.iter() { + let slug = descriptor.slug; + let flac_path = format!("/radioparadise/stream/{}/flac", slug); + let ogg_path = format!("/radioparadise/stream/{}/ogg", slug); + let icy_path = format!("/radioparadise/stream/{}/icy", slug); + let history_path = format!("/radioparadise/stream/{}/historic", slug); + let meta_path = format!("/radioparadise/metadata/{}", slug); + let channel_id = descriptor.id; + + server + .add_handler_with_state( + &flac_path, + move |State(state): State>| { + let manager = state.manager.clone(); + async move { stream_flac(manager, channel_id).await } + }, + app_state.clone(), + ) + .await; + + server + .add_handler_with_state( + &ogg_path, + move |State(state): State>| { + let manager = state.manager.clone(); + async move { stream_ogg(manager, channel_id).await } + }, + app_state.clone(), + ) + .await; + + server + .add_handler_with_state( + &icy_path, + move |State(state): State>| { + let manager = state.manager.clone(); + async move { stream_icy(manager, channel_id).await } + }, + app_state.clone(), + ) + .await; + + let history_router = Router::new() + .route( + "/{client_id}/flac", + get({ + let manager = manager.clone(); + move |Path(client_id): Path| { + let manager = manager.clone(); + async move { stream_history_flac(manager, channel_id, client_id).await } + } + }), + ) + .route( + "/{client_id}/ogg", + get({ + let manager = manager.clone(); + move |Path(client_id): Path| { + let manager = manager.clone(); + async move { stream_history_ogg(manager, channel_id, client_id).await } + } + }), + ); + + server.add_router(&history_path, history_router).await; + + server + .add_handler_with_state( + &meta_path, + move |State(state): State>| { + let manager = state.manager.clone(); + async move { get_metadata(manager, channel_id).await } + }, + app_state.clone(), + ) + .await; + } + + info!("========================================"); + info!("Radio Paradise streaming server running on http://localhost:8080"); + info!("Available channels:"); + for descriptor in ALL_CHANNELS.iter() { + info!( + " {}: /radioparadise/stream/{}/flac (also /ogg, /icy, metadata, /historic//(flac|ogg))", + descriptor.display_name, descriptor.slug + ); + } + info!("Press Ctrl+C to stop."); + info!("========================================"); + + server.start().await; + server.wait().await; + Ok(()) +} + +async fn stream_flac( + manager: Arc, + channel_id: u8, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.subscribe_flac(); + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "audio/flac") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} + +async fn stream_ogg( + manager: Arc, + channel_id: u8, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.subscribe_ogg(); + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/ogg") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} + +async fn stream_icy( + manager: Arc, + channel_id: u8, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.subscribe_icy(); + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "audio/flac") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .header("icy-metaint", "16000") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} + +async fn get_metadata( + manager: Arc, + channel_id: u8, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let metadata = channel.metadata().await; + Ok(Json(metadata)) +} + +async fn stream_history_flac( + manager: Arc, + channel_id: u8, + client_id: String, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.stream_history_flac(&client_id).await.map_err(|e| { + error!( + "Failed to start historical FLAC stream for channel {} (client_id={}): {}", + channel_id, client_id, e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "audio/flac") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} + +async fn stream_history_ogg( + manager: Arc, + channel_id: u8, + client_id: String, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.stream_history_ogg(&client_id).await.map_err(|e| { + error!( + "Failed to start historical OGG stream for channel {} (client_id={}): {}", + channel_id, client_id, e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/ogg") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} +-------End of pmoparadise/examples/serve_channels.rs --------- + +------------ pmoparadise/examples/single_channel_server.rs ---------- +//! Simple web server that exposes one Radio Paradise channel over HTTP. +//! +//! Usage: +//! ```bash +//! cargo run --example single_channel_server --features full -- main +//! ``` +//! Valid arguments are either the slug (`main`, `mellow`, `rock`, `eclectic`) or +//! the numeric channel id (`0`..`3`). When no argument is provided, the example +//! defaults to the “main” mix. + +use axum::{ + body::Body, + extract::{Path, Request, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use pmoaudio_ext::StreamingSinkOptions; +use pmoaudiocache::{ + new_cache_with_consolidation as new_audio_cache, + register_audio_cache as register_global_audio_cache, +}; +use pmocovers::{ + new_cache_with_consolidation as new_cover_cache, register_cover_cache, Cache as CoverCache, +}; +use pmoparadise::{ + channels::{ChannelDescriptor, ALL_CHANNELS}, + ParadiseHistoryBuilder, ParadiseStreamChannel, ParadiseStreamChannelConfig, +}; +use pmoplaylist::register_audio_cache as register_playlist_audio_cache; +use std::{fs, net::SocketAddr, sync::Arc}; +use tokio::net::TcpListener; +use tokio_util::io::ReaderStream; +use tracing::info; + +#[derive(Clone)] +struct AppState { + channel: Arc, + descriptor: ChannelDescriptor, + cover_cache: Arc, +} + +#[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(env_filter).init(); + + let descriptor = pick_descriptor(std::env::args().nth(1))?; + info!( + "Selected Radio Paradise channel: {} ({})", + descriptor.display_name, descriptor.slug + ); + + // Prepare caches under ./cache/single-channel + let cache_root = "./cache/single-channel"; + let audio_cache_dir = format!("{}/audio", cache_root); + let cover_cache_dir = format!("{}/covers", cache_root); + fs::create_dir_all(&audio_cache_dir)?; + fs::create_dir_all(&cover_cache_dir)?; + + let audio_cache = new_audio_cache(&audio_cache_dir, 1000).await?; + let cover_cache = new_cover_cache(&cover_cache_dir, 200).await?; + register_global_audio_cache(audio_cache.clone()); + register_playlist_audio_cache(audio_cache.clone()); + register_cover_cache(cover_cache.clone()); + + let mut history_builder = ParadiseHistoryBuilder::new(audio_cache.clone(), cover_cache.clone()); + history_builder.playlist_prefix = format!("single-channel-history-{}", descriptor.slug); + history_builder.collection_prefix = Some(format!("single-channel-{}", descriptor.slug)); + let history_opts = history_builder.build_for_channel(&descriptor).await?; + + let mut channel_config = ParadiseStreamChannelConfig::default(); + // Base URL for cover images in stream metadata + let server_base_url = "http://localhost:8080".to_string(); + + // Configuration commune pour FLAC et OGG + let common_options = StreamingSinkOptions::flac_defaults() + .with_default_artist(Some("Radio Paradise".to_string())) + .with_default_title(descriptor.display_name.to_string()) + .with_server_base_url(Some(server_base_url.clone())); + + channel_config.flac_options = common_options.clone(); + channel_config.ogg_options = StreamingSinkOptions::ogg_defaults() + .with_default_artist(Some("Radio Paradise".to_string())) + .with_default_title(descriptor.display_name.to_string()) + .with_server_base_url(Some(server_base_url)); + + let channel = Arc::new( + ParadiseStreamChannel::new( + descriptor, + channel_config, + Some(cover_cache.clone()), + Some(history_opts), + ) + .await?, + ); + + let state = AppState { + channel, + descriptor, + cover_cache, + }; + + let app = Router::new() + .route("/stream/flac", get(stream_flac)) + .route("/stream/ogg", get(stream_ogg)) + .route("/metadata", get(get_metadata)) + .route("/covers/image/{pk}", get(get_cover)) + .with_state(state); + + let addr: SocketAddr = ([0, 0, 0, 0], 8080).into(); + info!("========================================"); + info!("HTTP server listening on http://{addr}"); + info!("Available endpoints:"); + info!(" - /stream/flac : FLAC audio stream"); + info!(" - /stream/ogg : OGG-FLAC audio stream"); + info!(" - /metadata : Current track metadata (JSON)"); + info!(" - /covers/image/{{pk}} : Album cover images (WebP)"); + info!("========================================"); + info!("Connect with a FLAC player: ffplay http://localhost:8080/stream/flac"); + info!("Connect with an OGG-FLAC player: ffplay http://localhost:8080/stream/ogg"); + + let listener = TcpListener::bind(addr).await?; + axum::serve(listener, app.into_make_service()).await?; + + Ok(()) +} + +async fn stream_flac(State(state): State) -> Result { + let stream = state.channel.subscribe_flac(); + let body = Body::from_stream(ReaderStream::new(stream)); + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "audio/flac") + .header( + "X-PMO-Channel", + format!( + "{} ({})", + state.descriptor.display_name, state.descriptor.slug + ), + ) + .body(body) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +async fn stream_ogg(State(state): State) -> Result { + let stream = state.channel.subscribe_ogg(); + let body = Body::from_stream(ReaderStream::new(stream)); + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "audio/ogg") + .header( + "X-PMO-Channel", + format!( + "{} ({})", + state.descriptor.display_name, state.descriptor.slug + ), + ) + .body(body) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +async fn get_metadata( + State(state): State, + request: Request, +) -> Result { + let mut metadata = state.channel.metadata().await; + + // Si cover_pk est disponible, construire l'URL complète depuis les headers + // Format: /covers/image/{pk} (correspond à la structure du cache pmocovers) + if let Some(ref pk) = metadata.cover_pk { + let base_url = extract_base_url(&request); + metadata.cover_url = Some(format!("{}/covers/image/{}", base_url, pk)); + } + + Ok(Json(metadata)) +} + +/// Extrait l'URL de base depuis les headers HTTP de la requête +/// Supporte les proxies avec X-Forwarded-Host et X-Forwarded-Proto +fn extract_base_url(request: &Request) -> String { + let headers = request.headers(); + + // Déterminer le schéma (http ou https) + let scheme = headers + .get("x-forwarded-proto") + .and_then(|h| h.to_str().ok()) + .unwrap_or("http"); + + // Déterminer le host + let host = headers + .get("x-forwarded-host") + .or_else(|| headers.get("host")) + .and_then(|h| h.to_str().ok()) + .unwrap_or("localhost:8080"); + + format!("{}://{}", scheme, host) +} + +async fn get_cover( + State(state): State, + Path(pk): Path, +) -> Result { + // Récupérer le chemin de la cover depuis le cache + // Le cache retourne un PathBuf pointant vers le fichier .webp + let cover_path = state.cover_cache.get(&pk).await.map_err(|e| { + tracing::error!("Failed to get cover path for {}: {}", pk, e); + StatusCode::NOT_FOUND + })?; + + // Lire le fichier + let cover_data = tokio::fs::read(&cover_path).await.map_err(|e| { + tracing::error!("Failed to read cover file {:?}: {}", cover_path, e); + StatusCode::NOT_FOUND + })?; + + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "image/webp") + .header("Cache-Control", "public, max-age=86400") + .body(Body::from(cover_data)) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +fn pick_descriptor(arg: Option) -> anyhow::Result { + if let Some(token) = arg { + if let Some(desc) = ALL_CHANNELS.iter().find(|c| c.slug == token) { + return Ok(*desc); + } + if let Ok(id) = token.parse::() { + if let Some(desc) = ALL_CHANNELS.iter().find(|c| c.id == id) { + return Ok(*desc); + } + } + anyhow::bail!("Unknown channel identifier: {token}"); + } + Ok(ALL_CHANNELS[0]) +} +-------End of pmoparadise/examples/single_channel_server.rs --------- + +------------ pmoparadise/examples/stream_block.rs ---------- +//! Streams a Radio Paradise block via HTTP using pmoserver +//! +//! This example demonstrates streaming a single Radio Paradise block +//! using the StreamingFlacSink over HTTP via pmoserver. Perfect for +//! testing with VLC or other media players that support HTTP streaming. +//! +//! The example streams ONE block then terminates cleanly using END_OF_BLOCKS_SIGNAL. +//! For continuous streaming, push multiple block_ids without the END signal. +//! +//! Architecture: +//! ```text +//! RadioParadiseStreamSource → TimerBufferNode → StreamingFlacSink +//! ↓ +//! StreamHandle +//! ↓ +//! pmoserver (Axum) +//! ↓ +//! VLC / Media Player Client +//! ``` +//! +//! Usage: +//! cargo run --example stream_block --features full -- +//! +//! Example: +//! cargo run --example stream_block --features full -- 0 # Main Mix +//! +//! Then open in VLC: +//! vlc http://localhost:8080/test/stream (pure FLAC) +//! vlc http://localhost:8080/test/stream-ogg (OGG-FLAC streaming container) +//! vlc http://localhost:8080/test/stream-icy (FLAC + ICY metadata) +//! +//! To check current metadata: +//! curl http://localhost:8080/test/metadata + +use axum::{ + body::Body, + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, +}; +use pmoaudio::{AudioPipelineNode, TimerBufferNode}; +use pmoaudio_ext::{StreamingFlacSink, StreamingOggFlacSink}; +use pmoflac::EncoderOptions; +use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource, END_OF_BLOCKS_SIGNAL}; +use pmoserver::{init_logging, ServerBuilder}; +use std::env; +use std::sync::Arc; +use tokio_util::io::ReaderStream; +use tokio_util::sync::CancellationToken; + +/// Shared application state +struct AppState { + stream_handle: pmoaudio_ext::StreamHandle, + ogg_handle: pmoaudio_ext::OggFlacStreamHandle, +} + +/// Main HTTP handler for streaming (pure FLAC, no ICY metadata) +async fn stream_handler( + State(state): State>, + _headers: HeaderMap, +) -> Result { + tracing::info!("New client connected (pure FLAC mode)"); + + // Pure FLAC stream without ICY metadata + let flac_stream = state.stream_handle.subscribe_flac(); + + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "audio/flac") + .header("Cache-Control", "no-cache, no-store") + .body(Body::from_stream(ReaderStream::new(flac_stream))) + .unwrap()) +} + +/// ICY streaming handler (FLAC with embedded metadata) +async fn stream_icy_handler( + State(state): State>, + _headers: HeaderMap, +) -> Result { + tracing::info!("New client connected (ICY mode)"); + + // FLAC stream with ICY metadata + let icy_stream = state.stream_handle.subscribe_icy(); + + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "audio/flac") + .header("icy-metaint", "16000") + .header("icy-name", "Radio Paradise Stream Test") + .header("icy-genre", "Eclectic") + .header("icy-pub", "1") + .header("Cache-Control", "no-cache, no-store") + .body(Body::from_stream(ReaderStream::new(icy_stream))) + .unwrap()) +} + +/// OGG-FLAC streaming handler +async fn stream_ogg_handler( + State(state): State>, + _headers: HeaderMap, +) -> Result { + tracing::info!("New client connected (OGG-FLAC mode)"); + + // OGG-FLAC stream + let ogg_stream = state.ogg_handle.subscribe(); + + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "audio/ogg") + .header("Cache-Control", "no-cache, no-store") + .body(Body::from_stream(ReaderStream::new(ogg_stream))) + .unwrap()) +} + +/// Metadata endpoint (JSON) +async fn metadata_handler(State(state): State>) -> impl IntoResponse { + let metadata = state.stream_handle.get_metadata().await; + axum::Json(metadata) +} + +/// Health check endpoint +async fn health_handler() -> &'static str { + "OK" +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize logging via pmoserver + let _log_state = init_logging(); + + tracing::info!("=== Radio Paradise HTTP Streaming Test ==="); + + // Parse arguments + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!(); + eprintln!("Streams a Radio Paradise block via HTTP for testing."); + eprintln!(); + eprintln!("Channel IDs:"); + eprintln!(" 0 - Main Mix (eclectic, diverse mix)"); + eprintln!(" 1 - Mellow Mix (smooth, chilled music)"); + eprintln!(" 2 - Rock Mix (classic & modern rock)"); + eprintln!(" 3 - World/Etc Mix (global sounds)"); + eprintln!(); + eprintln!("After starting, open in VLC:"); + eprintln!(" vlc http://localhost:8080/test/stream (pure FLAC)"); + eprintln!(" vlc http://localhost:8080/test/stream-ogg (OGG-FLAC container)"); + eprintln!(" vlc http://localhost:8080/test/stream-icy (FLAC + ICY metadata)"); + std::process::exit(1); + } + + let channel_id: u8 = match args[1].parse() { + Ok(id) if id <= 3 => id, + _ => { + eprintln!("Error: channel_id must be a number between 0 and 3"); + std::process::exit(1); + } + }; + + tracing::info!("Channel ID: {}", channel_id); + + // ═══════════════════════════════════════════════════════════════════════════ + // Fetch block metadata + // ═══════════════════════════════════════════════════════════════════════════ + + tracing::info!("Fetching current block metadata..."); + let client = RadioParadiseClient::builder() + .channel(channel_id) + .build() + .await?; + + let block = client.get_block(None).await?; + + tracing::info!("Block Information:"); + tracing::info!(" Event ID: {}", block.event); + tracing::info!(" Songs: {}", block.song_count()); + tracing::info!(" Duration: {:.1} minutes", block.length as f64 / 60000.0); + tracing::info!(""); + + tracing::info!("Tracklist:"); + for (index, song) in block.songs_ordered() { + tracing::info!( + " {:2}. {} - {} ({})", + index + 1, + song.artist, + song.title, + song.album.as_deref().unwrap_or("Unknown Album") + ); + } + tracing::info!(""); + + // ═══════════════════════════════════════════════════════════════════════════ + // Create streaming pipelines (FLAC and OGG-FLAC) + // ═══════════════════════════════════════════════════════════════════════════ + + tracing::info!("Creating streaming pipelines..."); + + // Encoder options (shared) + let encoder_options = EncoderOptions { + compression_level: 5, + verify: false, + ..Default::default() + }; + + // ───────────────────────────────────────────────────────────────────────── + // Unique pipeline feeding both FLAC and OGG sinks + // ───────────────────────────────────────────────────────────────────────── + + let mut source = RadioParadiseStreamSource::new(client); + source.push_block_id(block.event); + source.push_block_id(END_OF_BLOCKS_SIGNAL); // Signal: no more blocks after this one + tracing::debug!( + "RadioParadiseStreamSource created with block {} + END signal", + block.event + ); + + // Use SMALL channel size to make backpressure plus fan-out manageable. + let buffer_sec = 0.1; + let max_lead_time = buffer_sec; + let channel_size = 512; + tracing::debug!( + "Using channel size: {} chunks ({:.1}s buffer à 50ms/chunk)", + channel_size, + channel_size as f64 * 0.05 + ); + + let mut timer_node = TimerBufferNode::with_channel_size(buffer_sec, channel_size); + tracing::debug!( + "TimerBufferNode created with {:.1}s buffer, {} chunk queue", + buffer_sec, + channel_size + ); + + // Streaming sinks + let (streaming_sink, stream_handle) = + StreamingFlacSink::with_max_broadcast_lead(encoder_options.clone(), 16, max_lead_time); + tracing::debug!("StreamingFlacSink created"); + + let (ogg_sink, ogg_handle) = + StreamingOggFlacSink::with_max_broadcast_lead(encoder_options, 16, max_lead_time); + tracing::debug!("StreamingOggFlacSink created"); + + // timer_node.register(Box::new(streaming_sink)); + // timer_node.register(Box::new(ogg_sink)); + // source.register(Box::new(timer_node)); + + source.register(Box::new(streaming_sink)); + source.register(Box::new(ogg_sink)); + + tracing::info!("Pipeline connected: StreamSource → TimerBufferNode → {{FLAC, OGG}} sinks"); + + // ═══════════════════════════════════════════════════════════════════════════ + // Setup pmoserver with streaming routes + // ═══════════════════════════════════════════════════════════════════════════ + + tracing::info!("Setting up pmoserver..."); + + let mut server = + ServerBuilder::new("RadioParadiseStreamTest", "http://localhost", 8080).build(); + + let app_state = Arc::new(AppState { + stream_handle, + ogg_handle, + }); + + // Add streaming routes + let base = "/radioparadise/test"; + server + .add_handler_with_state( + &format!("{}/stream", base), + stream_handler, + app_state.clone(), + ) + .await; + server + .add_handler_with_state( + &format!("{}/stream-icy", base), + stream_icy_handler, + app_state.clone(), + ) + .await; + server + .add_handler_with_state( + &format!("{}/stream-ogg", base), + stream_ogg_handler, + app_state.clone(), + ) + .await; + + // Add metadata route + server + .add_handler_with_state( + &format!("{}/metadata", base), + metadata_handler, + app_state.clone(), + ) + .await; + + // Add health check + server.add_handler("/test/health", health_handler).await; + + tracing::info!(""); + tracing::info!("========================================"); + tracing::info!("Ready to stream!"); + tracing::info!(""); + tracing::info!("Pure FLAC stream (for VLC, standard players):"); + tracing::info!(" vlc http://localhost:8080{}/stream", base); + tracing::info!(""); + tracing::info!("OGG-FLAC stream (streaming container with metadata support):"); + tracing::info!(" vlc http://localhost:8080{}/stream-ogg", base); + tracing::info!(""); + tracing::info!("FLAC + ICY metadata stream (for ICY-aware clients):"); + tracing::info!(" http://localhost:8080{}/stream-icy", base); + tracing::info!(""); + tracing::info!("Metadata endpoint (JSON):"); + tracing::info!(" curl http://localhost:8080{}/metadata", base); + tracing::info!("========================================"); + tracing::info!(""); + + // ═══════════════════════════════════════════════════════════════════════════ + // Start pipelines and server + // ═══════════════════════════════════════════════════════════════════════════ + + let stop_token = CancellationToken::new(); + let pipeline_stop = stop_token.clone(); + + // Start shared pipeline in background + let pipeline_handle = tokio::spawn(async move { + tracing::info!("[PIPELINE] Starting..."); + let result = Box::new(source).run(pipeline_stop).await; + match &result { + Ok(()) => tracing::info!("[PIPELINE] Completed successfully"), + Err(e) => tracing::error!("[PIPELINE] Error: {}", e), + } + result + }); + + // Start pmoserver (blocks until Ctrl+C) + tracing::info!("[SERVER] Starting pmoserver..."); + server.start().await; + server.wait().await; + + // Server stopped, cancel pipelines + tracing::info!("Server stopped, canceling pipelines..."); + stop_token.cancel(); + + // Wait for pipeline to finish + match pipeline_handle.await { + Ok(Ok(())) => tracing::info!("Pipeline completed successfully"), + Ok(Err(e)) => tracing::error!("Pipeline error: {}", e), + Err(e) => tracing::error!("Pipeline task error: {}", e), + } + + tracing::info!("Shutdown complete"); + Ok(()) +} +-------End of pmoparadise/examples/stream_block.rs --------- + diff --git a/pmoparadise_012.txt b/pmoparadise_012.txt new file mode 100644 index 00000000..6c10f232 --- /dev/null +++ b/pmoparadise_012.txt @@ -0,0 +1,7970 @@ +------------ pmoparadise/src/channels.rs ---------- +//! Radio Paradise channel definitions +//! +//! This module defines the available Radio Paradise channels and their metadata. + +use std::str::FromStr; + +/// Logical identifier for a Radio Paradise channel. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ParadiseChannelKind { + Main, + Mellow, + Rock, + Eclectic, +} + +impl ParadiseChannelKind { + pub const fn id(self) -> u8 { + match self { + Self::Main => 0, + Self::Mellow => 1, + Self::Rock => 2, + Self::Eclectic => 3, + } + } + + pub const fn slug(self) -> &'static str { + match self { + Self::Main => "main", + Self::Mellow => "mellow", + Self::Rock => "rock", + Self::Eclectic => "eclectic", + } + } + + pub const fn display_name(self) -> &'static str { + match self { + Self::Main => "Main Mix", + Self::Mellow => "Mellow Mix", + Self::Rock => "Rock Mix", + Self::Eclectic => "Eclectic Mix", + } + } + + pub const fn description(self) -> &'static str { + match self { + Self::Main => "Eclectic mix of rock, world, electronica, and more", + Self::Mellow => "Mellower, less aggressive music", + Self::Rock => "Heavier, more guitar-driven music", + Self::Eclectic => "Curated worldwide selection", + } + } +} + +impl FromStr for ParadiseChannelKind { + type Err = anyhow::Error; + + fn from_str(s: &str) -> std::result::Result { + match s.to_ascii_lowercase().as_str() { + "main" | "0" => Ok(Self::Main), + "mellow" | "1" => Ok(Self::Mellow), + "rock" | "2" => Ok(Self::Rock), + "eclectic" | "3" => Ok(Self::Eclectic), + other => Err(anyhow::anyhow!("Unknown Radio Paradise channel: {}", other)), + } + } +} + +/// Metadata descriptor for a channel. +#[derive(Debug, Clone, Copy)] +pub struct ChannelDescriptor { + pub kind: ParadiseChannelKind, + pub id: u8, + pub slug: &'static str, + pub display_name: &'static str, + pub description: &'static str, +} + +impl ChannelDescriptor { + pub const fn new(kind: ParadiseChannelKind) -> Self { + Self { + id: kind.id(), + slug: kind.slug(), + display_name: kind.display_name(), + description: kind.description(), + kind, + } + } +} + +/// All available Radio Paradise channels +pub const ALL_CHANNELS: [ChannelDescriptor; 4] = [ + ChannelDescriptor::new(ParadiseChannelKind::Main), + ChannelDescriptor::new(ParadiseChannelKind::Mellow), + ChannelDescriptor::new(ParadiseChannelKind::Rock), + ChannelDescriptor::new(ParadiseChannelKind::Eclectic), +]; + +/// Returns the maximum valid channel ID +pub const fn max_channel_id() -> u8 { + (ALL_CHANNELS.len() - 1) as u8 +} + +/// Default maximum number of tracks to keep in history +/// +/// This is used as the default if not configured via pmoconfig. +/// Value: 100 tracks - represents ~5-8 hours of playback history +pub const HISTORY_DEFAULT_MAX_TRACKS: usize = 100; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_channel_ids() { + assert_eq!(ParadiseChannelKind::Main.id(), 0); + assert_eq!(ParadiseChannelKind::Mellow.id(), 1); + assert_eq!(ParadiseChannelKind::Rock.id(), 2); + assert_eq!(ParadiseChannelKind::Eclectic.id(), 3); + } + + #[test] + fn test_max_channel_id() { + assert_eq!(max_channel_id(), 3); + } + + #[test] + fn test_all_channels_length() { + assert_eq!(ALL_CHANNELS.len(), 4); + } + + #[test] + fn test_channel_from_str() { + assert!(matches!( + "main".parse::(), + Ok(ParadiseChannelKind::Main) + )); + assert!(matches!( + "0".parse::(), + Ok(ParadiseChannelKind::Main) + )); + assert!("invalid".parse::().is_err()); + } +} +-------End of pmoparadise/src/channels.rs --------- + +------------ pmoparadise/src/client.rs ---------- +//! HTTP client for Radio Paradise API + +use crate::error::{Error, Result}; +use crate::models::{Block, EventId, NowPlaying}; +use reqwest::Client; +use std::time::Duration; +use url::Url; + +/// Default Radio Paradise API base URL +pub const DEFAULT_API_BASE: &str = "https://api.radioparadise.com/api"; + +/// Default block base URL (channel is appended) +pub const DEFAULT_BLOCK_BASE: &str = "https://apps.radioparadise.com/blocks/chan"; + +/// Default image base URL +pub const DEFAULT_IMAGE_BASE: &str = "https://img.radioparadise.com/"; + +/// Default timeout for metadata HTTP requests +pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; + +/// Default timeout for large block downloads/streams +/// IMPORTANT: Radio Paradise blocks can be ~20 minutes long, and with backpressure +/// from the audio pipeline, the HTTP stream must stay open for the entire duration. +/// Setting this to 2 hours to safely handle even the longest blocks. +pub const DEFAULT_BLOCK_TIMEOUT_SECS: u64 = 7200; // 2 hours + +/// Default User-Agent +pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0"; + +/// Default channel (0 = main mix) +pub const DEFAULT_CHANNEL: u8 = 0; + +/// Radio Paradise HTTP client +/// +/// This client provides access to Radio Paradise's streaming API, +/// including metadata retrieval and block streaming. +/// +/// # Example +/// +/// ```no_run +/// use pmoparadise::RadioParadiseClient; +/// +/// #[tokio::main] +/// async fn main() -> Result<(), Box> { +/// let client = RadioParadiseClient::new().await?; +/// let now_playing = client.now_playing().await?; +/// println!("Now playing: {} - {}", +/// now_playing.current_song.as_ref().unwrap().artist, +/// now_playing.current_song.as_ref().unwrap().title); +/// Ok(()) +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct RadioParadiseClient { + pub(crate) client: Client, + api_base: String, + channel: u8, + pub(crate) request_timeout: Duration, + pub(crate) block_timeout: Duration, + next_block_url: Option, +} + +impl RadioParadiseClient { + /// Create a new client with default settings + /// + /// Uses FLAC quality and channel 0 (main mix) + pub async fn new() -> Result { + Self::builder().build().await + } + + /// Create a builder for configuring the client + pub fn builder() -> ClientBuilder { + ClientBuilder::default() + } + + /// Create a client with a custom reqwest::Client + /// + /// Useful for sharing HTTP connection pools or custom proxy settings + /// + /// Note: Uses default settings (channel 0, default timeouts). + /// For more control, use `ClientBuilder::default().client(client).build()`. + pub fn with_client(client: Client) -> Self { + Self { + client, + api_base: DEFAULT_API_BASE.to_string(), + channel: DEFAULT_CHANNEL, + request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), + block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS), + next_block_url: None, + } + } + + /// Get the current channel (0 = main mix) + pub fn channel(&self) -> u8 { + self.channel + } + + /// Get the block base URL for this client's channel + pub fn block_base(&self) -> String { + format!("{}/{}", DEFAULT_BLOCK_BASE, self.channel) + } + + /// Clone the client with a different channel while preserving other settings. + pub fn clone_with_channel(&self, channel: u8) -> Self { + let mut cloned = self.clone(); + cloned.channel = channel; + cloned.next_block_url = None; + cloned + } + + /// Get a block by event ID + /// + /// If `event` is None, returns the current block. + /// + /// # Arguments + /// + /// * `event` - Optional event ID to fetch a specific block + /// + /// # Example + /// + /// ```no_run + /// # use pmoparadise::RadioParadiseClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let client = RadioParadiseClient::new().await?; + /// + /// // Get current block + /// let current = client.get_block(None).await?; + /// println!("Current block: {} songs", current.song_count()); + /// + /// // Get next block + /// let next = client.get_block(Some(current.end_event)).await?; + /// println!("Next block: {} songs", next.song_count()); + /// # Ok(()) + /// # } + /// ``` + pub async fn get_block(&self, event: Option) -> Result { + let mut url = Url::parse(&format!("{}/get_block", self.api_base))?; + + url.query_pairs_mut() + .append_pair("bitrate", "4") // FLAC lossless + .append_pair("info", "true") + // RP API expects `chan` rather than `channel` for channel selection. + .append_pair("chan", &self.channel.to_string()); + + if let Some(event_id) = event { + url.query_pairs_mut() + .append_pair("event", &event_id.to_string()); + } + + #[cfg(feature = "logging")] + tracing::debug!("Fetching block: {}", url); + + let response = self + .client + .get(url) + .timeout(self.request_timeout) + .send() + .await?; + + if !response.status().is_success() { + return Err(Error::other(format!( + "API returned error status: {}", + response.status() + ))); + } + + let mut block: Block = response.json().await?; + + // Normalize protocol-relative URLs from API (//img.radioparadise.com/) + if let Some(ref base) = block.image_base { + if base.starts_with("//") { + block.image_base = Some(format!("https:{}", base)); + } + } else { + // Fallback if API doesn't provide image_base (should never happen) + block.image_base = Some(DEFAULT_IMAGE_BASE.to_string()); + } + + #[cfg(feature = "logging")] + tracing::debug!( + "Received block: event={}, songs={}", + block.event, + block.song_count() + ); + + Ok(block) + } + + /// Get the currently playing block and song + /// + /// Returns a `NowPlaying` struct with the current block and + /// an estimate of which song is currently playing (first song). + /// + /// Note: Without real-time synchronization, we assume playback + /// starts from the beginning of the block. + pub async fn now_playing(&self) -> Result { + let block = self.get_block(None).await?; + Ok(NowPlaying::from_block(block)) + } + + /// Prefetch metadata for the next block + /// + /// Stores the next block URL internally for seamless transitions. + /// Call this before the current block finishes playing. + /// + /// # Arguments + /// + /// * `current` - The currently playing block + pub async fn prefetch_next(&mut self, current: &Block) -> Result<()> { + let next_block = self.get_block(Some(current.end_event)).await?; + self.next_block_url = Some(next_block.url.clone()); + + #[cfg(feature = "logging")] + tracing::debug!( + "Prefetched next block: {} -> {}", + current.end_event, + next_block.event + ); + + Ok(()) + } + + /// Get the prefetched next block URL + pub fn next_block_url(&self) -> Option<&str> { + self.next_block_url.as_deref() + } + + /// Clear the prefetched next block URL + pub fn clear_next_block(&mut self) { + self.next_block_url = None; + } + + /// Get the internal HTTP client + pub fn http_client(&self) -> &Client { + &self.client + } +} + +/// Builder for configuring a RadioParadiseClient +#[derive(Debug)] +pub struct ClientBuilder { + client: Option, + api_base: String, + channel: u8, + request_timeout: Duration, + block_timeout: Duration, + user_agent: String, + proxy: Option, +} + +impl Default for ClientBuilder { + fn default() -> Self { + Self { + client: None, + api_base: DEFAULT_API_BASE.to_string(), + channel: DEFAULT_CHANNEL, + request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), + block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS), + user_agent: DEFAULT_USER_AGENT.to_string(), + proxy: None, + } + } +} + +impl ClientBuilder { + /// Create a new builder with default settings + pub fn new() -> Self { + Self::default() + } + + /// Set a custom HTTP client + pub fn client(mut self, client: Client) -> Self { + self.client = Some(client); + self + } + + /// Set the API base URL + pub fn api_base(mut self, url: impl Into) -> Self { + self.api_base = url.into(); + self + } + + /// Set the channel (0 = main mix, 1 = mellow, 2 = rock, 3 = world/etc) + pub fn channel(mut self, channel: u8) -> Self { + self.channel = channel; + self + } + + /// Set the request timeout + pub fn timeout(mut self, timeout: Duration) -> Self { + self.request_timeout = timeout; + self + } + + /// Set the timeout specifically for block downloads/streams + pub fn block_timeout(mut self, timeout: Duration) -> Self { + self.block_timeout = timeout; + self + } + + /// Set a custom User-Agent header + pub fn user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = user_agent.into(); + self + } + + /// Set a proxy URL + pub fn proxy(mut self, proxy: impl Into) -> Self { + self.proxy = Some(proxy.into()); + self + } + + /// Build the client + pub async fn build(self) -> Result { + let client = if let Some(client) = self.client { + client + } else { + let mut builder = Client::builder() + .user_agent(&self.user_agent) + .timeout(self.request_timeout); + + if let Some(proxy_url) = &self.proxy { + let proxy = reqwest::Proxy::all(proxy_url) + .map_err(|e| Error::other(format!("Invalid proxy: {}", e)))?; + builder = builder.proxy(proxy); + } + + builder.build()? + }; + + Ok(RadioParadiseClient { + client, + api_base: self.api_base, + channel: self.channel, + request_timeout: self.request_timeout, + block_timeout: self.block_timeout, + next_block_url: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_builder_defaults() { + let builder = ClientBuilder::default(); + assert_eq!(builder.api_base, DEFAULT_API_BASE); + assert_eq!(builder.channel, DEFAULT_CHANNEL); + } +} +-------End of pmoparadise/src/client.rs --------- + +------------ pmoparadise/src/config_ext.rs ---------- +//! Extension pour intégrer Radio Paradise dans pmoconfig +//! +//! Ce module fournit le trait `RadioParadiseConfigExt` qui permet d'ajouter facilement +//! des méthodes de gestion de la configuration Radio Paradise à pmoconfig::Config. +//! +//! La configuration est minimale - seulement ce qui doit vraiment être configurable : +//! - Activation/désactivation de la source +//! +//! # Exemple +//! +//! ```rust,ignore +//! use pmoconfig::get_config; +//! use pmoparadise::RadioParadiseConfigExt; +//! +//! let config = get_config(); +//! +//! // Check if enabled +//! if !config.get_paradise_enabled()? { +//! println!("Radio Paradise is disabled"); +//! return Ok(()); +//! } +//! ``` + +use crate::{channels::ParadiseChannelKind, client::DEFAULT_CHANNEL}; +use anyhow::Result; +use pmoconfig::Config; +use serde_yaml::Value; + +/// Trait d'extension pour gérer la configuration Radio Paradise dans pmoconfig +/// +/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques +/// à la configuration minimale de Radio Paradise. +/// +/// # Auto-persist des valeurs par défaut +/// +/// Le getter persiste automatiquement la valeur par défaut dans la +/// configuration si elle n'existe pas encore. Cela permet à l'utilisateur de +/// voir la configuration effective dans le fichier YAML et de la modifier facilement. +/// +/// # Exemple +/// +/// ```rust,ignore +/// use pmoconfig::get_config; +/// use pmoparadise::RadioParadiseConfigExt; +/// +/// let config = get_config(); +/// +/// // Premier appel : persiste "enabled: true" dans la config et retourne true +/// let enabled = config.get_paradise_enabled()?; +/// +/// // L'utilisateur peut maintenant éditer cette valeur dans le fichier YAML +/// ``` +pub trait RadioParadiseConfigExt { + /// Vérifie si Radio Paradise est activé + /// + /// # Returns + /// + /// `true` si la source est activée (default), `false` sinon. + /// + /// Si la valeur n'existe pas dans la configuration, elle est automatiquement + /// définie à `true` (activé par défaut) et persistée. + /// + /// # Exemple + /// + /// ```rust,ignore + /// if config.get_paradise_enabled()? { + /// // Initialize Radio Paradise... + /// } + /// ``` + fn get_paradise_enabled(&self) -> Result; + + /// Active ou désactive Radio Paradise + /// + /// # Arguments + /// + /// * `enabled` - `true` pour activer, `false` pour désactiver + /// + /// # Exemple + /// + /// ```rust,ignore + /// // Disable Radio Paradise + /// config.set_paradise_enabled(false)?; + /// ``` + fn set_paradise_enabled(&self, enabled: bool) -> Result<()>; + + /// Récupère le channel par défaut + /// + /// # Returns + /// + /// Le channel par défaut (0 = Main Mix par défaut). + /// + /// Si la valeur n'existe pas dans la configuration, elle est automatiquement + /// définie à "main" et persistée. + /// + /// # Channels disponibles + /// + /// Peut être configuré comme chaîne de caractères ou nombre : + /// - "main" ou 0 = Main Mix (eclectic, diverse mix) + /// - "mellow" ou 1 = Mellow Mix (smooth, chilled music) + /// - "rock" ou 2 = Rock Mix (classic & modern rock) + /// - "eclectic" ou 3 = Eclectic Mix (global sounds) + /// + /// # Exemple de configuration YAML + /// + /// ```yaml + /// sources: + /// radio_paradise: + /// default_channel: mellow # or 1 + /// ``` + /// + /// # Exemple d'utilisation + /// + /// ```rust,ignore + /// let channel = config.get_paradise_default_channel()?; + /// let client = RadioParadiseClient::builder().channel(channel).build().await?; + /// ``` + fn get_paradise_default_channel(&self) -> Result; + + /// Définit le channel par défaut + /// + /// # Arguments + /// + /// * `channel` - Le channel (0-3) + /// + /// La valeur est stockée sous forme de nom convivial ("main", "mellow", etc.) + /// dans le fichier de configuration. + /// + /// # Exemple + /// + /// ```rust,ignore + /// use pmoparadise::channels::ParadiseChannelKind; + /// + /// // Use Mellow Mix by default + /// config.set_paradise_default_channel(ParadiseChannelKind::Mellow.id())?; + /// // Or simply: + /// config.set_paradise_default_channel(1)?; + /// ``` + fn set_paradise_default_channel(&self, channel: u8) -> Result<()>; +} + +impl RadioParadiseConfigExt for Config { + fn get_paradise_enabled(&self) -> Result { + match self.get_value(&["sources", "radio_paradise", "enabled"]) { + Ok(Value::Bool(b)) => Ok(b), + _ => { + // Use default (enabled) and persist it + self.set_paradise_enabled(true)?; + Ok(true) + } + } + } + + fn set_paradise_enabled(&self, enabled: bool) -> Result<()> { + self.set_value( + &["sources", "radio_paradise", "enabled"], + Value::Bool(enabled), + ) + } + + fn get_paradise_default_channel(&self) -> Result { + match self.get_value(&["sources", "radio_paradise", "default_channel"]) { + Ok(Value::String(s)) => { + // Try to parse as channel name (e.g., "main", "mellow", etc.) + match s.parse::() { + Ok(kind) => Ok(kind.id()), + Err(_) => { + // Invalid channel name, use default + self.set_paradise_default_channel(DEFAULT_CHANNEL)?; + Ok(DEFAULT_CHANNEL) + } + } + } + Ok(Value::Number(n)) => { + // Accept numeric channel ID (0-3) + if let Some(ch) = n.as_u64() { + if ch <= 3 { + Ok(ch as u8) + } else { + // Invalid channel number, use default + self.set_paradise_default_channel(DEFAULT_CHANNEL)?; + Ok(DEFAULT_CHANNEL) + } + } else { + // Not a valid number, use default + self.set_paradise_default_channel(DEFAULT_CHANNEL)?; + Ok(DEFAULT_CHANNEL) + } + } + _ => { + // Use default and persist it as "main" (user-friendly) + self.set_value( + &["sources", "radio_paradise", "default_channel"], + Value::String("main".to_string()), + )?; + Ok(DEFAULT_CHANNEL) + } + } + } + + fn set_paradise_default_channel(&self, channel: u8) -> Result<()> { + // Convert channel ID to user-friendly string name + let channel_name = match channel { + 0 => "main", + 1 => "mellow", + 2 => "rock", + 3 => "eclectic", + _ => return Err(anyhow::anyhow!("Invalid channel ID: {}", channel)), + }; + + self.set_value( + &["sources", "radio_paradise", "default_channel"], + Value::String(channel_name.to_string()), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_trait_exists() { + // Simple test to ensure the trait compiles + } +} +-------End of pmoparadise/src/config_ext.rs --------- + +------------ pmoparadise/src/error.rs ---------- +//! Error types for the Radio Paradise client + +/// Result type alias for Radio Paradise operations +pub type Result = std::result::Result; + +/// Errors that can occur when using the Radio Paradise client +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// HTTP request failed + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + + /// JSON parsing failed + #[error("JSON parsing failed: {0}")] + Json(#[from] serde_json::Error), + + /// Invalid URL + #[error("Invalid URL: {0}")] + InvalidUrl(#[from] url::ParseError), + + /// IO error + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + /// Invalid track index + #[error("Invalid track index: {0} (block has {1} tracks)")] + InvalidIndex(usize, usize), + + /// Invalid bitrate + #[error("Invalid bitrate value: {0} (must be 0-4)")] + InvalidBitrate(u8), + + /// Invalid event ID + #[error("Invalid event ID: {0}")] + InvalidEvent(String), + + /// Track not found in block + #[error("Track not found at index {0}")] + TrackNotFound(usize), + + /// Invalid elapsed time + #[error("Invalid elapsed time: {0}ms (exceeds block length)")] + InvalidElapsed(u64), + + /// Timeout error + #[error("Request timeout")] + Timeout, + + /// Generic error + #[error("{0}")] + Other(String), +} + +impl Error { + /// Create a generic error from a string + pub fn other(msg: impl Into) -> Self { + Self::Other(msg.into()) + } +} +-------End of pmoparadise/src/error.rs --------- + +------------ pmoparadise/src/lib.rs ---------- +//! # pmoparadise - Radio Paradise Client for Rust +//! +//! `pmoparadise` is an idiomatic Rust client library for accessing Radio Paradise's +//! streaming API. It provides metadata retrieval, block streaming, and optional +//! per-track extraction from FLAC blocks. +//! +//! ## Features +//! +//! - **Metadata Access**: Get current and historical block metadata with song information +//! - **Block Streaming**: Stream continuous FLAC blocks with automatic prefetching +//! - **FLAC Quality**: Lossless CD quality or better +//! - **Per-Track Extraction** (optional): Extract individual tracks from FLAC blocks +//! - **Async/Await**: Built on tokio for efficient async I/O +//! - **Type-Safe**: Strongly typed API with comprehensive error handling +//! +//! ## Quick Start +//! +//! ```no_run +//! use pmoparadise::RadioParadiseClient; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! // Create a client +//! let client = RadioParadiseClient::new().await?; +//! +//! // Get what's currently playing +//! let now_playing = client.now_playing().await?; +//! +//! if let Some(song) = &now_playing.current_song { +//! println!("Now Playing: {} - {}", song.artist, song.title); +//! if let Some(album) = &song.album { +//! println!("Album: {}", album); +//! } +//! } +//! +//! // Get all songs in the current block +//! for (index, song) in now_playing.block.songs_ordered() { +//! println!(" {}. {} - {} ({}s)", +//! index, +//! song.artist, +//! song.title, +//! song.duration / 1000); +//! } +//! +//! Ok(()) +//! } +//! ``` +//! +//! ## Streaming Blocks +//! +//! Radio Paradise broadcasts music in continuous "blocks" - each block is a single +//! FLAC file containing multiple songs with metadata indicating timing offsets. +//! +//! ```no_run +//! use pmoparadise::RadioParadiseClient; +//! use futures::StreamExt; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let client = RadioParadiseClient::new().await?; +//! let block = client.get_block(None).await?; +//! +//! // Stream the block +//! let mut stream = client.stream_block_from_metadata(&block).await?; +//! +//! while let Some(chunk) = stream.next().await { +//! let bytes = chunk?; +//! // Feed to audio player, write to file, etc. +//! } +//! +//! Ok(()) +//! } +//! ``` +//! +//! ## Per-Track Extraction (Feature: `per-track`) +//! +//! **Important**: This is an advanced feature with significant tradeoffs. +//! See the [`track`] module documentation for details. +//! +//! Most applications should stream blocks and use player-based seeking instead. +//! +//! ```no_run +//! # #[cfg(feature = "per-track")] +//! # { +//! use pmoparadise::RadioParadiseClient; +//! use std::path::Path; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let client = RadioParadiseClient::new().await?; +//! let block = client.get_block(None).await?; +//! +//! // Extract first track to WAV +//! let mut track = client.open_track_stream(&block, 0).await?; +//! track.export_wav(Path::new("track.wav"))?; +//! +//! // Or get position for player-based seeking (recommended) +//! let (start, duration) = client.track_position_seconds(&block, 0)?; +//! println!("Play with: mpv --start={} --length={} {}", start, duration, block.url); +//! +//! Ok(()) +//! } +//! # } +//! ``` +//! +//! ## Architecture +//! +//! The API is organized into several modules: +//! +//! - [`client`]: Main HTTP client for API access +//! - [`models`]: Data structures for blocks, songs, and metadata +//! - [`stream`]: Block streaming functionality +//! - [`track`]: Per-track extraction (feature-gated) +//! - [`error`]: Error types and result aliases +//! +//! ## Radio Paradise Block Format +//! +//! Radio Paradise streams use a block-based format: +//! +//! - Each block is a single FLAC audio file +//! - Blocks contain multiple songs (typically 10-15 minutes total) +//! - Metadata includes timing offsets (`song[i].elapsed` in ms) for each song +//! - Block URLs follow the pattern: `https://apps.radioparadise.com/blocks/chan/0/4/-.flac` +//! - The `end_event` of one block is the `event` of the next, enabling seamless transitions +//! +//! ## Best Practices +//! +//! ### For Continuous Playback +//! +//! 1. Get current block with `get_block(None)` +//! 2. Stream block with `stream_block_from_metadata()` +//! 3. Use `prefetch_next()` to prepare the next block +//! 4. When current block ends, stream the next block seamlessly +//! +//! ### For Per-Song Seeking +//! +//! **Recommended approach** (efficient): +//! ```bash +//! # Use your audio player's seek capability +//! mpv --start=123.5 --length=234.0 +//! ``` +//! +//! **Alternative** (resource-intensive, requires `per-track` feature): +//! - Download and decode block +//! - Extract specific track to PCM/WAV +//! +//! ## Error Handling +//! +//! All operations return `Result` with detailed error types: +//! +//! ```no_run +//! use pmoparadise::{RadioParadiseClient, Error}; +//! +//! #[tokio::main] +//! async fn main() { +//! let client = RadioParadiseClient::new().await.unwrap(); +//! +//! match client.get_block(Some(99999999)).await { +//! Ok(block) => println!("Got block: {}", block.event), +//! Err(Error::Http(e)) => eprintln!("Network error: {}", e), +//! Err(Error::Json(e)) => eprintln!("Parse error: {}", e), +//! Err(e) => eprintln!("Other error: {}", e), +//! } +//! } +//! ``` +//! +//! ## Audio Streaming (Feature: `pmoaudio`) +//! +//! For direct audio streaming and integration with pmoaudio pipelines, +//! use `RadioParadiseStreamSource`: +//! +//! ```no_run +//! # #[cfg(feature = "pmoaudio")] +//! # { +//! use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource}; +//! use pmoaudio::pipeline::Node; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let client = RadioParadiseClient::new().await?; +//! let stream_source = RadioParadiseStreamSource::new(client, None).await?; +//! +//! // Create audio node from stream source +//! let node = Node::from_logic(stream_source); +//! +//! // Use in pmoaudio pipeline... +//! +//! Ok(()) +//! } +//! # } +//! ``` +//! +//! **RadioParadiseStreamSource**: +//! - Downloads and decodes FLAC blocks in real-time +//! - Automatically detects bit depth (16/24/32-bit) +//! - Inserts track boundaries with metadata +//! - Integrates seamlessly with pmoaudio pipelines +//! +//! ## Cargo Features +//! +//! - `default`: Standard metadata and streaming (no FLAC decoding) +//! - `per-track`: Enable FLAC decoding and per-track extraction (adds `claxon`, `hound`, `tempfile`) +//! - `pmoserver`: Enable REST API extension for pmoserver integration (adds `utoipa`, `axum`) +//! - `pmoaudio`: Enable RadioParadiseStreamSource for pmoaudio integration +//! - `pmoconfig`: Enable configuration integration with pmoconfig +//! - `server`: Enable RadioParadiseSource for UPnP ContentDirectory integration +//! +//! ## See Also +//! +//! - [Radio Paradise](https://radioparadise.com) - Official website +//! - [Radio Paradise API](https://api.radioparadise.com) - API documentation + +pub mod channels; +pub mod client; +pub mod error; +pub mod models; +pub mod source; + +#[cfg(feature = "pmoaudio")] +pub mod node_stats; + +#[cfg(feature = "pmoserver")] +pub mod pmoserver_ext; + +#[cfg(feature = "pmoconfig")] +pub mod config_ext; + +#[cfg(feature = "pmoaudio")] +pub mod radio_paradise_stream_source; + +#[cfg(feature = "pmoaudio")] +pub mod stream_channel; + +#[cfg(feature = "pmoaudio")] +pub mod playlist_feeder; + +// Re-exports for convenience +pub use client::{ClientBuilder, RadioParadiseClient}; +pub use error::{Error, Result}; +pub use models::{Block, DurationMs, EventId, NowPlaying, Song}; +pub use source::RadioParadiseSource; + +#[cfg(feature = "pmoaudio")] +pub use radio_paradise_stream_source::RadioParadiseStreamSource; + +#[cfg(feature = "pmoaudio")] +pub use playlist_feeder::{RadioParadisePlaylistFeeder, END_OF_BLOCKS_SIGNAL}; + +#[cfg(feature = "pmoaudio")] +pub use stream_channel::{ + HistoryFlacStream, HistoryOggStream, HistoryStreamError, ParadiseChannelManager, + ParadiseHistoryBuilder, ParadiseHistoryOptions, ParadiseStreamChannel, + ParadiseStreamChannelConfig, +}; + +#[cfg(feature = "pmoserver")] +pub use pmoserver_ext::{ + create_api_router, RadioParadiseApiDoc, RadioParadiseExt, RadioParadiseState, +}; + +#[cfg(feature = "pmoconfig")] +pub use config_ext::RadioParadiseConfigExt; + +// Version information +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version() { + assert!(!VERSION.is_empty()); + } +} +-------End of pmoparadise/src/lib.rs --------- + +------------ pmoparadise/src/models.rs ---------- +//! Data models for Radio Paradise API responses + +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Number; +use std::collections::HashMap; +use url::Url; + +/// Deserialize a string or number into a u64 +fn deserialize_string_or_u64<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + use serde::de::Error; + + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrU64 { + String(String), + Number(u64), + } + + match StringOrU64::deserialize(deserializer)? { + StringOrU64::String(s) => s.parse::().map_err(D::Error::custom), + StringOrU64::Number(n) => Ok(n), + } +} + +/// Deserialize a string or number into a f64, then convert to u64 milliseconds +fn deserialize_length<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + use serde::de::Error; + + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrNumber { + String(String), + Number(Number), + } + + fn to_milliseconds(value: f64) -> u64 { + if value >= 100_000.0 { + value.round() as u64 + } else { + (value * 1000.0).round() as u64 + } + } + + match StringOrNumber::deserialize(deserializer)? { + StringOrNumber::String(s) => { + let value = s.parse::().map_err(D::Error::custom)?; + Ok(to_milliseconds(value)) + } + StringOrNumber::Number(n) => { + if let Some(int_value) = n.as_u64() { + Ok(to_milliseconds(int_value as f64)) + } else if let Some(float_value) = n.as_f64() { + Ok(to_milliseconds(float_value)) + } else { + Err(D::Error::custom("Invalid number for block length")) + } + } + } +} + +/// Deserialize an optional string or number into Option +fn deserialize_optional_string_or_u32<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::Error; + + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrU32 { + String(String), + Number(u32), + } + + let opt = Option::::deserialize(deserializer)?; + match opt { + None => Ok(None), + Some(StringOrU32::String(s)) => { + if s.is_empty() { + Ok(None) + } else { + s.parse::().map(Some).map_err(D::Error::custom) + } + } + Some(StringOrU32::Number(n)) => Ok(Some(n)), + } +} + +/// Deserialize an optional string or number into Option +fn deserialize_optional_string_or_f32<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::Error; + + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrF32 { + String(String), + Float(f32), + Int(i32), + } + + let opt = Option::::deserialize(deserializer)?; + match opt { + None => Ok(None), + Some(StringOrF32::String(s)) => { + if s.is_empty() { + Ok(None) + } else { + s.parse::().map(Some).map_err(D::Error::custom) + } + } + Some(StringOrF32::Float(f)) => Ok(Some(f)), + Some(StringOrF32::Int(i)) => Ok(Some(i as f32)), + } +} + +/// Duration in milliseconds +pub type DurationMs = u64; + +/// Event ID for block identification +pub type EventId = u64; + +/// Information about a song/track within a block +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Song { + /// Artist name + pub artist: String, + + /// Song title + pub title: String, + + /// Album name (may be missing for promos/announcements) + #[serde(default)] + pub album: Option, + + /// Year of release + /// Note: API returns this as a string, we deserialize to u32 + #[serde(default, deserialize_with = "deserialize_optional_string_or_u32")] + pub year: Option, + + /// Elapsed time from start of block in milliseconds + pub elapsed: DurationMs, + + /// Duration of the track in milliseconds + pub duration: DurationMs, + + /// Cover image filename/path + #[serde(default)] + pub cover: Option, + + /// Rating (0-10) + /// Note: API returns this as a string, we deserialize to f32 + #[serde(default, deserialize_with = "deserialize_optional_string_or_f32")] + pub rating: Option, + + /// Gapless URL for individual song FLAC + /// This URL points to a FLAC file containing only this song + #[serde(default)] + pub gapless_url: Option, + + /// Scheduled playback time on Radio Paradise (Unix timestamp in milliseconds, UTC) + #[serde(default)] + pub sched_time_millis: Option, + + /// Radio Paradise song ID (unique identifier) + #[serde(default)] + pub song_id: Option, + + /// Radio Paradise artist ID (for building artist URLs) + #[serde(default)] + pub artist_id: Option, + + /// Large cover image path (best quality) + #[serde(default)] + pub cover_large: Option, + + /// Additional metadata + #[serde(flatten)] + pub extra: HashMap, +} + +impl Song { + /// Get the end time of this song in the block (elapsed + duration) + pub fn end_time_ms(&self) -> DurationMs { + self.elapsed + self.duration + } + + /// Check if a given timestamp (ms) falls within this song + pub fn contains_timestamp(&self, timestamp_ms: DurationMs) -> bool { + timestamp_ms >= self.elapsed && timestamp_ms < self.end_time_ms() + } + + /// Calcule le timestamp de fin de diffusion (sched_time + duration) + pub fn sched_end_time_ms(&self) -> Option { + self.sched_time_millis.map(|start| start + self.duration) + } + + /// Vérifie si la chanson est encore en lecture ou à venir + pub fn is_still_playing(&self, now_ms: u64) -> bool { + self.sched_end_time_ms() + .map(|end| end >= now_ms) + .unwrap_or(false) + } +} + +/// Image information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageInfo { + /// Base URL for images + pub base: String, +} + +/// A block of songs from Radio Paradise +/// +/// Radio Paradise streams music in "blocks" - continuous FLAC files +/// containing multiple songs. Each block contains metadata about all +/// songs within it and timing information for seeking. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Block { + /// Event ID for this block (start event) + /// Note: API returns this as a string, we deserialize to u64 + #[serde(deserialize_with = "deserialize_string_or_u64")] + pub event: EventId, + + /// Event ID for the next block (end event) + /// Note: API returns this as a string, we deserialize to u64 + #[serde(deserialize_with = "deserialize_string_or_u64")] + pub end_event: EventId, + + /// Total length of the block in milliseconds + /// Note: API returns this as a string in seconds (e.g., "1715.54"), we convert to ms + #[serde(deserialize_with = "deserialize_length")] + pub length: DurationMs, + + /// URL to stream this block + pub url: String, + + /// Base URL for cover images + #[serde(default)] + pub image_base: Option, + + /// Scheduled start time for this block (Unix timestamp in milliseconds, UTC) + #[serde(default)] + pub sched_time_millis: Option, + + /// Map of song index (as string) to Song metadata + /// Keys are "0", "1", "2", etc. + #[serde(default)] + pub song: HashMap, + + /// Additional metadata + #[serde(flatten)] + pub extra: HashMap, +} + +impl Block { + /// Scheduled start time in milliseconds if available. + pub fn start_time_millis(&self) -> Option { + if let Some(ts) = self.sched_time_millis { + return Some(ts); + } + self.songs_ordered() + .into_iter() + .find_map(|(_, song)| song.sched_time_millis) + } + + /// Get songs in order by index + pub fn songs_ordered(&self) -> Vec<(usize, &Song)> { + let mut songs: Vec<_> = self + .song + .iter() + .filter_map(|(k, v)| k.parse::().ok().map(|idx| (idx, v))) + .collect(); + songs.sort_by_key(|(idx, _)| *idx); + songs + } + + /// Get a song by index + pub fn get_song(&self, index: usize) -> Option<&Song> { + self.song.get(&index.to_string()) + } + + /// Get the number of songs in this block + pub fn song_count(&self) -> usize { + self.song.len() + } + + /// Get the full URL for a cover image + pub fn cover_url(&self, cover_path: &str) -> Option { + let base = self.image_base.as_ref()?; + let base_url = Url::parse(base).ok()?; + base_url.join(cover_path).ok().map(|url| url.to_string()) + } + + /// Find which song is playing at a given timestamp (ms from block start) + pub fn song_at_timestamp(&self, timestamp_ms: DurationMs) -> Option<(usize, &Song)> { + self.songs_ordered() + .into_iter() + .find(|(_, song)| song.contains_timestamp(timestamp_ms)) + } + + /// Parse the block URL to get start and end event IDs + /// + /// Block URLs follow the pattern: + /// `https://apps.radioparadise.com/blocks/chan/0/4/-.flac` + pub fn parse_url_events(&self) -> Option<(EventId, EventId)> { + let url_path = self.url.split('/').last()?; + let filename = url_path.strip_suffix(".flac")?; + let mut parts = filename.split('-'); + let start = parts.next()?.parse::().ok()?; + let end = parts.next()?.parse::().ok()?; + Some((start, end)) + } +} + +/// Currently playing information +#[derive(Debug, Clone)] +pub struct NowPlaying { + /// The current block + pub block: Block, + + /// Current song index (if determinable) + pub current_song_index: Option, + + /// Current song + pub current_song: Option, + + /// Approximate elapsed time in current block (ms) + /// Note: This is estimated and may not be perfectly accurate + pub block_elapsed_ms: Option, +} + +impl NowPlaying { + /// Create from a block (assumes starting from beginning) + pub fn from_block(block: Block) -> Self { + let (current_song_index, current_song) = block + .get_song(0) + .map(|s| (Some(0), Some(s.clone()))) + .unwrap_or((None, None)); + + Self { + block, + current_song_index, + current_song, + block_elapsed_ms: Some(0), + } + } + + /// Get URL for the current block stream + pub fn stream_url(&self) -> &str { + &self.block.url + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_song_timing() { + let song = Song { + artist: "Test Artist".to_string(), + title: "Test Song".to_string(), + album: Some("Test Album".to_string()), + year: Some(2024), + elapsed: 1000, + duration: 5000, + cover: None, + rating: None, + extra: HashMap::new(), + gapless_url: Some("http://example.com/song.flac".into()), + sched_time_millis: Some(1_700_000_000_000), + song_id: Some("song-id".into()), + artist_id: Some("artist-id".into()), + cover_large: Some("cover-large.jpg".into()), + }; + + assert_eq!(song.end_time_ms(), 6000); + assert!(song.contains_timestamp(3000)); + assert!(!song.contains_timestamp(7000)); + assert!(!song.contains_timestamp(500)); + } + + #[test] + fn test_block_parse() { + let json = r#"{ + "event": 1234, + "end_event": 5678, + "length": 900000, + "url": "https://apps.radioparadise.com/blocks/chan/0/4/1234-5678.flac", + "image_base": "https://img.radioparadise.com/covers/l/", + "song": { + "0": { + "artist": "Miles Davis", + "title": "So What", + "album": "Kind of Blue", + "year": 1959, + "elapsed": 0, + "duration": 540000, + "cover": "B00000I0JF.jpg" + }, + "1": { + "artist": "John Coltrane", + "title": "Giant Steps", + "album": "Giant Steps", + "year": 1960, + "elapsed": 540000, + "duration": 360000, + "cover": "B000002I4U.jpg" + } + } + }"#; + + let block: Block = serde_json::from_str(json).unwrap(); + assert_eq!(block.event, 1234); + assert_eq!(block.end_event, 5678); + assert_eq!(block.song_count(), 2); + + let songs = block.songs_ordered(); + assert_eq!(songs.len(), 2); + assert_eq!(songs[0].1.title, "So What"); + assert_eq!(songs[1].1.title, "Giant Steps"); + + let (start, end) = block.parse_url_events().unwrap(); + assert_eq!(start, 1234); + assert_eq!(end, 5678); + + let (idx, song) = block.song_at_timestamp(600000).unwrap(); + assert_eq!(idx, 1); + assert_eq!(song.title, "Giant Steps"); + } + + #[test] + fn test_block_length_from_seconds_string() { + let json = serde_json::json!({ + "event": 1, + "end_event": 2, + "length": "1715.54", + "url": "https://example.com/block.flac", + "song": {} + }); + + let block: Block = serde_json::from_value(json).unwrap(); + assert_eq!(block.length, 1_715_540); + } + + #[test] + fn test_block_length_from_seconds_integer() { + let json = serde_json::json!({ + "event": 1, + "end_event": 2, + "length": 1800, + "url": "https://example.com/block.flac", + "song": {} + }); + + let block: Block = serde_json::from_value(json).unwrap(); + assert_eq!(block.length, 1_800_000); + } + + #[test] + fn test_block_length_from_milliseconds_integer() { + let json = serde_json::json!({ + "event": 1, + "end_event": 2, + "length": 900_000, + "url": "https://example.com/block.flac", + "song": {} + }); + + let block: Block = serde_json::from_value(json).unwrap(); + assert_eq!(block.length, 900_000); + } + + #[test] + fn test_block_length_from_milliseconds_float() { + let json = serde_json::json!({ + "event": 1, + "end_event": 2, + "length": 900_000.0, + "url": "https://example.com/block.flac", + "song": {} + }); + + let block: Block = serde_json::from_value(json).unwrap(); + assert_eq!(block.length, 900_000); + } +} +-------End of pmoparadise/src/models.rs --------- + +------------ pmoparadise/src/node_stats.rs ---------- +//! Node statistics tracking +//! +//! Provides detailed statistics for pipeline nodes to understand +//! data flow, backpressure behavior, and timing. + +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Instant; + +/// Statistics pour un node audio +#[derive(Debug)] +pub struct NodeStats { + /// Nom du node pour identification + pub name: String, + + /// Instant de démarrage du node + pub start_time: Instant, + + /// Nombre total de segments reçus + pub segments_received: AtomicUsize, + + /// Nombre total de segments envoyés + pub segments_sent: AtomicUsize, + + /// Nombre total de bytes traités + pub bytes_processed: AtomicU64, + + /// Nombre de fois où l'envoi a été bloqué (backpressure) + pub backpressure_blocks: AtomicUsize, + + /// Temps total passé bloqué en millisecondes + pub backpressure_time_ms: AtomicU64, + + /// Timestamp du premier segment (secondes) + pub first_segment_timestamp: AtomicU64, // Stocké comme u64 * 1000 pour précision + + /// Timestamp du dernier segment (secondes) + pub last_segment_timestamp: AtomicU64, // Stocké comme u64 * 1000 pour précision +} + +impl NodeStats { + pub fn new(name: impl Into) -> Arc { + Arc::new(Self { + name: name.into(), + start_time: Instant::now(), + segments_received: AtomicUsize::new(0), + segments_sent: AtomicUsize::new(0), + bytes_processed: AtomicU64::new(0), + backpressure_blocks: AtomicUsize::new(0), + backpressure_time_ms: AtomicU64::new(0), + first_segment_timestamp: AtomicU64::new(u64::MAX), + last_segment_timestamp: AtomicU64::new(0), + }) + } + + /// Enregistre la réception d'un segment + pub fn record_segment_received(&self, timestamp_sec: f64) { + self.segments_received.fetch_add(1, Ordering::Relaxed); + + let ts_millis = (timestamp_sec * 1000.0) as u64; + + // Update first timestamp (atomic min) + let mut current = self.first_segment_timestamp.load(Ordering::Relaxed); + while current > ts_millis { + match self.first_segment_timestamp.compare_exchange_weak( + current, + ts_millis, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(x) => current = x, + } + } + + // Update last timestamp (atomic max) + let mut current = self.last_segment_timestamp.load(Ordering::Relaxed); + while current < ts_millis { + match self.last_segment_timestamp.compare_exchange_weak( + current, + ts_millis, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(x) => current = x, + } + } + } + + /// Enregistre l'envoi d'un segment + pub fn record_segment_sent(&self, bytes: usize) { + self.segments_sent.fetch_add(1, Ordering::Relaxed); + self.bytes_processed + .fetch_add(bytes as u64, Ordering::Relaxed); + } + + /// Enregistre un événement de backpressure + pub fn record_backpressure(&self, duration_ms: u64) { + self.backpressure_blocks.fetch_add(1, Ordering::Relaxed); + self.backpressure_time_ms + .fetch_add(duration_ms, Ordering::Relaxed); + } + + /// Retourne un rapport formaté des statistiques + pub fn report(&self) -> String { + let elapsed = self.start_time.elapsed().as_secs_f64(); + let received = self.segments_received.load(Ordering::Relaxed); + let sent = self.segments_sent.load(Ordering::Relaxed); + let bytes = self.bytes_processed.load(Ordering::Relaxed); + let bp_blocks = self.backpressure_blocks.load(Ordering::Relaxed); + let bp_time_ms = self.backpressure_time_ms.load(Ordering::Relaxed); + + let first_ts = self.first_segment_timestamp.load(Ordering::Relaxed); + let last_ts = self.last_segment_timestamp.load(Ordering::Relaxed); + + let first_ts_sec = if first_ts == u64::MAX { + 0.0 + } else { + first_ts as f64 / 1000.0 + }; + let last_ts_sec = last_ts as f64 / 1000.0; + let audio_duration = last_ts_sec - first_ts_sec; + + let mb = bytes as f64 / 1_048_576.0; + let throughput_mbps = if elapsed > 0.0 { mb / elapsed } else { 0.0 }; + + format!( + "[{}]\n\ + Elapsed: {:.1}s | Received: {} | Sent: {} | Lost: {}\n\ + Data: {:.1} MB | Throughput: {:.2} MB/s\n\ + Audio: {:.1}s (first: {:.1}s, last: {:.1}s) | Real-time ratio: {:.1}%\n\ + Backpressure: {} blocks, {:.2}s total ({:.1}% of time)", + self.name, + elapsed, + received, + sent, + received.saturating_sub(sent), + mb, + throughput_mbps, + audio_duration, + first_ts_sec, + last_ts_sec, + if audio_duration > 0.0 { + (elapsed / audio_duration) * 100.0 + } else { + 0.0 + }, + bp_blocks, + bp_time_ms as f64 / 1000.0, + if elapsed > 0.0 { + (bp_time_ms as f64 / 1000.0 / elapsed) * 100.0 + } else { + 0.0 + } + ) + } +} +-------End of pmoparadise/src/node_stats.rs --------- + +------------ pmoparadise/src/playlist_feeder.rs ---------- +//! RadioParadisePlaylistFeeder - Télécharge et alimente une playlist à partir des blocs RP +//! +//! Architecture simplifiée utilisant les URLs gapless individuelles au lieu du bloc FLAC entier. + +use crate::{client::RadioParadiseClient, models::EventId}; +use anyhow::Result; +use pmoaudiocache::Cache as AudioCache; +use pmocovers::Cache as CoversCache; +use pmoplaylist::{PlaylistManager, ReadHandle, WriteHandle}; +use std::{ + collections::{HashMap, VecDeque}, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::Notify; + +/// Signal de fin de blocs +pub const END_OF_BLOCKS_SIGNAL: EventId = EventId::MAX; +const RECENT_BLOCKS_CACHE_SIZE: usize = 10; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BlockStatus { + Pending, + InProgress, + Done, +} + +struct RecentBlocks { + states: HashMap, + order: VecDeque, + capacity: usize, +} + +impl RecentBlocks { + fn new(capacity: usize) -> Self { + Self { + states: HashMap::new(), + order: VecDeque::new(), + capacity, + } + } + + fn try_enqueue(&mut self, event_id: EventId) -> bool { + match self.states.get(&event_id) { + Some(_) => false, + None => { + self.order.push_back(event_id); + self.states.insert(event_id, BlockStatus::Pending); + self.evict_old_done(); + true + } + } + } + + fn mark_in_progress(&mut self, event_id: EventId) { + if let Some(state) = self.states.get_mut(&event_id) { + *state = BlockStatus::InProgress; + } else { + self.order.push_back(event_id); + self.states.insert(event_id, BlockStatus::InProgress); + } + self.evict_old_done(); + } + + fn mark_done(&mut self, event_id: EventId) { + if let Some(state) = self.states.get_mut(&event_id) { + *state = BlockStatus::Done; + } else { + self.order.push_back(event_id); + self.states.insert(event_id, BlockStatus::Done); + } + self.evict_old_done(); + } + + fn purge(&mut self, event_id: EventId) { + self.states.remove(&event_id); + } + + fn evict_old_done(&mut self) { + while self.order.len() > self.capacity { + let Some(front) = self.order.front().copied() else { + break; + }; + match self.states.get(&front) { + Some(BlockStatus::Done) | None => { + self.order.pop_front(); + self.states.remove(&front); + } + Some(_) => break, + } + } + } +} + +/// Feeder qui télécharge les blocs RP et alimente une playlist +pub struct RadioParadisePlaylistFeeder { + client: RadioParadiseClient, + audio_cache: Arc, + covers_cache: Arc, + playlist_handle: Arc, + block_queue: Arc>>, + notify: Arc, + collection: Option, + recent_blocks: tokio::sync::Mutex, +} + +impl RadioParadisePlaylistFeeder { + /// Crée un nouveau feeder et retourne (feeder, read_handle) + pub async fn new( + client: RadioParadiseClient, + audio_cache: Arc, + covers_cache: Arc, + playlist_id: String, + collection: Option, + ) -> Result<(Self, ReadHandle)> { + let manager = PlaylistManager::get(); + let write_handle = manager + .create_persistent_playlist(playlist_id.clone()) + .await?; + let read_handle = manager.get_read_handle(&playlist_id).await?; + + Ok(( + Self { + client, + audio_cache, + covers_cache, + playlist_handle: Arc::new(write_handle), + block_queue: Arc::new(tokio::sync::Mutex::new(VecDeque::new())), + notify: Arc::new(Notify::new()), + collection, + recent_blocks: tokio::sync::Mutex::new(RecentBlocks::new(RECENT_BLOCKS_CACHE_SIZE)), + }, + read_handle, + )) + } + + /// Enqueue un bloc pour traitement + pub async fn push_block_id(&self, event_id: EventId) { + { + let mut recent = self.recent_blocks.lock().await; + if !recent.try_enqueue(event_id) { + tracing::debug!( + "RadioParadisePlaylistFeeder: Ignoring duplicate enqueue for block {}", + event_id + ); + return; + } + } + + { + let mut queue = self.block_queue.lock().await; + queue.push_back(event_id); + } + self.notify.notify_one(); + } + + async fn mark_in_progress(&self, event_id: EventId) { + let mut recent = self.recent_blocks.lock().await; + recent.mark_in_progress(event_id); + } + + async fn mark_done(&self, event_id: EventId) { + let mut recent = self.recent_blocks.lock().await; + recent.mark_done(event_id); + } + + async fn purge_block_state(&self, event_id: EventId) { + let mut recent = self.recent_blocks.lock().await; + recent.purge(event_id); + } + + pub(crate) async fn retry_block(&self, event_id: EventId) { + self.purge_block_state(event_id).await; + self.push_block_id(event_id).await; + } + + /// Boucle principale de traitement (à exécuter dans une tâche tokio) + pub async fn run(self: Arc) -> Result<()> { + loop { + // Attendre un bloc + let event_id = loop { + { + let mut queue = self.block_queue.lock().await; + if let Some(id) = queue.pop_front() { + if id == END_OF_BLOCKS_SIGNAL { + tracing::info!( + "RadioParadisePlaylistFeeder: END_OF_BLOCKS_SIGNAL received" + ); + return Ok(()); + } + break id; + } + } + self.notify.notified().await; + }; + + self.mark_in_progress(event_id).await; + + // Traiter le bloc + if let Err(e) = self.process_block(event_id).await { + tracing::error!( + "RadioParadisePlaylistFeeder: Failed to process block {}: {}", + event_id, + e + ); + self.purge_block_state(event_id).await; + tracing::debug!( + "RadioParadisePlaylistFeeder: Cleared block {} state after error", + event_id + ); + } else { + self.mark_done(event_id).await; + } + } + } + + /// Traite un bloc : fetch, filtre, download, push playlist + async fn process_block(&self, event_id: EventId) -> Result<()> { + tracing::info!("RadioParadisePlaylistFeeder: Processing block {}", event_id); + + // 1. Fetch le bloc + let block = self.client.get_block(Some(event_id)).await?; + + // 2. Timestamp actuel + let now_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64; + + // 3. Filtrer les chansons encore en lecture ou à venir + let songs = block.songs_ordered(); + let mut processed = 0; + + for (idx, song) in songs { + if !song.is_still_playing(now_ms) { + tracing::debug!( + "RadioParadisePlaylistFeeder: Skipping finished song {} - {} (ended at {})", + idx, + song.title, + song.sched_end_time_ms().unwrap_or(0) + ); + continue; + } + + // 4. Télécharger la chanson + let gapless_url = song + .gapless_url + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Missing gapless_url for song {}", idx))?; + + tracing::info!( + "RadioParadisePlaylistFeeder: Downloading song {} - {} by {}", + idx, + song.title, + song.artist + ); + + let pk = self + .audio_cache + .add_from_url(gapless_url, self.collection.as_deref()) + .await?; + + // 5. Sauvegarder les métadonnées + self.save_metadata(&pk, song, &block).await?; + + // 6. Calculer le TTL + let sched_end = song + .sched_end_time_ms() + .ok_or_else(|| anyhow::anyhow!("Cannot calculate TTL without sched_time_millis"))?; + let ttl_ms = sched_end.saturating_sub(now_ms); + let ttl = Duration::from_millis(ttl_ms); + + // 7. Push dans la playlist avec TTL + self.playlist_handle.push_with_ttl(pk.clone(), ttl).await?; + + tracing::info!( + "RadioParadisePlaylistFeeder: Added {} to playlist (pk={}, ttl={}s)", + song.title, + pk, + ttl.as_secs() + ); + + processed += 1; + } + + tracing::info!( + "RadioParadisePlaylistFeeder: Processed block {} - added {} songs to playlist", + event_id, + processed + ); + + Ok(()) + } + + /// Sauvegarde les métadonnées dans le cache audio + async fn save_metadata( + &self, + pk: &str, + song: &crate::models::Song, + block: &crate::models::Block, + ) -> Result<()> { + use pmoaudiocache::AudioTrackMetadataExt; + + let metadata = self.audio_cache.track_metadata(pk); + let mut meta = metadata.write().await; + + // Métadonnées de base + meta.set_title(Some(song.title.clone())).await?; + meta.set_artist(Some(song.artist.clone())).await?; + if let Some(ref album) = song.album { + meta.set_album(Some(album.clone())).await?; + } + if let Some(year) = song.year { + meta.set_year(Some(year)).await?; + } + + // Cover + if let Some(ref cover_large) = song.cover_large { + if let Some(cover_url) = block.cover_url(cover_large) { + meta.set_cover_url(Some(cover_url.clone())).await?; + + // Télécharger la cover + match self + .covers_cache + .add_from_url(&cover_url, self.collection.as_deref()) + .await + { + Ok(cover_pk) => { + meta.set_cover_pk(Some(cover_pk)).await?; + tracing::debug!( + "RadioParadisePlaylistFeeder: Cached cover for {}", + song.title + ); + } + Err(e) => { + tracing::warn!("RadioParadisePlaylistFeeder: Failed to cache cover: {}", e); + } + } + } + } + + Ok(()) + } +} +-------End of pmoparadise/src/playlist_feeder.rs --------- + +------------ pmoparadise/src/pmoserver_ext.rs ---------- +//! Extension pmoserver pour Radio Paradise +//! +//! Ce module fournit un trait d'extension pour ajouter facilement l'API Radio Paradise +//! à un serveur pmoserver. + +use crate::channels::{max_channel_id, ChannelDescriptor, ALL_CHANNELS}; +use crate::{Block, NowPlaying, RadioParadiseClient}; +use async_trait::async_trait; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::get, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::RwLock; +use utoipa::{OpenApi, ToSchema}; + +/// État partagé pour l'API Radio Paradise +#[derive(Clone)] +pub struct RadioParadiseState { + client: Arc>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct ParadiseQuery { + channel: Option, +} + +impl RadioParadiseState { + pub async fn new() -> anyhow::Result { + let client = RadioParadiseClient::new() + .await + .map_err(|e| anyhow::anyhow!("Failed to create RadioParadise client: {}", e))?; + + Ok(Self { + client: Arc::new(RwLock::new(client)), + }) + } + + async fn client_for_params( + &self, + params: &ParadiseQuery, + ) -> Result { + let base_client = { + let client_guard = self.client.read().await; + client_guard.clone() + }; + + let mut client = base_client; + + if let Some(channel) = params.channel { + if channel > max_channel_id() { + tracing::warn!("Invalid Radio Paradise channel requested: {}", channel); + return Err(StatusCode::BAD_REQUEST); + } + client = client.clone_with_channel(channel); + } + + Ok(client) + } +} + +/// Information sur un canal Radio Paradise +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ChannelInfo { + /// ID du canal (0-3) + pub id: u8, + /// Nom du canal + pub name: String, + /// Description + pub description: String, +} + +impl From<&ChannelDescriptor> for ChannelInfo { + fn from(descriptor: &ChannelDescriptor) -> Self { + Self { + id: descriptor.id, + name: descriptor.display_name.to_string(), + description: descriptor.description.to_string(), + } + } +} + +/// Réponse avec informations étendues sur le morceau en cours +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct NowPlayingResponse { + /// Event ID du block actuel + pub event: u64, + /// Event ID du prochain block + pub end_event: u64, + /// URL de streaming du block + pub stream_url: String, + /// Durée totale du block en ms + pub block_length_ms: u64, + /// Index du morceau actuel + pub current_song_index: Option, + /// Morceau actuel + pub current_song: Option, + /// Tous les morceaux du block + pub songs: Vec, +} + +/// Information sur un morceau +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SongInfo { + /// Index dans le block + pub index: usize, + /// Artiste + pub artist: String, + /// Titre + pub title: String, + /// Album + pub album: String, + /// Année + pub year: Option, + /// Temps écoulé depuis le début du block (ms) + pub elapsed_ms: u64, + /// Durée du morceau (ms) + pub duration_ms: u64, + /// URL de la pochette + pub cover_url: Option, + /// Note (0-10) + pub rating: Option, +} + +/// Réponse pour un block +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct BlockResponse { + /// Event ID du block + pub event: u64, + /// Event ID du prochain block + pub end_event: u64, + /// URL de streaming + pub url: String, + /// Durée totale (ms) + pub length_ms: u64, + /// Morceaux du block + pub songs: Vec, +} + +/// Réponse pour l'URL de streaming +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct StreamUrlResponse { + /// Event ID du block + #[schema(example = 1234567)] + pub event: u64, + /// URL de streaming FLAC + #[schema(example = "https://apps.radioparadise.com/blocks/chan/0/4/1234567-1234580.flac")] + pub stream_url: String, + /// Durée totale (ms) + #[schema(example = 900000)] + pub length_ms: u64, +} + +/// Réponse pour l'URL de pochette +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct CoverUrlResponse { + /// Event ID du block + #[schema(example = 1234567)] + pub event: u64, + /// Index du morceau + #[schema(example = 0)] + pub song_index: usize, + /// URL de la pochette (résolution complète) + #[schema(example = "https://img.radioparadise.com/covers/l/B00000I0JF.jpg")] + pub cover_url: Option, + /// Type de pochette: "cover" (petite) ou "cover_large" (grande) + #[schema(example = "cover_large")] + pub cover_type: String, +} + +impl From for BlockResponse { + fn from(block: Block) -> Self { + let songs = block + .songs_ordered() + .into_iter() + .map(|(index, song)| SongInfo { + index, + artist: song.artist.clone(), + title: song.title.clone(), + album: song.album.clone().unwrap_or_default(), + year: song.year, + elapsed_ms: song.elapsed, + duration_ms: song.duration, + cover_url: song.cover.as_ref().and_then(|c| block.cover_url(c)), + rating: song.rating, + }) + .collect(); + + Self { + event: block.event, + end_event: block.end_event, + url: block.url, + length_ms: block.length, + songs, + } + } +} + +impl From for NowPlayingResponse { + fn from(np: NowPlaying) -> Self { + let songs: Vec = np + .block + .songs_ordered() + .into_iter() + .map(|(index, song)| SongInfo { + index, + artist: song.artist.clone(), + title: song.title.clone(), + album: song.album.clone().unwrap_or_default(), + year: song.year, + elapsed_ms: song.elapsed, + duration_ms: song.duration, + cover_url: song.cover.as_ref().and_then(|c| np.block.cover_url(c)), + rating: song.rating, + }) + .collect(); + + let current_song = np.current_song.as_ref().and_then(|song| { + let index = np.current_song_index?; + Some(SongInfo { + index, + artist: song.artist.clone(), + title: song.title.clone(), + album: song.album.clone().unwrap_or_default(), + year: song.year, + elapsed_ms: song.elapsed, + duration_ms: song.duration, + cover_url: song.cover.as_ref().and_then(|c| np.block.cover_url(c)), + rating: song.rating, + }) + }); + + Self { + event: np.block.event, + end_event: np.block.end_event, + stream_url: np.block.url, + block_length_ms: np.block.length, + current_song_index: np.current_song_index, + current_song, + songs, + } + } +} + +/// GET /now-playing - Récupère le morceau en cours +#[utoipa::path( + get, + path = "/now-playing", + params( + ("channel" = Option, Query, description = "Channel ID (0-3)") + ), + responses( + (status = 200, description = "Morceau en cours", body = NowPlayingResponse), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_now_playing( + State(state): State, + Query(params): Query, +) -> Result, StatusCode> { + let client = state.client_for_params(¶ms).await?; + let now_playing = client.now_playing().await.map_err(|e| { + tracing::error!("Failed to fetch now playing from Radio Paradise: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(now_playing.into())) +} + +/// GET /block/current - Récupère le block actuel +#[utoipa::path( + get, + path = "/block/current", + params( + ("channel" = Option, Query, description = "Channel ID (0-3)") + ), + responses( + (status = 200, description = "Block actuel", body = BlockResponse), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_current_block( + State(state): State, + Query(params): Query, +) -> Result, StatusCode> { + let client = state.client_for_params(¶ms).await?; + let block = client.get_block(None).await.map_err(|e| { + tracing::error!("Failed to fetch current block from Radio Paradise: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(block.into())) +} + +/// GET /block/{event_id} - Récupère un block spécifique +#[utoipa::path( + get, + path = "/block/{event_id}", + params( + ("event_id" = u64, Path, description = "Event ID du block"), + ("channel" = Option, Query, description = "Channel ID (0-3)") + ), + responses( + (status = 200, description = "Block demandé", body = BlockResponse), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_block_by_id( + State(state): State, + Path(event_id): Path, + Query(params): Query, +) -> Result, StatusCode> { + let client = state.client_for_params(¶ms).await?; + let block = client.get_block(Some(event_id)).await.map_err(|e| { + tracing::error!( + "Failed to fetch block {} from Radio Paradise: {}", + event_id, + e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(block.into())) +} + +/// GET /channels - Liste les canaux disponibles +#[utoipa::path( + get, + path = "/channels", + responses( + (status = 200, description = "Liste des canaux", body = Vec) + ), + tag = "Radio Paradise" +)] +async fn get_channels() -> Json> { + let channels: Vec = ALL_CHANNELS.iter().map(Into::into).collect(); + Json(channels) +} + +/// GET /block/{event_id}/song/{index} - Récupère un morceau spécifique d'un block +#[utoipa::path( + get, + path = "/block/{event_id}/song/{index}", + params( + ("event_id" = u64, Path, description = "Event ID du block"), + ("index" = usize, Path, description = "Index du morceau (0-based)"), + ("channel" = Option, Query, description = "Channel ID (0-3)") + ), + responses( + (status = 200, description = "Morceau demandé", body = SongInfo), + (status = 404, description = "Morceau non trouvé"), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_song_by_index( + State(state): State, + Path((event_id, index)): Path<(u64, usize)>, + Query(params): Query, +) -> Result, StatusCode> { + let client = state.client_for_params(¶ms).await?; + let block = client.get_block(Some(event_id)).await.map_err(|e| { + tracing::error!( + "Failed to fetch block {} from Radio Paradise: {}", + event_id, + e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let song = block.get_song(index).ok_or_else(|| { + tracing::warn!("Song index {} not found in block {}", index, event_id); + StatusCode::NOT_FOUND + })?; + + let song_info = SongInfo { + index, + artist: song.artist.clone(), + title: song.title.clone(), + album: song.album.clone().unwrap_or_default(), + year: song.year, + elapsed_ms: song.elapsed, + duration_ms: song.duration, + cover_url: song.cover.as_ref().and_then(|c| block.cover_url(c)), + rating: song.rating, + }; + + Ok(Json(song_info)) +} + +/// GET /cover-url/{event_id}/{song_index} - Récupère l'URL de la pochette d'un morceau +/// +/// Utilise automatiquement cover_large si disponible, sinon cover en fallback +#[utoipa::path( + get, + path = "/cover-url/{event_id}/{song_index}", + params( + ("event_id" = u64, Path, description = "Event ID du block"), + ("song_index" = usize, Path, description = "Index du morceau (0-based)"), + ("channel" = Option, Query, description = "Channel ID (0-3)") + ), + responses( + (status = 200, description = "URL de la pochette avec fallback automatique", body = CoverUrlResponse), + (status = 404, description = "Morceau non trouvé"), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_cover_url( + State(state): State, + Path((event_id, song_index)): Path<(u64, usize)>, + Query(params): Query, +) -> Result, StatusCode> { + let client = state.client_for_params(¶ms).await?; + let block = client.get_block(Some(event_id)).await.map_err(|e| { + tracing::error!( + "Failed to fetch block {} from Radio Paradise: {}", + event_id, + e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let song = block.get_song(song_index).ok_or_else(|| { + tracing::warn!("Song index {} not found in block {}", song_index, event_id); + StatusCode::NOT_FOUND + })?; + + // Fallback: cover_large → cover → none + let (cover_url, cover_type) = if let Some(ref cover_large) = song.cover_large { + (block.cover_url(cover_large), "cover_large") + } else if let Some(ref cover) = song.cover { + (block.cover_url(cover), "cover") + } else { + (None, "none") + }; + + Ok(Json(CoverUrlResponse { + event: event_id, + song_index, + cover_url, + cover_type: cover_type.to_string(), + })) +} + +/// GET /stream-url/{event_id} - Récupère l'URL de streaming direct d'un block +#[utoipa::path( + get, + path = "/stream-url/{event_id}", + params( + ("event_id" = u64, Path, description = "Event ID du block (None pour le block actuel)"), + ("channel" = Option, Query, description = "Channel ID (0-3)") + ), + responses( + (status = 200, description = "URL de streaming", body = StreamUrlResponse), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_stream_url( + State(state): State, + Path(event_id): Path, + Query(params): Query, +) -> Result, StatusCode> { + let client = state.client_for_params(¶ms).await?; + let block = client.get_block(Some(event_id)).await.map_err(|e| { + tracing::error!( + "Failed to fetch block {} from Radio Paradise: {}", + event_id, + e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(StreamUrlResponse { + event: block.event, + stream_url: block.url, + length_ms: block.length, + })) +} + +/// Documentation OpenAPI pour l'API Radio Paradise +#[derive(OpenApi)] +#[openapi( + info( + title = "Radio Paradise API", + version = "1.0.0", + description = r#" +# API REST pour Radio Paradise + +Cette API permet d'accéder aux métadonnées et flux de Radio Paradise. + +## Fonctionnalités + +- **Métadonnées en temps réel** : Récupération du morceau en cours et des blocks +- **Multi-canaux** : Support des 4 canaux Radio Paradise (Main, Mellow, Rock, Eclectic) +- **Streaming FLAC** : Accès direct aux URLs de streaming haute qualité +- **Pochettes d'albums** : URLs complètes des couvertures (petite et grande taille) +- **Historique** : Accès aux blocks passés via event_id + +## Canaux disponibles + +- **0: Main Mix** - Eclectic mix of rock, world, electronica, and more +- **1: Mellow Mix** - Mellower, less aggressive music +- **2: Rock Mix** - Heavier, more guitar-driven music +- **3: Eclectic Mix** - Curated worldwide selection + +## Format des données + +### Blocks +Les blocks sont des fichiers FLAC continus contenant plusieurs morceaux. +Chaque block a un `event` (ID de début) et `end_event` (ID du prochain block). + +### Timing +- Tous les temps sont en millisecondes (ms) +- `elapsed_ms` : temps écoulé depuis le début du block +- `duration_ms` : durée du morceau + +## Exemples d'utilisation + +### Récupérer le morceau en cours +``` +GET /api/radioparadise/now-playing?channel=0 +``` + +### Récupérer un block spécifique +``` +GET /api/radioparadise/block/1234567?channel=0 +``` + +### Récupérer la pochette d'un morceau (avec fallback automatique) +``` +GET /api/radioparadise/cover-url/1234567/0?channel=0 +``` + "# + ), + paths( + get_now_playing, + get_current_block, + get_block_by_id, + get_channels, + get_song_by_index, + get_cover_url, + get_stream_url + ), + components(schemas( + NowPlayingResponse, + BlockResponse, + SongInfo, + ChannelInfo, + StreamUrlResponse, + CoverUrlResponse + )), + tags( + (name = "Radio Paradise", description = "Endpoints pour Radio Paradise") + ) +)] +pub struct RadioParadiseApiDoc; + +/// Crée le router pour l'API Radio Paradise +pub fn create_api_router(state: RadioParadiseState) -> Router { + Router::new() + .route("/now-playing", get(get_now_playing)) + .route("/block/current", get(get_current_block)) + .route("/block/{event_id}", get(get_block_by_id)) + .route("/block/{event_id}/song/{index}", get(get_song_by_index)) + .route("/cover-url/{event_id}/{song_index}", get(get_cover_url)) + .route("/stream-url/{event_id}", get(get_stream_url)) + .route("/channels", get(get_channels)) + .with_state(state) +} + +/// Trait d'extension pour pmoserver::Server +/// +/// Permet d'initialiser Radio Paradise avec routes HTTP complètes +#[cfg(feature = "pmoserver")] +#[async_trait] +pub trait RadioParadiseExt { + /// Initialise l'API Radio Paradise + /// + /// # Routes créées + /// + /// - API: `/api/radioparadise/*` + /// - `/now-playing` + /// - `/block/*` + /// - `/channels` + /// - Swagger: `/swagger-ui/radioparadise` + async fn init_radioparadise(&mut self) -> anyhow::Result; +} + +#[cfg(feature = "pmoserver")] +#[async_trait] +impl RadioParadiseExt for pmoserver::Server { + async fn init_radioparadise(&mut self) -> anyhow::Result { + let state = RadioParadiseState::new().await?; + + // Créer le router API + let api_router = create_api_router(state.clone()); + + // L'enregistrer avec OpenAPI + self.add_openapi(api_router, RadioParadiseApiDoc::openapi(), "radioparadise") + .await; + + Ok(state) + } +} +-------End of pmoparadise/src/pmoserver_ext.rs --------- + +------------ pmoparadise/src/radio_paradise_stream_source.rs ---------- +//! RadioParadiseStreamSource - Node audio pmoaudio pour Radio Paradise +//! +//! Ce node télécharge et décode les blocs FLAC de Radio Paradise en streaming, +//! avec insertion automatique des TrackBoundary au bon timing. + +use crate::{ + client::RadioParadiseClient, + models::{Block, EventId, Song}, + node_stats::NodeStats, +}; +use futures_util::StreamExt; +use pmoaudio::{ + nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS}, + pipeline::{send_to_children, send_to_children_with_timing, Node, NodeLogic}, + type_constraints::TypeRequirement, + AudioPipelineNode, AudioSegment, SyncMarker, I24, +}; +use pmoflac::decode_audio_stream; +use pmometadata::{MemoryTrackMetadata, TrackMetadata}; +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +use tokio::io::AsyncReadExt; +use tokio::sync::{mpsc, Notify, RwLock}; +use tokio_util::{io::StreamReader, sync::CancellationToken}; + +/// Signal spécial pour indiquer qu'il n'y aura plus de blocs +/// Quand ce blockid est poussé dans la queue, le source termine proprement +/// après avoir fini de traiter le bloc en cours +pub const END_OF_BLOCKS_SIGNAL: EventId = EventId::MAX; + +/// Nombre de blocs récents à mémoriser pour éviter les re-téléchargements +const RECENT_BLOCKS_CACHE_SIZE: usize = 10; + +/// Handle pour alimenter la queue de blocs pendant que la source tourne. +#[derive(Clone, Default)] +pub struct BlockQueueHandle { + queue: Arc>>, + notify: Arc, +} + +impl BlockQueueHandle { + fn new() -> Self { + Self { + queue: Arc::new(Mutex::new(VecDeque::new())), + notify: Arc::new(Notify::new()), + } + } + + /// Enfile un block pour traitement. + pub fn enqueue(&self, event_id: EventId) { + { + let mut queue = self.queue.lock().expect("block queue poisoned"); + queue.push_back(event_id); + } + self.notify.notify_one(); + } + + /// Retire le prochain block s'il existe. + fn pop(&self) -> Option { + let mut queue = self.queue.lock().expect("block queue poisoned"); + queue.pop_front() + } + + /// Nombre d'éléments en attente. + pub fn len(&self) -> usize { + let queue = self.queue.lock().expect("block queue poisoned"); + queue.len() + } + + fn snapshot(&self) -> Vec { + let queue = self.queue.lock().expect("block queue poisoned"); + queue.iter().copied().collect() + } + + fn front(&self) -> Option { + let queue = self.queue.lock().expect("block queue poisoned"); + queue.front().copied() + } + + fn back(&self) -> Option { + let queue = self.queue.lock().expect("block queue poisoned"); + queue.back().copied() + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// RadioParadiseStreamSourceLogic - Logique métier pure +// ═══════════════════════════════════════════════════════════════════════════ + +/// Logique pure de téléchargement et décodage des blocs Radio Paradise +pub struct RadioParadiseStreamSourceLogic { + client: RadioParadiseClient, + chunk_frames: usize, + recent_blocks: VecDeque, + block_queue: BlockQueueHandle, + stats: Arc, +} + +impl RadioParadiseStreamSourceLogic { + pub fn new(client: RadioParadiseClient, chunk_duration_ms: u32) -> Self { + let handle = BlockQueueHandle::new(); + Self::with_queue(client, chunk_duration_ms, handle) + } + + fn with_queue( + client: RadioParadiseClient, + chunk_duration_ms: u32, + block_queue: BlockQueueHandle, + ) -> Self { + // Calculer chunk_frames pour la durée cible (on suppose 44.1kHz) + let chunk_frames = ((chunk_duration_ms as f64 / 1000.0) * 44100.0) as usize; + + Self { + client, + chunk_frames, + recent_blocks: VecDeque::with_capacity(RECENT_BLOCKS_CACHE_SIZE), + block_queue, + stats: NodeStats::new("RadioParadiseStreamSource"), + } + } + + /// Ajoute un block ID à la file d'attente + pub fn push_block_id(&self, event_id: EventId) { + self.block_queue.enqueue(event_id); + } + + /// Vérifie si un bloc a été téléchargé récemment + fn is_recent_block(&self, event_id: EventId) -> bool { + self.recent_blocks.contains(&event_id) + } + + /// Marque un bloc comme récemment téléchargé (FIFO) + fn mark_block_downloaded(&mut self, event_id: EventId) { + // Retirer tous les éléments excédentaires (garantit <= CACHE_SIZE) + while self.recent_blocks.len() >= RECENT_BLOCKS_CACHE_SIZE { + self.recent_blocks.pop_front(); + } + + // Puis ajouter le nouveau bloc + self.recent_blocks.push_back(event_id); + } + + /// Télécharge et décode un bloc FLAC + /// Retourne (timestamp_final, instant_debut) pour permettre le timing correct + async fn download_and_decode_block( + &mut self, + block: &Block, + output: &[mpsc::Sender>], + stop_token: &CancellationToken, + order: &mut u64, + ) -> Result<(f64, Instant), AudioError> { + // Télécharger le FLAC + tracing::info!( + "Sending HTTP GET request for block FLAC (expected duration: {:.1}min, url: {})", + block.length as f64 / 60000.0, + block.url + ); + let response = self + .client + .client + .get(&block.url) + .timeout(self.client.block_timeout) + .send() + .await + .map_err(|e| AudioError::ProcessingError(format!("Block download failed: {}", e)))?; + + tracing::debug!("HTTP response received, status={}", response.status()); + if !response.status().is_success() { + return Err(AudioError::ProcessingError(format!( + "Block download returned status {}", + response.status() + ))); + } + + // Vérifier la taille du contenu si disponible + if let Some(content_length) = response.content_length() { + tracing::info!( + "HTTP Content-Length: {} bytes ({:.1} MB)", + content_length, + content_length as f64 / 1_048_576.0 + ); + } else { + tracing::warn!("HTTP response has no Content-Length header"); + } + + // Créer un stream reader + tracing::debug!("Creating byte stream reader"); + let byte_stream = response + .bytes_stream() + .map(|result| result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))); + let stream_reader = StreamReader::new(byte_stream); + tracing::debug!("Stream reader created"); + + // Décoder le FLAC + tracing::debug!("Decoding FLAC stream..."); + let mut decoder = decode_audio_stream(stream_reader) + .await + .map_err(|e| AudioError::ProcessingError(format!("FLAC decode failed: {}", e)))?; + + let stream_info = decoder.info().clone(); + let sample_rate = stream_info.sample_rate; + let bits_per_sample = stream_info.bits_per_sample; + tracing::debug!( + "FLAC decoder initialized: {}Hz, {} bits/sample", + sample_rate, + bits_per_sample + ); + + // Préparer les songs ordonnées pour tracking + let songs = block.songs_ordered(); + let mut song_index = 0; + let mut total_samples = 0u64; + tracing::debug!("Block has {} songs", songs.len()); + + // Noter l'instant de début AVANT d'envoyer TopZeroSync + // Ceci permet de synchroniser la durée réelle du bloc + let start_instant = Instant::now(); + + // Envoyer TopZeroSync au début du bloc + tracing::debug!("Sending TopZeroSync to {} outputs", output.len()); + let top_zero = Arc::new(AudioSegment { + order: *order, + timestamp_sec: 0.0, + segment: pmoaudio::_AudioSegment::Sync(Arc::new(SyncMarker::TopZeroSync)), + }); + self.send_to_children(output, top_zero).await?; + tracing::debug!("TopZeroSync sent"); + + // Envoyer TrackBoundary pour la première song AVANT le premier chunk audio + // Même si son elapsed > 0, cela garantit que FlacCacheSink a des métadonnées + // dès le début (sinon il attendrait indéfiniment un TrackBoundary) + let mut next_song: Option<(usize, &Song)> = if let Some((idx, song)) = songs.get(0).copied() + { + tracing::debug!( + "Sending TrackBoundary for first song (idx={}, elapsed={}ms) at timestamp 0", + idx, + song.elapsed + ); + let metadata = song_to_metadata(song, block).await; + let track_boundary = AudioSegment::new_track_boundary( + *order, 0.0, // timestamp = 0 au début du stream + metadata, + ); + self.send_to_children(output, track_boundary).await?; + song_index = 1; + // Le prochain TrackBoundary sera pour la deuxième song quand elapsed_ms >= song.elapsed + songs.get(1).copied() + } else { + None + }; + tracing::debug!("Starting audio chunk loop"); + + // Buffer pour lecture + let bytes_per_sample = (bits_per_sample / 8) as usize; + let frame_bytes = bytes_per_sample * 2; // stereo + let chunk_frames = self.chunk_frames; + let chunk_byte_len = chunk_frames * frame_bytes; + let mut read_buf = vec![0u8; chunk_byte_len * 2]; + let mut pending: Vec = Vec::with_capacity(chunk_byte_len * 2); + + // Traiter les chunks audio + let mut chunk_count = 0; + let mut total_bytes_decoded = 0u64; + let expected_duration_sec = block.length as f64 / 1000.0; + let mut stats_last_log = Instant::now(); + + loop { + // Vérifier stop_token + if stop_token.is_cancelled() { + // Retourner le timestamp actuel et start_instant si on est interrompu + let current_timestamp = total_samples as f64 / sample_rate as f64; + tracing::warn!( + "Block decode CANCELLED: sent {} chunks, {:.2}s duration ({:.1}% of expected {:.2}s), decoded {} bytes", + chunk_count, current_timestamp, + (current_timestamp / expected_duration_sec) * 100.0, + expected_duration_sec, total_bytes_decoded + ); + return Ok((current_timestamp, start_instant)); + } + + // Remplir le buffer + if pending.len() < chunk_byte_len { + let read = decoder + .read(&mut read_buf) + .await + .map_err(|e| AudioError::ProcessingError(format!("Read error: {}", e)))?; + + if read == 0 { + let actual_duration = total_samples as f64 / sample_rate as f64; + let percentage = (actual_duration / expected_duration_sec) * 100.0; + + if percentage < 95.0 { + tracing::error!( + "FLAC decode EOF PREMATURE: sent {} chunks, {:.2}s actual vs {:.2}s expected ({:.1}%), decoded {} bytes", + chunk_count, actual_duration, expected_duration_sec, percentage, total_bytes_decoded + ); + } else { + tracing::info!( + "FLAC decode EOF reached: sent {} chunks, {:.2}s duration ({:.1}% of expected), decoded {} bytes", + chunk_count, actual_duration, percentage, total_bytes_decoded + ); + } + break; // EOF + } + total_bytes_decoded += read as u64; + pending.extend_from_slice(&read_buf[..read]); + } + + if pending.is_empty() { + break; + } + + // Extraire un chunk + let frames_in_pending = pending.len() / frame_bytes; + let frames_to_emit = frames_in_pending.min(chunk_frames); + let take_bytes = frames_to_emit * frame_bytes; + let pcm_data = pending.drain(..take_bytes).collect::>(); + + // Calculer le nombre de frames (samples par canal) + let bytes_per_sample = (bits_per_sample / 8) as usize; + let chunk_len = (pcm_data.len() / (bytes_per_sample * 2)) as u64; // 2 = stereo + + // Vérifier si on doit insérer un TrackBoundary avant ce chunk + if let Some((idx, song)) = next_song { + let elapsed_ms = (total_samples * 1000) / sample_rate as u64; + + if elapsed_ms >= song.elapsed { + // Envoyer TrackBoundary AVANT le chunk (avec le même order) + tracing::debug!( + "Sending TrackBoundary for song {} at elapsed_ms={} (song.elapsed={}, timestamp_sec={:.2})", + idx, elapsed_ms, song.elapsed, (total_samples as f64 / sample_rate as f64) + ); + let metadata = song_to_metadata(song, block).await; + let timestamp_sec = total_samples as f64 / sample_rate as f64; + let track_boundary = + AudioSegment::new_track_boundary(*order, timestamp_sec, metadata); + self.send_to_children(output, track_boundary).await?; + + // Passer à la song suivante + song_index += 1; + next_song = songs.get(song_index).copied(); + tracing::debug!( + "Moved to next song, song_index={}, next_song present={}", + song_index, + next_song.is_some() + ); + } + } + + // Envoyer le chunk audio + let timestamp_sec = total_samples as f64 / sample_rate as f64; + if stats_last_log.elapsed() >= Duration::from_secs(1) { + let real_elapsed = start_instant.elapsed().as_secs_f64(); + tracing::debug!( + "RP timing: chunk={} ts={:.3}s real_elapsed={:.3}s delta={:.3}s chunk_len={} frames", + chunk_count, + timestamp_sec, + real_elapsed, + timestamp_sec - real_elapsed, + chunk_len + ); + stats_last_log = Instant::now(); + } + let audio_segment = pcm_to_audio_segment( + &pcm_data, + *order, + timestamp_sec, + sample_rate, + bits_per_sample, + )?; + self.send_to_children(output, audio_segment).await?; + + *order += 1; + total_samples += chunk_len; + chunk_count += 1; + } + + // Retourner le timestamp du dernier chunk (durée totale du bloc) et l'instant de début + let final_timestamp = total_samples as f64 / sample_rate as f64; + tracing::debug!( + "Block decode complete: {} samples, {:.2}s duration", + total_samples, + final_timestamp + ); + + Ok((final_timestamp, start_instant)) + } + + /// Envoie un segment à tous les enfants + async fn send_to_children( + &self, + output: &[mpsc::Sender>], + segment: Arc, + ) -> Result<(), AudioError> { + let segment_ts = segment.timestamp_sec; + self.stats.record_segment_received(segment_ts); + + let segment_bytes = match &segment.segment { + pmoaudio::_AudioSegment::Chunk(chunk) => chunk.len() * 2 * 4, + _ => 0, + }; + + send_to_children_with_timing( + std::any::type_name::(), + output, + segment, + |i, send_duration, capacity_before| { + tracing::trace!( + "send_to_children: Sending to child {} (channel capacity={}, timestamp={:.3}s)", + i, + capacity_before, + segment_ts + ); + + if send_duration.as_millis() > 10 { + let duration_ms = send_duration.as_millis() as u64; + self.stats.record_backpressure(duration_ms); + tracing::trace!( + "send_to_children: Send to child {} BLOCKED for {:.3}s (channel capacity before send={}, timestamp={:.3}s)", + i, + send_duration.as_secs_f64(), + capacity_before, + segment_ts + ); + } + + self.stats.record_segment_sent(segment_bytes); + }, + ) + .await?; + Ok(()) + } +} + +/// Convertit PCM bytes en AudioSegment +fn pcm_to_audio_segment( + pcm_data: &[u8], + order: u64, + timestamp_sec: f64, + sample_rate: u32, + bits_per_sample: u8, +) -> Result, AudioError> { + use pmoaudio::{AudioChunk, AudioChunkData, _AudioSegment}; + + let bytes_per_sample = (bits_per_sample / 8) as usize; + let channels = 2; // Stereo + let frame_bytes = bytes_per_sample * channels; + let frames = pcm_data.len() / frame_bytes; + + // Valider que la taille des données est correcte + if pcm_data.len() % frame_bytes != 0 { + return Err(AudioError::ProcessingError(format!( + "Invalid PCM data size: {} bytes is not a multiple of frame size {} ({}bit, {} channels)", + pcm_data.len(), + frame_bytes, + bits_per_sample, + channels + ))); + } + + let chunk = match bits_per_sample { + 16 => { + // Type I16 + let mut stereo = Vec::with_capacity(frames); + for frame_idx in 0..frames { + let base = frame_idx * frame_bytes; + let left = i16::from_le_bytes([pcm_data[base], pcm_data[base + 1]]); + let right = i16::from_le_bytes([pcm_data[base + 2], pcm_data[base + 3]]); + stereo.push([left, right]); + } + let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0); + AudioChunk::I16(chunk_data) + } + 24 => { + // Type I24 avec sign extension correcte + let mut stereo = Vec::with_capacity(frames); + for frame_idx in 0..frames { + let base = frame_idx * frame_bytes; + + // Left channel (bytes 0,1,2) avec sign extension + let left_i32 = { + let mut buf = [0u8; 4]; + buf[..3].copy_from_slice(&pcm_data[base..base + 3]); + // Sign extend si négatif + if pcm_data[base + 2] & 0x80 != 0 { + buf[3] = 0xFF; + } + i32::from_le_bytes(buf) + }; + let left = I24::new(left_i32).ok_or_else(|| { + AudioError::ProcessingError(format!("Invalid I24 value: {}", left_i32)) + })?; + + // Right channel (bytes 3,4,5) avec sign extension + let right_i32 = { + let mut buf = [0u8; 4]; + buf[..3].copy_from_slice(&pcm_data[base + 3..base + 6]); + // Sign extend si négatif + if pcm_data[base + 5] & 0x80 != 0 { + buf[3] = 0xFF; + } + i32::from_le_bytes(buf) + }; + let right = I24::new(right_i32).ok_or_else(|| { + AudioError::ProcessingError(format!("Invalid I24 value: {}", right_i32)) + })?; + + stereo.push([left, right]); + } + let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0); + AudioChunk::I24(chunk_data) + } + 32 => { + // Type I32 + let mut stereo = Vec::with_capacity(frames); + for frame_idx in 0..frames { + let base = frame_idx * frame_bytes; + let left = i32::from_le_bytes([ + pcm_data[base], + pcm_data[base + 1], + pcm_data[base + 2], + pcm_data[base + 3], + ]); + let right = i32::from_le_bytes([ + pcm_data[base + 4], + pcm_data[base + 5], + pcm_data[base + 6], + pcm_data[base + 7], + ]); + stereo.push([left, right]); + } + let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0); + AudioChunk::I32(chunk_data) + } + _ => { + return Err(AudioError::ProcessingError(format!( + "Unsupported bit depth: {}", + bits_per_sample + ))) + } + }; + + Ok(Arc::new(AudioSegment { + order, + timestamp_sec, + segment: _AudioSegment::Chunk(Arc::new(chunk)), + })) +} + +/// Convertit Song en TrackMetadata +/// +/// Configure toutes les métadonnées de manière asynchrone et attend que la configuration +/// soit terminée avant de retourner, garantissant que les métadonnées (y compris cover_url) +/// sont disponibles immédiatement pour les nodes suivants +async fn song_to_metadata(song: &Song, block: &Block) -> Arc> { + let metadata = MemoryTrackMetadata::new(); + let metadata_arc = Arc::new(RwLock::new(metadata)) as Arc>; + + // Cloner les données + let title = song.title.clone(); + let artist = song.artist.clone(); + let album = song.album.clone(); + let year = song.year; + let cover_url = song.cover.as_ref().and_then(|cover| block.cover_url(cover)); + + // Configurer les métadonnées de manière synchrone (mais async await) + { + let mut meta = metadata_arc.write().await; + + // Ces méthodes peuvent échouer (retournent Result), donc on log les erreurs + if let Err(e) = meta.set_title(Some(title)).await { + tracing::warn!("Failed to set title: {}", e); + } + if let Err(e) = meta.set_artist(Some(artist)).await { + tracing::warn!("Failed to set artist: {}", e); + } + if let Some(album) = album { + if let Err(e) = meta.set_album(Some(album)).await { + tracing::warn!("Failed to set album: {}", e); + } + } + if let Some(year) = year { + if let Err(e) = meta.set_year(Some(year)).await { + tracing::warn!("Failed to set year: {}", e); + } + } + if let Some(ref url) = cover_url { + tracing::debug!("RadioParadiseStreamSource: Setting cover_url to: {}", url); + if let Err(e) = meta.set_cover_url(Some(url.clone())).await { + tracing::warn!("Failed to set cover_url: {}", e); + } else { + tracing::debug!("RadioParadiseStreamSource: Successfully set cover_url"); + } + } else { + tracing::debug!("RadioParadiseStreamSource: No cover URL available for song"); + } + } + + metadata_arc +} + +#[async_trait::async_trait] +impl NodeLogic for RadioParadiseStreamSourceLogic { + async fn process( + &mut self, + _input: Option>>, + output: Vec>>, + stop_token: CancellationToken, + ) -> Result<(), AudioError> { + tracing::debug!( + "RadioParadiseStreamSource::process() started, block_queue has {} items", + self.block_queue.len() + ); + for (i, event_id) in self.block_queue.snapshot().iter().enumerate() { + tracing::debug!(" block_queue[{}] = {}", i, event_id); + } + + let mut order = 0u64; + let mut last_timestamp = 0.0; + let mut last_start_instant: Option = None; + + loop { + // Attendre un block ID depuis la queue (pas de timeout - mode idle) + tracing::debug!("Waiting for block_id from queue (idle mode, no timeout)..."); + let event_id = loop { + // Vérifier d'abord le stop_token + if stop_token.is_cancelled() { + tracing::info!("Stop token cancelled while waiting for block_id"); + break None; + } + + // Essayer de pop un event_id + if let Some(id) = self.block_queue.pop() { + tracing::debug!("Got event_id {} from queue", id); + + // Vérifier si c'est le signal de fin + if id == END_OF_BLOCKS_SIGNAL { + tracing::info!( + "Received END_OF_BLOCKS_SIGNAL, finishing after current block" + ); + break None; + } + + break Some(id); + } + + tracing::trace!("block_queue is empty, waiting for new events..."); + tokio::select! { + _ = stop_token.cancelled() => break None, + _ = self.block_queue.notify.notified() => {}, + _ = tokio::time::sleep(Duration::from_millis(100)) => {} + }; + }; + + // Si on n'a pas d'event_id, on termine + let event_id = match event_id { + Some(id) => id, + None => { + tracing::info!("No more blocks to process, exiting loop"); + break; + } + }; + + // Vérifier si déjà téléchargé récemment + if self.is_recent_block(event_id) { + tracing::debug!("Block {} was recently downloaded, skipping", event_id); + continue; + } + + // Récupérer les métadonnées du bloc + tracing::debug!("Fetching block metadata for event_id {}...", event_id); + let block = + self.client.get_block(Some(event_id)).await.map_err(|e| { + AudioError::ProcessingError(format!("Failed to get block: {}", e)) + })?; + tracing::debug!("Block metadata received: url={}", block.url); + + // Marquer comme téléchargé + self.mark_block_downloaded(event_id); + + // Télécharger et décoder le bloc + tracing::info!("Starting download and decode for block {}...", event_id); + let (block_duration, start_instant) = self + .download_and_decode_block(&block, &output, &stop_token, &mut order) + .await?; + last_timestamp = block_duration; + last_start_instant = Some(start_instant); + tracing::info!( + "Finished download and decode for block {} (duration: {:.2}s)", + event_id, + block_duration + ); + } + + // Envoyer EndOfStream avec le timestamp du dernier chunk + tracing::info!( + "Sending EndOfStream with timestamp {:.2}s to {} outputs", + last_timestamp, + output.len() + ); + let eos = AudioSegment::new_end_of_stream(order, last_timestamp); + send_to_children(std::any::type_name::(), &output, eos).await?; + + // IMPORTANT: Attendre que tous les channels soient fermés par les enfants + // Cela garantit que tous les chunks (y compris ceux en attente dans les buffers MPSC) + // ont été traités avant que nous ne fermions notre bout + tracing::info!("Waiting for all child nodes to close their channels..."); + for (i, tx) in output.iter().enumerate() { + tracing::debug!("Waiting for child {} to close channel...", i); + tx.closed().await; + tracing::debug!("Child {} channel closed", i); + } + tracing::info!("All child channels closed, pipeline complete"); + + if let Some(start_instant) = last_start_instant { + let total_elapsed = start_instant.elapsed().as_secs_f64(); + tracing::info!( + "Block processing complete: duration={:.2}s, total_elapsed={:.2}s ({:.1}% of real-time)", + last_timestamp, total_elapsed, (total_elapsed / last_timestamp) * 100.0 + ); + } + + // Log des statistiques finales + tracing::info!("\n{}", self.stats.report()); + + Ok(()) + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// RadioParadiseStreamSource - Wrapper utilisant Node +// ═══════════════════════════════════════════════════════════════════════════ + +pub struct RadioParadiseStreamSource { + inner: Node, + block_handle: BlockQueueHandle, +} + +impl RadioParadiseStreamSource { + /// Crée une nouvelle source Radio Paradise avec durée de chunk par défaut + pub fn new(client: RadioParadiseClient) -> Self { + Self::with_chunk_duration(client, DEFAULT_CHUNK_DURATION_MS as u32) + } + + /// Crée une nouvelle source avec durée de chunk personnalisée + pub fn with_chunk_duration(client: RadioParadiseClient, chunk_duration_ms: u32) -> Self { + let handle = BlockQueueHandle::new(); + let logic = + RadioParadiseStreamSourceLogic::with_queue(client, chunk_duration_ms, handle.clone()); + Self { + inner: Node::new_source(logic), + block_handle: handle, + } + } + + /// Ajoute un block ID à la file d'attente de téléchargement + pub fn push_block_id(&self, event_id: EventId) { + self.block_handle.enqueue(event_id); + } + + /// Retourne un handle permettant d'enfiler des blocks dynamiquement. + pub fn block_handle(&self) -> BlockQueueHandle { + self.block_handle.clone() + } +} + +#[async_trait::async_trait] +impl AudioPipelineNode for RadioParadiseStreamSource { + fn get_tx(&self) -> Option>> { + self.inner.get_tx() + } + + fn register(&mut self, child: Box) { + self.inner.register(child); + } + + async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { + Box::new(self.inner).run(stop_token).await + } +} + +impl TypedAudioNode for RadioParadiseStreamSource { + fn input_type(&self) -> Option { + None // Source node + } + + fn output_type(&self) -> Option { + // Radio Paradise FLAC peut être 16-bit, 24-bit, ou 32-bit + // La profondeur est détectée automatiquement depuis le header FLAC + Some(TypeRequirement::any_integer()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_client() -> RadioParadiseClient { + RadioParadiseClient::with_client(reqwest::Client::new()) + } + + #[test] + fn test_cache_fifo_basic() { + let client = create_test_client(); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + + // Ajouter 5 blocs + for i in 1..=5 { + logic.mark_block_downloaded(i); + } + + // Vérifier que tous sont dans le cache + for i in 1..=5 { + assert!(logic.is_recent_block(i), "Block {} should be in cache", i); + } + assert_eq!(logic.recent_blocks.len(), 5); + } + + #[test] + fn test_cache_fifo_exactly_10_elements() { + let client = create_test_client(); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + + // Ajouter exactement 10 blocs + for i in 1..=10 { + logic.mark_block_downloaded(i); + } + + // Vérifier qu'on a exactement 10 éléments + assert_eq!( + logic.recent_blocks.len(), + 10, + "Cache should have exactly 10 elements" + ); + + // Tous devraient être dans le cache + for i in 1..=10 { + assert!(logic.is_recent_block(i), "Block {} should be in cache", i); + } + } + + #[test] + fn test_cache_fifo_eviction_oldest() { + let client = create_test_client(); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + + // Remplir le cache avec 10 éléments (1..=10) + for i in 1..=10 { + logic.mark_block_downloaded(i); + } + + // Ajouter un 11ème élément + logic.mark_block_downloaded(11); + + // Le cache doit toujours avoir 10 éléments + assert_eq!( + logic.recent_blocks.len(), + 10, + "Cache should still have 10 elements" + ); + + // Le premier (plus ancien) doit avoir été évincé + assert!( + !logic.is_recent_block(1), + "Oldest block (1) should be evicted" + ); + + // Les éléments 2..=11 doivent être présents + for i in 2..=11 { + assert!(logic.is_recent_block(i), "Block {} should be in cache", i); + } + } + + #[test] + fn test_cache_fifo_multiple_evictions() { + let client = create_test_client(); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + + // Remplir avec 10 éléments + for i in 1..=10 { + logic.mark_block_downloaded(i); + } + + // Ajouter 5 éléments supplémentaires + for i in 11..=15 { + logic.mark_block_downloaded(i); + } + + // Toujours 10 éléments + assert_eq!( + logic.recent_blocks.len(), + 10, + "Cache should have 10 elements" + ); + + // Les 5 premiers doivent avoir été évincés + for i in 1..=5 { + assert!(!logic.is_recent_block(i), "Block {} should be evicted", i); + } + + // Les éléments 6..=15 doivent être présents + for i in 6..=15 { + assert!(logic.is_recent_block(i), "Block {} should be in cache", i); + } + } + + #[test] + fn test_cache_never_exceeds_capacity() { + let client = create_test_client(); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + + // Vérifier la capacité pré-allouée + assert_eq!(logic.recent_blocks.capacity(), RECENT_BLOCKS_CACHE_SIZE); + + // Ajouter beaucoup d'éléments + for i in 1..=100 { + logic.mark_block_downloaded(i); + + // À chaque itération, vérifier qu'on ne dépasse jamais 10 + assert!( + logic.recent_blocks.len() <= RECENT_BLOCKS_CACHE_SIZE, + "Cache size {} exceeded max {}", + logic.recent_blocks.len(), + RECENT_BLOCKS_CACHE_SIZE + ); + } + + // Finalement, on doit avoir exactement 10 éléments + assert_eq!(logic.recent_blocks.len(), 10); + + // Ce doivent être les 10 derniers (91..=100) + for i in 91..=100 { + assert!(logic.is_recent_block(i), "Block {} should be in cache", i); + } + } + + #[test] + fn test_cache_fifo_order_preserved() { + let client = create_test_client(); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + + // Ajouter 10 éléments + for i in 1..=10 { + logic.mark_block_downloaded(i); + } + + // Vérifier l'ordre dans la VecDeque (le front devrait être le plus ancien) + let front = logic.recent_blocks.front().copied(); + assert_eq!(front, Some(1), "Front should be the oldest element"); + + let back = logic.recent_blocks.back().copied(); + assert_eq!(back, Some(10), "Back should be the newest element"); + } + + #[test] + fn test_block_queue_push() { + let client = create_test_client(); + let mut logic = + RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); + + // Tester push_block_id + logic.push_block_id(100); + logic.push_block_id(200); + logic.push_block_id(300); + + assert_eq!(logic.block_queue.len(), 3); + assert_eq!(logic.block_queue.front(), Some(100)); + assert_eq!(logic.block_queue.back(), Some(300)); + } +} +-------End of pmoparadise/src/radio_paradise_stream_source.rs --------- + +------------ pmoparadise/src/source.rs ---------- +//! RadioParadiseSource - Implementation of MusicSource for Radio Paradise +//! +//! This module provides a UPnP ContentDirectory source for Radio Paradise, +//! exposing live streams and historical playlists for all 4 channels. + +use crate::channels::{ChannelDescriptor, ALL_CHANNELS}; +use pmosource::pmodidl::{Container, Item, Resource}; +use pmosource::{ + async_trait, AudioFormat, BrowseResult, MusicSource, MusicSourceError, Result, + SourceCapabilities, +}; +use std::fmt; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime}; +use tokio::sync::RwLock; + +/// Default Radio Paradise image (embedded in binary) +const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); + +#[cfg(feature = "playlist")] +const LIVE_PLAYLIST_MIN_READY_ITEMS: usize = 5; +#[cfg(feature = "playlist")] +const LIVE_PLAYLIST_READY_TIMEOUT: Duration = Duration::from_secs(10); +#[cfg(feature = "playlist")] +const LIVE_PLAYLIST_READY_POLL: Duration = Duration::from_millis(200); + +/// RadioParadiseSource - UPnP ContentDirectory source for Radio Paradise +/// +/// Provides access to: +/// - Live FLAC streams for all 4 channels (Main, Mellow, Rock, Eclectic) +/// - Historical playlists (FIFO) for each channel +/// +/// # Object ID Schema +/// +/// - Root: `radio-paradise` +/// - Channel container: `radio-paradise:channel:{slug}` +/// - Live stream item: `radio-paradise:channel:{slug}:live` +/// - Live playlist container: `radio-paradise:channel:{slug}:liveplaylist` +/// - Live playlist track: `radio-paradise:channel:{slug}:liveplaylist:track:{pk}` +/// - History container: `radio-paradise:channel:{slug}:history` +/// - History track: `radio-paradise:channel:{slug}:history:track:{pk}` +#[derive(Clone)] +pub struct RadioParadiseSource { + /// Base URL for streaming server (e.g., "http://localhost:8080") + base_url: String, + /// Update counter for change notifications + update_counter: Arc>, + /// Last change timestamp + last_change: Arc>, + /// Tokens des callbacks enregistrés auprès du PlaylistManager + callback_tokens: Arc>>, + /// Notifier optionnel pour signaler les mises à jour de conteneurs au ContentDirectory + container_notifier: Option>, +} + +impl fmt::Debug for RadioParadiseSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RadioParadiseSource") + .field("base_url", &self.base_url) + .finish_non_exhaustive() + } +} + +impl RadioParadiseSource { + /// Create a new RadioParadiseSource + /// + /// # Arguments + /// + /// * `base_url` - Base URL for streaming server (e.g., "http://localhost:8080") + /// + /// # Note + /// + /// With the "playlist" feature enabled, this source will use the global PlaylistManager + /// singleton to access history playlists. + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + update_counter: Arc::new(RwLock::new(0)), + last_change: Arc::new(RwLock::new(SystemTime::now())), + callback_tokens: Arc::new(std::sync::Mutex::new(Vec::new())), + container_notifier: None, + } + } + + /// Injecte un notifier pour propager les changements de playlists vers le ContentDirectory + pub fn with_container_notifier( + mut self, + notifier: Arc, + ) -> Self { + self.container_notifier = Some(notifier); + self + } + + /// Build a live stream URL for a channel + fn build_live_url(&self, slug: &str) -> String { + format!("{}/radioparadise/stream/{}/flac", self.base_url, slug) + } + + /// Build an OGG-FLAC live stream URL for clients that support it + fn build_live_ogg_url(&self, slug: &str) -> String { + format!("{}/radioparadise/stream/{}/ogg", self.base_url, slug) + } + + /// Incrémente l'update_counter et met à jour last_change + async fn bump_update_counter(&self) { + { + let mut c = self.update_counter.write().await; + *c = c.wrapping_add(1).max(1); + } + let mut lc = self.last_change.write().await; + *lc = SystemTime::now(); + } + + /// Enregistre des callbacks sur les playlists live/historique pour notifier les changements + pub fn attach_playlist_callbacks(self: &Arc) { + use pmoplaylist::PlaylistManager; + + // Préparer les IDs de playlists à surveiller (live + history pour chaque canal) + let ids: Vec = ALL_CHANNELS + .iter() + .flat_map(|ch| { + vec![ + Self::live_playlist_id(ch.slug), + Self::history_playlist_id(ch.slug), + ] + }) + .collect(); + + let mgr = PlaylistManager(); + let mut tokens = self.callback_tokens.lock().unwrap(); + + for pid in ids { + let weak = Arc::downgrade(self); + let pid_clone = pid.clone(); + let token = mgr.register_callback(move |event| { + let pid = pid_clone.clone(); + if event.playlist_id == pid { + // On ne réagit qu'aux mises à jour structurelles (ajout/suppression) + if !matches!(event.kind, pmoplaylist::PlaylistEventKind::Updated) { + return; + } + if let Some(strong) = weak.upgrade() { + tokio::spawn(async move { + strong.bump_update_counter().await; + // Notifier ContentDirectory des conteneurs concernés + let containers: Vec = if pid.contains("history") { + // history playlist -> container history + ALL_CHANNELS + .iter() + .find(|ch| pid.ends_with(ch.slug)) + .map(|ch| { + vec![format!("radio-paradise:channel:{}:history", ch.slug)] + }) + .unwrap_or_default() + } else { + // live playlist -> container liveplaylist + ALL_CHANNELS + .iter() + .find(|ch| pid.ends_with(ch.slug)) + .map(|ch| { + vec![format!( + "radio-paradise:channel:{}:liveplaylist", + ch.slug + )] + }) + .unwrap_or_default() + }; + + if !containers.is_empty() { + if let Some(notifier) = strong.container_notifier.as_ref() { + notifier(&containers); + } + } + }); + } + } + }); + tokens.push(token); + } + } + + /// URL de fallback pour l'image par défaut de la source + fn default_cover_url(&self) -> String { + format!("{}/api/sources/{}/image", self.base_url, self.id()) + } + + /// Fetch current metadata from the live stream + async fn fetch_live_metadata(&self, slug: &str) -> Result> { + let metadata_url = format!("{}/radioparadise/metadata/{}", self.base_url, slug); + + // Try to fetch metadata via HTTP + match reqwest::get(&metadata_url).await { + Ok(response) if response.status().is_success() => { + match response.json::().await { + Ok(json) => { + // Parse metadata from JSON and create an Item + let title = json["title"] + .as_str() + .unwrap_or("Unknown Title") + .to_string(); + let artist = json["artist"].as_str().map(|s| s.to_string()); + let album = json["album"].as_str().map(|s| s.to_string()); + let year = json["year"].as_u64().map(|y| y as u32); + // Préférer l'URL de cache si cover_pk est fourni par le pipeline + let cover_pk = json["cover_pk"].as_str().map(|s| s.to_string()); + let cover_url = cover_pk + .as_ref() + .map(|pk| format!("{}/covers/jpeg/{}", self.base_url, pk)) + .or_else(|| json["cover_url"].as_str().map(|s| s.to_string())) + .or_else(|| Some(self.default_cover_url())); + + // Parse duration from JSON (in seconds as a float) + let duration = json["duration"] + .as_object() + .and_then(|d| d.get("secs")) + .and_then(|s| s.as_f64()) + .or_else(|| json["duration"].as_f64()) + .map(|secs| { + let total_secs = secs as u64; + format!( + "{}:{:02}:{:02}", + total_secs / 3600, + (total_secs % 3600) / 60, + total_secs % 60 + ) + }); + + // Create the item with current metadata + let item = Item { + id: format!("radio-paradise:channel:{}:live", slug), + parent_id: format!("radio-paradise:channel:{}", slug), + restricted: Some("1".to_string()), + title, + creator: artist.clone(), + class: "object.item.audioItem.audioBroadcast".to_string(), + artist, + album, + genre: Some("Radio".to_string()), + album_art: cover_url, + album_art_pk: cover_pk, + date: year.map(|y| y.to_string()), + original_track_number: None, + resources: vec![Resource { + protocol_info: "http-get:*:audio/flac:*".to_string(), + bits_per_sample: None, + sample_frequency: None, + nr_audio_channels: Some("2".to_string()), + duration, + url: self.build_live_url(slug), + }], + descriptions: vec![], + }; + + Ok(Some(item)) + } + Err(_) => Ok(None), + } + } + _ => Ok(None), + } + } + + /// Get the playlist ID for a channel's history + #[cfg(feature = "playlist")] + fn history_playlist_id(slug: &str) -> String { + // Must match the prefix used in ParadiseHistoryBuilder + format!("radio-paradise-history-{}", slug) + } + + /// Live playlist id for a channel + fn live_playlist_id(slug: &str) -> String { + format!("radio-paradise-live-{}", slug) + } + + #[cfg(feature = "playlist")] + async fn wait_for_live_playlist_ready(&self, slug: &str) -> Result<()> { + let playlist_id = Self::live_playlist_id(slug); + let manager = pmoplaylist::PlaylistManager(); + let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { + MusicSourceError::BrowseError(format!( + "Failed to get live playlist {}: {}", + playlist_id, e + )) + })?; + let start = Instant::now(); + loop { + match reader.remaining().await { + Ok(count) if count >= LIVE_PLAYLIST_MIN_READY_ITEMS => return Ok(()), + Ok(_) => {} + Err(e) => { + return Err(MusicSourceError::BrowseError(format!( + "Failed to inspect live playlist {}: {}", + playlist_id, e + ))); + } + } + + if start.elapsed() >= LIVE_PLAYLIST_READY_TIMEOUT { + tracing::warn!( + "Timeout waiting for live playlist {} to reach {} items", + playlist_id, + LIVE_PLAYLIST_MIN_READY_ITEMS + ); + return Ok(()); + } + + tokio::time::sleep(LIVE_PLAYLIST_READY_POLL).await; + } + } + + /// Get channel descriptor by slug + fn get_channel_by_slug(slug: &str) -> Option<&'static ChannelDescriptor> { + ALL_CHANNELS.iter().find(|ch| ch.slug == slug) + } + + /// Parse an object ID into its components + fn parse_object_id(id: &str) -> ObjectIdType { + let parts: Vec<&str> = id.split(':').collect(); + match parts.as_slice() { + ["radio-paradise"] => ObjectIdType::Root, + ["radio-paradise", "channel", slug] => ObjectIdType::Channel { + slug: (*slug).to_string(), + }, + ["radio-paradise", "channel", slug, "live"] => ObjectIdType::LiveStream { + slug: (*slug).to_string(), + }, + ["radio-paradise", "channel", slug, "liveplaylist"] => ObjectIdType::LivePlaylist { + slug: (*slug).to_string(), + }, + ["radio-paradise", "channel", slug, "liveplaylist", "track", pk] => { + ObjectIdType::LivePlaylistTrack { + slug: (*slug).to_string(), + pk: (*pk).to_string(), + } + } + ["radio-paradise", "channel", slug, "history"] => ObjectIdType::History { + slug: (*slug).to_string(), + }, + ["radio-paradise", "channel", slug, "history", "track", pk] => { + ObjectIdType::HistoryTrack { + slug: (*slug).to_string(), + pk: (*pk).to_string(), + } + } + _ => ObjectIdType::Unknown, + } + } + + /// Build a channel container + fn build_channel_container(&self, descriptor: &ChannelDescriptor) -> Container { + Container { + id: format!("radio-paradise:channel:{}", descriptor.slug), + parent_id: "radio-paradise".to_string(), + restricted: Some("1".to_string()), + child_count: None, + searchable: Some("1".to_string()), + title: descriptor.display_name.to_string(), + class: "object.container".to_string(), + containers: vec![], + items: vec![], + } + } + + /// Build the live playlist container for a channel + fn build_live_playlist_container(&self, descriptor: &ChannelDescriptor) -> Container { + Container { + id: format!("radio-paradise:channel:{}:liveplaylist", descriptor.slug), + parent_id: format!("radio-paradise:channel:{}", descriptor.slug), + restricted: Some("1".to_string()), + child_count: None, + searchable: Some("0".to_string()), + title: format!("{} - Live Playlist", descriptor.display_name), + class: "object.container.playlistContainer".to_string(), + containers: vec![], + items: vec![], + } + } + + /// Build a live stream item for a channel + fn build_live_stream_item(&self, descriptor: &ChannelDescriptor) -> Item { + let stream_url = self.build_live_url(descriptor.slug); + + Item { + id: format!("radio-paradise:channel:{}:live", descriptor.slug), + parent_id: format!("radio-paradise:channel:{}", descriptor.slug), + restricted: Some("1".to_string()), + title: format!("{} - Live Stream", descriptor.display_name), + creator: Some("Radio Paradise".to_string()), + class: "object.item.audioItem.audioBroadcast".to_string(), + artist: Some("Radio Paradise".to_string()), + album: Some(descriptor.display_name.to_string()), + genre: Some("Radio".to_string()), + album_art: Some(self.default_cover_url()), + album_art_pk: None, + date: None, + original_track_number: None, + resources: vec![ + Resource { + protocol_info: "http-get:*:audio/flac:*".to_string(), + bits_per_sample: Some("16".to_string()), + sample_frequency: Some("44100".to_string()), + nr_audio_channels: Some("2".to_string()), + duration: None, + url: stream_url.clone(), + }, + Resource { + protocol_info: "http-get:*:audio/ogg:*".to_string(), + bits_per_sample: Some("16".to_string()), + sample_frequency: Some("44100".to_string()), + nr_audio_channels: Some("2".to_string()), + duration: None, + url: self.build_live_ogg_url(descriptor.slug), + }, + ], + descriptions: vec![], + } + } + + /// Build a history container for a channel + fn build_history_container(&self, descriptor: &ChannelDescriptor) -> Container { + Container { + id: format!("radio-paradise:channel:{}:history", descriptor.slug), + parent_id: format!("radio-paradise:channel:{}", descriptor.slug), + restricted: Some("1".to_string()), + child_count: None, + searchable: Some("1".to_string()), + title: format!("{} - History", descriptor.display_name), + // Expose l'historique comme une playlist jouable + class: "object.container.playlistContainer".to_string(), + containers: vec![], + items: vec![], + } + } + + /// Build a history container with accurate child count from playlist + #[cfg(feature = "playlist")] + async fn build_history_container_with_count( + &self, + descriptor: &ChannelDescriptor, + ) -> Container { + let mut container = self.build_history_container(descriptor); + + // Try to get actual count from playlist + let playlist_id = Self::history_playlist_id(descriptor.slug); + let manager = pmoplaylist::PlaylistManager(); + + if let Ok(reader) = manager.get_read_handle(&playlist_id).await { + if let Ok(count) = reader.remaining().await { + container.child_count = Some(count.to_string()); + } + } + + container + } + + /// Get items from history playlist + #[cfg(feature = "playlist")] + async fn get_history_items( + &self, + slug: &str, + _offset: usize, + count: usize, + ) -> Result> { + let playlist_id = Self::history_playlist_id(slug); + + // Get read handle for the playlist from the singleton + let manager = pmoplaylist::PlaylistManager(); + let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { + MusicSourceError::BrowseError(format!("Failed to get playlist {}: {}", playlist_id, e)) + })?; + + // Get items from playlist (to_items starts from cursor position) + let mut items = reader.to_items(count).await.map_err(|e| { + MusicSourceError::BrowseError(format!("Failed to read playlist entries: {}", e)) + })?; + + // Transform item IDs, parent_ids, and resource URLs to match Radio Paradise schema + // Expected: radio-paradise:channel:{slug}:history:track:{pk} + // Parent: radio-paradise:channel:{slug}:history + for item in items.iter_mut() { + // Extract cache_pk from the resource URL (last segment) + if let Some(resource) = item.resources.first_mut() { + if let Some(pk) = resource.url.split('/').last() { + // Update item ID and parent ID + item.id = format!("radio-paradise:channel:{}:history:track:{}", slug, pk); + item.parent_id = format!("radio-paradise:channel:{}:history", slug); + + // Convert relative URL to absolute URL + // From: /audio/flac/pk + // To: http://base_url/audio/flac/pk + if resource.url.starts_with('/') { + resource.url = format!("{}{}", self.base_url, resource.url); + } + } + } + + // Fix: Ajouter un genre par défaut si absent + // Certains clients UPnP (comme gupnp-av-cp) requièrent le champ + // pour parser correctement les items de classe musicTrack, même si ce champ + // est optionnel selon la spec UPnP ContentDirectory. + if item.genre.is_none() { + item.genre = Some("Radio Paradise".to_string()); + } + + // Normaliser l'albumArtURI : rendre absolu si chemin relatif, sinon fallback par défaut + if let Some(art) = item.album_art.as_mut() { + if art.starts_with('/') { + *art = format!("{}{}", self.base_url, art); + } + } else { + item.album_art = Some(self.default_cover_url()); + } + } + + Ok(items) + } + + /// Get items from live playlist (current stream queue) + #[cfg(feature = "playlist")] + async fn get_live_playlist_items( + &self, + slug: &str, + _offset: usize, + count: usize, + ) -> Result> { + #[cfg(all(feature = "playlist", feature = "pmoaudio"))] + if let Some(descriptor) = Self::get_channel_by_slug(slug) { + if let Some(manager) = crate::stream_channel::get_global_channel_manager() { + if let Err(e) = manager.prefetch_until_horizon(descriptor.id).await { + tracing::warn!( + "Failed to prefetch live playlist for {}: {}", + descriptor.slug, + e + ); + } + } + } + + #[cfg(feature = "playlist")] + if let Err(e) = self.wait_for_live_playlist_ready(slug).await { + tracing::warn!( + "Failed to wait for live playlist readiness on {}: {}", + slug, + e + ); + } + + let playlist_id = Self::live_playlist_id(slug); + + let manager = pmoplaylist::PlaylistManager(); + let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { + MusicSourceError::BrowseError(format!( + "Failed to get live playlist {}: {}", + playlist_id, e + )) + })?; + + let mut items = reader.to_items(count).await.map_err(|e| { + MusicSourceError::BrowseError(format!("Failed to read live playlist entries: {}", e)) + })?; + + for item in items.iter_mut() { + // Ajuster id/parent/url pour coller au schéma Radio Paradise + if let Some(resource) = item.resources.first_mut() { + if let Some(pk) = resource.url.split('/').last() { + item.id = format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk); + item.parent_id = format!("radio-paradise:channel:{}:liveplaylist", slug); + + if resource.url.starts_with('/') { + resource.url = format!("{}{}", self.base_url, resource.url); + } + } + } + + if item.genre.is_none() { + item.genre = Some("Radio Paradise".to_string()); + } + + if let Some(art) = item.album_art.as_mut() { + if art.starts_with('/') { + *art = format!("{}{}", self.base_url, art); + } + } else { + item.album_art = Some(self.default_cover_url()); + } + } + + Ok(items) + } + + /// Get a single item from the live playlist by pk + #[cfg(feature = "playlist")] + async fn get_live_playlist_item(&self, slug: &str, pk: &str) -> Result { + let items = self.get_live_playlist_items(slug, 0, 1000).await?; + let expected_id = format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk); + for item in items { + if item.id == expected_id { + return Ok(item); + } + } + Err(MusicSourceError::ObjectNotFound(format!( + "Track with pk {} not found in live playlist", + pk + ))) + } +} + +/// Types of object IDs in the Radio Paradise source +#[derive(Debug, Clone, PartialEq)] +enum ObjectIdType { + Root, + Channel { slug: String }, + LiveStream { slug: String }, + LivePlaylist { slug: String }, + LivePlaylistTrack { slug: String, pk: String }, + History { slug: String }, + HistoryTrack { slug: String, pk: String }, + Unknown, +} + +#[async_trait] +impl MusicSource for RadioParadiseSource { + fn name(&self) -> &str { + "Radio Paradise" + } + + fn id(&self) -> &str { + "radio-paradise" + } + + fn default_image(&self) -> &[u8] { + DEFAULT_IMAGE + } + + async fn root_container(&self) -> Result { + Ok(Container { + id: "radio-paradise".to_string(), + parent_id: "0".to_string(), + restricted: Some("1".to_string()), + // childCount retiré pour éviter les soucis de compatibilité côté CP + child_count: None, + searchable: Some("1".to_string()), + title: "Radio Paradise".to_string(), + class: "object.container".to_string(), + containers: vec![], + items: vec![], + }) + } + + async fn browse(&self, object_id: &str) -> Result { + match Self::parse_object_id(object_id) { + ObjectIdType::Root => { + // Return the 4 channel containers + let containers: Vec = ALL_CHANNELS + .iter() + .map(|ch| self.build_channel_container(ch)) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + ObjectIdType::Channel { slug } => { + // Return live stream item + history container + let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { + MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) + })?; + + let live_item = self.build_live_stream_item(descriptor); + let live_playlist_container = self.build_live_playlist_container(descriptor); + + #[cfg(feature = "playlist")] + let history_container = self.build_history_container_with_count(descriptor).await; + #[cfg(not(feature = "playlist"))] + let history_container = self.build_history_container(descriptor); + + Ok(BrowseResult::Mixed { + containers: vec![live_playlist_container, history_container], + items: vec![live_item], + }) + } + + ObjectIdType::History { slug } => { + // Return history container (for BrowseMetadata) and items (for BrowseDirectChildren) + // The content_handler will filter out the container when browsing direct children + let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { + MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) + })?; + + #[cfg(feature = "playlist")] + { + let history_container = + self.build_history_container_with_count(descriptor).await; + let items = self.get_history_items(&slug, 0, 100).await?; + Ok(BrowseResult::Mixed { + containers: vec![history_container], + items, + }) + } + + #[cfg(not(feature = "playlist"))] + { + // If playlist feature is disabled, return just the container + let history_container = self.build_history_container(descriptor); + Ok(BrowseResult::Containers(vec![history_container])) + } + } + + 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::LivePlaylist { slug } => { + // Playlist du live : container + items + let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { + MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) + })?; + + #[cfg(feature = "playlist")] + { + let container = self.build_live_playlist_container(descriptor); + let items = self.get_live_playlist_items(&slug, 0, 100).await?; + Ok(BrowseResult::Mixed { + containers: vec![container], + items, + }) + } + + #[cfg(not(feature = "playlist"))] + { + let container = self.build_live_playlist_container(descriptor); + Ok(BrowseResult::Containers(vec![container])) + } + } + + 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::LivePlaylistTrack { slug, pk } => { + // Détails d'un titre du live (playlist live) + #[cfg(feature = "playlist")] + { + let item = self.get_live_playlist_item(&slug, &pk).await?; + Ok(BrowseResult::Items(vec![item])) + } + + #[cfg(not(feature = "playlist"))] + { + let _ = (slug, pk); + Err(MusicSourceError::NotSupported( + "Playlist feature not enabled".to_string(), + )) + } + } + + ObjectIdType::Unknown => Err(MusicSourceError::ObjectNotFound(format!( + "Unknown object ID: {}", + object_id + ))), + } + } + + async fn resolve_uri(&self, object_id: &str) -> Result { + match Self::parse_object_id(object_id) { + ObjectIdType::LiveStream { slug } => { + // Return live stream URL + Ok(self.build_live_url(&slug)) + } + + ObjectIdType::HistoryTrack { pk, .. } => { + // Return cached audio URL + Ok(format!("{}/cache/audio/{}", self.base_url, pk)) + } + + ObjectIdType::LivePlaylistTrack { pk, .. } => { + // Return cached audio URL + Ok(format!("{}/cache/audio/{}", self.base_url, pk)) + } + + _ => Err(MusicSourceError::ObjectNotFound(format!( + "Cannot resolve URI for object: {}", + object_id + ))), + } + } + + fn capabilities(&self) -> SourceCapabilities { + SourceCapabilities { + supports_fifo: self.supports_fifo(), + supports_search: false, + supports_favorites: false, + supports_playlists: false, + supports_user_content: false, + supports_high_res_audio: true, + max_sample_rate: Some(44100), + supports_multiple_formats: true, + supports_advanced_search: false, + supports_pagination: false, + } + } + + async fn get_available_formats(&self, object_id: &str) -> Result> { + match Self::parse_object_id(object_id) { + ObjectIdType::LiveStream { .. } => Ok(vec![ + AudioFormat { + format_id: "flac".to_string(), + mime_type: "audio/flac".to_string(), + sample_rate: Some(44100), + bit_depth: Some(16), + bitrate: None, + channels: Some(2), + }, + AudioFormat { + format_id: "ogg-flac".to_string(), + mime_type: "audio/ogg".to_string(), + sample_rate: Some(44100), + bit_depth: Some(16), + bitrate: None, + channels: Some(2), + }, + ]), + ObjectIdType::HistoryTrack { .. } => Ok(vec![AudioFormat { + format_id: "flac".to_string(), + mime_type: "audio/flac".to_string(), + sample_rate: Some(44100), + bit_depth: Some(16), + bitrate: None, + channels: Some(2), + }]), + ObjectIdType::LivePlaylistTrack { .. } => Ok(vec![AudioFormat { + format_id: "flac".to_string(), + mime_type: "audio/flac".to_string(), + sample_rate: Some(44100), + bit_depth: Some(16), + bitrate: None, + channels: Some(2), + }]), + _ => Err(MusicSourceError::ObjectNotFound(format!( + "Cannot list formats for object: {}", + object_id + ))), + } + } + + async fn get_item(&self, object_id: &str) -> Result { + match Self::parse_object_id(object_id) { + ObjectIdType::LiveStream { slug } => { + // Try to fetch current metadata from live stream + if let Ok(Some(item)) = self.fetch_live_metadata(&slug).await { + return Ok(item); + } + + // Fallback to static item if metadata fetch fails + let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { + MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) + })?; + Ok(self.build_live_stream_item(descriptor)) + } + + ObjectIdType::HistoryTrack { slug, pk } => { + // Get from history playlist + #[cfg(feature = "playlist")] + { + let playlist_id = Self::history_playlist_id(&slug); + let manager = pmoplaylist::PlaylistManager(); + let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { + MusicSourceError::BrowseError(format!( + "Failed to get playlist {}: {}", + playlist_id, 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 + )) + })?; + + // Ajuster les IDs/parent_id/URL pour coller au schéma Radio Paradise, + // comme dans get_history_items. + let mut adjusted = Vec::new(); + for mut item in items { + if let Some(resource) = item.resources.first_mut() { + if let Some(pk2) = resource.url.split('/').last() { + item.id = format!( + "radio-paradise:channel:{}:history:track:{}", + slug, pk2 + ); + item.parent_id = format!("radio-paradise:channel:{}:history", slug); + + if resource.url.starts_with('/') { + resource.url = format!("{}{}", self.base_url, resource.url); + } + } + } + if item.genre.is_none() { + item.genre = Some("Radio Paradise".to_string()); + } + adjusted.push(item); + } + + // Find the item matching this pk in the item ID + let expected_id = + format!("radio-paradise:channel:{}:history:track:{}", slug, pk); + for item in adjusted { + if item.id == expected_id { + return Ok(item); + } + } + + Err(MusicSourceError::ObjectNotFound(format!( + "Track with pk {} not found in history", + pk + ))) + } + + #[cfg(not(feature = "playlist"))] + { + let _ = (slug, pk); + Err(MusicSourceError::NotSupported( + "Playlist feature not enabled".to_string(), + )) + } + } + + ObjectIdType::LivePlaylistTrack { slug, pk } => { + #[cfg(feature = "playlist")] + { + let playlist_id = Self::live_playlist_id(&slug); + let manager = pmoplaylist::PlaylistManager(); + let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { + MusicSourceError::BrowseError(format!( + "Failed to get live playlist {}: {}", + playlist_id, e + )) + })?; + + let items = reader.to_items(1000).await.map_err(|e| { + MusicSourceError::BrowseError(format!( + "Failed to read live playlist entries: {}", + e + )) + })?; + + for mut item in items { + if let Some(resource) = item.resources.first_mut() { + if let Some(pk2) = resource.url.split('/').last() { + item.id = format!( + "radio-paradise:channel:{}:liveplaylist:track:{}", + slug, pk2 + ); + item.parent_id = + format!("radio-paradise:channel:{}:liveplaylist", slug); + + if resource.url.starts_with('/') { + resource.url = format!("{}{}", self.base_url, resource.url); + } + } + } + + if item.genre.is_none() { + item.genre = Some("Radio Paradise".to_string()); + } + + if let Some(art) = item.album_art.as_mut() { + if art.starts_with('/') { + *art = format!("{}{}", self.base_url, art); + } + } else { + item.album_art = Some(self.default_cover_url()); + } + + let expected_id = + format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk); + if item.id == expected_id { + return Ok(item); + } + } + + Err(MusicSourceError::ObjectNotFound(format!( + "Track with pk {} not found in live playlist", + pk + ))) + } + + #[cfg(not(feature = "playlist"))] + { + let _ = (slug, pk); + Err(MusicSourceError::NotSupported( + "Playlist feature not enabled".to_string(), + )) + } + } + + _ => Err(MusicSourceError::ObjectNotFound(format!( + "Cannot get item for object: {}", + object_id + ))), + } + } + + fn supports_fifo(&self) -> bool { + // History playlists are FIFO + cfg!(feature = "playlist") + } + + async fn append_track(&self, _track: Item) -> Result<()> { + // Tracks are added automatically by FlacCacheSink + Err(MusicSourceError::NotSupported( + "Tracks are automatically added to history by the streaming system".to_string(), + )) + } + + async fn remove_oldest(&self) -> Result> { + // Managed automatically by playlist FIFO + Ok(None) + } + + async fn update_id(&self) -> u32 { + *self.update_counter.read().await + } + + async fn last_change(&self) -> Option { + Some(*self.last_change.read().await) + } + + async fn get_items(&self, offset: usize, count: usize) -> Result> { + // For Radio Paradise, we don't have a global FIFO + // Each channel has its own history + // Return empty for now - clients should browse specific channel histories + let _ = (offset, count); + Ok(vec![]) + } +} +-------End of pmoparadise/src/source.rs --------- + +------------ pmoparadise/src/stream_channel_old.rs ---------- +use std::{ + collections::HashMap, + pin::Pin, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + task::{Context, Poll}, + time::Duration, +}; + +use crate::{ + channels::{ChannelDescriptor, ParadiseChannelKind, ALL_CHANNELS}, + client::RadioParadiseClient, + radio_paradise_stream_source::RadioParadiseStreamSource, +}; +use anyhow::{anyhow, Result}; +use pmoaudio::{nodes::DEFAULT_CHANNEL_SIZE, AudioPipelineNode}; +use pmoaudio_ext::{ + FlacCacheSink, FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, + OggFlacStreamHandle, PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink, + TrackBoundaryCoverNode, StreamingSinkOptions, +}; +use pmoaudiocache::Cache as AudioCache; +use pmocovers::Cache as CoverCache; +use pmoflac::EncoderOptions; +use pmoplaylist::WriteHandle; +use thiserror::Error; +use tokio::io::{AsyncRead, ReadBuf}; +use tokio::sync::Notify; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; + +/// Configuration pour un canal Radio Paradise. +#[derive(Clone, Debug)] +pub struct ParadiseStreamChannelConfig { + /// Durée maximale (en secondes) d'avance acceptée par le broadcast. + pub max_lead_seconds: f64, + pub flac_options: StreamingSinkOptions, + pub ogg_options: StreamingSinkOptions, + pub server_base_url: Option, +} + +impl Default for ParadiseStreamChannelConfig { + fn default() -> Self { + Self { + max_lead_seconds: 1.0, + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } +} + +/// Options pour activer l'archivage/historique d'un canal. +pub struct ParadiseHistoryOptions { + pub audio_cache: Arc, + pub cover_cache: Arc, + pub playlist_id: String, + pub playlist_writer: WriteHandle, + pub collection: Option, + pub replay_max_lead_seconds: f64, +} + +/// Builder pratique pour configurer automatiquement les playlists historiques. +#[derive(Clone)] +pub struct ParadiseHistoryBuilder { + pub audio_cache: Arc, + pub cover_cache: Arc, + pub playlist_prefix: String, + pub playlist_title_prefix: Option, + pub max_history_tracks: Option, + pub collection_prefix: Option, + pub replay_max_lead_seconds: f64, +} + +impl ParadiseHistoryBuilder { + pub fn new(audio_cache: Arc, cover_cache: Arc) -> Self { + Self { + audio_cache, + cover_cache, + playlist_prefix: "radio-paradise-history".into(), + playlist_title_prefix: Some("Radio Paradise History".into()), + max_history_tracks: Some(500), + collection_prefix: Some("radio-paradise".into()), + replay_max_lead_seconds: 1.0, + } + } + + pub async fn build_for_channel( + &self, + descriptor: &ChannelDescriptor, + ) -> Result { + let playlist_id = format!("{}-{}", self.playlist_prefix, descriptor.slug); + let manager = pmoplaylist::PlaylistManager(); + let writer = manager + .get_persistent_write_handle(playlist_id.clone()) + .await?; + + if let Some(prefix) = &self.playlist_title_prefix { + let title = format!("{} - {}", prefix, descriptor.display_name); + writer.set_title(title).await?; + } + + if let Some(capacity) = self.max_history_tracks { + writer.set_capacity(Some(capacity)).await?; + } + + let collection = self + .collection_prefix + .as_ref() + .map(|prefix| format!("{}-{}", prefix, descriptor.slug)); + + Ok(ParadiseHistoryOptions { + audio_cache: self.audio_cache.clone(), + cover_cache: self.cover_cache.clone(), + playlist_id, + playlist_writer: writer, + collection, + replay_max_lead_seconds: self.replay_max_lead_seconds, + }) + } +} + +struct HistoryState { + playlist_id: String, + audio_cache: Arc, + replay_max_lead_seconds: f64, +} + +#[cfg(feature = "pmoconfig")] +impl ParadiseStreamChannelConfig { + pub fn from_config(cfg: &pmoconfig::Config, channel: ParadiseChannelKind) -> Self { + use serde_yaml::Value; + let path = [ + "sources", + "radio_paradise", + "channels", + channel.slug(), + "max_lead_seconds", + ]; + match cfg.get_value(&path) { + Ok(Value::Number(num)) => { + if let Some(v) = num.as_f64() { + Self { + max_lead_seconds: v.max(0.1), + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } else { + let default = Self::default(); + let _ = + cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + Ok(Value::String(s)) => { + if let Ok(v) = s.parse::() { + Self { + max_lead_seconds: v.max(0.1), + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } else { + let default = Self::default(); + let _ = + cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + _ => { + let default = Self::default(); + let _ = cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + } +} + +/// Stream complet (FLAC pur + OGG-FLAC) pour un canal Radio Paradise. +pub struct ParadiseStreamChannel { + descriptor: ChannelDescriptor, + state: Arc, + pipeline_handle: JoinHandle<()>, + feeder_handle: JoinHandle<()>, + history: Option, +} + +impl ParadiseStreamChannel { + /// Crée un canal avec client déjà configuré. + pub fn with_client( + descriptor: ChannelDescriptor, + client: RadioParadiseClient, + config: ParadiseStreamChannelConfig, + cover_cache: Option>, + history: Option, + ) -> Self { + let mut source = RadioParadiseStreamSource::new(client.clone()); + let block_handle = source.block_handle(); + + let (flac_sink, stream_handle) = StreamingFlacSink::with_options( + EncoderOptions::default(), + 16, + config.max_lead_seconds, + config.flac_options.clone(), + ); + let (ogg_sink, ogg_handle) = StreamingOggFlacSink::with_options( + EncoderOptions::default(), + 16, + config.max_lead_seconds, + config.ogg_options.clone(), + ); + + let mut downstream_children: Vec> = Vec::new(); + downstream_children.push(Box::new(flac_sink)); + downstream_children.push(Box::new(ogg_sink)); + + let mut history_state = None; + + if let Some(history_opts) = history { + let ParadiseHistoryOptions { + audio_cache, + cover_cache, + playlist_id, + playlist_writer, + collection, + replay_max_lead_seconds, + } = history_opts; + let mut cache_sink = FlacCacheSink::with_config( + audio_cache.clone(), + cover_cache, + DEFAULT_CHANNEL_SIZE, + EncoderOptions::default(), + collection, + ); + cache_sink.register_playlist(playlist_writer); + downstream_children.push(Box::new(cache_sink)); + history_state = Some(HistoryState { + playlist_id, + audio_cache, + replay_max_lead_seconds, + }); + } + + if let Some(cache) = cover_cache { + let mut cover_node = TrackBoundaryCoverNode::new(cache); + for child in downstream_children { + cover_node.register(child); + } + source.register(Box::new(cover_node)); + } else { + for child in downstream_children { + source.register(child); + } + } + stream_handle.set_auto_stop(false); + ogg_handle.set_auto_stop(false); + + let stop_token = CancellationToken::new(); + let pipeline_stop = stop_token.clone(); + let pipeline_handle = tokio::spawn(async move { + info!( + "RadioParadise stream pipeline started for channel {}", + descriptor.display_name + ); + if let Err(e) = Box::new(source).run(pipeline_stop).await { + error!( + "Pipeline error for channel {}: {}", + descriptor.display_name, e + ); + } + }); + + let state = Arc::new(ChannelState { + descriptor, + config, + client, + block_handle, + stream_handle, + ogg_handle, + active_clients: AtomicUsize::new(0), + activity_notify: Notify::new(), + stop_token, + }); + + let feeder_state = state.clone(); + let feeder_handle = tokio::spawn(async move { + feeder_state.run_scheduler().await; + }); + + Self { + descriptor, + state, + pipeline_handle, + feeder_handle, + history: history_state, + } + } + + /// Crée un canal en construisant automatiquement le client pour ce descriptor. + pub async fn new( + descriptor: ChannelDescriptor, + config: ParadiseStreamChannelConfig, + cover_cache: Option>, + history: Option, + ) -> Result { + let client = RadioParadiseClient::builder() + .channel(descriptor.id) + .build() + .await?; + Ok(Self::with_client( + descriptor, + client, + config, + cover_cache, + history, + )) + } + + /// S'abonne au flux FLAC pur. + pub fn subscribe_flac(&self) -> ChannelFlacStream { + self.state.on_client_added(); + let inner = self.state.stream_handle.subscribe_flac(); + ChannelFlacStream::new(inner, self.state.clone()) + } + + /// S'abonne au flux FLAC + ICY metadata. + pub fn subscribe_icy(&self) -> ChannelIcyStream { + self.state.on_client_added(); + let inner = self.state.stream_handle.subscribe_icy(); + ChannelIcyStream::new(inner, self.state.clone()) + } + + /// S'abonne au flux OGG-FLAC. + pub fn subscribe_ogg(&self) -> ChannelOggStream { + self.state.on_client_added(); + let inner = self.state.ogg_handle.subscribe(); + ChannelOggStream::new(inner, self.state.clone()) + } + + /// Snapshot des métadonnées actuelles. + pub async fn metadata(&self) -> MetadataSnapshot { + self.state.stream_handle.get_metadata().await + } + + /// Nombre de clients actifs. + pub fn active_clients(&self) -> usize { + self.state.active_clients.load(Ordering::SeqCst) + } + + pub fn descriptor(&self) -> ChannelDescriptor { + self.descriptor + } + + /// Lance un pipeline dédié pour rejouer l'historique (FLAC pur) pour un client. + pub async fn stream_history_flac( + &self, + client_id: &str, + ) -> Result { + let history = self + .history + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + tracing::info!( + "Starting historical FLAC replay for channel {} (client_id={})", + self.descriptor.display_name, + client_id + ); + + let reader = pmoplaylist::PlaylistManager() + .get_read_handle(&history.playlist_id) + .await + .map_err(|e| HistoryStreamError::Playlist(e.to_string()))?; + let mut source = PlaylistSource::new(reader, history.audio_cache.clone()); + let (flac_sink, handle) = StreamingFlacSink::with_options( + EncoderOptions::default(), + 16, + history.replay_max_lead_seconds, + self.state.config.flac_options.clone(), + ); + source.register(Box::new(flac_sink)); + let stop_token = CancellationToken::new(); + let mut pipeline_source = source; + let stop_clone = stop_token.clone(); + let pipeline = tokio::spawn(async move { + let _ = Box::new(pipeline_source).run(stop_clone).await; + }); + let stream = handle.subscribe_flac(); + Ok(HistoryFlacStream::new(stream, stop_token, pipeline)) + } + + /// Lance un pipeline dédié pour rejouer l'historique (OGG-FLAC) pour un client. + pub async fn stream_history_ogg( + &self, + client_id: &str, + ) -> Result { + let history = self + .history + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + tracing::info!( + "Starting historical OGG replay for channel {} (client_id={})", + self.descriptor.display_name, + client_id + ); + + let reader = pmoplaylist::PlaylistManager() + .get_read_handle(&history.playlist_id) + .await + .map_err(|e| HistoryStreamError::Playlist(e.to_string()))?; + let mut source = PlaylistSource::new(reader, history.audio_cache.clone()); + let (ogg_sink, handle) = StreamingOggFlacSink::with_options( + EncoderOptions::default(), + 16, + history.replay_max_lead_seconds, + self.state.config.ogg_options.clone(), + ); + source.register(Box::new(ogg_sink)); + let stop_token = CancellationToken::new(); + let mut pipeline_source = source; + let stop_clone = stop_token.clone(); + let pipeline = tokio::spawn(async move { + let _ = Box::new(pipeline_source).run(stop_clone).await; + }); + let stream = handle.subscribe(); + Ok(HistoryOggStream::new(stream, stop_token, pipeline)) + } +} + +impl Drop for ParadiseStreamChannel { + fn drop(&mut self) { + self.state.stop_token.cancel(); + self.pipeline_handle.abort(); + self.feeder_handle.abort(); + } +} + +struct ChannelState { + descriptor: ChannelDescriptor, + config: ParadiseStreamChannelConfig, + client: RadioParadiseClient, + block_handle: crate::radio_paradise_stream_source::BlockQueueHandle, + stream_handle: StreamHandle, + ogg_handle: OggFlacStreamHandle, + active_clients: AtomicUsize, + activity_notify: Notify, + stop_token: CancellationToken, +} + +impl ChannelState { + fn on_client_added(&self) { + if self.active_clients.fetch_add(1, Ordering::SeqCst) == 0 { + self.activity_notify.notify_one(); + } + } + + fn on_client_removed(&self) { + self.active_clients.fetch_sub(1, Ordering::SeqCst); + } + + async fn wait_for_clients(&self) -> bool { + while self.active_clients.load(Ordering::SeqCst) == 0 { + tokio::select! { + _ = self.stop_token.cancelled() => return false, + _ = self.activity_notify.notified() => {}, + } + } + true + } + + async fn run_scheduler(self: Arc) { + let mut backoff = Duration::from_secs(5); + loop { + if self.stop_token.is_cancelled() { + break; + } + + if !self.wait_for_clients().await { + break; + } + + match self.client.get_block(None).await { + Ok(block) => { + info!( + "Channel {} streaming block {}", + self.descriptor.display_name, block.event + ); + self.block_handle.enqueue(block.event); + let mut next_event = block.end_event; + + loop { + if self.stop_token.is_cancelled() { + return; + } + + if self.active_clients.load(Ordering::SeqCst) == 0 { + break; + } + + match self.client.get_block(Some(next_event)).await { + Ok(next_block) => { + self.block_handle.enqueue(next_block.event); + next_event = next_block.end_event; + backoff = Duration::from_secs(5); + } + Err(e) => { + warn!( + "Failed to fetch next block for channel {}: {}", + self.descriptor.display_name, e + ); + tokio::select! { + _ = self.stop_token.cancelled() => return, + _ = tokio::time::sleep(backoff) => {}, + } + backoff = (backoff * 2).min(Duration::from_secs(60)); + } + } + } + } + Err(e) => { + warn!( + "Failed to fetch current block for channel {}: {}", + self.descriptor.display_name, e + ); + tokio::select! { + _ = self.stop_token.cancelled() => break, + _ = tokio::time::sleep(backoff) => {}, + } + backoff = (backoff * 2).min(Duration::from_secs(60)); + } + } + } + } +} + +macro_rules! wrap_stream { + ($name:ident, $inner:ty) => { + pub struct $name { + inner: $inner, + state: Arc, + } + + impl $name { + fn new(inner: $inner, state: Arc) -> Self { + Self { inner, state } + } + } + + impl AsyncRead for $name { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } + } + + impl Drop for $name { + fn drop(&mut self) { + self.state.on_client_removed(); + } + } + }; +} + +wrap_stream!(ChannelFlacStream, FlacClientStream); +wrap_stream!(ChannelIcyStream, IcyClientStream); +wrap_stream!(ChannelOggStream, OggFlacClientStream); + +#[derive(Debug, Error)] +pub enum HistoryStreamError { + #[error("history replay not enabled for this channel")] + HistoryDisabled, + #[error("playlist error: {0}")] + Playlist(String), +} + +pub struct HistoryFlacStream { + inner: FlacClientStream, + stop_token: CancellationToken, + pipeline: Option>, +} + +impl HistoryFlacStream { + fn new( + inner: FlacClientStream, + stop_token: CancellationToken, + pipeline: JoinHandle<()>, + ) -> Self { + Self { + inner, + stop_token, + pipeline: Some(pipeline), + } + } +} + +impl AsyncRead for HistoryFlacStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl Unpin for HistoryFlacStream {} + +impl Drop for HistoryFlacStream { + fn drop(&mut self) { + self.stop_token.cancel(); + if let Some(handle) = self.pipeline.take() { + handle.abort(); + } + } +} + +pub struct HistoryOggStream { + inner: OggFlacClientStream, + stop_token: CancellationToken, + pipeline: Option>, +} + +impl HistoryOggStream { + fn new( + inner: OggFlacClientStream, + stop_token: CancellationToken, + pipeline: JoinHandle<()>, + ) -> Self { + Self { + inner, + stop_token, + pipeline: Some(pipeline), + } + } +} + +impl AsyncRead for HistoryOggStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl Unpin for HistoryOggStream {} + +impl Drop for HistoryOggStream { + fn drop(&mut self) { + self.stop_token.cancel(); + if let Some(handle) = self.pipeline.take() { + handle.abort(); + } + } +} + +/// Gestionnaire multi-canaux. +pub struct ParadiseChannelManager { + channels: HashMap>, +} + +impl ParadiseChannelManager { + pub fn new(channels: HashMap>) -> Self { + Self { channels } + } + + pub async fn with_defaults_with_cover_cache( + cover_cache: Option>, + history_builder: Option, + server_base_url: Option, + ) -> Result { + let mut map = HashMap::new(); + for descriptor in ALL_CHANNELS.iter().copied() { + let mut config = ParadiseStreamChannelConfig::default(); + config.server_base_url = server_base_url.clone(); + + let history_opts = if let Some(builder) = &history_builder { + Some( + builder + .build_for_channel(&descriptor) + .await + .map_err(|e| anyhow!("Failed to init history playlist: {}", e))?, + ) + } else { + None + }; + let channel = ParadiseStreamChannel::new( + descriptor, + config, + cover_cache.clone(), + history_opts, + ) + .await?; + map.insert(descriptor.id, Arc::new(channel)); + } + Ok(Self { channels: map }) + } + + pub async fn with_defaults() -> Result { + Self::with_defaults_with_cover_cache(None, None, None).await + } + + pub fn get(&self, id: u8) -> Option> { + self.channels.get(&id).cloned() + } + + pub fn iter(&self) -> impl Iterator> { + self.channels.values() + } +} +-------End of pmoparadise/src/stream_channel_old.rs --------- + +------------ pmoparadise/src/stream_channel.rs ---------- +//! Version simplifiée de stream_channel.rs utilisant RadioParadisePlaylistFeeder + PlaylistSource +//! +//! Cette version remplace l'architecture complexe RadioParadiseStreamSource par : +//! - RadioParadisePlaylistFeeder : télécharge les URLs gapless et alimente une playlist +//! - PlaylistSource::with_history() : lit la playlist et gère l'historique automatiquement + +use std::{ + collections::HashMap, + pin::Pin, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + task::{Context, Poll}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use crate::{ + channels::{ChannelDescriptor, ParadiseChannelKind, ALL_CHANNELS}, + client::RadioParadiseClient, + models::{Block, EventId}, + playlist_feeder::RadioParadisePlaylistFeeder, +}; +use anyhow::{anyhow, Context as AnyhowContext, Result}; +use once_cell::sync::OnceCell; +use pmoaudio::{AudioError, AudioPipelineNode}; +use pmoaudio_ext::{ + FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, OggFlacStreamHandle, + PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink, StreamingSinkOptions, + TrackBoundaryCoverNode, +}; +use pmoaudiocache::{get_audio_cache, Cache as AudioCache}; +use pmocovers::{get_cover_cache, Cache as CoverCache}; +use pmoflac::EncoderOptions; +use pmoplaylist::PlaylistManager; +use thiserror::Error; +use tokio::io::{AsyncRead, ReadBuf}; +use tokio::sync::{Mutex, Notify}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; + +/// Configuration pour un canal Radio Paradise. +#[derive(Clone, Debug)] +pub struct ParadiseStreamChannelConfig { + /// Durée maximale (en secondes) d'avance acceptée par le broadcast. + pub max_lead_seconds: f64, + /// Options pour le flux FLAC pur. + pub flac_options: StreamingSinkOptions, + /// Options pour le flux OGG-FLAC. + pub ogg_options: StreamingSinkOptions, + /// URL de base du serveur (pour les métadonnées, covers...) + pub server_base_url: Option, +} + +impl Default for ParadiseStreamChannelConfig { + fn default() -> Self { + Self { + max_lead_seconds: 3.0, // Compromis live/fluidité : assez pour absorber les transitions + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } +} + +/// Options pour activer l'archivage/historique d'un canal. +pub struct ParadiseHistoryOptions { + pub audio_cache: Arc, + pub cover_cache: Arc, + pub playlist_id: String, + pub collection: Option, + pub replay_max_lead_seconds: f64, + pub max_history_tracks: Option, +} + +/// Builder pratique pour configurer automatiquement les playlists historiques. +#[derive(Clone)] +pub struct ParadiseHistoryBuilder { + pub audio_cache: Arc, + pub cover_cache: Arc, + pub playlist_prefix: String, + pub playlist_title_prefix: Option, + pub max_history_tracks: Option, + pub collection_prefix: Option, + pub replay_max_lead_seconds: f64, +} + +impl ParadiseHistoryBuilder { + pub fn new(audio_cache: Arc, cover_cache: Arc) -> Self { + Self { + audio_cache, + cover_cache, + playlist_prefix: "radio-paradise-history".into(), + playlist_title_prefix: Some("Radio Paradise History".into()), + max_history_tracks: Some(500), + collection_prefix: Some("radio-paradise".into()), + replay_max_lead_seconds: 3.0, // Aligné avec le live + } + } + + pub async fn build_for_channel( + &self, + descriptor: &ChannelDescriptor, + ) -> Result { + let playlist_id = format!("{}-{}", self.playlist_prefix, descriptor.slug); + + let collection = self + .collection_prefix + .as_ref() + .map(|prefix| format!("{}-{}", prefix, descriptor.slug)); + + Ok(ParadiseHistoryOptions { + audio_cache: self.audio_cache.clone(), + cover_cache: self.cover_cache.clone(), + playlist_id, + collection, + replay_max_lead_seconds: self.replay_max_lead_seconds, + max_history_tracks: self.max_history_tracks, + }) + } +} + +impl Default for ParadiseHistoryBuilder { + fn default() -> Self { + let audio_cache = get_audio_cache() + .expect("pmoaudiocache::register_audio_cache must be called before using ParadiseHistoryBuilder::default()"); + let cover_cache = get_cover_cache() + .expect("pmocovers::register_cover_cache must be called before using ParadiseHistoryBuilder::default()"); + Self::new(audio_cache, cover_cache) + } +} + +#[cfg(feature = "pmoconfig")] +impl ParadiseStreamChannelConfig { + pub fn from_config(cfg: &pmoconfig::Config, channel: ParadiseChannelKind) -> Self { + use serde_yaml::Value; + let path = [ + "sources", + "radio_paradise", + "channels", + channel.slug(), + "max_lead_seconds", + ]; + match cfg.get_value(&path) { + Ok(Value::Number(num)) => { + if let Some(v) = num.as_f64() { + Self { + max_lead_seconds: v.max(0.1), + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } else { + let default = Self::default(); + let _ = + cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + Ok(Value::String(s)) => { + if let Ok(v) = s.parse::() { + Self { + max_lead_seconds: v.max(0.1), + flac_options: StreamingSinkOptions::flac_defaults(), + ogg_options: StreamingSinkOptions::ogg_defaults(), + server_base_url: None, + } + } else { + let default = Self::default(); + let _ = + cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + _ => { + let default = Self::default(); + let _ = cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); + default + } + } + } +} + +/// Stream complet (FLAC pur + OGG-FLAC) pour un canal Radio Paradise. +/// +/// Version simplifiée utilisant RadioParadisePlaylistFeeder + PlaylistSource +pub struct ParadiseStreamChannel { + descriptor: ChannelDescriptor, + state: Arc, + pipeline_handle: JoinHandle<()>, + feeder_handle: JoinHandle<()>, +} + +impl ParadiseStreamChannel { + /// Crée un canal avec client déjà configuré. + pub async fn with_client( + descriptor: ChannelDescriptor, + client: RadioParadiseClient, + config: ParadiseStreamChannelConfig, + cover_cache: Option>, + history: Option, + ) -> Result { + // Propager server_base_url dans les options pour que les encoders injectent les covers du cache + let mut config = config; + if let Some(ref base) = config.server_base_url { + config.flac_options = config + .flac_options + .clone() + .with_server_base_url(Some(base.clone())); + config.ogg_options = config + .ogg_options + .clone() + .with_server_base_url(Some(base.clone())); + } + let cover_cache = cover_cache + .or_else(|| history.as_ref().map(|opts| opts.cover_cache.clone())) + .or_else(|| get_cover_cache()); + let manager = PlaylistManager::get(); + + // 1. Créer la playlist live pour ce canal + let live_playlist_id = format!("radio-paradise-live-{}", descriptor.slug); + let (feeder, live_read) = if let Some(ref history_opts) = history { + RadioParadisePlaylistFeeder::new( + client.clone(), + history_opts.audio_cache.clone(), + history_opts.cover_cache.clone(), + live_playlist_id.clone(), + history_opts.collection.clone(), + ) + .await? + } else { + // Pas d'historique, on a besoin quand même d'un cache audio basique + return Err(anyhow!( + "History options required for now (audio cache needed)" + )); + }; + + let feeder = Arc::new(feeder); + + // 2. Créer/récupérer la playlist historique si activée + let history_write = if let Some(ref history_opts) = history { + let write = manager + .get_persistent_write_handle(history_opts.playlist_id.clone()) + .await?; + + // Configurer la capacité + if let Some(capacity) = history_opts.max_history_tracks { + write.set_capacity(Some(capacity)).await?; + } + + // Configurer le titre + let title = format!("Radio Paradise History - {}", descriptor.display_name); + write.set_title(title).await?; + + Some(Arc::new(write)) + } else { + None + }; + + // 3. Créer la source playlist avec historique + let audio_cache = history.as_ref().unwrap().audio_cache.clone(); + let mut source = if let Some(history_write) = history_write.clone() { + PlaylistSource::with_history(live_read, audio_cache.clone(), history_write) + } else { + PlaylistSource::new(live_read, audio_cache.clone()) + }; + + // 4. Créer les sinks de broadcast (FLAC + OGG) + let (flac_sink, stream_handle) = StreamingFlacSink::with_options( + EncoderOptions::default(), + 16, + config.max_lead_seconds, + config.flac_options.clone(), + ); + let (ogg_sink, ogg_handle) = StreamingOggFlacSink::with_options( + EncoderOptions::default(), + 16, + config.max_lead_seconds, + config.ogg_options.clone(), + ); + + let mut downstream_children: Vec> = Vec::new(); + downstream_children.push(Box::new(flac_sink)); + downstream_children.push(Box::new(ogg_sink)); + + // 5. Optionnel : ajouter le nœud de cache de covers + if let Some(cache) = cover_cache { + let mut cover_node = TrackBoundaryCoverNode::new(cache); + for child in downstream_children { + cover_node.register(child); + } + source.register(Box::new(cover_node)); + } else { + for child in downstream_children { + source.register(child); + } + } + + stream_handle.set_auto_stop(false); + ogg_handle.set_auto_stop(false); + + // 6. Lancer le pipeline audio + let stop_token = CancellationToken::new(); + let pipeline_stop = stop_token.clone(); + let channel_display_name = descriptor.display_name; + + let state = Arc::new(ChannelState { + descriptor, + config, + client, + feeder: feeder.clone(), + stream_handle, + ogg_handle, + history_playlist_id: history.map(|h| h.playlist_id), + history_audio_cache: history_write.map(|_| audio_cache), + active_clients: AtomicUsize::new(0), + activity_notify: Notify::new(), + stop_token, + current_block: Mutex::new(None), + prefetch_lock: Mutex::new(()), + }); + + let pipeline_state = state.clone(); + let pipeline_handle = tokio::spawn(async move { + info!( + "RadioParadise stream pipeline started for channel {}", + channel_display_name + ); + if let Err(e) = Box::new(source).run(pipeline_stop).await { + error!("Pipeline error for channel {}: {}", channel_display_name, e); + pipeline_state.handle_pipeline_error(&e).await; + } + }); + + // 7. Lancer le feeder qui traite les blocs + let feeder_runner = feeder.clone(); + tokio::spawn(async move { + if let Err(e) = feeder_runner.run().await { + error!("RadioParadisePlaylistFeeder error: {}", e); + } + }); + + // 8. Lancer le scheduler qui enqueue les blocs + let feeder_state = state.clone(); + let feeder_handle = tokio::spawn(async move { + feeder_state.run_scheduler().await; + }); + + Ok(Self { + descriptor, + state, + pipeline_handle, + feeder_handle, + }) + } + + /// Crée un canal en construisant automatiquement le client pour ce descriptor. + pub async fn new( + descriptor: ChannelDescriptor, + config: ParadiseStreamChannelConfig, + cover_cache: Option>, + history: Option, + ) -> Result { + let client = RadioParadiseClient::builder() + .channel(descriptor.id) + .build() + .await?; + Self::with_client(descriptor, client, config, cover_cache, history).await + } + + /// S'abonne au flux FLAC pur. + pub fn subscribe_flac(&self) -> ChannelFlacStream { + self.state.on_client_added(); + let inner = self.state.stream_handle.subscribe_flac(); + ChannelFlacStream::new(inner, self.state.clone()) + } + + /// S'abonne au flux FLAC + ICY metadata. + pub fn subscribe_icy(&self) -> ChannelIcyStream { + self.state.on_client_added(); + let inner = self.state.stream_handle.subscribe_icy(); + ChannelIcyStream::new(inner, self.state.clone()) + } + + /// S'abonne au flux OGG-FLAC. + pub fn subscribe_ogg(&self) -> ChannelOggStream { + self.state.on_client_added(); + let inner = self.state.ogg_handle.subscribe(); + ChannelOggStream::new(inner, self.state.clone()) + } + + /// Snapshot des métadonnées actuelles. + pub async fn metadata(&self) -> MetadataSnapshot { + self.state.stream_handle.get_metadata().await + } + + /// Nombre de clients actifs. + pub fn active_clients(&self) -> usize { + self.state.active_clients.load(Ordering::SeqCst) + } + + pub fn descriptor(&self) -> ChannelDescriptor { + self.descriptor + } + + /// Lance un pipeline dédié pour rejouer l'historique (FLAC pur) pour un client. + pub async fn stream_history_flac( + &self, + client_id: &str, + ) -> Result { + let history_id = self + .state + .history_playlist_id + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + + let audio_cache = self + .state + .history_audio_cache + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + + tracing::info!( + "Starting historical FLAC replay for channel {} (client_id={})", + self.descriptor.display_name, + client_id + ); + + let reader = pmoplaylist::PlaylistManager::get() + .get_read_handle(history_id) + .await + .map_err(|e| HistoryStreamError::Playlist(e.to_string()))?; + + let mut source = PlaylistSource::new(reader, audio_cache.clone()); + let (flac_sink, handle) = StreamingFlacSink::with_max_broadcast_lead( + EncoderOptions::default(), + 16, + self.state.config.max_lead_seconds, + ); + source.register(Box::new(flac_sink)); + let stop_token = CancellationToken::new(); + let stop_clone = stop_token.clone(); + let pipeline = tokio::spawn(async move { + let _ = Box::new(source).run(stop_clone).await; + }); + let stream = handle.subscribe_flac(); + Ok(HistoryFlacStream::new(stream, stop_token, pipeline)) + } + + /// Lance un pipeline dédié pour rejouer l'historique (OGG-FLAC) pour un client. + pub async fn stream_history_ogg( + &self, + client_id: &str, + ) -> Result { + let history_id = self + .state + .history_playlist_id + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + + let audio_cache = self + .state + .history_audio_cache + .as_ref() + .ok_or(HistoryStreamError::HistoryDisabled)?; + + tracing::info!( + "Starting historical OGG replay for channel {} (client_id={})", + self.descriptor.display_name, + client_id + ); + + let reader = pmoplaylist::PlaylistManager::get() + .get_read_handle(history_id) + .await + .map_err(|e| HistoryStreamError::Playlist(e.to_string()))?; + + let mut source = PlaylistSource::new(reader, audio_cache.clone()); + let (ogg_sink, handle) = StreamingOggFlacSink::with_max_broadcast_lead( + EncoderOptions::default(), + 16, + self.state.config.max_lead_seconds, + ); + source.register(Box::new(ogg_sink)); + let stop_token = CancellationToken::new(); + let stop_clone = stop_token.clone(); + let pipeline = tokio::spawn(async move { + let _ = Box::new(source).run(stop_clone).await; + }); + let stream = handle.subscribe(); + Ok(HistoryOggStream::new(stream, stop_token, pipeline)) + } +} + +impl Drop for ParadiseStreamChannel { + fn drop(&mut self) { + self.state.stop_token.cancel(); + self.pipeline_handle.abort(); + self.feeder_handle.abort(); + } +} + +const MAX_BLOCK_LEAD: Duration = Duration::from_secs(3600); +const BLOCK_LEAD_CHECK_CHUNK: Duration = Duration::from_secs(300); +const LIVE_PREFETCH_MIN_TRACKS: usize = 5; +const LIVE_PREFETCH_TIMEOUT: Duration = Duration::from_secs(10); +const LIVE_PREFETCH_POLL_INTERVAL: Duration = Duration::from_millis(200); +const LIVE_PREFETCH_MAX_BLOCKS: usize = 4; + +static GLOBAL_CHANNEL_MANAGER: OnceCell> = OnceCell::new(); + +struct ChannelState { + descriptor: ChannelDescriptor, + config: ParadiseStreamChannelConfig, + client: RadioParadiseClient, + feeder: Arc, + stream_handle: StreamHandle, + ogg_handle: OggFlacStreamHandle, + history_playlist_id: Option, + history_audio_cache: Option>, + active_clients: AtomicUsize, + activity_notify: Notify, + stop_token: CancellationToken, + current_block: Mutex>, + prefetch_lock: Mutex<()>, +} + +impl ChannelState { + fn current_unix_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) + } + + fn block_lead_delay(&self, block: &Block) -> Option { + let start = block.start_time_millis()?; + let now = Self::current_unix_millis(); + let max_lead_ms = MAX_BLOCK_LEAD.as_millis() as u64; + if start <= now + max_lead_ms { + None + } else { + Some(Duration::from_millis(start - now - max_lead_ms)) + } + } + + fn on_client_added(&self) { + if self.active_clients.fetch_add(1, Ordering::SeqCst) == 0 { + self.activity_notify.notify_one(); + } + } + + fn on_client_removed(&self) { + self.active_clients.fetch_sub(1, Ordering::SeqCst); + } + + async fn wait_for_clients(&self) -> bool { + while self.active_clients.load(Ordering::SeqCst) == 0 { + tokio::select! { + _ = self.stop_token.cancelled() => return false, + _ = self.activity_notify.notified() => {}, + } + } + true + } + + async fn wait_until_block_ready(&self, block: &Block) -> BlockReadiness { + loop { + if self.stop_token.is_cancelled() { + return BlockReadiness::Stopped; + } + if self.active_clients.load(Ordering::SeqCst) == 0 { + return BlockReadiness::NoClients; + } + + if let Some(delay) = self.block_lead_delay(block) { + let sleep_for = delay.min(BLOCK_LEAD_CHECK_CHUNK); + let lead_secs = delay.as_secs_f64(); + info!( + "Block {} scheduled too far in the future ({:.1} min). Sleeping {:?} before retrying.", + block.event, + lead_secs / 60.0, + sleep_for + ); + tokio::select! { + _ = self.stop_token.cancelled() => return BlockReadiness::Stopped, + _ = tokio::time::sleep(sleep_for) => {}, + } + continue; + } + + return BlockReadiness::Ready; + } + } + + fn live_playlist_id(&self) -> String { + format!("radio-paradise-live-{}", self.descriptor.slug) + } + + async fn prefetch_until_horizon(&self) -> Result<()> { + let _guard = self.prefetch_lock.lock().await; + let playlist_id = self.live_playlist_id(); + let manager = PlaylistManager::get(); + let reader = manager + .get_read_handle(&playlist_id) + .await + .with_context(|| format!("Failed to get live playlist {}", playlist_id))?; + let start = Instant::now(); + let mut next_event: Option = None; + let mut attempts = 0usize; + + loop { + let available = reader + .remaining() + .await + .with_context(|| format!("Failed to inspect playlist {}", playlist_id))?; + if available >= LIVE_PREFETCH_MIN_TRACKS { + return Ok(()); + } + + if start.elapsed() >= LIVE_PREFETCH_TIMEOUT { + warn!( + "Prefetch timeout for channel {} ({} tracks available)", + self.descriptor.display_name, available + ); + return Ok(()); + } + + if attempts >= LIVE_PREFETCH_MAX_BLOCKS { + warn!( + "Prefetch block limit reached for channel {} ({} tracks available)", + self.descriptor.display_name, available + ); + return Ok(()); + } + + match self.client.get_block(next_event).await { + Ok(block) => { + attempts += 1; + next_event = Some(block.end_event); + self.feeder.push_block_id(block.event).await; + } + Err(e) => { + warn!( + "Failed to fetch block during prefetch for channel {}: {}", + self.descriptor.display_name, e + ); + return Ok(()); + } + } + + tokio::time::sleep(LIVE_PREFETCH_POLL_INTERVAL).await; + } + } + + async fn set_current_block(&self, event_id: EventId) { + let mut guard = self.current_block.lock().await; + *guard = Some(event_id); + } + + async fn take_current_block(&self) -> Option { + self.current_block.lock().await.take() + } + + async fn handle_pipeline_error(&self, err: &AudioError) { + if let Some(event_id) = self.take_current_block().await { + warn!( + "Pipeline error while streaming block {} on channel {}: {}. Rescheduling block.", + event_id, self.descriptor.display_name, err + ); + self.feeder.retry_block(event_id).await; + } else { + warn!( + "Pipeline error for channel {} but no tracked block: {}", + self.descriptor.display_name, err + ); + } + } + + async fn run_scheduler(self: Arc) { + let mut backoff = Duration::from_secs(5); + 'scheduler: loop { + if self.stop_token.is_cancelled() { + break; + } + + if !self.wait_for_clients().await { + break; + } + + match self.client.get_block(None).await { + Ok(block) => { + match self.wait_until_block_ready(&block).await { + BlockReadiness::Ready => {} + BlockReadiness::NoClients => continue, + BlockReadiness::Stopped => break, + } + info!( + "Channel {} streaming block {}", + self.descriptor.display_name, block.event + ); + self.set_current_block(block.event).await; + self.feeder.push_block_id(block.event).await; + let mut next_event = block.end_event; + + loop { + if self.stop_token.is_cancelled() { + return; + } + + if self.active_clients.load(Ordering::SeqCst) == 0 { + break; + } + + match self.client.get_block(Some(next_event)).await { + Ok(next_block) => { + match self.wait_until_block_ready(&next_block).await { + BlockReadiness::Ready => {} + BlockReadiness::NoClients => break, + BlockReadiness::Stopped => break 'scheduler, + } + self.set_current_block(next_block.event).await; + self.feeder.push_block_id(next_block.event).await; + next_event = next_block.end_event; + backoff = Duration::from_secs(5); + } + Err(e) => { + warn!( + "Failed to fetch next block for channel {}: {}", + self.descriptor.display_name, e + ); + tokio::select! { + _ = self.stop_token.cancelled() => return, + _ = tokio::time::sleep(backoff) => {}, + } + backoff = (backoff * 2).min(Duration::from_secs(60)); + } + } + } + } + Err(e) => { + warn!( + "Failed to fetch current block for channel {}: {}", + self.descriptor.display_name, e + ); + tokio::select! { + _ = self.stop_token.cancelled() => break, + _ = tokio::time::sleep(backoff) => {}, + } + backoff = (backoff * 2).min(Duration::from_secs(60)); + } + } + } + } +} + +enum BlockReadiness { + Ready, + NoClients, + Stopped, +} + +macro_rules! wrap_stream { + ($name:ident, $inner:ty) => { + pub struct $name { + inner: $inner, + state: Arc, + } + + impl $name { + fn new(inner: $inner, state: Arc) -> Self { + Self { inner, state } + } + } + + impl AsyncRead for $name { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } + } + + impl Drop for $name { + fn drop(&mut self) { + self.state.on_client_removed(); + } + } + }; +} + +wrap_stream!(ChannelFlacStream, FlacClientStream); +wrap_stream!(ChannelIcyStream, IcyClientStream); +wrap_stream!(ChannelOggStream, OggFlacClientStream); + +#[derive(Debug, Error)] +pub enum HistoryStreamError { + #[error("history replay not enabled for this channel")] + HistoryDisabled, + #[error("playlist error: {0}")] + Playlist(String), +} + +pub struct HistoryFlacStream { + inner: FlacClientStream, + stop_token: CancellationToken, + pipeline: Option>, +} + +impl HistoryFlacStream { + fn new( + inner: FlacClientStream, + stop_token: CancellationToken, + pipeline: JoinHandle<()>, + ) -> Self { + Self { + inner, + stop_token, + pipeline: Some(pipeline), + } + } +} + +impl AsyncRead for HistoryFlacStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl Unpin for HistoryFlacStream {} + +impl Drop for HistoryFlacStream { + fn drop(&mut self) { + self.stop_token.cancel(); + if let Some(handle) = self.pipeline.take() { + handle.abort(); + } + } +} + +pub struct HistoryOggStream { + inner: OggFlacClientStream, + stop_token: CancellationToken, + pipeline: Option>, +} + +impl HistoryOggStream { + fn new( + inner: OggFlacClientStream, + stop_token: CancellationToken, + pipeline: JoinHandle<()>, + ) -> Self { + Self { + inner, + stop_token, + pipeline: Some(pipeline), + } + } +} + +impl AsyncRead for HistoryOggStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl Unpin for HistoryOggStream {} + +impl Drop for HistoryOggStream { + fn drop(&mut self) { + self.stop_token.cancel(); + if let Some(handle) = self.pipeline.take() { + handle.abort(); + } + } +} + +/// Gestionnaire multi-canaux. +pub struct ParadiseChannelManager { + channels: HashMap>, +} + +impl ParadiseChannelManager { + pub fn new(channels: HashMap>) -> Self { + Self { channels } + } + + pub async fn with_defaults_with_cover_cache( + cover_cache: Option>, + history_builder: Option, + server_base_url: Option, + ) -> Result { + tracing::warn!( + "➡️ Entering with_defaults_with_cover_cache ({} channels, base_url={:?})", + ALL_CHANNELS.len(), + server_base_url + ); + let mut map = HashMap::new(); + for descriptor in ALL_CHANNELS.iter().copied() { + let mut config = ParadiseStreamChannelConfig::default(); + config.server_base_url = server_base_url.clone(); + + let start = Instant::now(); + tracing::warn!( + "⏳ Initializing Radio Paradise channel {} ({})...", + descriptor.display_name, + descriptor.slug + ); + + let history_opts = if let Some(builder) = &history_builder { + tracing::warn!( + " ⏳ Building history options for channel {} ({})", + descriptor.display_name, + descriptor.slug + ); + Some( + builder + .build_for_channel(&descriptor) + .await + .map_err(|e| anyhow!("Failed to init history playlist: {}", e))?, + ) + } else { + None + }; + tracing::warn!( + " ⏩ History options ready for channel {} ({})", + descriptor.display_name, + descriptor.slug + ); + let channel = match tokio::time::timeout( + Duration::from_secs(20), + ParadiseStreamChannel::new(descriptor, config, cover_cache.clone(), history_opts), + ) + .await + { + Ok(Ok(ch)) => { + tracing::warn!( + "✅ Channel {} ({}) initialized in {:?}", + descriptor.display_name, + descriptor.slug, + start.elapsed() + ); + ch + } + Ok(Err(e)) => { + tracing::error!( + "⚠️ Failed to initialize channel {} ({}): {}", + descriptor.display_name, + descriptor.slug, + e + ); + continue; + } + Err(_) => { + tracing::error!( + "⚠️ Timeout initializing channel {} ({}) after 20s, skipping", + descriptor.display_name, + descriptor.slug + ); + continue; + } + }; + map.insert(descriptor.id, Arc::new(channel)); + } + Ok(Self { channels: map }) + } + + pub async fn with_defaults() -> Result { + Self::with_defaults_with_cover_cache(None, None, None).await + } + + pub fn get(&self, id: u8) -> Option> { + self.channels.get(&id).cloned() + } + + pub fn iter(&self) -> impl Iterator> { + self.channels.values() + } + + pub async fn prefetch_until_horizon(&self, channel_id: u8) -> Result<()> { + let channel = self + .get(channel_id) + .ok_or_else(|| anyhow!("Unknown channel id {}", channel_id))?; + channel.prefetch_until_horizon().await + } +} + +pub fn register_global_channel_manager(manager: Arc) { + let _ = GLOBAL_CHANNEL_MANAGER.set(Arc::downgrade(&manager)); +} + +pub fn get_global_channel_manager() -> Option> { + GLOBAL_CHANNEL_MANAGER.get().and_then(|weak| weak.upgrade()) +} + +impl ParadiseStreamChannel { + pub async fn prefetch_until_horizon(&self) -> Result<()> { + self.state.prefetch_until_horizon().await + } +} +-------End of pmoparadise/src/stream_channel.rs --------- + +------------ pmoparadise/examples/download_block.rs ---------- +//! Télécharge un bloc complet de Radio Paradise et sauvegarde toutes les pistes en FLAC +//! +//! Ce programme démontre l'utilisation de la chaîne : +//! 1. RadioParadiseStreamSource - Télécharge et décode un bloc FLAC de Radio Paradise +//! 2. FlacFileSink - Sauvegarde automatiquement chaque piste dans un fichier FLAC séparé +//! +//! La nouvelle architecture AudioPipelineNode permet de : +//! - Télécharger et décoder automatiquement les blocs FLAC de Radio Paradise +//! - Détecter les limites de pistes (TrackBoundary) +//! - Sauvegarder automatiquement chaque piste dans un fichier séparé +//! - Gérer proprement l'arrêt du pipeline avec un CancellationToken +//! +//! Usage: +//! cargo run --example download_block -- +//! +//! Exemple: +//! cargo run --example download_block -- 0 # Main Mix +//! cargo run --example download_block -- 1 # Mellow Mix +//! cargo run --example download_block -- 2 # Rock Mix +//! cargo run --example download_block -- 3 # World/Etc Mix + +use pmoaudio::{AudioPipelineNode, FlacFileSink}; +use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource}; +use std::env; +use tokio_util::sync::CancellationToken; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialiser tracing pour le debug + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive(tracing::Level::INFO.into()), + ) + .init(); + + // Récupérer les arguments + let args: Vec = env::args().collect(); + if args.len() != 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!(); + eprintln!("Downloads a complete Radio Paradise block and saves all tracks as FLAC files."); + eprintln!(); + eprintln!("Channel IDs:"); + eprintln!(" 0 - Main Mix (eclectic, diverse mix)"); + eprintln!(" 1 - Mellow Mix (smooth, chilled music)"); + eprintln!(" 2 - Rock Mix (classic & modern rock)"); + eprintln!(" 3 - World/Etc Mix (global sounds)"); + eprintln!(); + eprintln!("Example:"); + eprintln!(" {} 0 # Download Main Mix", args[0]); + eprintln!(" {} 2 # Download Rock Mix", args[0]); + std::process::exit(1); + } + + let channel_id: u8 = match args[1].parse() { + Ok(id) => id, + Err(_) => { + eprintln!("Error: channel_id must be a number between 0 and 3"); + std::process::exit(1); + } + }; + + if channel_id > 3 { + eprintln!("Error: channel_id must be between 0 and 3"); + std::process::exit(1); + } + + println!("=== Radio Paradise Block Downloader ==="); + println!(); + println!("Channel ID: {}", channel_id); + println!(); + + // Créer le client Radio Paradise pour le channel spécifié + println!("Fetching current block metadata..."); + let client = RadioParadiseClient::builder() + .channel(channel_id) + .build() + .await?; + + // Récupérer le bloc actuel + let block = client.get_block(None).await?; + + println!("Block Information:"); + println!(" Event ID: {}", block.event); + println!(" Songs: {}", block.song_count()); + println!(" Duration: {:.1} minutes", block.length as f64 / 60000.0); + println!(); + + // Afficher la liste des pistes + println!("Tracklist:"); + for (index, song) in block.songs_ordered() { + println!( + " {:2}. {} - {} ({})", + index + 1, + song.artist, + song.title, + song.album.as_deref().unwrap_or("Unknown Album") + ); + } + println!(); + + // Créer le répertoire de sortie + let output_dir = format!("./rp_channel_{}block{}", channel_id, block.event); + std::fs::create_dir_all(&output_dir)?; + println!("Output directory: {}", output_dir); + println!(); + + // Créer le pipeline: RadioParadiseStreamSource → FlacFileSink + let mut source = RadioParadiseStreamSource::new(client); + + // Ajouter le bloc à télécharger + source.push_block_id(block.event); + + // Créer le sink qui sauvegarde chaque piste dans un fichier séparé + let base_path = format!("{}/track.flac", output_dir); + let sink = FlacFileSink::new(&base_path); + + // Construire la chaîne: source → sink + source.register(Box::new(sink)); + + // Créer un token d'arrêt + let stop_token = CancellationToken::new(); + + // Gérer Ctrl+C pour arrêt propre + let stop_token_clone = stop_token.clone(); + tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + println!("\n\nReceived Ctrl+C, stopping..."); + stop_token_clone.cancel(); + }); + + // Lancer tout le pipeline + println!("Downloading and processing block..."); + println!("Press Ctrl+C to stop."); + println!(); + let start = std::time::Instant::now(); + + let result = Box::new(source).run(stop_token).await; + + let elapsed = start.elapsed(); + + // Vérifier le résultat + match result { + Ok(()) => { + println!(); + println!( + "✓ Download completed successfully in {:.2}s", + elapsed.as_secs_f64() + ); + println!(" Output directory: {}", output_dir); + println!(); + + // Afficher les fichiers créés + let entries = std::fs::read_dir(&output_dir)?; + let mut files: Vec<_> = entries + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .and_then(|s| s.to_str()) + .map(|s| s == "flac") + .unwrap_or(false) + }) + .collect(); + files.sort_by_key(|e| e.path()); + + println!("Files created:"); + for (i, entry) in files.iter().enumerate() { + let path = entry.path(); + let metadata = std::fs::metadata(&path)?; + let size_mb = metadata.len() as f64 / (1024.0 * 1024.0); + println!( + " {:2}. {} ({:.2} MB)", + i + 1, + path.file_name().unwrap().to_string_lossy(), + size_mb + ); + } + println!(); + + // Calculer la taille totale + let total_size: u64 = files + .iter() + .filter_map(|e| std::fs::metadata(e.path()).ok()) + .map(|m| m.len()) + .sum(); + println!( + "Total size: {:.2} MB", + total_size as f64 / (1024.0 * 1024.0) + ); + } + Err(e) => { + eprintln!(); + eprintln!("✗ Download error: {}", e); + eprintln!(); + return Err(e.into()); + } + } + + Ok(()) +} +-------End of pmoparadise/examples/download_block.rs --------- + +------------ pmoparadise/examples/now_playing.rs ---------- +//! Example: Display currently playing song and block information +//! +//! This example demonstrates: +//! - Creating a Radio Paradise client +//! - Fetching the current block +//! - Displaying song metadata +//! - Generating cover image URLs +//! +//! Run with: cargo run --example now_playing + +use pmoparadise::{RadioParadiseClient, Result}; + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize logging (optional) + #[cfg(feature = "logging")] + tracing_subscriber::fmt::init(); + + println!("Radio Paradise - Now Playing"); + println!("=============================\n"); + + // Create client with default settings (FLAC quality, channel 0) + let client = RadioParadiseClient::new().await?; + + // Get what's currently playing + let now_playing = client.now_playing().await?; + let block = &now_playing.block; + + // Display block information + println!("Block Information:"); + println!(" Event ID: {}", block.event); + println!(" Next Event: {}", block.end_event); + println!(" Duration: {:.1} minutes", block.length as f64 / 60000.0); + println!(" Songs in block: {}", block.song_count()); + println!(" Stream URL: {}\n", block.url); + + // Display current song (if available) + if let Some(song) = &now_playing.current_song { + println!("Now Playing:"); + println!(" Title: {}", song.title); + println!(" Artist: {}", song.artist); + if let Some(ref album) = song.album { + println!(" Album: {}", album); + } + if let Some(year) = song.year { + println!(" Year: {}", year); + } + if let Some(rating) = song.rating { + println!(" Rating: {:.1}/10", rating); + } + println!( + " Duration: {}:{:02}", + song.duration / 60000, + (song.duration % 60000) / 1000 + ); + + // Display cover URL + if let Some(cover) = &song.cover { + if let Some(cover_url) = block.cover_url(cover) { + println!(" Cover: {}", cover_url); + } + } + println!(); + } + + // Display all songs in the block + println!("All Songs in This Block:"); + println!("------------------------"); + + for (index, song) in block.songs_ordered() { + let start_sec = song.elapsed / 1000; + let duration_sec = song.duration / 1000; + + println!( + "{}. [{:02}:{:02}] {} - {} ({:02}:{:02})", + index + 1, + start_sec / 60, + start_sec % 60, + song.artist, + song.title, + duration_sec / 60, + duration_sec % 60 + ); + if let Some(ref album) = song.album { + println!(" Album: {}", album); + } + + if let Some(year) = song.year { + print!(" Year: {}", year); + } + if let Some(rating) = song.rating { + print!(" Rating: {:.1}/10", rating); + } + println!("\n"); + } + + // Show how to get the next block + println!("Fetching Next Block..."); + let next_block = client.get_block(Some(block.end_event)).await?; + println!(" Next block event: {}", next_block.event); + println!(" Songs in next block: {}", next_block.song_count()); + + if let Some((_, first_song)) = next_block.songs_ordered().first() { + println!(" First song: {} - {}", first_song.artist, first_song.title); + } + + Ok(()) +} +-------End of pmoparadise/examples/now_playing.rs --------- + +------------ pmoparadise/examples/play_and_cache.rs ---------- +//! Télécharge un bloc Radio Paradise, le cache, et le joue en même temps +//! +//! Ce programme démontre l'utilisation complète de la chaîne : +//! 1. RadioParadiseStreamSource - Télécharge et décode un bloc FLAC +//! 2. FlacCacheSink - Cache chaque piste en FLAC et alimente une playlist +//! 3. PlaylistSource - Lit la playlist pendant le téléchargement +//! 4. TimerNode - Régule le débit pour éviter EOF prématurés (progressive cache) +//! 5. AudioSink - Joue l'audio sur la sortie standard +//! +//! Architecture : +//! ```text +//! Pipeline 1 (Download & Cache): +//! RadioParadiseStreamSource → FlacCacheSink (avec playlist abonnée) +//! +//! Pipeline 2 (Playback): +//! PlaylistSource → TimerNode (rate limiting) → AudioSink +//! ↓ +//! Prévention EOF +//! (3s max lead) +//! ``` +//! +//! Usage: +//! cargo run --example play_and_cache --features full -- +//! +//! Exemple: +//! cargo run --example play_and_cache --features full -- 0 # Main Mix +//! cargo run --example play_and_cache --features full -- 2 # Rock Mix + +use pmoaudio::{AudioPipelineNode, AudioSink, TimerNode}; +use pmoaudio_ext::{FlacCacheSink, PlaylistSource}; +use pmoaudiocache::{ + new_cache_with_consolidation as new_audio_cache, + register_audio_cache as register_global_audio_cache, +}; +use pmocovers::{new_cache_with_consolidation as new_cover_cache, register_cover_cache}; +use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource}; +use pmoplaylist::register_audio_cache as register_playlist_audio_cache; +use std::env; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialiser tracing avec beaucoup de logs + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive(tracing::Level::DEBUG.into()) + .add_directive("pmoaudio=debug".parse()?) + .add_directive("pmoaudio_ext=debug".parse()?) + .add_directive("pmoplaylist=debug".parse()?) + .add_directive("pmoparadise=debug".parse()?) + .add_directive("pmoaudiocache=debug".parse()?), + ) + .init(); + + tracing::info!("=== Radio Paradise Play & Cache ==="); + + // Récupérer les arguments + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} [--null-audio]", args[0]); + eprintln!(); + eprintln!("Downloads a Radio Paradise block, caches it, and plays it simultaneously."); + eprintln!(); + eprintln!("Channel IDs:"); + eprintln!(" 0 - Main Mix (eclectic, diverse mix)"); + eprintln!(" 1 - Mellow Mix (smooth, chilled music)"); + eprintln!(" 2 - Rock Mix (classic & modern rock)"); + eprintln!(" 3 - World/Etc Mix (global sounds)"); + eprintln!(); + eprintln!("Options:"); + eprintln!(" --null-audio Don't play audio (for testing without audio device)"); + std::process::exit(1); + } + + let channel_id: u8 = match args[1].parse() { + Ok(id) if id <= 3 => id, + _ => { + eprintln!("Error: channel_id must be a number between 0 and 3"); + std::process::exit(1); + } + }; + + let use_null_audio = args.len() > 2 && args[2] == "--null-audio"; + + tracing::info!("Channel ID: {}", channel_id); + if use_null_audio { + tracing::info!("Using null audio output (no playback)"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Initialiser les caches et le gestionnaire de playlist + // ═══════════════════════════════════════════════════════════════════════════ + + let base_dir = + std::env::var("PMO_CONFIG_DIR").unwrap_or_else(|_| "/tmp/pmomusic_test".to_string()); + std::fs::create_dir_all(&base_dir)?; + + tracing::info!("Initializing caches in: {}", base_dir); + + // Créer le cache audio + let audio_cache_dir = format!("{}/audio_cache", base_dir); + std::fs::create_dir_all(&audio_cache_dir)?; + let audio_cache = new_audio_cache(&audio_cache_dir, 1000).await?; + tracing::debug!("Audio cache initialized at: {}", audio_cache_dir); + + // Créer le cache de covers + let cover_cache_dir = format!("{}/cover_cache", base_dir); + std::fs::create_dir_all(&cover_cache_dir)?; + let cover_cache = new_cover_cache(&cover_cache_dir, 100).await?; + tracing::debug!("Cover cache initialized at: {}", cover_cache_dir); + + // Enregistrer le cache audio dans pmoplaylist + // (requis par pmoplaylist pour valider les pks) + register_global_audio_cache(audio_cache.clone()); + register_playlist_audio_cache(audio_cache.clone()); + register_cover_cache(cover_cache.clone()); + tracing::debug!("Audio cache registered in pmoplaylist"); + + // Utiliser le gestionnaire de playlist singleton + tracing::info!("Getting playlist manager..."); + let playlist_manager = pmoplaylist::PlaylistManager(); + tracing::debug!("Playlist manager obtained"); + + // ═══════════════════════════════════════════════════════════════════════════ + // Créer la playlist pour ce channel + // ═══════════════════════════════════════════════════════════════════════════ + + let playlist_id = format!("radio-paradise-ch{}", channel_id); + tracing::info!("Creating playlist: {}", playlist_id); + + // Créer une playlist éphémère (non persistante) pour cet exemple + let writer = playlist_manager + .get_write_handle(playlist_id.clone()) + .await?; + writer + .set_title(format!("Radio Paradise - Channel {}", channel_id)) + .await?; + writer.flush().await?; // Vider la playlist si elle existait + tracing::debug!("Playlist created and flushed"); + + // Créer le reader pour la lecture + let reader = playlist_manager.get_read_handle(&playlist_id).await?; + tracing::debug!("Read handle created"); + + // ═══════════════════════════════════════════════════════════════════════════ + // Récupérer les infos du bloc à télécharger + // ═══════════════════════════════════════════════════════════════════════════ + + tracing::info!("Fetching current block metadata..."); + let client = RadioParadiseClient::builder() + .channel(channel_id) + .build() + .await?; + + let block = client.get_block(None).await?; + + tracing::info!("Block Information:"); + tracing::info!(" Event ID: {}", block.event); + tracing::info!(" Songs: {}", block.song_count()); + tracing::info!(" Duration: {:.1} minutes", block.length as f64 / 60000.0); + tracing::info!(""); + + tracing::info!("Tracklist:"); + for (index, song) in block.songs_ordered() { + tracing::info!( + " {:2}. {} - {} ({})", + index + 1, + song.artist, + song.title, + song.album.as_deref().unwrap_or("Unknown Album") + ); + } + tracing::info!(""); + + // ═══════════════════════════════════════════════════════════════════════════ + // Pipeline 1: Téléchargement et cache + // ═══════════════════════════════════════════════════════════════════════════ + + tracing::info!("Creating download pipeline..."); + + // Créer la source Radio Paradise + let mut download_source = RadioParadiseStreamSource::new(client); + download_source.push_block_id(block.event); + tracing::debug!( + "RadioParadiseStreamSource created with block {}", + block.event + ); + + // Créer le sink de cache FLAC + let mut cache_sink = FlacCacheSink::new(audio_cache.clone(), cover_cache.clone()); + cache_sink.register_playlist(writer); + tracing::debug!("FlacCacheSink created and registered with playlist"); + + // Connecter source → sink + download_source.register(Box::new(cache_sink)); + tracing::info!("Download pipeline connected: RadioParadiseStreamSource → FlacCacheSink"); + + // ═══════════════════════════════════════════════════════════════════════════ + // Pipeline 2: Lecture depuis la playlist + // ═══════════════════════════════════════════════════════════════════════════ + + tracing::info!("Creating playback pipeline..."); + + // Créer la source playlist + let mut playlist_source = PlaylistSource::new(reader, audio_cache.clone()); + tracing::debug!("PlaylistSource created"); + + // Créer le timer node pour réguler le débit (empêche EOF prématurés) + // Tolère 3 secondes d'avance max pour permettre le buffering + let mut timer = TimerNode::new(3.0); + tracing::debug!("TimerNode created (max_lead_time=3.0s)"); + + // Créer le sink audio + let audio_sink = if use_null_audio { + AudioSink::with_null_output() + } else { + AudioSink::new() + }; + tracing::debug!("AudioSink created"); + + // Connecter timer → audio (AVANT de mettre timer dans une Box) + timer.register(Box::new(audio_sink)); + + // Connecter playlist → timer + playlist_source.register(Box::new(timer)); + tracing::info!("Playback pipeline connected: PlaylistSource → TimerNode → AudioSink"); + + // ═══════════════════════════════════════════════════════════════════════════ + // Lancer les deux pipelines en parallèle + // ═══════════════════════════════════════════════════════════════════════════ + + tracing::info!(""); + tracing::info!("========================================"); + tracing::info!("Starting both pipelines..."); + tracing::info!("Pipeline 1: Downloading and caching"); + tracing::info!("Pipeline 2: Playing from playlist"); + tracing::info!("========================================"); + tracing::info!(""); + + let stop_token = CancellationToken::new(); + let stop_token_download = stop_token.clone(); + let stop_token_playback = stop_token.clone(); + + // Gérer Ctrl+C + let stop_token_ctrl_c = stop_token.clone(); + tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + tracing::warn!("Received Ctrl+C, stopping..."); + stop_token_ctrl_c.cancel(); + }); + + let start = std::time::Instant::now(); + + // Lancer les deux pipelines en parallèle + let download_handle = tokio::spawn(async move { + tracing::info!("[DOWNLOAD] Pipeline starting..."); + let result = Box::new(download_source).run(stop_token_download).await; + match &result { + Ok(()) => tracing::info!("[DOWNLOAD] Pipeline completed successfully"), + Err(e) => tracing::error!("[DOWNLOAD] Pipeline error: {}", e), + } + result + }); + + let playback_handle = tokio::spawn(async move { + // Pas de sleep - le cache progressif permet de démarrer immédiatement + // dès que le prebuffer (512 KB) est atteint + tracing::info!("[PLAYBACK] Pipeline starting (will wait for prebuffer)..."); + let result = Box::new(playlist_source).run(stop_token_playback).await; + match &result { + Ok(()) => tracing::info!("[PLAYBACK] Pipeline completed successfully"), + Err(e) => tracing::error!("[PLAYBACK] Pipeline error: {}", e), + } + result + }); + + // Attendre les deux pipelines + let (download_result, playback_result) = tokio::join!(download_handle, playback_handle); + + let elapsed = start.elapsed(); + + // Vérifier les résultats + match (download_result, playback_result) { + (Ok(Ok(())), Ok(Ok(()))) => { + tracing::info!(""); + tracing::info!("========================================"); + tracing::info!("✓ Both pipelines completed successfully"); + tracing::info!(" Total time: {:.2}s", elapsed.as_secs_f64()); + tracing::info!("========================================"); + } + (download_res, playback_res) => { + tracing::error!(""); + tracing::error!("========================================"); + if let Err(e) = download_res { + tracing::error!("✗ Download pipeline error: {:?}", e); + } else if let Ok(Err(e)) = download_res { + tracing::error!("✗ Download pipeline error: {}", e); + } + if let Err(e) = playback_res { + tracing::error!("✗ Playback pipeline error: {:?}", e); + } else if let Ok(Err(e)) = playback_res { + tracing::error!("✗ Playback pipeline error: {}", e); + } + tracing::error!("========================================"); + return Err("Pipeline error".into()); + } + } + + Ok(()) +} +-------End of pmoparadise/examples/play_and_cache.rs --------- + +------------ pmoparadise/examples/serve_channels.rs ---------- +//! Minimal HTTP server exposing all four Radio Paradise channels. +//! +//! Routes: +//! - `/radioparadise/stream//flac` +//! - `/radioparadise/stream//ogg` +//! - `/radioparadise/stream//icy` +//! - `/radioparadise/stream//historic//flac` +//! - `/radioparadise/stream//historic//ogg` +//! - `/radioparadise/metadata/` + +use std::{fs, sync::Arc}; + +use axum::{ + body::Body, + extract::{Path, State}, + http::{ + header::{ACCEPT_RANGES, CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, + StatusCode, + }, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use pmoaudiocache::{ + new_cache_with_consolidation as new_audio_cache, + register_audio_cache as register_global_audio_cache, +}; +use pmocovers::{new_cache_with_consolidation as new_cover_cache, register_cover_cache}; +use pmoparadise::{channels::ALL_CHANNELS, ParadiseChannelManager, ParadiseHistoryBuilder}; +use pmoplaylist::register_audio_cache as register_playlist_audio_cache; +use pmoserver::{init_logging, ServerBuilder}; +use tokio_util::io::ReaderStream; +use tracing::{error, info}; + +#[derive(Clone)] +struct AppState { + manager: Arc, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let _ = init_logging(); + + // Préparer les caches partagés + let cover_cache_dir = "./cache/rp_covers"; + let audio_cache_dir = "./cache/rp_audio"; + fs::create_dir_all(cover_cache_dir)?; + fs::create_dir_all(audio_cache_dir)?; + + let cover_cache = new_cover_cache(cover_cache_dir, 500).await?; + let audio_cache = new_audio_cache(audio_cache_dir, 1000).await?; + register_global_audio_cache(audio_cache.clone()); + register_playlist_audio_cache(audio_cache.clone()); + register_cover_cache(cover_cache.clone()); + let _playlist_manager = pmoplaylist::PlaylistManager(); + + let history_builder = ParadiseHistoryBuilder { + audio_cache: audio_cache.clone(), + cover_cache: cover_cache.clone(), + playlist_prefix: "radio-paradise-history".into(), + playlist_title_prefix: Some("Radio Paradise History".into()), + max_history_tracks: Some(500), + collection_prefix: Some("radioparadise".into()), + replay_max_lead_seconds: 1.0, + }; + + info!("Initializing Radio Paradise channels..."); + let server_base_url = format!("http://localhost:{}", 8080); + let manager = Arc::new( + ParadiseChannelManager::with_defaults_with_cover_cache( + Some(cover_cache), + Some(history_builder), + Some(server_base_url), + ) + .await?, + ); + let app_state = Arc::new(AppState { + manager: manager.clone(), + }); + + let mut server = ServerBuilder::new("RadioParadiseChannels", "http://localhost", 8080).build(); + + for descriptor in ALL_CHANNELS.iter() { + let slug = descriptor.slug; + let flac_path = format!("/radioparadise/stream/{}/flac", slug); + let ogg_path = format!("/radioparadise/stream/{}/ogg", slug); + let icy_path = format!("/radioparadise/stream/{}/icy", slug); + let history_path = format!("/radioparadise/stream/{}/historic", slug); + let meta_path = format!("/radioparadise/metadata/{}", slug); + let channel_id = descriptor.id; + + server + .add_handler_with_state( + &flac_path, + move |State(state): State>| { + let manager = state.manager.clone(); + async move { stream_flac(manager, channel_id).await } + }, + app_state.clone(), + ) + .await; + + server + .add_handler_with_state( + &ogg_path, + move |State(state): State>| { + let manager = state.manager.clone(); + async move { stream_ogg(manager, channel_id).await } + }, + app_state.clone(), + ) + .await; + + server + .add_handler_with_state( + &icy_path, + move |State(state): State>| { + let manager = state.manager.clone(); + async move { stream_icy(manager, channel_id).await } + }, + app_state.clone(), + ) + .await; + + let history_router = Router::new() + .route( + "/{client_id}/flac", + get({ + let manager = manager.clone(); + move |Path(client_id): Path| { + let manager = manager.clone(); + async move { stream_history_flac(manager, channel_id, client_id).await } + } + }), + ) + .route( + "/{client_id}/ogg", + get({ + let manager = manager.clone(); + move |Path(client_id): Path| { + let manager = manager.clone(); + async move { stream_history_ogg(manager, channel_id, client_id).await } + } + }), + ); + + server.add_router(&history_path, history_router).await; + + server + .add_handler_with_state( + &meta_path, + move |State(state): State>| { + let manager = state.manager.clone(); + async move { get_metadata(manager, channel_id).await } + }, + app_state.clone(), + ) + .await; + } + + info!("========================================"); + info!("Radio Paradise streaming server running on http://localhost:8080"); + info!("Available channels:"); + for descriptor in ALL_CHANNELS.iter() { + info!( + " {}: /radioparadise/stream/{}/flac (also /ogg, /icy, metadata, /historic//(flac|ogg))", + descriptor.display_name, descriptor.slug + ); + } + info!("Press Ctrl+C to stop."); + info!("========================================"); + + server.start().await; + server.wait().await; + Ok(()) +} + +async fn stream_flac( + manager: Arc, + channel_id: u8, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.subscribe_flac(); + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "audio/flac") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} + +async fn stream_ogg( + manager: Arc, + channel_id: u8, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.subscribe_ogg(); + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/ogg") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} + +async fn stream_icy( + manager: Arc, + channel_id: u8, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.subscribe_icy(); + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "audio/flac") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .header("icy-metaint", "16000") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} + +async fn get_metadata( + manager: Arc, + channel_id: u8, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let metadata = channel.metadata().await; + Ok(Json(metadata)) +} + +async fn stream_history_flac( + manager: Arc, + channel_id: u8, + client_id: String, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.stream_history_flac(&client_id).await.map_err(|e| { + error!( + "Failed to start historical FLAC stream for channel {} (client_id={}): {}", + channel_id, client_id, e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "audio/flac") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} + +async fn stream_history_ogg( + manager: Arc, + channel_id: u8, + client_id: String, +) -> Result { + let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; + let stream = channel.stream_history_ogg(&client_id).await.map_err(|e| { + error!( + "Failed to start historical OGG stream for channel {} (client_id={}): {}", + channel_id, client_id, e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/ogg") + .header(CACHE_CONTROL, "no-store, no-transform") + .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "none") + .body(Body::from_stream(ReaderStream::new(stream))) + .unwrap()) +} +-------End of pmoparadise/examples/serve_channels.rs --------- + +------------ pmoparadise/examples/single_channel_server.rs ---------- +//! Simple web server that exposes one Radio Paradise channel over HTTP. +//! +//! Usage: +//! ```bash +//! cargo run --example single_channel_server --features full -- main +//! ``` +//! Valid arguments are either the slug (`main`, `mellow`, `rock`, `eclectic`) or +//! the numeric channel id (`0`..`3`). When no argument is provided, the example +//! defaults to the “main” mix. + +use axum::{ + body::Body, + extract::{Path, Request, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use pmoaudio_ext::StreamingSinkOptions; +use pmoaudiocache::{ + new_cache_with_consolidation as new_audio_cache, + register_audio_cache as register_global_audio_cache, +}; +use pmocovers::{ + new_cache_with_consolidation as new_cover_cache, register_cover_cache, Cache as CoverCache, +}; +use pmoparadise::{ + channels::{ChannelDescriptor, ALL_CHANNELS}, + ParadiseHistoryBuilder, ParadiseStreamChannel, ParadiseStreamChannelConfig, +}; +use pmoplaylist::register_audio_cache as register_playlist_audio_cache; +use std::{fs, net::SocketAddr, sync::Arc}; +use tokio::net::TcpListener; +use tokio_util::io::ReaderStream; +use tracing::info; + +#[derive(Clone)] +struct AppState { + channel: Arc, + descriptor: ChannelDescriptor, + cover_cache: Arc, +} + +#[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(env_filter).init(); + + let descriptor = pick_descriptor(std::env::args().nth(1))?; + info!( + "Selected Radio Paradise channel: {} ({})", + descriptor.display_name, descriptor.slug + ); + + // Prepare caches under ./cache/single-channel + let cache_root = "./cache/single-channel"; + let audio_cache_dir = format!("{}/audio", cache_root); + let cover_cache_dir = format!("{}/covers", cache_root); + fs::create_dir_all(&audio_cache_dir)?; + fs::create_dir_all(&cover_cache_dir)?; + + let audio_cache = new_audio_cache(&audio_cache_dir, 1000).await?; + let cover_cache = new_cover_cache(&cover_cache_dir, 200).await?; + register_global_audio_cache(audio_cache.clone()); + register_playlist_audio_cache(audio_cache.clone()); + register_cover_cache(cover_cache.clone()); + + let mut history_builder = ParadiseHistoryBuilder::new(audio_cache.clone(), cover_cache.clone()); + history_builder.playlist_prefix = format!("single-channel-history-{}", descriptor.slug); + history_builder.collection_prefix = Some(format!("single-channel-{}", descriptor.slug)); + let history_opts = history_builder.build_for_channel(&descriptor).await?; + + let mut channel_config = ParadiseStreamChannelConfig::default(); + // Base URL for cover images in stream metadata + let server_base_url = "http://localhost:8080".to_string(); + + // Configuration commune pour FLAC et OGG + let common_options = StreamingSinkOptions::flac_defaults() + .with_default_artist(Some("Radio Paradise".to_string())) + .with_default_title(descriptor.display_name.to_string()) + .with_server_base_url(Some(server_base_url.clone())); + + channel_config.flac_options = common_options.clone(); + channel_config.ogg_options = StreamingSinkOptions::ogg_defaults() + .with_default_artist(Some("Radio Paradise".to_string())) + .with_default_title(descriptor.display_name.to_string()) + .with_server_base_url(Some(server_base_url)); + + let channel = Arc::new( + ParadiseStreamChannel::new( + descriptor, + channel_config, + Some(cover_cache.clone()), + Some(history_opts), + ) + .await?, + ); + + let state = AppState { + channel, + descriptor, + cover_cache, + }; + + let app = Router::new() + .route("/stream/flac", get(stream_flac)) + .route("/stream/ogg", get(stream_ogg)) + .route("/metadata", get(get_metadata)) + .route("/covers/image/{pk}", get(get_cover)) + .with_state(state); + + let addr: SocketAddr = ([0, 0, 0, 0], 8080).into(); + info!("========================================"); + info!("HTTP server listening on http://{addr}"); + info!("Available endpoints:"); + info!(" - /stream/flac : FLAC audio stream"); + info!(" - /stream/ogg : OGG-FLAC audio stream"); + info!(" - /metadata : Current track metadata (JSON)"); + info!(" - /covers/image/{{pk}} : Album cover images (WebP)"); + info!("========================================"); + info!("Connect with a FLAC player: ffplay http://localhost:8080/stream/flac"); + info!("Connect with an OGG-FLAC player: ffplay http://localhost:8080/stream/ogg"); + + let listener = TcpListener::bind(addr).await?; + axum::serve(listener, app.into_make_service()).await?; + + Ok(()) +} + +async fn stream_flac(State(state): State) -> Result { + let stream = state.channel.subscribe_flac(); + let body = Body::from_stream(ReaderStream::new(stream)); + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "audio/flac") + .header( + "X-PMO-Channel", + format!( + "{} ({})", + state.descriptor.display_name, state.descriptor.slug + ), + ) + .body(body) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +async fn stream_ogg(State(state): State) -> Result { + let stream = state.channel.subscribe_ogg(); + let body = Body::from_stream(ReaderStream::new(stream)); + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "audio/ogg") + .header( + "X-PMO-Channel", + format!( + "{} ({})", + state.descriptor.display_name, state.descriptor.slug + ), + ) + .body(body) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +async fn get_metadata( + State(state): State, + request: Request, +) -> Result { + let mut metadata = state.channel.metadata().await; + + // Si cover_pk est disponible, construire l'URL complète depuis les headers + // Format: /covers/image/{pk} (correspond à la structure du cache pmocovers) + if let Some(ref pk) = metadata.cover_pk { + let base_url = extract_base_url(&request); + metadata.cover_url = Some(format!("{}/covers/image/{}", base_url, pk)); + } + + Ok(Json(metadata)) +} + +/// Extrait l'URL de base depuis les headers HTTP de la requête +/// Supporte les proxies avec X-Forwarded-Host et X-Forwarded-Proto +fn extract_base_url(request: &Request) -> String { + let headers = request.headers(); + + // Déterminer le schéma (http ou https) + let scheme = headers + .get("x-forwarded-proto") + .and_then(|h| h.to_str().ok()) + .unwrap_or("http"); + + // Déterminer le host + let host = headers + .get("x-forwarded-host") + .or_else(|| headers.get("host")) + .and_then(|h| h.to_str().ok()) + .unwrap_or("localhost:8080"); + + format!("{}://{}", scheme, host) +} + +async fn get_cover( + State(state): State, + Path(pk): Path, +) -> Result { + // Récupérer le chemin de la cover depuis le cache + // Le cache retourne un PathBuf pointant vers le fichier .webp + let cover_path = state.cover_cache.get(&pk).await.map_err(|e| { + tracing::error!("Failed to get cover path for {}: {}", pk, e); + StatusCode::NOT_FOUND + })?; + + // Lire le fichier + let cover_data = tokio::fs::read(&cover_path).await.map_err(|e| { + tracing::error!("Failed to read cover file {:?}: {}", cover_path, e); + StatusCode::NOT_FOUND + })?; + + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "image/webp") + .header("Cache-Control", "public, max-age=86400") + .body(Body::from(cover_data)) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +fn pick_descriptor(arg: Option) -> anyhow::Result { + if let Some(token) = arg { + if let Some(desc) = ALL_CHANNELS.iter().find(|c| c.slug == token) { + return Ok(*desc); + } + if let Ok(id) = token.parse::() { + if let Some(desc) = ALL_CHANNELS.iter().find(|c| c.id == id) { + return Ok(*desc); + } + } + anyhow::bail!("Unknown channel identifier: {token}"); + } + Ok(ALL_CHANNELS[0]) +} +-------End of pmoparadise/examples/single_channel_server.rs --------- + +------------ pmoparadise/examples/stream_block.rs ---------- +//! Streams a Radio Paradise block via HTTP using pmoserver +//! +//! This example demonstrates streaming a single Radio Paradise block +//! using the StreamingFlacSink over HTTP via pmoserver. Perfect for +//! testing with VLC or other media players that support HTTP streaming. +//! +//! The example streams ONE block then terminates cleanly using END_OF_BLOCKS_SIGNAL. +//! For continuous streaming, push multiple block_ids without the END signal. +//! +//! Architecture: +//! ```text +//! RadioParadiseStreamSource → TimerBufferNode → StreamingFlacSink +//! ↓ +//! StreamHandle +//! ↓ +//! pmoserver (Axum) +//! ↓ +//! VLC / Media Player Client +//! ``` +//! +//! Usage: +//! cargo run --example stream_block --features full -- +//! +//! Example: +//! cargo run --example stream_block --features full -- 0 # Main Mix +//! +//! Then open in VLC: +//! vlc http://localhost:8080/test/stream (pure FLAC) +//! vlc http://localhost:8080/test/stream-ogg (OGG-FLAC streaming container) +//! vlc http://localhost:8080/test/stream-icy (FLAC + ICY metadata) +//! +//! To check current metadata: +//! curl http://localhost:8080/test/metadata + +use axum::{ + body::Body, + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, +}; +use pmoaudio::{AudioPipelineNode, TimerBufferNode}; +use pmoaudio_ext::{StreamingFlacSink, StreamingOggFlacSink}; +use pmoflac::EncoderOptions; +use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource, END_OF_BLOCKS_SIGNAL}; +use pmoserver::{init_logging, ServerBuilder}; +use std::env; +use std::sync::Arc; +use tokio_util::io::ReaderStream; +use tokio_util::sync::CancellationToken; + +/// Shared application state +struct AppState { + stream_handle: pmoaudio_ext::StreamHandle, + ogg_handle: pmoaudio_ext::OggFlacStreamHandle, +} + +/// Main HTTP handler for streaming (pure FLAC, no ICY metadata) +async fn stream_handler( + State(state): State>, + _headers: HeaderMap, +) -> Result { + tracing::info!("New client connected (pure FLAC mode)"); + + // Pure FLAC stream without ICY metadata + let flac_stream = state.stream_handle.subscribe_flac(); + + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "audio/flac") + .header("Cache-Control", "no-cache, no-store") + .body(Body::from_stream(ReaderStream::new(flac_stream))) + .unwrap()) +} + +/// ICY streaming handler (FLAC with embedded metadata) +async fn stream_icy_handler( + State(state): State>, + _headers: HeaderMap, +) -> Result { + tracing::info!("New client connected (ICY mode)"); + + // FLAC stream with ICY metadata + let icy_stream = state.stream_handle.subscribe_icy(); + + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "audio/flac") + .header("icy-metaint", "16000") + .header("icy-name", "Radio Paradise Stream Test") + .header("icy-genre", "Eclectic") + .header("icy-pub", "1") + .header("Cache-Control", "no-cache, no-store") + .body(Body::from_stream(ReaderStream::new(icy_stream))) + .unwrap()) +} + +/// OGG-FLAC streaming handler +async fn stream_ogg_handler( + State(state): State>, + _headers: HeaderMap, +) -> Result { + tracing::info!("New client connected (OGG-FLAC mode)"); + + // OGG-FLAC stream + let ogg_stream = state.ogg_handle.subscribe(); + + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "audio/ogg") + .header("Cache-Control", "no-cache, no-store") + .body(Body::from_stream(ReaderStream::new(ogg_stream))) + .unwrap()) +} + +/// Metadata endpoint (JSON) +async fn metadata_handler(State(state): State>) -> impl IntoResponse { + let metadata = state.stream_handle.get_metadata().await; + axum::Json(metadata) +} + +/// Health check endpoint +async fn health_handler() -> &'static str { + "OK" +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize logging via pmoserver + let _log_state = init_logging(); + + tracing::info!("=== Radio Paradise HTTP Streaming Test ==="); + + // Parse arguments + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!(); + eprintln!("Streams a Radio Paradise block via HTTP for testing."); + eprintln!(); + eprintln!("Channel IDs:"); + eprintln!(" 0 - Main Mix (eclectic, diverse mix)"); + eprintln!(" 1 - Mellow Mix (smooth, chilled music)"); + eprintln!(" 2 - Rock Mix (classic & modern rock)"); + eprintln!(" 3 - World/Etc Mix (global sounds)"); + eprintln!(); + eprintln!("After starting, open in VLC:"); + eprintln!(" vlc http://localhost:8080/test/stream (pure FLAC)"); + eprintln!(" vlc http://localhost:8080/test/stream-ogg (OGG-FLAC container)"); + eprintln!(" vlc http://localhost:8080/test/stream-icy (FLAC + ICY metadata)"); + std::process::exit(1); + } + + let channel_id: u8 = match args[1].parse() { + Ok(id) if id <= 3 => id, + _ => { + eprintln!("Error: channel_id must be a number between 0 and 3"); + std::process::exit(1); + } + }; + + tracing::info!("Channel ID: {}", channel_id); + + // ═══════════════════════════════════════════════════════════════════════════ + // Fetch block metadata + // ═══════════════════════════════════════════════════════════════════════════ + + tracing::info!("Fetching current block metadata..."); + let client = RadioParadiseClient::builder() + .channel(channel_id) + .build() + .await?; + + let block = client.get_block(None).await?; + + tracing::info!("Block Information:"); + tracing::info!(" Event ID: {}", block.event); + tracing::info!(" Songs: {}", block.song_count()); + tracing::info!(" Duration: {:.1} minutes", block.length as f64 / 60000.0); + tracing::info!(""); + + tracing::info!("Tracklist:"); + for (index, song) in block.songs_ordered() { + tracing::info!( + " {:2}. {} - {} ({})", + index + 1, + song.artist, + song.title, + song.album.as_deref().unwrap_or("Unknown Album") + ); + } + tracing::info!(""); + + // ═══════════════════════════════════════════════════════════════════════════ + // Create streaming pipelines (FLAC and OGG-FLAC) + // ═══════════════════════════════════════════════════════════════════════════ + + tracing::info!("Creating streaming pipelines..."); + + // Encoder options (shared) + let encoder_options = EncoderOptions { + compression_level: 5, + verify: false, + ..Default::default() + }; + + // ───────────────────────────────────────────────────────────────────────── + // Unique pipeline feeding both FLAC and OGG sinks + // ───────────────────────────────────────────────────────────────────────── + + let mut source = RadioParadiseStreamSource::new(client); + source.push_block_id(block.event); + source.push_block_id(END_OF_BLOCKS_SIGNAL); // Signal: no more blocks after this one + tracing::debug!( + "RadioParadiseStreamSource created with block {} + END signal", + block.event + ); + + // Use SMALL channel size to make backpressure plus fan-out manageable. + let buffer_sec = 0.1; + let max_lead_time = buffer_sec; + let channel_size = 512; + tracing::debug!( + "Using channel size: {} chunks ({:.1}s buffer à 50ms/chunk)", + channel_size, + channel_size as f64 * 0.05 + ); + + let mut timer_node = TimerBufferNode::with_channel_size(buffer_sec, channel_size); + tracing::debug!( + "TimerBufferNode created with {:.1}s buffer, {} chunk queue", + buffer_sec, + channel_size + ); + + // Streaming sinks + let (streaming_sink, stream_handle) = + StreamingFlacSink::with_max_broadcast_lead(encoder_options.clone(), 16, max_lead_time); + tracing::debug!("StreamingFlacSink created"); + + let (ogg_sink, ogg_handle) = + StreamingOggFlacSink::with_max_broadcast_lead(encoder_options, 16, max_lead_time); + tracing::debug!("StreamingOggFlacSink created"); + + // timer_node.register(Box::new(streaming_sink)); + // timer_node.register(Box::new(ogg_sink)); + // source.register(Box::new(timer_node)); + + source.register(Box::new(streaming_sink)); + source.register(Box::new(ogg_sink)); + + tracing::info!("Pipeline connected: StreamSource → TimerBufferNode → {{FLAC, OGG}} sinks"); + + // ═══════════════════════════════════════════════════════════════════════════ + // Setup pmoserver with streaming routes + // ═══════════════════════════════════════════════════════════════════════════ + + tracing::info!("Setting up pmoserver..."); + + let mut server = + ServerBuilder::new("RadioParadiseStreamTest", "http://localhost", 8080).build(); + + let app_state = Arc::new(AppState { + stream_handle, + ogg_handle, + }); + + // Add streaming routes + let base = "/radioparadise/test"; + server + .add_handler_with_state( + &format!("{}/stream", base), + stream_handler, + app_state.clone(), + ) + .await; + server + .add_handler_with_state( + &format!("{}/stream-icy", base), + stream_icy_handler, + app_state.clone(), + ) + .await; + server + .add_handler_with_state( + &format!("{}/stream-ogg", base), + stream_ogg_handler, + app_state.clone(), + ) + .await; + + // Add metadata route + server + .add_handler_with_state( + &format!("{}/metadata", base), + metadata_handler, + app_state.clone(), + ) + .await; + + // Add health check + server.add_handler("/test/health", health_handler).await; + + tracing::info!(""); + tracing::info!("========================================"); + tracing::info!("Ready to stream!"); + tracing::info!(""); + tracing::info!("Pure FLAC stream (for VLC, standard players):"); + tracing::info!(" vlc http://localhost:8080{}/stream", base); + tracing::info!(""); + tracing::info!("OGG-FLAC stream (streaming container with metadata support):"); + tracing::info!(" vlc http://localhost:8080{}/stream-ogg", base); + tracing::info!(""); + tracing::info!("FLAC + ICY metadata stream (for ICY-aware clients):"); + tracing::info!(" http://localhost:8080{}/stream-icy", base); + tracing::info!(""); + tracing::info!("Metadata endpoint (JSON):"); + tracing::info!(" curl http://localhost:8080{}/metadata", base); + tracing::info!("========================================"); + tracing::info!(""); + + // ═══════════════════════════════════════════════════════════════════════════ + // Start pipelines and server + // ═══════════════════════════════════════════════════════════════════════════ + + let stop_token = CancellationToken::new(); + let pipeline_stop = stop_token.clone(); + + // Start shared pipeline in background + let pipeline_handle = tokio::spawn(async move { + tracing::info!("[PIPELINE] Starting..."); + let result = Box::new(source).run(pipeline_stop).await; + match &result { + Ok(()) => tracing::info!("[PIPELINE] Completed successfully"), + Err(e) => tracing::error!("[PIPELINE] Error: {}", e), + } + result + }); + + // Start pmoserver (blocks until Ctrl+C) + tracing::info!("[SERVER] Starting pmoserver..."); + server.start().await; + server.wait().await; + + // Server stopped, cancel pipelines + tracing::info!("Server stopped, canceling pipelines..."); + stop_token.cancel(); + + // Wait for pipeline to finish + match pipeline_handle.await { + Ok(Ok(())) => tracing::info!("Pipeline completed successfully"), + Ok(Err(e)) => tracing::error!("Pipeline error: {}", e), + Err(e) => tracing::error!("Pipeline task error: {}", e), + } + + tracing::info!("Shutdown complete"); + Ok(()) +} +-------End of pmoparadise/examples/stream_block.rs --------- + diff --git a/pmoplaylist/Cargo.toml b/pmoplaylist/Cargo.toml index b842ea27..0c96c3f1 100644 --- a/pmoplaylist/Cargo.toml +++ b/pmoplaylist/Cargo.toml @@ -14,9 +14,6 @@ pmometadata = { path = "../pmometadata" } # DIDL-Lite pour UPnP pmodidl = { path = "../pmodidl" } -# UPnP (pour accéder au cache audio global) -pmoupnp = { path = "../pmoupnp" } - # Configuration (optionnelle) pmoconfig = { path = "../pmoconfig", optional = true } @@ -37,7 +34,13 @@ once_cell = "1.20" # Logging tracing = "0.1" +chrono = { version = "0.4", features = ["serde"] } +axum = { version = "0.8", optional = true, features = ["macros", "json"] } +tokio-stream = { version = "0.1", optional = true, features = ["sync"] } +utoipa = { version = "5.4", optional = true, features = ["axum_extras", "chrono"] } +async-stream = { version = "0.3", optional = true } [features] default = ["pmoconfig"] pmoconfig = ["dep:pmoconfig"] +pmoserver = ["dep:axum", "dep:tokio-stream", "dep:utoipa", "dep:async-stream"] diff --git a/pmoplaylist/src/handle/read.rs b/pmoplaylist/src/handle/read.rs index 1fa832fc..b1909f92 100644 --- a/pmoplaylist/src/handle/read.rs +++ b/pmoplaylist/src/handle/read.rs @@ -60,6 +60,7 @@ impl ReadHandle { tracing::warn!("Cache entry {} missing, removing from playlist", cache_pk); let mut core = self.playlist.core.write().await; core.remove_by_cache_pk(&cache_pk); + let snapshot = core.snapshot(); drop(core); // Sauvegarder si persistante @@ -73,6 +74,11 @@ impl ReadHandle { } } + // Mettre à jour l'index pk -> playlists + crate::manager::PlaylistManager() + .rebuild_track_index(&self.playlist.id, &snapshot) + .await; + // Ne pas avancer le curseur, continuer avec la position actuelle continue; } @@ -160,13 +166,13 @@ impl ReadHandle { } let title = self.playlist.title().await; - let remaining = self.remaining().await?; + let _remaining = self.remaining().await?; Ok(Container { id: self.playlist.id.clone(), parent_id: "0".to_string(), restricted: Some("1".to_string()), - child_count: Some(remaining.to_string()), + child_count: None, searchable: Some("0".to_string()), title, class: "object.container.playlistContainer".to_string(), @@ -204,33 +210,51 @@ impl ReadHandle { continue; } - // Charger métadonnées - let metadata = match pmoaudiocache::get_metadata(&*cache, &record.cache_pk) { - Ok(m) => m, - Err(_) => continue, - }; + // Charger métadonnées via TrackMetadata + use pmoaudiocache::metadata_ext::{AudioTrackMetadataExt, TrackMetadataDidlExt}; + let track_meta = cache.track_metadata(&record.cache_pk); + let meta = track_meta.read().await; // Construire l'URL via route_for let url = cache.route_for(&record.cache_pk, None); - // Créer le Resource DIDL - let resource = metadata.to_didl_resource(url); + // Créer le Resource DIDL via l'extension trait + let resource = meta.to_didl_resource(url).await; + + // Récupérer les métadonnées pour construire l'Item DIDL + let title = meta + .get_title() + .await + .ok() + .flatten() + .unwrap_or_else(|| "Unknown".to_string()); + let artist = meta.get_artist().await.ok().flatten(); + let album = meta.get_album().await.ok().flatten(); + let genre = meta.get_genre().await.ok().flatten(); + let year = meta.get_year().await.ok().flatten(); + let track_number = meta.get_track_number().await.ok().flatten(); + let cover_pk = meta.get_cover_pk().await.ok().flatten(); + let cover_url = if let Some(pk) = cover_pk.as_ref() { + Some(format!("/covers/jpeg/{}/256", pk)) + } else { + meta.get_cover_url().await.ok().flatten() + }; // Créer l'Item let item = Item { id: format!("{}:{}", self.playlist.id, pos + idx), parent_id: self.playlist.id.clone(), restricted: Some("1".to_string()), - title: metadata.title.unwrap_or_else(|| "Unknown".to_string()), - creator: metadata.artist.clone(), + title: title.clone(), + creator: artist.clone(), class: "object.item.audioItem.musicTrack".to_string(), - artist: metadata.artist, - album: metadata.album, - genre: metadata.genre, - album_art: None, // TODO: intégrer pmocovers - album_art_pk: None, - date: metadata.year.map(|y| y.to_string()), - original_track_number: metadata.track_number.map(|n| n.to_string()), + artist, + album, + genre, + album_art: cover_url, + album_art_pk: cover_pk, + date: year.map(|y| y.to_string()), + original_track_number: track_number.map(|n| n.to_string()), resources: vec![resource], descriptions: vec![], }; diff --git a/pmoplaylist/src/handle/write.rs b/pmoplaylist/src/handle/write.rs index 48feb2f5..2a561511 100644 --- a/pmoplaylist/src/handle/write.rs +++ b/pmoplaylist/src/handle/write.rs @@ -38,6 +38,7 @@ impl WriteHandle { let record = Record::new(cache_pk); let mut core = self.playlist.core.write().await; core.push(record); + let snapshot = core.snapshot(); drop(core); self.playlist.touch().await; @@ -47,6 +48,48 @@ impl WriteHandle { self.save_to_db().await?; } + // Notifier le manager + let manager = crate::manager::PlaylistManager(); + manager + .rebuild_track_index(&self.playlist.id, &snapshot) + .await; + manager.notify_playlist_changed(&self.playlist.id); + + Ok(()) + } + + /// Ajoute un morceau avec un TTL personnalisé + pub async fn push_with_ttl(&self, cache_pk: String, ttl: Duration) -> Result<()> { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + // Vérifier que le pk existe dans le cache + let cache = crate::manager::audio_cache()?; + if !cache.is_valid_pk(&cache_pk).await { + return Err(crate::Error::CacheEntryNotFound(cache_pk)); + } + + // Ajouter à la playlist avec TTL + let record = Record::with_ttl(cache_pk, ttl); + let mut core = self.playlist.core.write().await; + core.push(record); + let snapshot = core.snapshot(); + drop(core); + + self.playlist.touch().await; + + // Sauvegarder si persistante + if self.playlist.persistent { + self.save_to_db().await?; + } + + let manager = crate::manager::PlaylistManager(); + manager + .rebuild_track_index(&self.playlist.id, &snapshot) + .await; + manager.notify_playlist_changed(&self.playlist.id); + Ok(()) } @@ -70,6 +113,7 @@ impl WriteHandle { // Ajouter atomiquement let mut core = self.playlist.core.write().await; core.push_all(records); + let snapshot = core.snapshot(); drop(core); self.playlist.touch().await; @@ -79,6 +123,12 @@ impl WriteHandle { self.save_to_db().await?; } + let manager = crate::manager::PlaylistManager(); + manager + .rebuild_track_index(&self.playlist.id, &snapshot) + .await; + manager.notify_playlist_changed(&self.playlist.id); + Ok(()) } @@ -90,6 +140,7 @@ impl WriteHandle { let mut core = self.playlist.core.write().await; core.clear(); + let snapshot = core.snapshot(); drop(core); self.playlist.touch().await; @@ -98,6 +149,12 @@ impl WriteHandle { self.save_to_db().await?; } + let manager = crate::manager::PlaylistManager(); + manager + .rebuild_track_index(&self.playlist.id, &snapshot) + .await; + manager.notify_playlist_changed(&self.playlist.id); + Ok(()) } @@ -111,8 +168,14 @@ impl WriteHandle { self.playlist.mark_deleted(); // Supprimer du manager + // Nettoyer les index puis supprimer du manager + let manager = crate::manager::PlaylistManager(); + manager.rebuild_track_index(&self.playlist.id, &[]).await; crate::manager::delete_playlist_internal(&self.playlist.id).await?; + // Notifier la suppression + manager.notify_playlist_changed(&self.playlist.id); + Ok(()) } @@ -128,6 +191,8 @@ impl WriteHandle { self.save_to_db().await?; } + crate::manager::PlaylistManager().notify_playlist_changed(&self.playlist.id); + Ok(()) } @@ -139,6 +204,7 @@ impl WriteHandle { let mut core = self.playlist.core.write().await; core.set_capacity(max_size); + let snapshot = core.snapshot(); drop(core); self.playlist.touch().await; @@ -147,9 +213,25 @@ impl WriteHandle { self.save_to_db().await?; } + let manager = crate::manager::PlaylistManager(); + manager + .rebuild_track_index(&self.playlist.id, &snapshot) + .await; + manager.notify_playlist_changed(&self.playlist.id); + Ok(()) } + /// Vérifie si la playlist contient déjà un pk + pub async fn contains_pk(&self, cache_pk: &str) -> Result { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + let core = self.playlist.core.read().await; + Ok(core.tracks.iter().any(|record| record.cache_pk == cache_pk)) + } + /// Change le TTL par défaut pub async fn set_default_ttl(&self, ttl: Option) -> Result<()> { if !self.playlist.is_alive() { @@ -158,6 +240,7 @@ impl WriteHandle { let mut core = self.playlist.core.write().await; core.set_default_ttl(ttl); + let snapshot = core.snapshot(); drop(core); self.playlist.touch().await; @@ -166,6 +249,12 @@ impl WriteHandle { self.save_to_db().await?; } + let manager = crate::manager::PlaylistManager(); + manager + .rebuild_track_index(&self.playlist.id, &snapshot) + .await; + manager.notify_playlist_changed(&self.playlist.id); + Ok(()) } diff --git a/pmoplaylist/src/lib.rs b/pmoplaylist/src/lib.rs index 19d03899..090038d6 100644 --- a/pmoplaylist/src/lib.rs +++ b/pmoplaylist/src/lib.rs @@ -47,8 +47,12 @@ mod error; mod handle; mod manager; +#[cfg(feature = "pmoserver")] +pub mod openapi; mod persistence; mod playlist; +#[cfg(feature = "pmoserver")] +mod sse; mod track; #[cfg(feature = "pmoconfig")] @@ -58,6 +62,9 @@ mod config_ext; pub use error::{Error, Result}; pub use handle::{ReadHandle, WriteHandle}; pub use manager::{register_audio_cache, PlaylistManager, PlaylistManager as Manager}; +pub use manager::{subscribe_events, PlaylistEvent, PlaylistEventEnvelope, PlaylistEventKind}; +#[cfg(feature = "pmoserver")] +pub use sse::playlist_events_router; pub use track::PlaylistTrack; #[cfg(feature = "pmoconfig")] diff --git a/pmoplaylist/src/manager.rs b/pmoplaylist/src/manager.rs index 2daf75f8..753a6582 100644 --- a/pmoplaylist/src/manager.rs +++ b/pmoplaylist/src/manager.rs @@ -6,10 +6,16 @@ use crate::playlist::core::PlaylistConfig; use crate::playlist::Playlist; use crate::Result; use once_cell::sync::OnceCell; +use pmocache::{CacheBroadcastEvent, CacheSubscription}; use std::collections::HashMap; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::RwLock as StdRwLock; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; use std::time::Duration; +use tokio::sync::broadcast; use tokio::sync::RwLock; /// Singleton PlaylistManager @@ -22,6 +28,35 @@ static AUDIO_CACHE: OnceCell> = OnceCell::new(); struct ManagerInner { playlists: RwLock>>, persistence: Option>, + callbacks: StdRwLock>>, + cb_counter: AtomicU64, + track_index: StdRwLock>>, // cache_pk -> playlists + cache_subscriptions: StdRwLock>, + event_tx: broadcast::Sender, +} + +/// Type d'évènement émis par le PlaylistManager. +#[derive(Debug, Clone)] +pub struct PlaylistEvent { + pub playlist_id: String, + pub kind: PlaylistEventKind, +} + +/// Variantes d'évènements playlist. +#[derive(Debug, Clone)] +pub enum PlaylistEventKind { + /// La playlist a été modifiée (ajout/suppression/changement de config). + Updated, + /// Un morceau référencé par la playlist a été servi par le cache audio. + TrackPlayed { cache_pk: String, qualifier: String }, +} + +/// Evènement enrichi pour diffusion (timestamp + source client éventuel). +#[derive(Debug, Clone)] +pub struct PlaylistEventEnvelope { + pub event: PlaylistEvent, + pub timestamp: std::time::SystemTime, + pub source_client: Option, } /// Gestionnaire central de playlists @@ -35,10 +70,23 @@ impl PlaylistManager { // Initialiser la persistance let persistence = Arc::new(PersistenceManager::new(&db_path)?); + // Lancer la consolidation en arrière-plan + let persistence_clone = persistence.clone(); + tokio::spawn(async move { + if let Err(e) = persistence_clone.consolidate().await { + tracing::warn!("Failed to consolidate playlist database on startup: {}", e); + } + }); + let manager = Self { inner: Arc::new(ManagerInner { playlists: RwLock::new(HashMap::new()), persistence: Some(persistence.clone()), + callbacks: StdRwLock::new(HashMap::new()), + cb_counter: AtomicU64::new(1), + track_index: StdRwLock::new(HashMap::new()), + cache_subscriptions: StdRwLock::new(HashMap::new()), + event_tx: broadcast::channel(256).0, }), }; @@ -115,6 +163,207 @@ impl PlaylistManager { Ok(WriteHandle::new(playlist, write_token)) } + /// Enregistre un callback d'évènement playlist (update, track joué). + /// + /// Retourne un jeton (u64) pour désenregistrer plus tard. + pub fn register_callback(&self, cb: F) -> u64 + where + F: Fn(&PlaylistEvent) + Send + Sync + 'static, + { + let token = self.inner.cb_counter.fetch_add(1, Ordering::Relaxed); + let mut guard = self.inner.callbacks.write().unwrap(); + guard.insert(token, Arc::new(cb)); + token + } + + /// Désenregistre un callback via son jeton. + pub fn unregister_callback(&self, token: u64) { + let mut guard = self.inner.callbacks.write().unwrap(); + guard.remove(&token); + } + + /// Notifie tous les callbacks qu'une playlist a changé. + pub(crate) fn notify_playlist_changed(&self, id: &str) { + self.notify_playlist_event(id, PlaylistEventKind::Updated); + } + + /// Notifie les callbacks qu'un morceau a été joué pour une playlist donnée. + pub(crate) fn notify_playlist_track_played( + &self, + playlist_id: &str, + cache_pk: &str, + qualifier: &str, + ) { + self.notify_playlist_event( + playlist_id, + PlaylistEventKind::TrackPlayed { + cache_pk: cache_pk.to_string(), + qualifier: qualifier.to_string(), + }, + ); + } + + fn notify_playlist_event(&self, id: &str, kind: PlaylistEventKind) { + let event = PlaylistEvent { + playlist_id: id.to_string(), + kind, + }; + let envelope = PlaylistEventEnvelope { + event: event.clone(), + timestamp: std::time::SystemTime::now(), + source_client: None, + }; + + let guard = self.inner.callbacks.read().unwrap(); + for cb in guard.values() { + cb(&event); + } + + // Diffusion via canal interne (ignoré si aucun abonné) + let _ = self.inner.event_tx.send(envelope); + } + + /// Ré-inscrit les abonnements cache pour tous les pk connus (utilisé au boot ou après enregistrement du cache audio). + async fn sync_cache_subscriptions(&self) { + let pks: Vec = { + let index = self.inner.track_index.read().unwrap(); + index.keys().cloned().collect() + }; + + if pks.is_empty() { + return; + } + + if let Ok(cache) = audio_cache() { + for pk in pks { + // Ne pas doubler les abonnements + let already = { + let subs = self.inner.cache_subscriptions.read().unwrap(); + subs.contains_key(&pk) + }; + if already { + continue; + } + + let manager = self.clone(); + let token = cache + .subscribe_broadcast(pk.clone(), move |event: &CacheBroadcastEvent| { + manager.handle_cache_broadcast(event); + let index = manager.inner.track_index.read().unwrap(); + index.contains_key(&event.pk) + }) + .await; + + self.inner + .cache_subscriptions + .write() + .unwrap() + .insert(pk, token); + } + } + } + + /// Réconcilie l'index pk→playlists et les souscriptions cache pour une playlist donnée. + pub(crate) async fn rebuild_track_index( + &self, + playlist_id: &str, + records: &[Arc], + ) { + // 1) Retirer la playlist de toutes les entrées + let mut removed_pks = Vec::new(); + { + let mut index = self.inner.track_index.write().unwrap(); + for (pk, playlists) in index.iter_mut() { + playlists.retain(|p| p != playlist_id); + if playlists.is_empty() { + removed_pks.push(pk.clone()); + } + } + for pk in &removed_pks { + index.remove(pk); + } + // 2) Ajouter les nouveaux records + for record in records { + let entry = index.entry(record.cache_pk.clone()).or_default(); + if !entry.iter().any(|p| p == playlist_id) { + entry.push(playlist_id.to_string()); + } + } + } + + // 3) Se désabonner des pk qui ne sont plus référencés + // Collecter les tokens à désinscrire sans bloquer pendant l'await + let removed_tokens: Vec = { + if removed_pks.is_empty() { + Vec::new() + } else { + let mut subs = self.inner.cache_subscriptions.write().unwrap(); + removed_pks + .into_iter() + .filter_map(|pk| subs.remove(&pk)) + .collect() + } + }; + + if !removed_tokens.is_empty() { + if let Ok(cache) = audio_cache() { + for token in removed_tokens { + cache.unsubscribe_broadcast(&token).await; + } + } + } + + // 4) S'abonner aux nouveaux pk sans souscription + let missing: Vec = { + let index = self.inner.track_index.read().unwrap(); + let subs = self.inner.cache_subscriptions.read().unwrap(); + index + .iter() + .filter_map(|(pk, playlists)| { + if playlists.contains(&playlist_id.to_string()) && !subs.contains_key(pk) { + Some(pk.clone()) + } else { + None + } + }) + .collect() + }; + + if !missing.is_empty() { + if let Ok(cache) = audio_cache() { + for pk in missing { + let manager = self.clone(); + let token = cache + .subscribe_broadcast(pk.clone(), move |event: &CacheBroadcastEvent| { + manager.handle_cache_broadcast(event); + // Garder l'abonnement tant que le pk est référencé + let index = manager.inner.track_index.read().unwrap(); + index.contains_key(&event.pk) + }) + .await; + self.inner + .cache_subscriptions + .write() + .unwrap() + .insert(pk, token); + } + } + } + } + + fn handle_cache_broadcast(&self, event: &CacheBroadcastEvent) { + let playlists = { + let index = self.inner.track_index.read().unwrap(); + index.get(&event.pk).cloned() + }; + + if let Some(playlists) = playlists { + for playlist_id in playlists { + self.notify_playlist_track_played(&playlist_id, &event.pk, &event.qualifier); + } + } + } + /// R�cup�re un write handle (cr�e �ph�m�re si n'existe pas) pub async fn get_write_handle(&self, id: String) -> Result { let mut playlists = self.inner.playlists.write().await; @@ -153,7 +402,7 @@ impl PlaylistManager { let playlists = self.inner.playlists.read().await; if let Some(playlist) = playlists.get(&id) { - // Playlist existe + // Playlist existe en mémoire if !playlist.persistent { return Err(crate::Error::PlaylistNotPersistent(id)); } @@ -168,7 +417,37 @@ impl PlaylistManager { drop(playlists); - // N'existe pas, cr�er persistent + // Pas en mémoire, essayer de charger depuis la DB + if let Some(persistence) = &self.inner.persistence { + if let Some((title, config, tracks)) = persistence.load_playlist(&id).await? { + // Reconstruire la playlist + let mut playlists = self.inner.playlists.write().await; + + let playlist = Arc::new(Playlist::new(id.clone(), title.clone(), config, true)); + + // Restaurer les tracks + { + let mut core = playlist.core.write().await; + core.tracks = tracks; + let snapshot = core.snapshot(); + drop(core); + self.rebuild_track_index(&id, &snapshot).await; + } + + // Acquérir le write lock + let write_token = playlist + .acquire_write_lock() + .await + .map_err(|_| crate::Error::WriteLockHeld(id.clone()))?; + + playlists.insert(id.clone(), playlist.clone()); + drop(playlists); + + return Ok(WriteHandle::new(playlist, write_token)); + } + } + + // N'existe pas en DB, créer une nouvelle playlist persistante self.create_persistent_playlist(id).await } @@ -197,6 +476,9 @@ impl PlaylistManager { { let mut core = playlist.core.write().await; core.tracks = tracks; + let snapshot = core.snapshot(); + drop(core); + self.rebuild_track_index(id, &snapshot).await; } playlists.insert(id.to_string(), playlist.clone()); @@ -219,6 +501,9 @@ impl PlaylistManager { drop(playlists); + // Nettoyer l'index et les souscriptions + self.rebuild_track_index(id, &[]).await; + // Supprimer de la DB if let Some(persistence) = &self.inner.persistence { persistence.delete_playlist(id).await?; @@ -282,6 +567,11 @@ impl PlaylistManager { } } +/// Souscrit au flux d'évènements playlist (Updated / TrackPlayed) avec timestamp. +pub fn subscribe_events() -> broadcast::Receiver { + PlaylistManager::get().inner.event_tx.subscribe() +} + /// Helper pour supprimer une playlist (appel� depuis WriteHandle) pub(crate) async fn delete_playlist_internal(id: &str) -> Result<()> { PlaylistManager::get().delete_playlist(id).await @@ -304,6 +594,14 @@ pub(crate) async fn delete_playlist_internal(id: &str) -> Result<()> { /// ``` pub fn register_audio_cache(cache: Arc) { let _ = AUDIO_CACHE.set(cache); + + // Si le PlaylistManager est déjà initialisé, synchroniser les abonnements + if let Some(manager) = PLAYLIST_MANAGER.get() { + let manager = manager.clone(); + tokio::spawn(async move { + manager.sync_cache_subscriptions().await; + }); + } } /// Helper pour acc�der au cache audio diff --git a/pmoplaylist/src/openapi.rs b/pmoplaylist/src/openapi.rs new file mode 100644 index 00000000..b46b6df9 --- /dev/null +++ b/pmoplaylist/src/openapi.rs @@ -0,0 +1,44 @@ +//! Documentation OpenAPI pour les endpoints playlists (SSE évènements). + +#[cfg(feature = "pmoserver")] +use utoipa::OpenApi; + +/// Documentation OpenAPI pour l'API playlist (flux SSE). +#[derive(OpenApi)] +#[openapi( + paths( + crate::sse::playlist_events_sse, + ), + components( + schemas( + crate::sse::EventPayload, + crate::sse::EventsQuery, + ) + ), + tags( + (name = "playlists", description = "Suivi des playlists et des morceaux joués") + ), + info( + title = "PMO Playlist API", + version = "0.1.0", + description = r#" +# Flux d'évènements playlists + +Endpoint SSE pour suivre : +- les modifications de playlists (updated) +- les lectures de morceaux appartenant aux playlists (track_played) + +Payload JSON par évènement : +- `playlist_id` : identifiant de la playlist +- `kind` : `updated` ou `track_played` +- `cache_pk` : pk du morceau (si track_played) +- `qualifier` : qualifier de diffusion (orig/stream/etc.) +- `timestamp` : horodatage UTC +- `source_client` : client à l'origine (optionnel) + "#, + license( + name = "MIT", + ), + ) +)] +pub struct ApiDoc; diff --git a/pmoplaylist/src/persistence/mod.rs b/pmoplaylist/src/persistence/mod.rs index 5d53b8ba..3d9c0891 100644 --- a/pmoplaylist/src/persistence/mod.rs +++ b/pmoplaylist/src/persistence/mod.rs @@ -245,4 +245,85 @@ impl PersistenceManager { })?; Ok(()) } + + /// Consolide la base de données des playlists + /// + /// Cette fonction nettoie les incohérences: + /// - Active les contraintes de clés étrangères + /// - Supprime les tracks orphelins (référençant des playlists inexistantes) + /// - Nettoie les tracks avec TTL expirés + pub async fn consolidate(&self) -> Result<()> { + let conn = self.conn.lock().unwrap(); + + // Activer les contraintes de clés étrangères (désactivées par défaut dans SQLite) + conn.execute("PRAGMA foreign_keys = ON", []).map_err(|e| { + crate::Error::PersistenceError(format!("Failed to enable foreign keys: {}", e)) + })?; + + // Vérifier l'intégrité des clés étrangères + let mut stmt = conn.prepare("PRAGMA foreign_key_check").map_err(|e| { + crate::Error::PersistenceError(format!("Failed to prepare FK check: {}", e)) + })?; + + let violations: Vec<(String, i64, String, i64)> = stmt + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) + .map_err(|e| { + crate::Error::PersistenceError(format!("Failed to check foreign keys: {}", e)) + })? + .collect::, _>>() + .map_err(|e| { + crate::Error::PersistenceError(format!("Failed to read FK violations: {}", e)) + })?; + + if !violations.is_empty() { + tracing::warn!( + "Found {} foreign key violations, cleaning up orphaned tracks", + violations.len() + ); + + // Supprimer les tracks orphelins (ceux qui référencent des playlists inexistantes) + let deleted = conn + .execute( + "DELETE FROM tracks WHERE playlist_id NOT IN (SELECT id FROM playlists)", + [], + ) + .map_err(|e| { + crate::Error::PersistenceError(format!( + "Failed to delete orphaned tracks: {}", + e + )) + })?; + + if deleted > 0 { + tracing::info!("Removed {} orphaned tracks during consolidation", deleted); + } + } + + // Nettoyer les tracks avec TTL expirés + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let deleted_expired = conn + .execute( + "DELETE FROM tracks WHERE ttl_secs IS NOT NULL AND (added_at + ttl_secs) < ?1", + params![now], + ) + .map_err(|e| { + crate::Error::PersistenceError(format!("Failed to delete expired tracks: {}", e)) + })?; + + if deleted_expired > 0 { + tracing::info!( + "Removed {} expired tracks during consolidation", + deleted_expired + ); + } + + tracing::info!("Playlist database consolidation completed successfully"); + Ok(()) + } } diff --git a/pmoplaylist/src/sse.rs b/pmoplaylist/src/sse.rs new file mode 100644 index 00000000..b4492a6d --- /dev/null +++ b/pmoplaylist/src/sse.rs @@ -0,0 +1,89 @@ +//! SSE pour suivre les évènements de playlists (updates + morceaux joués). +//! +//! Route type : `GET /api/playlists/events?playlist_id=foo` + +use crate::{subscribe_events, PlaylistEventKind}; +#[cfg(feature = "pmoserver")] +use async_stream::stream; +use axum::{ + extract::Query, + response::sse::{Event, KeepAlive, Sse}, + response::IntoResponse, + Router, +}; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "pmoserver")] +use tokio_stream::StreamExt; + +#[derive(Debug, Default, Deserialize)] +#[cfg_attr(feature = "pmoserver", derive(utoipa::IntoParams, utoipa::ToSchema))] +pub struct EventsQuery { + /// Filtrer sur une playlist précise (optionnel). + #[serde(default)] + pub playlist_id: Option, +} + +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "pmoserver", derive(utoipa::ToSchema))] +pub struct EventPayload { + pub playlist_id: String, + pub kind: String, + pub cache_pk: Option, + pub qualifier: Option, + pub timestamp: chrono::DateTime, + pub source_client: Option, +} + +/// Handler SSE : diffuse les évènements playlist enrichis. +#[utoipa::path( + get, + path = "/api/playlists/events", + tag = "playlists", + params(EventsQuery), + responses( + (status = 200, description = "Flux SSE des évènements playlists (updated, track_played)", content_type = "text/event-stream") + ) +)] +pub async fn playlist_events_sse(Query(params): Query) -> impl IntoResponse { + let mut rx = subscribe_events(); + + let stream = stream! { + while let Ok(envelope) = rx.recv().await { + if let Some(filter) = ¶ms.playlist_id { + if &envelope.event.playlist_id != filter { + continue; + } + } + + let (kind, cache_pk, qualifier) = match &envelope.event.kind { + PlaylistEventKind::Updated => ("updated", None, None), + PlaylistEventKind::TrackPlayed { cache_pk, qualifier } => { + ("track_played", Some(cache_pk.as_str()), Some(qualifier.as_str())) + } + }; + + let ts = chrono::DateTime::::from(envelope.timestamp); + let payload = EventPayload { + playlist_id: envelope.event.playlist_id.clone(), + kind: kind.to_string(), + cache_pk: cache_pk.map(|s| s.to_string()), + qualifier: qualifier.map(|s| s.to_string()), + timestamp: ts, + source_client: envelope.source_client.clone(), + }; + + if let Ok(json) = serde_json::to_string(&payload) { + yield Ok::<_, axum::Error>(Event::default().event("playlist").data(json)); + } + } + }; + + Sse::new(stream).keep_alive(KeepAlive::default()) +} + +/// Router prêt à être monté (ex: `/api/playlists/events`). +pub fn playlist_events_router() -> Router { + use axum::routing::get; + + Router::new().route("/events", get(playlist_events_sse)) +} diff --git a/pmoplaylist/src/track.rs b/pmoplaylist/src/track.rs index 94bd493c..dad6beb1 100755 --- a/pmoplaylist/src/track.rs +++ b/pmoplaylist/src/track.rs @@ -63,32 +63,6 @@ impl PlaylistTrack { /// Récupère les métadonnées audio complètes depuis le cache /// - /// **Important** : Cette méthode récupère TOUTES les métadonnées de la base de données. - /// Si vous n'avez besoin que d'un seul champ (ex: titre), utilisez plutôt les méthodes - /// légères `title()`, `artist()`, etc. qui utilisent `get_a_metadata()`. - /// - /// # Exemples - /// - /// ```no_run - /// # use pmoplaylist::*; - /// # async fn example(track: PlaylistTrack) -> Result<()> { - /// // ✅ BON : Si vous avez besoin de plusieurs champs - /// let metadata = track.metadata().await?; - /// let title = metadata.title.as_deref().unwrap_or("Unknown"); - /// let artist = metadata.artist.as_deref().unwrap_or("Unknown"); - /// let album = metadata.album.as_deref().unwrap_or("Unknown"); - /// - /// // ✅ MIEUX : Si vous n'avez besoin que d'un seul champ (plus léger) - /// let title = track.title().await?.unwrap_or_else(|| "Unknown".to_string()); - /// # Ok(()) - /// # } - /// ``` - pub async fn metadata(&self) -> Result { - let cache = crate::manager::audio_cache()?; - pmoaudiocache::get_metadata(&*cache, &self.cache_pk) - .map_err(|e| crate::Error::CacheError(e.to_string())) - } - /// Retourne une instance de TrackMetadata pour ce morceau /// /// Cette méthode fournit un accès unifié aux métadonnées via le trait `TrackMetadata`. diff --git a/pmoserver/Cargo.toml b/pmoserver/Cargo.toml index f41daba8..27a34529 100644 --- a/pmoserver/Cargo.toml +++ b/pmoserver/Cargo.toml @@ -10,6 +10,7 @@ anyhow = "1.0" axum = "0.8.4" tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time", "signal"] } tokio-stream = "0.1" +tokio-util = "0.7" futures-util = "0.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/pmoserver/src/logs/mod.rs b/pmoserver/src/logs/mod.rs index 80a4b3e2..cfb82fab 100644 --- a/pmoserver/src/logs/mod.rs +++ b/pmoserver/src/logs/mod.rs @@ -23,10 +23,7 @@ use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; use tracing::Level; use tracing_subscriber::{ - Registry, - filter::LevelFilter, - layer::SubscriberExt, - reload, + Registry, filter::EnvFilter, filter::LevelFilter, layer::SubscriberExt, reload, util::SubscriberInitExt, }; @@ -45,11 +42,11 @@ pub struct LogState { buffer: Arc>>, tx: broadcast::Sender, max_level: Arc>, - reload_handle: Arc>>, + reload_handle: Arc>>, } impl LogState { - pub fn new(capacity: usize, reload_handle: reload::Handle) -> Self { + pub fn new(capacity: usize, reload_handle: reload::Handle) -> Self { Self { buffer: Arc::new(RwLock::new(VecDeque::with_capacity(capacity))), tx: broadcast::channel(1000).0, @@ -61,20 +58,23 @@ impl LogState { pub fn set_max_level(&self, level: Level) { *self.max_level.write().unwrap() = level; - // Convertir Level en LevelFilter - let level_filter = level_to_levelfilter(level); + // Construire un filtre simple à partir du niveau global + let filter = + EnvFilter::try_new(level_to_string(level)).unwrap_or_else(|_| EnvFilter::new("trace")); // Recharger le filtre dynamiquement - if let Err(e) = self.reload_handle.write().unwrap().reload(level_filter) { + if let Err(e) = self.reload_handle.write().unwrap().reload(filter) { eprintln!("❌ Failed to reload log level filter: {}", e); } else { - eprintln!( - "✅ Log level filter reloaded successfully to: {:?}", - level_filter - ); + eprintln!("✅ Log level filter reloaded successfully to: {:?}", level); } } + /// Définit le niveau initial avant tout rechargement dynamique + pub fn set_initial_level(&self, level: Level) { + *self.max_level.write().unwrap() = level; + } + pub fn get_max_level(&self) -> Level { *self.max_level.read().unwrap() } @@ -266,17 +266,57 @@ impl Default for LoggingOptions { /// ``` pub fn init_logging() -> LogState { let config = get_config(); - // Créer un filtre rechargeable qui commence à TRACE + // Créer un filtre rechargeable qui commence au niveau déterminé par + // RUST_LOG (prioritaire) ou la configuration. - let log_level = match config.get_log_min_level() { - Ok(l) => match string_to_level(&l) { - Some(lev) => level_to_levelfilter(lev), - None => LevelFilter::TRACE, - }, - Err(_) => LevelFilter::TRACE, + let (env_filter, initial_level, level_source) = match std::env::var("RUST_LOG") { + Ok(value) => { + let trimmed = value.trim(); + if let Some(level) = string_to_level(trimmed) { + let filter = + EnvFilter::try_new(trimmed).unwrap_or_else(|_| EnvFilter::new("trace")); + (filter, level, format!("RUST_LOG ({})", trimmed)) + } else { + match EnvFilter::try_new(trimmed) { + Ok(filter) => { + let level_hint = filter + .max_level_hint() + .and_then(levelfilter_to_level) + .unwrap_or(Level::TRACE); + (filter, level_hint, format!("RUST_LOG ({})", trimmed)) + } + Err(e) => { + eprintln!( + "⚠️ Invalid RUST_LOG value '{}': {}, falling back to configuration", + value, e + ); + let cfg_level = config + .get_log_min_level() + .ok() + .and_then(|cfg| string_to_level(cfg.trim())) + .unwrap_or(Level::TRACE); + let filter = EnvFilter::new(level_to_string(cfg_level)); + (filter, cfg_level, "config".to_string()) + } + } + } + } + Err(_) => { + let cfg_level = config + .get_log_min_level() + .ok() + .and_then(|cfg| string_to_level(cfg.trim())) + .unwrap_or(Level::TRACE); + let filter = EnvFilter::new(level_to_string(cfg_level)); + (filter, cfg_level, "config".to_string()) + } }; + eprintln!( + "ℹ️ Initial log level set to {:?} (source: {})", + initial_level, level_source + ); - let (filter, reload_handle) = reload::Layer::new(log_level); + let (filter_layer, reload_handle) = reload::Layer::new(env_filter); let buffer_capacity = match config.get_log_cache_size() { Ok(c) => c, @@ -285,11 +325,12 @@ pub fn init_logging() -> LogState { // Créer le LogState avec le handle de rechargement let log_state = LogState::new(buffer_capacity, reload_handle); + log_state.set_initial_level(initial_level); // Construire le subscriber avec le filtre rechargeable AVANT le SseLayer // L'ordre est important : le filtre doit être appliqué en premier let subscriber = Registry::default() - .with(filter) + .with(filter_layer) .with(SseLayer::new(log_state.clone())); let enable_console = match config.get_log_enable_console() { @@ -427,13 +468,14 @@ fn level_to_string(level: Level) -> String { .to_string() } -fn level_to_levelfilter(level: Level) -> LevelFilter { - match level { - Level::ERROR => LevelFilter::ERROR, - Level::WARN => LevelFilter::WARN, - Level::INFO => LevelFilter::INFO, - Level::DEBUG => LevelFilter::DEBUG, - Level::TRACE => LevelFilter::TRACE, +fn levelfilter_to_level(filter: LevelFilter) -> Option { + match filter { + LevelFilter::ERROR => Some(Level::ERROR), + LevelFilter::WARN => Some(Level::WARN), + LevelFilter::INFO => Some(Level::INFO), + LevelFilter::DEBUG => Some(Level::DEBUG), + LevelFilter::TRACE => Some(Level::TRACE), + _ => None, } } diff --git a/pmoserver/src/server.rs b/pmoserver/src/server.rs index 73c3b033..859d2cbc 100644 --- a/pmoserver/src/server.rs +++ b/pmoserver/src/server.rs @@ -17,7 +17,7 @@ use crate::logs::{LogState, init_logging, log_dump, log_sse}; use axum::extract::State; use axum::handler::Handler; use axum::response::Redirect; -use axum::routing::{get, post}; +use axum::routing::{any, get, post}; use axum::{Json, Router}; use axum_embed::ServeEmbed; use pmoconfig::get_config; @@ -27,6 +27,7 @@ use std::future::Future; use std::net::SocketAddr; use std::sync::Arc; use tokio::{signal, sync::RwLock, task::JoinHandle}; +use tokio_util::sync::CancellationToken; use tracing::info; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; @@ -92,6 +93,7 @@ pub struct Server { join_handle: Option>, log_state: Option, api_registry: ApiRegistryState, + shutdown_token: CancellationToken, } impl Server { @@ -126,6 +128,7 @@ impl Server { join_handle: None, log_state: None, api_registry, + shutdown_token: CancellationToken::new(), } } @@ -136,6 +139,14 @@ impl Server { Self::new("PMO-Music-Server", url, port) } + /// Retourne une copie du token d'arrêt gracieux + /// + /// Ce token peut être donné aux composants qui ont besoin de savoir + /// quand le serveur s'arrête (threads, tâches longues, etc.) + pub fn shutdown_token(&self) -> CancellationToken { + self.shutdown_token.clone() + } + /// Ajoute une route JSON dynamique /// /// Crée un endpoint qui retourne du JSON. La closure fournie sera appelée @@ -240,6 +251,25 @@ impl Server { }; } + /// Ajoute un handler qui accepte tous les verbes HTTP (ANY) avec état + pub async fn add_any_handler_with_state(&mut self, path: &str, handler: H, state: S) + where + H: Handler + Clone + 'static, + T: 'static, + S: Clone + Send + Sync + 'static, + { + let route = Router::new() + .route("/", any(handler.clone())) + .with_state(state.clone()); + + let mut r = self.router.write().await; + *r = if path == "/" { + std::mem::take(&mut *r).merge(route) + } else { + std::mem::take(&mut *r).nest(path, route) + }; + } + /// Ajoute un répertoire statique pub async fn add_dir(&mut self, path: &str) where @@ -513,22 +543,43 @@ impl Server { ); let router = self.router.clone(); + + // Créer un channel pour signaler l'arrêt gracieux + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + // Tâche qui écoute Ctrl+C et signale l'arrêt + let shutdown_token_for_signal = self.shutdown_token.clone(); + let shutdown_signal = tokio::spawn(async move { + signal::ctrl_c().await.expect("failed to listen for ctrl_c"); + info!("Ctrl+C reçu, arrêt gracieux"); + // Déclencher le token d'arrêt pour tous les composants + shutdown_token_for_signal.cancel(); + // Envoyer le signal d'arrêt au serveur HTTP (ignorer l'erreur si le receiver a déjà été drop) + let _ = shutdown_tx.send(()); + }); + + // Tâche serveur avec arrêt gracieux let server_task = tokio::spawn(async move { let r = router.read().await.clone(); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - axum::serve(listener, r.into_make_service()).await.unwrap(); - }); - let shutdown_task = tokio::spawn(async move { - signal::ctrl_c().await.expect("failed to listen for ctrl_c"); - info!("Ctrl+C reçu, arrêt gracieux"); + // Serveur avec arrêt gracieux + axum::serve(listener, r.into_make_service()) + .with_graceful_shutdown(async move { + // Attendre le signal d'arrêt + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + + info!("Serveur HTTP arrêté proprement"); }); self.join_handle = Some(tokio::spawn(async move { - tokio::select! { - _ = server_task => {}, - _ = shutdown_task => {}, - } + // Attendre que le serveur se termine (après réception du signal) + let _ = server_task.await; + // Nettoyer la tâche de signal + let _ = shutdown_signal.await; })); } diff --git a/pmosource/Cargo.toml b/pmosource/Cargo.toml index c45ea91c..5baf3e7d 100644 --- a/pmosource/Cargo.toml +++ b/pmosource/Cargo.toml @@ -44,8 +44,10 @@ axum = { version = "0.8", optional = true } utoipa = { version = "5.3", optional = true } tracing = { version = "0.1", optional = true } lazy_static = { version = "1.4", optional = true } +tokio-stream = { version = "0.1", optional = true, features = ["time"] } +futures = { version = "0.3", optional = true } [features] default = ["cache"] cache = ["pmoaudiocache", "pmocovers"] -server = ["pmoserver", "pmoconfig", "pmoupnp", "axum", "utoipa", "tracing", "lazy_static"] +server = ["pmoserver", "pmoconfig", "pmoupnp", "axum", "utoipa", "tracing", "lazy_static", "tokio-stream", "futures"] diff --git a/pmosource/src/api.rs b/pmosource/src/api.rs index 68202dd8..9beda562 100644 --- a/pmosource/src/api.rs +++ b/pmosource/src/api.rs @@ -855,6 +855,120 @@ async fn resolve_source_uri( } } +/// Récupère les métadonnées détaillées d'un item +#[cfg(feature = "server")] +#[utoipa::path( + get, + path = "/{id}/item", + params( + ("id" = String, Path, description = "ID de la source"), + ObjectQuery + ), + responses( + (status = 200, description = "Métadonnées de l'item", body = BrowseItemInfo), + (status = 404, description = "Source ou objet introuvable", body = ErrorResponse), + (status = 501, description = "Fonctionnalité non supportée", body = ErrorResponse), + (status = 500, description = "Erreur lors de la récupération de l'item", body = ErrorResponse), + ), + tag = "sources" +)] +async fn get_source_item( + Path(id): Path, + Query(params): Query, +) -> impl IntoResponse { + match get_source(&id).await { + Some(source) => match source.get_item(¶ms.object_id).await { + Ok(item) => { + let item_info = BrowseItemInfo::from(&item); + (StatusCode::OK, Json(item_info)).into_response() + } + Err(MusicSourceError::ObjectNotFound(_)) => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Item not found".to_string(), + }), + ) + .into_response(), + Err(MusicSourceError::NotSupported(msg)) => ( + StatusCode::NOT_IMPLEMENTED, + Json(ErrorResponse { error: msg }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to get item: {}", e), + }), + ) + .into_response(), + }, + None => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Source '{}' not found", id), + }), + ) + .into_response(), + } +} + +/// Stream les métadonnées d'un item en temps réel via Server-Sent Events +#[cfg(feature = "server")] +async fn stream_source_item_metadata( + Path(id): Path, + Query(params): Query, +) -> impl IntoResponse { + use axum::response::sse::{Event, KeepAlive, Sse}; + use futures::stream::{self, Stream}; + use std::convert::Infallible; + use std::time::Duration; + use tokio_stream::StreamExt as _; + + match get_source(&id).await { + Some(source) => { + let object_id = params.object_id.clone(); + + // Create a stream that fetches metadata frequently for near-realtime updates + let stream = stream::repeat_with(move || { + let source = source.clone(); + let object_id = object_id.clone(); + async move { + match source.get_item(&object_id).await { + Ok(item) => { + let item_info = BrowseItemInfo::from(&item); + match serde_json::to_string(&item_info) { + Ok(json) => Ok(Event::default().data(json)), + Err(e) => Err(format!("Failed to serialize metadata: {}", e)), + } + } + Err(e) => Err(format!("Failed to get item: {}", e)), + } + } + }) + .then(|fut| fut) + .throttle(Duration::from_millis(500)) + .filter_map(|result| match result { + Ok(event) => Some(Ok::<_, Infallible>(event)), + Err(e) => { + eprintln!("Error fetching metadata: {}", e); + None + } + }); + + Sse::new(stream) + .keep_alive(KeepAlive::default()) + .into_response() + } + None => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Source '{}' not found", id), + }), + ) + .into_response(), + } +} + /// Récupère le statut du cache pour un objet #[cfg(feature = "server")] #[utoipa::path( @@ -1096,6 +1210,8 @@ pub fn create_sources_router() -> Router { .route("/{id}/root", get(get_source_root)) .route("/{id}/browse", get(browse_source)) .route("/{id}/image", get(get_source_image)) + .route("/{id}/item", get(get_source_item)) + .route("/{id}/item/stream", get(stream_source_item_metadata)) .route("/{id}/resolve", get(resolve_source_uri)) .route("/{id}/cache/status", get(get_source_cache_status)) .route("/{id}/cache", post(request_source_cache)) @@ -1118,6 +1234,7 @@ pub fn create_sources_router() -> Router { get_source_root, browse_source, get_source_image, + get_source_item, resolve_source_uri, get_source_cache_status, request_source_cache, diff --git a/pmosource/src/lib.rs b/pmosource/src/lib.rs index 3e0cfa01..12b21471 100644 --- a/pmosource/src/lib.rs +++ b/pmosource/src/lib.rs @@ -419,6 +419,38 @@ pub trait MusicSource: Debug + Send + Sync { /// ``` async fn browse(&self, object_id: &str) -> Result; + /// Get detailed metadata for a specific item + /// + /// Returns the full metadata of an item by its object_id. + /// This is useful for refreshing metadata during playback or + /// getting details of a specific track without browsing its parent. + /// + /// # Arguments + /// + /// * `object_id` - The ID of the item to retrieve + /// + /// # Returns + /// + /// The complete `Item` with all metadata. + /// + /// # Errors + /// + /// Returns `MusicSourceError::ObjectNotFound` if the item doesn't exist. + /// + /// # Examples + /// + /// ```ignore + /// let item = source.get_item("track-123").await?; + /// println!("Now playing: {} by {}", item.title, item.artist.unwrap_or_default()); + /// ``` + async fn get_item(&self, object_id: &str) -> Result { + // Default implementation: try to find it in parent's browse result + // This is inefficient and should be overridden by implementations + Err(MusicSourceError::NotSupported( + "get_item not implemented, override this method".to_string(), + )) + } + /// Resolve the actual URI for a track /// /// This method should return the URI that can be used to stream/download diff --git a/pmoupnp/Cargo.toml b/pmoupnp/Cargo.toml index 5891b8cf..9f41ffd1 100644 --- a/pmoupnp/Cargo.toml +++ b/pmoupnp/Cargo.toml @@ -10,6 +10,7 @@ pmoutils = { path = "../pmoutils" } pmoserver = { path = "../pmoserver" } pmocovers = { path = "../pmocovers", features = ["pmoserver"] } pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"] } +pmoplaylist = { path = "../pmoplaylist", optional = true, features = ["pmoserver"] } pmocache = { path = "../pmocache" } url = "2.5.7" @@ -23,7 +24,7 @@ axum = "0.8.4" tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -quick-xml = { version = "0.37.0", features = ["serialize"] } +quick-xml = { version = "0.37.5", features = ["serialize"] } chrono = { version = "0.4.42", features = ["serde"] } once_cell = "1.20" parking_lot = "0.12" @@ -33,3 +34,14 @@ bevy_reflect = "0.17.1" bevy_reflect_derive = "0.17.1" reqwest = "0.12.23" utoipa = { version = "5.3", features = ["axum_extras"] } +socket2 = "0.5" +get_if_addrs = "0.5" + +[features] +default = ["server"] +server = ["pmoplaylist"] +paradise = [] +qobuz = [] + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/pmoupnp/src/actions/arg_inst_set_methods.rs b/pmoupnp/src/actions/arg_inst_set_methods.rs index d2b07966..619bdf22 100644 --- a/pmoupnp/src/actions/arg_inst_set_methods.rs +++ b/pmoupnp/src/actions/arg_inst_set_methods.rs @@ -29,6 +29,7 @@ impl UpnpInstance for ArgInstanceSet { fn new(_: &ArgumentSet) -> Self { Self { objects: RwLock::new(HashMap::new()), + order: RwLock::new(Vec::new()), } } } diff --git a/pmoupnp/src/actions/arg_instance_methods.rs b/pmoupnp/src/actions/arg_instance_methods.rs index 73429929..ef355f9b 100644 --- a/pmoupnp/src/actions/arg_instance_methods.rs +++ b/pmoupnp/src/actions/arg_instance_methods.rs @@ -259,6 +259,7 @@ impl UpnpInstance for ActionInstanceSet { fn new(_: &ActionSet) -> Self { Self { objects: RwLock::new(HashMap::new()), + order: RwLock::new(Vec::new()), } } } diff --git a/pmoupnp/src/actions/arg_set_methods.rs b/pmoupnp/src/actions/arg_set_methods.rs index 74a60798..00ce44a5 100644 --- a/pmoupnp/src/actions/arg_set_methods.rs +++ b/pmoupnp/src/actions/arg_set_methods.rs @@ -1,7 +1,7 @@ use crate::UpnpModel; use crate::actions::ArgInstanceSet; use crate::{UpnpObject, actions::ArgumentSet}; -use xmltree::Element; +use xmltree::{Element, XMLNode}; impl UpnpObject for ArgumentSet { // Méthode pour convertir en XML (à implémenter avec une librairie XML) @@ -9,11 +9,8 @@ impl UpnpObject for ArgumentSet { let mut elem = Element::new("argumentList"); for arg in self.all() { - let arg_elem = arg.to_xml_element(); // toujours un contenant 1 ou 2 - - // Pour InOut, on ajoute tous les enfants du généré - for child in arg_elem.children { - elem.children.push(child); + for arg_elem in arg.to_xml_elements() { + elem.children.push(XMLNode::Element(arg_elem)); } } diff --git a/pmoupnp/src/actions/argument_methods.rs b/pmoupnp/src/actions/argument_methods.rs index c8263f5a..a21ebea7 100644 --- a/pmoupnp/src/actions/argument_methods.rs +++ b/pmoupnp/src/actions/argument_methods.rs @@ -16,31 +16,11 @@ impl UpnpTyped for Argument { impl UpnpObject for Argument { fn to_xml_element(&self) -> Element { - let mut parent = Element::new("argumentList"); - - if self.is_in() && self.is_out() { - // InOut → deux arguments - parent.children.push(XMLNode::Element(make_argument_elem( - self.get_name(), - "in", - self.state_variable().get_name(), - ))); - parent.children.push(XMLNode::Element(make_argument_elem( - self.get_name(), - "out", - self.state_variable().get_name(), - ))); - } else { - // Cas simple - let direction = if self.is_in() { "in" } else { "out" }; - parent.children.push(XMLNode::Element(make_argument_elem( - self.get_name(), - direction, - self.state_variable().get_name(), - ))); - } - - parent + // Compat: retourne le premier argument (utile si consommé isolément) + self.to_xml_elements() + .into_iter() + .next() + .unwrap_or_else(|| Element::new("argument")) } } @@ -91,6 +71,23 @@ impl Argument { pub fn is_out(&self) -> bool { self.is_out } + + /// Retourne les éléments XML (1 ou 2 si InOut) + pub fn to_xml_elements(&self) -> Vec { + if self.is_in() && self.is_out() { + vec![ + make_argument_elem(self.get_name(), "in", self.state_variable().get_name()), + make_argument_elem(self.get_name(), "out", self.state_variable().get_name()), + ] + } else { + let direction = if self.is_in() { "in" } else { "out" }; + vec![make_argument_elem( + self.get_name(), + direction, + self.state_variable().get_name(), + )] + } + } } /// Fabrique un complet avec ses sous-éléments diff --git a/pmoupnp/src/cache_registry.rs b/pmoupnp/src/cache_registry.rs index ee5a2840..913381ca 100644 --- a/pmoupnp/src/cache_registry.rs +++ b/pmoupnp/src/cache_registry.rs @@ -55,11 +55,10 @@ pub fn get_audio_cache() -> Option> { /// ``` pub fn build_cover_url(pk: &str, size: Option) -> anyhow::Result { // Récupérer l'URL de base depuis la variable d'environnement ou une config - let base_url = std::env::var("PMO_SERVER_URL") - .unwrap_or_else(|_| "http://localhost:8080".to_string()); + let base_url = + std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()); - let cache = get_cover_cache() - .ok_or_else(|| anyhow::anyhow!("No registered cover cache"))?; + let cache = get_cover_cache().ok_or_else(|| anyhow::anyhow!("No registered cover cache"))?; let param = match size { Some(size_) => Some(size_.to_string()), @@ -86,11 +85,10 @@ pub fn build_cover_url(pk: &str, size: Option) -> anyhow::Result /// ``` pub fn build_audio_url(pk: &str, param: Option<&str>) -> anyhow::Result { // Récupérer l'URL de base depuis la variable d'environnement ou une config - let base_url = std::env::var("PMO_SERVER_URL") - .unwrap_or_else(|_| "http://localhost:8080".to_string()); + let base_url = + std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()); - let cache = get_audio_cache() - .ok_or_else(|| anyhow::anyhow!("No registered audio cache"))?; + let cache = get_audio_cache().ok_or_else(|| anyhow::anyhow!("No registered audio cache"))?; let route = cache.route_for(pk, param); Ok(format!("{}{}", base_url, route)) diff --git a/pmoupnp/src/devices/device_instance.rs b/pmoupnp/src/devices/device_instance.rs index e8b9b88c..5e626853 100644 --- a/pmoupnp/src/devices/device_instance.rs +++ b/pmoupnp/src/devices/device_instance.rs @@ -7,6 +7,7 @@ use axum::{ use std::{ collections::HashMap, sync::{Arc, RwLock}, + time::Duration, }; use tracing::info; use xmltree::{Element, EmitterConfig, XMLNode}; @@ -17,6 +18,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 +307,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 diff --git a/pmoupnp/src/lib.rs b/pmoupnp/src/lib.rs index 67af0ac0..8340a145 100644 --- a/pmoupnp/src/lib.rs +++ b/pmoupnp/src/lib.rs @@ -32,6 +32,7 @@ pub struct UpnpObjectType { #[derive(Debug)] pub struct UpnpObjectSet { objects: RwLock>>, + order: RwLock>, } #[derive(Debug)] diff --git a/pmoupnp/src/object_set.rs b/pmoupnp/src/object_set.rs index 79eb6a9d..3b22d1f6 100644 --- a/pmoupnp/src/object_set.rs +++ b/pmoupnp/src/object_set.rs @@ -12,6 +12,7 @@ use crate::{UpnpDeepClone, UpnpObjectSet, UpnpObjectSetError, UpnpTypedObject}; impl UpnpDeepClone for UpnpObjectSet { fn deep_clone(&self) -> Self { let guard = self.objects.read().unwrap(); + let order_guard = self.order.read().unwrap(); let cloned_map: HashMap> = guard .iter() @@ -20,6 +21,7 @@ impl UpnpDeepClone for UpnpObjectSet { Self { objects: RwLock::new(cloned_map), + order: RwLock::new(order_guard.clone()), } } } @@ -39,9 +41,11 @@ impl UpnpDeepClone for UpnpObjectSet { impl Clone for UpnpObjectSet { fn clone(&self) -> Self { let guard = self.objects.read().unwrap(); + let order_guard = self.order.read().unwrap(); Self { objects: RwLock::new(guard.clone()), + order: RwLock::new(order_guard.clone()), } } } @@ -57,6 +61,7 @@ impl UpnpObjectSet { pub fn new() -> Self { Self { objects: RwLock::new(HashMap::new()), + order: RwLock::new(Vec::new()), } } @@ -80,13 +85,15 @@ impl UpnpObjectSet { /// ``` pub fn insert(&mut self, object: Arc) -> Result<(), UpnpObjectSetError> { let mut guard = self.objects.write().unwrap(); + let mut order_guard = self.order.write().unwrap(); let key = object.get_name().to_string(); if guard.contains_key(&key) { return Err(UpnpObjectSetError::AlreadyExists(key)); } - guard.insert(key, object); + guard.insert(key.clone(), object); + order_guard.push(key); Ok(()) } @@ -110,8 +117,13 @@ impl UpnpObjectSet { /// ``` pub fn insert_or_replace(&mut self, object: Arc) { let mut guard = self.objects.write().unwrap(); + let mut order_guard = self.order.write().unwrap(); let key: String = object.get_name().to_string(); + if !guard.contains_key(&key) { + order_guard.push(key.clone()); + } + guard.insert(key, object); } @@ -192,6 +204,11 @@ impl UpnpObjectSet { /// appeler cette méthode simultanément sans blocage. pub fn all(&self) -> Vec> { let guard = self.objects.read().unwrap(); - guard.values().cloned().collect() + let order_guard = self.order.read().unwrap(); + + order_guard + .iter() + .filter_map(|k| guard.get(k).cloned()) + .collect() } } diff --git a/pmoupnp/src/services/service_instance.rs b/pmoupnp/src/services/service_instance.rs index 6a703a4f..efc8db11 100644 --- a/pmoupnp/src/services/service_instance.rs +++ b/pmoupnp/src/services/service_instance.rs @@ -579,10 +579,10 @@ impl ServiceInstance { .add_post_handler_with_state(&self.control_route(), control_handler, instance_control) .await; - // Handler événements + // Handler événements (SUBSCRIBE/UNSUBSCRIBE sont des verbes spécifiques, pas GET) let instance_event = self.clone(); server - .add_handler_with_state(&self.event_route(), event_sub_handler, instance_event) + .add_any_handler_with_state(&self.event_route(), event_sub_handler, instance_event) .await; Ok(()) @@ -626,16 +626,16 @@ impl ServiceInstance { elem.children.push(XMLNode::Element(spec)); - // actionList - if !self.actions.all().is_empty() { + // actionList (depuis le modèle) + if !self.model.actions.all().is_empty() { elem.children - .push(XMLNode::Element(self.actions.to_xml_element())); + .push(XMLNode::Element(self.model.actions.to_xml_element())); } - // serviceStateTable - if !self.statevariables.all().is_empty() { + // serviceStateTable (depuis le modèle) + if !self.model.state_table.all().is_empty() { elem.children - .push(XMLNode::Element(self.statevariables.to_xml_element())); + .push(XMLNode::Element(self.model.state_table.to_xml_element())); } elem diff --git a/pmoupnp/src/services/service_methods.rs b/pmoupnp/src/services/service_methods.rs index 3d3e5374..6595260c 100644 --- a/pmoupnp/src/services/service_methods.rs +++ b/pmoupnp/src/services/service_methods.rs @@ -60,7 +60,9 @@ impl UpnpObject for Service { // eventSubURL let mut event_sub_url = Element::new("eventSubURL"); - event_sub_url.children.push(XMLNode::Text(self.event_route())); + event_sub_url + .children + .push(XMLNode::Text(self.event_route())); elem.children.push(XMLNode::Element(event_sub_url)); elem diff --git a/pmoupnp/src/soap/builder.rs b/pmoupnp/src/soap/builder.rs index 6c760f6a..5eb1a62e 100644 --- a/pmoupnp/src/soap/builder.rs +++ b/pmoupnp/src/soap/builder.rs @@ -2,6 +2,33 @@ use xmltree::{Element, XMLNode}; +fn build_soap_envelope_with_body(body_child: Element) -> Result { + // Body + let mut body = Element::new("s:Body"); + body.children.push(XMLNode::Element(body_child)); + + // Envelope + let mut envelope = Element::new("s:Envelope"); + envelope.attributes.insert( + "xmlns:s".to_string(), + "http://schemas.xmlsoap.org/soap/envelope/".to_string(), + ); + envelope.attributes.insert( + "s:encodingStyle".to_string(), + "http://schemas.xmlsoap.org/soap/encoding/".to_string(), + ); + envelope.children.push(XMLNode::Element(body)); + + let mut buf = Vec::new(); + let config = xmltree::EmitterConfig::new() + .write_document_declaration(true) + .perform_indent(true) + .indent_string(" "); + envelope.write_with_config(&mut buf, config)?; + + Ok(String::from_utf8(buf).unwrap()) +} + /// Construit une réponse SOAP UPnP /// /// # Arguments @@ -18,46 +45,39 @@ pub fn build_soap_response( action: &str, values: Vec<(String, String)>, ) -> Result { - // Construire l'élément de réponse - // Format: let response_name = format!("u:{}Response", action); let mut response_elem = Element::new(&response_name); response_elem .attributes .insert("xmlns:u".to_string(), service_urn.to_string()); - // Ajouter les valeurs de retour for (key, value) in values { let mut child = Element::new(&key); child.children.push(XMLNode::Text(value)); response_elem.children.push(XMLNode::Element(child)); } - // Construire le Body - let mut body = Element::new("s:Body"); - body.children.push(XMLNode::Element(response_elem)); + build_soap_envelope_with_body(response_elem) +} - // Construire l'Envelope - let mut envelope = Element::new("s:Envelope"); - envelope.attributes.insert( - "xmlns:s".to_string(), - "http://schemas.xmlsoap.org/soap/envelope/".to_string(), - ); - envelope.attributes.insert( - "s:encodingStyle".to_string(), - "http://schemas.xmlsoap.org/soap/encoding/".to_string(), - ); - envelope.children.push(XMLNode::Element(body)); +pub fn build_soap_request( + service_urn: &str, + action: &str, + args: &[(&str, &str)], +) -> Result { + let request_name = format!("u:{}", action); + let mut request_elem = Element::new(&request_name); + request_elem + .attributes + .insert("xmlns:u".to_string(), service_urn.to_string()); - // Sérialiser en XML - let mut buf = Vec::new(); - let config = xmltree::EmitterConfig::new() - .write_document_declaration(true) - .perform_indent(true) - .indent_string(" "); - envelope.write_with_config(&mut buf, config)?; + for (name, value) in args { + let mut child = Element::new(*name); + child.children.push(XMLNode::Text((*value).to_string())); + request_elem.children.push(XMLNode::Element(child)); + } - Ok(String::from_utf8(buf).unwrap()) + build_soap_envelope_with_body(request_elem) } #[cfg(test)] diff --git a/pmoupnp/src/soap/mod.rs b/pmoupnp/src/soap/mod.rs index c2f7a63f..91c14009 100644 --- a/pmoupnp/src/soap/mod.rs +++ b/pmoupnp/src/soap/mod.rs @@ -53,10 +53,10 @@ mod envelope; mod fault; mod parser; -pub use builder::build_soap_response; +pub use builder::{build_soap_request, build_soap_response}; pub use envelope::{SoapBody, SoapEnvelope, SoapHeader}; pub use fault::{SoapFault, build_soap_fault}; -pub use parser::{SoapAction, parse_soap_action}; +pub use parser::{SoapAction, parse_soap_action, parse_soap_envelope}; /// Codes d'erreur SOAP UPnP standards pub mod error_codes { diff --git a/pmoupnp/src/ssdp/client.rs b/pmoupnp/src/ssdp/client.rs new file mode 100644 index 00000000..8210b108 --- /dev/null +++ b/pmoupnp/src/ssdp/client.rs @@ -0,0 +1,356 @@ +/*! +The PMOMusic SSDP client is a *control point*. +It must **not** bind to UDP port 1900. + +Reason: + +* The SSDP *server* (UPnP device mode) must listen on 0.0.0.0:1900 for M-SEARCH discovery. +* The SSDP *client* only needs to send M-SEARCH and receive unicast HTTP/200 replies. +* If both client and server bind on 1900 (even with SO_REUSEPORT) the kernel load-balances + incoming datagrams between sockets. As a result, NOTIFY and HTTP/200 messages are lost + randomly by the client. + +Therefore: + +* SSDP server → bind(0.0.0.0:1900), join multicast, answer M-SEARCH. +* SSDP client → bind(0.0.0.0:0), use an ephemeral port, send M-SEARCH, receive replies. + +The client may still join the multicast group for debugging, but NOTIFY reception is optional. +*/ +//! Client SSDP pour la découverte des devices UPnP + +use super::{MAX_AGE, SSDP_MULTICAST_ADDR, SSDP_PORT}; +use socket2::{Domain, Protocol, Socket, Type}; +use std::collections::HashMap; +use std::net::{SocketAddr, UdpSocket}; +use std::sync::Arc; +use std::time::Duration; +use tracing::{debug, info, trace, warn}; + +/// Événements SSDP intéressants pour un control point +#[derive(Debug, Clone)] +pub enum SsdpEvent { + Alive { + usn: String, + nt: String, + location: String, + server: String, + max_age: u32, + from: SocketAddr, + }, + ByeBye { + usn: String, + nt: String, + from: SocketAddr, + }, + SearchResponse { + usn: String, + st: String, + location: String, + server: String, + max_age: u32, + from: SocketAddr, + }, +} + +/// Client SSDP pour envoyer des M-SEARCH et écouter les annonces +pub struct SsdpClient { + socket: Arc, +} + +impl SsdpClient { + /// Crée un nouveau client SSDP + pub fn new() -> std::io::Result { + let addr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT); + + let socket2 = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; + socket2.set_reuse_address(true)?; + + #[cfg(windows)] + { + debug!("✅ SsdpClient SO_REUSEADDR enabled (Windows - SO_REUSEPORT not needed)"); + } + + let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap(); + socket2.bind(&bind_addr.into())?; + + let socket: UdpSocket = socket2.into(); + socket.set_read_timeout(Some(Duration::from_secs(1)))?; + socket.set_multicast_loop_v4(true)?; // utile en dev local + + for iface in get_if_addrs::get_if_addrs()? { + if let std::net::IpAddr::V4(ipv4) = iface.ip() { + if !ipv4.is_loopback() { + match socket.join_multicast_v4(&SSDP_MULTICAST_ADDR.parse().unwrap(), &ipv4) { + Ok(()) => { + debug!("SSDP: joined {} on {}", SSDP_MULTICAST_ADDR, ipv4); + } + Err(e) => { + warn!( + "SSDP: failed to join {} on {}: {}", + SSDP_MULTICAST_ADDR, ipv4, e + ); + } + } + } + } + } + + info!("✅ SSDP client ready on {}", addr); + + Ok(Self { + socket: Arc::new(socket), + }) + } + + /// Envoie un M-SEARCH pour un type donné + pub fn send_msearch(&self, st: &str, mx: u32) -> std::io::Result<()> { + let mx = mx.max(1); // MX doit être >= 1 + let msg = format!( + "M-SEARCH * HTTP/1.1\r\n\ + HOST: {}:{}\r\n\ + MAN: \"ssdp:discover\"\r\n\ + MX: {}\r\n\ + ST: {}\r\n\ + USER-AGENT: PMOMusic SSDP Client\r\n\ + \r\n", + SSDP_MULTICAST_ADDR, SSDP_PORT, mx, st + ); + + let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT) + .parse() + .unwrap(); + + match self.socket.send_to(msg.as_bytes(), addr) { + Ok(_) => { + info!("📤 M-SEARCH sent (ST={}, MX={})", st, mx); + debug!( + "📨 M-SEARCH payload\n

\n\n```\n{}\n```\n
\n", + msg + ); + Ok(()) + } + Err(e) => { + warn!("❌ Failed to send M-SEARCH: {}", e); + Err(e) + } + } + } + + /// Boucle de réception bloquante pour traiter les événements SSDP + pub fn run_event_loop(&self, mut on_event: F) -> ! + where + F: FnMut(SsdpEvent) + Send + 'static, + { + let socket = Arc::clone(&self.socket); + let mut buf = [0u8; 8192]; + loop { + match socket.recv_from(&mut buf) { + Ok((n, from)) => { + let data = String::from_utf8_lossy(&buf[..n]); + if let Some(event) = parse_message(&data, from) { + debug!("📥 SSDP event from {}: {:?}", from, event); + on_event(event); + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // Timeout, recommencer + continue; + } + Err(e) => { + warn!("❌ SSDP client read error: {}", e); + } + } + } + } +} + +fn parse_message(data: &str, from: SocketAddr) -> Option { + let mut lines = data.lines(); + let first_line = lines.next()?.trim(); + let upper = first_line.to_ascii_uppercase(); + let headers = parse_headers(lines); + + let result = if upper.starts_with("NOTIFY ") { + handle_notify(&headers, from) + } else if upper.starts_with("HTTP/") && upper.contains(" 200 ") { + handle_search_response(&headers, from) + } else if upper.starts_with("M-SEARCH ") { + // Another control point querying us; we are not a device, so we ignore. + None + } else { + trace!("Unknown SSDP message type from {}: {}", from, first_line); + None + }; + + if result.is_none() { + trace!("SSDP message from {} could not be parsed:\n{}", from, data); + } + + result +} + +fn handle_notify(headers: &HashMap, from: SocketAddr) -> Option { + // Critical headers: NTS, NT, USN (required by UPnP spec) + let nts = headers.get("NTS")?.to_ascii_lowercase(); + let nt = headers.get("NT")?.to_string(); + let usn = headers.get("USN")?.to_string(); + + if nts == "ssdp:alive" { + // LOCATION is required for alive notifications + let location = match headers.get("LOCATION") { + Some(loc) => loc.to_string(), + None => { + trace!( + "NOTIFY ssdp:alive from {} missing LOCATION header, ignoring", + from + ); + return None; + } + }; + + // Non-critical headers: SERVER, CACHE-CONTROL + let server = headers + .get("SERVER") + .map(|s| s.to_string()) + .unwrap_or_else(|| { + trace!("NOTIFY from {} has no SERVER header, using 'Unknown'", from); + "Unknown".to_string() + }); + let max_age = parse_max_age(headers.get("CACHE-CONTROL")); + + Some(SsdpEvent::Alive { + usn, + nt, + location, + server, + max_age, + from, + }) + } else if nts == "ssdp:byebye" { + Some(SsdpEvent::ByeBye { usn, nt, from }) + } else { + trace!("Unknown NTS value from {}: {}", from, nts); + None + } +} + +fn handle_search_response( + headers: &HashMap, + from: SocketAddr, +) -> Option { + // Critical headers: ST, USN, LOCATION (required by UPnP spec) + let st = match headers.get("ST") { + Some(s) => s.to_string(), + None => { + trace!( + "M-SEARCH response from {} missing ST header, ignoring", + from + ); + return None; + } + }; + let usn = match headers.get("USN") { + Some(u) => u.to_string(), + None => { + trace!( + "M-SEARCH response from {} missing USN header, ignoring", + from + ); + return None; + } + }; + let location = match headers.get("LOCATION") { + Some(loc) => loc.to_string(), + None => { + trace!( + "M-SEARCH response from {} missing LOCATION header, ignoring", + from + ); + return None; + } + }; + + // Non-critical headers: SERVER, CACHE-CONTROL + let server = headers + .get("SERVER") + .map(|s| s.to_string()) + .unwrap_or_else(|| { + trace!( + "M-SEARCH response from {} has no SERVER header, using 'Unknown'", + from + ); + "Unknown".to_string() + }); + let max_age = parse_max_age(headers.get("CACHE-CONTROL")); + + Some(SsdpEvent::SearchResponse { + usn, + st, + location, + server, + max_age, + from, + }) +} + +fn parse_headers<'a, I>(lines: I) -> HashMap +where + I: Iterator, +{ + let mut headers = HashMap::new(); + for line in lines { + let line = line.trim(); + + // Empty line marks end of headers + if line.is_empty() { + break; + } + + // Split on first ':' only (values may contain ':') + if let Some(colon_pos) = line.find(':') { + let (name, value_with_colon) = line.split_at(colon_pos); + let value = &value_with_colon[1..]; // Skip the ':' + + let name = name.trim().to_ascii_uppercase(); + let value = value.trim().to_string(); + + // Skip empty header names or values + if !name.is_empty() && !value.is_empty() { + headers.insert(name, value); + } else { + trace!("Skipping malformed header: '{}'", line); + } + } else { + // Line without ':' is invalid, skip it + trace!("Skipping line without colon: '{}'", line); + } + } + headers +} + +fn parse_max_age(value: Option<&String>) -> u32 { + if let Some(v) = value { + // Try case-insensitive search for "max-age=" + let lower = v.to_ascii_lowercase(); + if let Some(idx) = lower.find("max-age") { + // Extract everything after "max-age" + let after_key = &v[idx + 7..]; + // Skip any whitespace and '=' characters + let after_eq = after_key.trim_start().trim_start_matches('=').trim_start(); + // Try to parse the first sequence of digits + let digits: String = after_eq + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + if let Ok(age) = digits.parse::() { + return age; + } + } + trace!( + "Could not parse max-age from CACHE-CONTROL: '{}', using default {}", + v, MAX_AGE + ); + } + MAX_AGE +} diff --git a/pmoupnp/src/ssdp/mod.rs b/pmoupnp/src/ssdp/mod.rs index 17e5cb97..54bcf59a 100644 --- a/pmoupnp/src/ssdp/mod.rs +++ b/pmoupnp/src/ssdp/mod.rs @@ -22,9 +22,11 @@ //! - **Max-Age**: 1800 secondes (30 minutes) //! - **Announcement Period**: 900 secondes (15 minutes, Max-Age/2) +mod client; mod device; mod server; +pub use client::{SsdpClient, SsdpEvent}; pub use device::SsdpDevice; pub use server::SsdpServer; diff --git a/pmoupnp/src/ssdp/server.rs b/pmoupnp/src/ssdp/server.rs index 2982634f..2ab68d0c 100644 --- a/pmoupnp/src/ssdp/server.rs +++ b/pmoupnp/src/ssdp/server.rs @@ -1,6 +1,7 @@ //! Serveur SSDP use super::{MAX_AGE, SSDP_MULTICAST_ADDR, SSDP_PORT, SsdpDevice}; +use socket2::{Domain, Protocol, Socket, Type}; use std::collections::HashMap; use std::net::{SocketAddr, UdpSocket}; use std::sync::{Arc, RwLock}; @@ -32,7 +33,49 @@ impl SsdpServer { /// `Ok(())` si le démarrage a réussi, `Err` sinon pub fn start(&mut self) -> std::io::Result<()> { let addr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT); - let socket = UdpSocket::bind(("0.0.0.0", SSDP_PORT))?; + + // Créer le socket avec socket2 pour permettre la réutilisation du port + // Ceci est essentiel pour que plusieurs clients/serveurs UPnP puissent coexister + let socket2 = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; + + // SO_REUSEADDR : permet à plusieurs sockets de bind sur le même port + // Essentiel sur toutes les plateformes pour le multicast + socket2.set_reuse_address(true)?; + + // SO_REUSEPORT : nécessaire sur Unix (macOS/Linux/BSD) pour que plusieurs processus + // puissent recevoir du trafic multicast sur le même port. + // Windows n'a pas besoin de SO_REUSEPORT - SO_REUSEADDR suffit. + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + let fd = socket2.as_raw_fd(); + let optval: libc::c_int = 1; + unsafe { + let result = libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_REUSEPORT, + &optval as *const _ as *const libc::c_void, + std::mem::size_of_val(&optval) as libc::socklen_t, + ); + if result != 0 { + return Err(std::io::Error::last_os_error()); + } + } + debug!("✅ SO_REUSEPORT enabled (Unix)"); + } + + #[cfg(windows)] + { + debug!("✅ SO_REUSEADDR enabled (Windows - SO_REUSEPORT not needed)"); + } + + // Bind sur 0.0.0.0:1900 + let bind_addr: SocketAddr = format!("0.0.0.0:{}", SSDP_PORT).parse().unwrap(); + socket2.bind(&bind_addr.into())?; + + // Convertir en UdpSocket standard + let socket: UdpSocket = socket2.into(); // Rejoindre le groupe multicast socket.join_multicast_v4( diff --git a/pmoupnp/src/state_variables/var_inst_set_methods.rs b/pmoupnp/src/state_variables/var_inst_set_methods.rs index 667f6030..21da19cf 100644 --- a/pmoupnp/src/state_variables/var_inst_set_methods.rs +++ b/pmoupnp/src/state_variables/var_inst_set_methods.rs @@ -29,6 +29,7 @@ impl UpnpInstance for StateVarInstanceSet { fn new(_: &StateVariableSet) -> Self { Self { objects: RwLock::new(HashMap::new()), + order: RwLock::new(Vec::new()), } } } diff --git a/pmoupnp/src/upnp_server.rs b/pmoupnp/src/upnp_server.rs index 881b7e9c..2e3e0ad0 100644 --- a/pmoupnp/src/upnp_server.rs +++ b/pmoupnp/src/upnp_server.rs @@ -289,49 +289,9 @@ impl UpnpServerExt for Server { cache_dir: &str, limit: usize, ) -> Result, anyhow::Error> { - use pmocache::pmoserver_ext::{create_api_router, create_file_router_with_generator}; - use pmocovers::new_cache; - - let base_url = self.info().base_url.clone(); - let cache = Arc::new(new_cache(cache_dir, limit)?); - - // Routes de fichiers avec génération de variantes - // Routes: GET /covers/image/{pk} et GET /covers/image/{pk}/{size} - let variant_generator: pmocache::pmoserver_ext::ParamGenerator = - Arc::new(|cache, pk, param| { - Box::pin(async move { - // Si le param est numérique, c'est une taille de variante - if let Ok(size) = param.parse::() { - match pmocovers::webp::generate_variant(&cache, &pk, size).await { - Ok(data) => return Some(data), - Err(e) => { - tracing::warn!( - "Cannot generate variant {}x{} for {}: {}", - size, - size, - pk, - e - ); - return None; - } - } - } - None - }) - }); - - let file_router = - create_file_router_with_generator(cache.clone(), "image/webp", Some(variant_generator)); - self.add_router("/", file_router).await; - - // API REST générique (pmocache) - let api_router = create_api_router(cache.clone()); - let openapi = pmocovers::ApiDoc::openapi(); - self.add_openapi(api_router, openapi, "covers").await; - - // Enregistrer le cache dans le registre global - pmocovers::register_cover_cache(cache.clone()); + // Délègue à l'implémentation pmocovers (qui enregistre WebP + JPEG + API) + let cache = pmocovers::CoverCacheExt::init_cover_cache(self, cache_dir, limit).await?; Ok(cache) } @@ -355,6 +315,19 @@ impl UpnpServerExt for Server { let openapi = pmoaudiocache::ApiDoc::openapi(); self.add_openapi(api_router, openapi, "audio").await; + // API playlists (SSE + OpenAPI) + #[cfg(feature = "server")] + { + use pmoplaylist::{openapi::ApiDoc, playlist_events_router}; + // SSE /api/playlists/events + self.add_router("/api/playlists", playlist_events_router()) + .await; + // OpenAPI pour playlists + let openapi = ApiDoc::openapi(); + self.add_openapi(axum::Router::new(), openapi, "playlists") + .await; + } + // Enregistrer le cache dans le registre global pmoaudiocache::register_audio_cache(cache.clone()); diff --git a/pmoutils/Cargo.toml b/pmoutils/Cargo.toml index cd1380c2..49e86a59 100644 --- a/pmoutils/Cargo.toml +++ b/pmoutils/Cargo.toml @@ -9,3 +9,5 @@ os_info = "3.8" netstat2 = "0.11" sysinfo = "0.30" users = "0.11" +quick-xml = "0.38.3" +xmltree = "0.10" diff --git a/pmoutils/src/lib.rs b/pmoutils/src/lib.rs index 8169a51a..0e0a0279 100644 --- a/pmoutils/src/lib.rs +++ b/pmoutils/src/lib.rs @@ -20,6 +20,7 @@ pub mod ip_utils; pub use ip_utils::guess_local_ip; pub mod process; pub use process::{ProcessPortInfo, TransportProtocol, find_process_using_port}; +use xmltree::{Element, EmitterConfig}; /// Retourne une chaîne décrivant le système d'exploitation et sa version. /// @@ -52,3 +53,24 @@ pub fn get_os_string() -> String { format!("{}/Unknown", os_type) } } + +/// Trait générique pour obtenir un élément XML (xmltree::Element). +/// +/// Aligné sur la signature utilisée dans pmoupnp (UpnpObject::to_xml_element), +/// afin de pouvoir factoriser la sérialisation XML entre crates. +pub trait ToXmlElement { + /// Convertit l'objet en élément XML. + fn to_xml_element(&self) -> Element; + + /// Sérialise en chaîne XML formatée. + fn to_xml(&self) -> String { + let elem = self.to_xml_element(); + let config = EmitterConfig::new() + .perform_indent(true) + .indent_string(" "); + let mut buf = Vec::new(); + elem.write_with_config(&mut buf, config) + .expect("Failed to write XML"); + String::from_utf8(buf).expect("Invalid UTF-8") + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 00000000..e69de29b diff --git a/tools/compare_upnp.py b/tools/compare_upnp.py new file mode 100755 index 00000000..5e880e7e --- /dev/null +++ b/tools/compare_upnp.py @@ -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'([^<]+)', + 'friendlyName': r'([^<]+)', + 'manufacturer': r'([^<]+)', + 'modelName': r'([^<]+)', + 'UDN': r'([^<]+)', + 'specVersion': r'.*?(\d+).*?(\d+)', + } + + 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'([^<]+)', xml) + info['services'] = services + + # Vérifier les icônes + has_icons = bool(re.search(r'', 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() diff --git a/tools/discover_upnp.py b/tools/discover_upnp.py new file mode 100755 index 00000000..99e90284 --- /dev/null +++ b/tools/discover_upnp.py @@ -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'([^<]+)', desc) + manufacturer = re.search(r'([^<]+)', desc) + model_name = re.search(r'([^<]+)', desc) + udn = re.search(r'([^<]+)', 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() diff --git a/tools/discover_upnp_devices.sh b/tools/discover_upnp_devices.sh new file mode 100755 index 00000000..6dacfcdb --- /dev/null +++ b/tools/discover_upnp_devices.sh @@ -0,0 +1,142 @@ +#!/bin/bash +# +# Script de découverte des devices UPnP via SSDP +# +# Usage: ./discover_upnp_devices.sh [timeout_seconds] + +set -e + +TIMEOUT="${1:-5}" + +echo "=== Découverte des devices UPnP ===" >&2 +echo "Timeout: ${TIMEOUT}s" >&2 +echo >&2 + +# Créer un socket UDP pour envoyer la requête SSDP +DISCOVERY_MESSAGE="M-SEARCH * HTTP/1.1\r +Host: 239.255.255.250:1900\r +Man: \"ssdp:discover\"\r +MX: ${TIMEOUT}\r +ST: upnp:rootdevice\r +\r +" + +# Envoyer la requête SSDP et collecter les réponses +echo "Envoi de la requête SSDP..." >&2 +echo >&2 + +# Utiliser Python pour écouter les réponses SSDP +python3 - <<'PYTHON_SCRIPT' "$TIMEOUT" +import socket +import sys +import time + +timeout = int(sys.argv[1]) + +# Créer un socket UDP +sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +sock.settimeout(timeout) + +# Message SSDP M-SEARCH +msg = ( + 'M-SEARCH * HTTP/1.1\r\n' + 'Host: 239.255.255.250:1900\r\n' + 'Man: "ssdp:discover"\r\n' + 'MX: {}\r\n' + 'ST: upnp:rootdevice\r\n' + '\r\n' +).format(timeout) + +# Envoyer la requête au multicast SSDP +sock.sendto(msg.encode(), ('239.255.255.250', 1900)) + +print("Écoute des réponses SSDP...", file=sys.stderr) +print(file=sys.stderr) + +devices = {} +start_time = time.time() + +try: + while time.time() - start_time < timeout: + try: + data, addr = sock.recvfrom(8192) + response = data.decode('utf-8', errors='ignore') + + # Extraire l'URL de la description + location = None + server = None + usn = None + for line in response.split('\r\n'): + if line.lower().startswith('location:'): + location = line.split(':', 1)[1].strip() + elif line.lower().startswith('server:'): + server = line.split(':', 1)[1].strip() + elif line.lower().startswith('usn:'): + usn = line.split(':', 1)[1].strip() + + if location and location not in devices: + devices[location] = { + 'addr': addr[0], + 'server': server, + 'usn': usn + } + print(f"Trouvé: {location}", file=sys.stderr) + except socket.timeout: + break +except KeyboardInterrupt: + pass +finally: + sock.close() + +print(file=sys.stderr) +print(f"=== {len(devices)} device(s) trouvé(s) ===", file=sys.stderr) +print(file=sys.stderr) + +# Afficher les détails de chaque device +for location, info in devices.items(): + print(f"Device: {location}", file=sys.stderr) + print(f" IP: {info['addr']}", file=sys.stderr) + if info['server']: + print(f" Server: {info['server']}", file=sys.stderr) + if info['usn']: + print(f" USN: {info['usn']}", file=sys.stderr) + + # Récupérer la description XML + import urllib.request + try: + with urllib.request.urlopen(location, timeout=2) as response: + xml = response.read().decode('utf-8') + + # Parser le XML pour trouver les services + import xml.etree.ElementTree as ET + root = ET.fromstring(xml) + + # Namespaces UPnP + ns = { + 'device': 'urn:schemas-upnp-org:device-1-0', + 'service': 'urn:schemas-upnp-org:service-1-0' + } + + # Trouver le nom du device + device_name = root.find('.//device:friendlyName', ns) + if device_name is not None: + print(f" Name: {device_name.text}", file=sys.stderr) + + # Trouver le ContentDirectory service + for service in root.findall('.//device:service', ns): + service_type = service.find('device:serviceType', ns) + if service_type is not None and 'ContentDirectory' in service_type.text: + control_url = service.find('device:controlURL', ns) + if control_url is not None: + # Construire l'URL complète + from urllib.parse import urljoin + full_control_url = urljoin(location, control_url.text) + print(f" ContentDirectory Control URL: {full_control_url}", file=sys.stderr) + print(full_control_url) # Output pour utilisation dans scripts + except Exception as e: + print(f" Erreur lors de la récupération de la description: {e}", file=sys.stderr) + + print(file=sys.stderr) + +PYTHON_SCRIPT diff --git a/tools/test_soap.py b/tools/test_soap.py new file mode 100644 index 00000000..589994ee --- /dev/null +++ b/tools/test_soap.py @@ -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 = """ + + + + +""" + +# SOAP request pour Browse +BROWSE_REQUEST = """ + + + + 0 + BrowseDirectChildren + * + 0 + 10 + + + +""" + +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'.*?([^<]+)', scpd_xml, re.DOTALL) + print(f" Actions: {', '.join(actions)}") + except Exception as e: + print(f" ❌ Error: {e}") + +if __name__ == "__main__": + main() diff --git a/tools/test_upnp_browse.sh b/tools/test_upnp_browse.sh new file mode 100755 index 00000000..1c4f6cf2 --- /dev/null +++ b/tools/test_upnp_browse.sh @@ -0,0 +1,132 @@ +#!/bin/bash +# +# Script de test pour les requêtes Browse UPnP ContentDirectory +# +# Usage: ./test_upnp_browse.sh [options] +# +# Options: +# -u URL URL de contrôle du service ContentDirectory +# -o ID Object ID à parcourir (défaut: "0") +# -f FLAG BrowseFlag: BrowseMetadata ou BrowseDirectChildren (défaut: BrowseDirectChildren) +# -s INDEX StartingIndex (défaut: 0) +# -c COUNT RequestedCount (défaut: 0 = tous) +# -h Afficher cette aide + +set -e + +# Valeurs par défaut +CONTROL_URL="http://localhost:8080/device/63623ff4-ee41-4850-90aa-2d39395df981/service/ContentDirectory/control" +OBJECT_ID="0" +BROWSE_FLAG="BrowseDirectChildren" +STARTING_INDEX=0 +REQUESTED_COUNT=0 + +# Fonction d'aide +show_help() { + cat << EOF +Script de test pour les requêtes Browse UPnP ContentDirectory + +Usage: $0 [options] + +Options: + -u URL URL de contrôle du service ContentDirectory + (défaut: http://localhost:8080/device/.../ContentDirectory/control) + -o ID Object ID à parcourir (défaut: "0") + -f FLAG BrowseFlag: BrowseMetadata ou BrowseDirectChildren + (défaut: BrowseDirectChildren) + -s INDEX StartingIndex (défaut: 0) + -c COUNT RequestedCount (défaut: 0 = tous) + -h Afficher cette aide + +Exemples: + # Parcourir la racine + $0 + + # Parcourir un canal Radio Paradise + $0 -o "radio-paradise:channel:main" + + # Obtenir les métadonnées d'un container + $0 -o "radio-paradise:channel:main:history" -f BrowseMetadata + + # Parcourir l'historique + $0 -o "radio-paradise:channel:main:history" + +EOF +} + +# Parser les options +while getopts "u:o:f:s:c:h" opt; do + case $opt in + u) CONTROL_URL="$OPTARG" ;; + o) OBJECT_ID="$OPTARG" ;; + f) BROWSE_FLAG="$OPTARG" ;; + s) STARTING_INDEX="$OPTARG" ;; + c) REQUESTED_COUNT="$OPTARG" ;; + h) show_help; exit 0 ;; + \?) echo "Option invalide: -$OPTARG" >&2; show_help; exit 1 ;; + esac +done + +# Afficher les paramètres +echo "=== Test Browse UPnP ===" >&2 +echo "Control URL: $CONTROL_URL" >&2 +echo "Object ID: $OBJECT_ID" >&2 +echo "Browse Flag: $BROWSE_FLAG" >&2 +echo "Starting Index: $STARTING_INDEX" >&2 +echo "Requested Count: $REQUESTED_COUNT" >&2 +echo >&2 + +# Échapper l'Object ID pour XML +OBJECT_ID_ESCAPED=$(echo "$OBJECT_ID" | sed 's/&/\&/g; s//\>/g; s/"/\"/g; s/'"'"'/\'/g') + +# Construire et envoyer la requête SOAP +RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X POST "$CONTROL_URL" \ + -H "Content-Type: text/xml; charset=\"utf-8\"" \ + -H "SOAPACTION: \"urn:schemas-upnp-org:service:ContentDirectory:1#Browse\"" \ + -d " + + + + $OBJECT_ID_ESCAPED + $BROWSE_FLAG + * + $STARTING_INDEX + $REQUESTED_COUNT + + + +") + +# Extraire le code de statut HTTP +HTTP_STATUS=$(echo "$RESPONSE" | grep "HTTP_STATUS:" | cut -d: -f2) +BODY=$(echo "$RESPONSE" | sed '/HTTP_STATUS:/d') + +echo "=== HTTP Status: $HTTP_STATUS ===" >&2 +echo >&2 + +# Afficher la réponse formatée +if [ -n "$BODY" ]; then + echo "=== SOAP Response ===" >&2 + echo "$BODY" | xmllint --format - 2>&1 + + # Extraire et décoder le DIDL-Lite + DIDL=$(echo "$BODY" | xmllint --xpath "string(//Result)" - 2>/dev/null || true) + if [ -n "$DIDL" ]; then + echo >&2 + echo "=== DIDL-Lite Content ===" >&2 + echo "$DIDL" | xmllint --format - 2>&1 || echo "$DIDL" + fi + + # Afficher NumberReturned et TotalMatches + echo >&2 + echo "=== Statistics ===" >&2 + NUMBER_RETURNED=$(echo "$BODY" | xmllint --xpath "string(//NumberReturned)" - 2>/dev/null || echo "N/A") + TOTAL_MATCHES=$(echo "$BODY" | xmllint --xpath "string(//TotalMatches)" - 2>/dev/null || echo "N/A") + UPDATE_ID=$(echo "$BODY" | xmllint --xpath "string(//UpdateID)" - 2>/dev/null || echo "N/A") + echo "NumberReturned: $NUMBER_RETURNED" >&2 + echo "TotalMatches: $TOTAL_MATCHES" >&2 + echo "UpdateID: $UPDATE_ID" >&2 +else + echo "Aucune réponse du serveur" >&2 + exit 1 +fi