···234234 shuffle: Option<bool>,
235235 position: Option<i32>,
236236 ) -> Result<i32, Error> {
237237+ let path = path.trim().to_string();
237238 let client = ctx.data::<reqwest::Client>().unwrap();
238239 let mut tracks: Vec<String> = vec![];
239240
+7
crates/graphql/src/schema/playlist.rs
···1212 rockbox_url, schema::objects::playlist::Playlist, simplebroker::SimpleBroker, types::StatusCode,
1313};
14141515+fn trim_path(s: &str) -> String {
1616+ let s = s.trim();
1717+ s.split('#').next().unwrap_or(s).to_string()
1818+}
1919+1520#[derive(Default)]
1621pub struct PlaylistQuery;
1722···148153 name: String,
149154 tracks: Vec<String>,
150155 ) -> Result<i32, Error> {
156156+ let tracks: Vec<String> = tracks.into_iter().map(|t| trim_path(&t)).collect();
151157 let client = ctx.data::<reqwest::Client>().unwrap();
152158 let body = serde_json::json!({
153159 "name": name,
···167173 position: i32,
168174 tracks: Vec<String>,
169175 ) -> Result<i32, Error> {
176176+ let tracks: Vec<String> = tracks.into_iter().map(|t| trim_path(&t)).collect();
170177 let client = ctx.data::<reqwest::Client>().unwrap();
171178 let body = serde_json::json!({
172179 "position": position,
+16
crates/library/src/audio_scan.rs
···340340 const MAX_PROBE_BYTES: usize = 8 * 1024 * 1024;
341341342342 let client = reqwest::Client::new();
343343+344344+ // Try a bounded range first. If the file is smaller than MAX_PROBE_BYTES
345345+ // some servers return 416; in that case fall back to an open-ended range
346346+ // which downloads only to the (small) end of file.
343347 let mut response = client
344348 .get(url)
345349 .header(
···348352 )
349353 .send()
350354 .await?;
355355+356356+ if response.status() == reqwest::StatusCode::RANGE_NOT_SATISFIABLE {
357357+ tracing::info!(
358358+ "probe remote media {}: 416 Range Not Satisfiable, retrying with open-ended range",
359359+ url
360360+ );
361361+ response = client
362362+ .get(url)
363363+ .header(reqwest::header::RANGE, "bytes=0-")
364364+ .send()
365365+ .await?;
366366+ }
351367352368 if !response.status().is_success() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT
353369 {
+78-12
crates/netstream/src/lib.rs
···9393 /// Falls back to reopening from byte 0 and discarding bytes if the server
9494 /// ignores Range and responds with the full body.
9595 fn seek_to(&mut self, new_pos: u64) -> bool {
9696+ // Use a bounded range when content_length is known so the request is
9797+ // always within [0, content_length). This prevents spurious 416
9898+ // responses and tells the server exactly how many bytes we want.
9999+ let range_header = match self.content_length {
100100+ Some(cl) if cl > 0 => format!("bytes={}-{}", new_pos, cl - 1),
101101+ _ => format!("bytes={}-", new_pos),
102102+ };
96103 self.response = None;
9797- let result = CLIENT
9898- .get(&self.url)
9999- .header("Range", format!("bytes={}-", new_pos))
100100- .send();
104104+ let result = CLIENT.get(&self.url).header("Range", range_header).send();
101105102106 match result {
103107 Ok(resp) if resp.status().as_u16() == 206 => {
···181185 return INVALID_HANDLE;
182186 }
183187 let url_str = match CStr::from_ptr(url).to_str() {
184184- Ok(s) => s.to_owned(),
188188+ Ok(s) => {
189189+ let s = s.trim();
190190+ // Drop any URL fragment — the codec only needs the resource itself.
191191+ let s = s.split('#').next().unwrap_or(s);
192192+ s.to_owned()
193193+ }
185194 Err(_) => return INVALID_HANDLE,
186195 };
187196···234243 let pos_before = state.pos;
235244 let resp = match &mut state.response {
236245 Some(r) => r,
237237- None => return -1,
246246+ None => {
247247+ // No active response. If we know content_length and pos is at or
248248+ // beyond it, signal EOF (0) rather than an error (-1) so callers
249249+ // that iterate until EOF terminate cleanly.
250250+ if state.content_length.map_or(false, |cl| state.pos >= cl) {
251251+ return 0;
252252+ }
253253+ return -1;
254254+ }
238255 };
239256 let buf = std::slice::from_raw_parts_mut(dst as *mut u8, n);
240257 match read_as_file(resp, buf) {
···314331 }
315332 _ => return -1,
316333 };
334334+335335+ // Guard: never issue a Range request past EOF — the server would return 416
336336+ // and seek_to would leave response=None, permanently breaking the stream.
337337+ // This can happen when the C MP4 parser has a uint32_t underflow in its
338338+ // atom-size arithmetic and tries to seek gigabytes forward.
339339+ if let Some(cl) = state.content_length {
340340+ if new_pos >= cl {
341341+ warn!(
342342+ "[netstream] rb_net_lseek: h={} off={} whence={} new_pos={} >= content_length={}, clamping to EOF",
343343+ h, off, whence, new_pos, cl
344344+ );
345345+ state.pos = cl;
346346+ state.response = None; // EOF: rb_net_read will return 0
347347+ return cl as i64;
348348+ }
349349+ }
317350318351 // Fast-path: already there (no need to restart the request).
319352 if new_pos == state.pos {
···664697 .with_body(full_body)
665698 .create();
666699667667- // Range request from byte 8.
700700+ // Range request from byte 8 — bounded range since content-length is known.
668701 let _range = server
669702 .mock("GET", "/seekable.mp3")
670670- .match_header("range", "bytes=8-")
703703+ .match_header("range", "bytes=8-15")
671704 .with_status(206)
672705 .with_header("content-range", "bytes 8-15/16")
673706 .with_header("content-length", "8")
···734767735768 let _range = server
736769 .mock("GET", "/end.mp3")
737737- .match_header("range", "bytes=8-")
770770+ .match_header("range", "bytes=8-9")
738771 .with_status(206)
739772 .with_header("content-range", "bytes 8-9/10")
740773 .with_body(&full_body[8..])
···840873841874 let _ignored_range = server
842875 .mock("GET", "/ignore-range.mp3")
843843- .match_header("range", "bytes=8-")
876876+ .match_header("range", "bytes=8-15")
844877 .with_status(200)
845878 .with_header("content-length", "16")
846879 .with_body(full_body)
···878911879912 let _bad_range = server
880913 .mock("GET", "/bad-range.mp3")
881881- .match_header("range", "bytes=8-")
914914+ .match_header("range", "bytes=8-15")
882915 .with_status(206)
883916 .with_header("content-range", "bytes 0-7/16")
884917 .with_body(&full_body[..8])
···914947 // Range request: 206 includes Content-Range which also reveals total size.
915948 let _range = server
916949 .mock("GET", "/range-len.mp3")
917917- .match_header("range", "bytes=5-")
950950+ .match_header("range", "bytes=5-9")
918951 .with_status(206)
919952 .with_header("content-range", "bytes 5-9/10")
920953 .with_body(&full_body[5..])
···941974 10,
942975 "length should remain correct after seek"
943976 );
977977+978978+ rb_net_close(handle);
979979+ }
980980+981981+ /// Seeking past content_length (e.g. from a uint32_t underflow in the MP4
982982+ /// parser) is clamped to content_length. The stream is not permanently
983983+ /// broken: reads return 0 (EOF) rather than -1 (error).
984984+ #[test]
985985+ fn test_seek_past_eof_is_clamped() {
986986+ let full_body: &[u8] = b"0123456789"; // 10 bytes, content-length=10
987987+ let mut server = mockito::Server::new();
988988+989989+ let _initial = server
990990+ .mock("GET", "/clamped.mp3")
991991+ .match_header("range", Matcher::Missing)
992992+ .with_status(200)
993993+ .with_header("content-length", "10")
994994+ .with_body(full_body)
995995+ .create();
996996+997997+ let url = c_url(&server, "/clamped.mp3");
998998+ let handle = unsafe { rb_net_open(url.as_ptr()) };
999999+ assert!(handle >= 0);
10001000+10011001+ // Seek 4 GB past current position (simulates uint32_t underflow in C).
10021002+ let result = rb_net_lseek(handle, 4_294_967_295, libc::SEEK_CUR);
10031003+ // Should clamp to content_length (10), not return -1.
10041004+ assert_eq!(result, 10, "seek past EOF should clamp to content_length");
10051005+10061006+ // Subsequent reads must return 0 (EOF), not -1 (broken stream).
10071007+ let mut buf = vec![0u8; 16];
10081008+ let n = unsafe { rb_net_read(handle, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
10091009+ assert_eq!(n, 0, "read after clamped seek should return 0 (EOF)");
94410109451011 rb_net_close(handle);
9461012 }
···397397 request: tonic::Request<PlayDirectoryRequest>,
398398 ) -> Result<tonic::Response<PlayDirectoryResponse>, tonic::Status> {
399399 let request = request.into_inner();
400400- let path = request.path;
400400+ let path = request.path.trim().to_string();
401401 let recurse = request.recurse;
402402 let shuffle = request.shuffle;
403403 let position = request.position;
···583583 request: tonic::Request<PlayTrackRequest>,
584584 ) -> Result<tonic::Response<PlayTrackResponse>, tonic::Status> {
585585 let request = request.into_inner();
586586- let path = request.path.replace("file://", "");
586586+ let raw = request.path.replace("file://", "");
587587+ let raw = raw.trim();
588588+ let path = raw.split('#').next().unwrap_or(raw).to_string();
587589 let tracks = vec![path.clone()];
588590589591 let body = serde_json::json!({
···11+# rockbox-upnp
22+33+UPnP/DLNA support for Rockbox Zig. This crate provides three independent but
44+complementary features:
55+66+| Feature | What it does |
77+|--------------------------------------|-----------------------------------------------------------------------------------------------------|
88+| **Media Server** (ContentDirectory) | Exposes the music library so UPnP control points (BubbleUPnP, Kodi, etc.) can browse and pull tracks |
99+| **MediaRenderer** | Lets control points push media to Rockbox (Rockbox becomes the speaker) |
1010+| **PCM sink / WAV output** | Streams live PCM audio as WAV-over-HTTP to an external UPnP renderer (Kodi, etc.) |
1111+1212+---
1313+1414+## How UPnP/DLNA works
1515+1616+### Protocol stack
1717+1818+```
1919+Application BubbleUPnP / Kodi / any DLNA app
2020+ │
2121+ SOAP/XML over HTTP (AVTransport, ContentDirectory)
2222+ │
2323+ UPnP Device Description (XML, fetched from LOCATION URL)
2424+ │
2525+ SSDP (UDP multicast 239.255.255.250:1900) ← device discovery
2626+ │
2727+ LAN
2828+```
2929+3030+**SSDP** (Simple Service Discovery Protocol) is how devices announce themselves
3131+and how control points find them. Each device sends periodic `NOTIFY` multicasts
3232+and responds to `M-SEARCH` queries.
3333+3434+**Device Description** is an XML document (fetched via HTTP from the `LOCATION`
3535+URL advertised in SSDP) that lists the device's friendly name, UDN (unique ID),
3636+and the services it supports, with their control/event URLs.
3737+3838+**SOAP** is the RPC mechanism. Every action — browse library, set transport URI,
3939+start playback — is a `POST` with an XML envelope to the service's `controlURL`.
4040+4141+### UPnP service roles
4242+4343+| UPnP term | Rockbox role | Description |
4444+|---------------------------------|----------------|----------------------------------------|
4545+| MediaServer / ContentDirectory | **server** | Hosts the music library for browsing |
4646+| MediaRenderer / AVTransport | **renderer** | Receives push-play commands |
4747+| Control Point | *external app* | BubbleUPnP, Kodi, Foobar2000, … |
4848+4949+---
5050+5151+## Feature 1 — Media Server (ContentDirectory)
5252+5353+When `upnp_server_enabled = true`, Rockbox starts a UPnP ContentDirectory
5454+service that exposes the tag database (artists, albums, tracks).
5555+5656+### What a control point sees
5757+5858+```
5959+Root
6060+├── Artists
6161+│ └── Daft Punk
6262+│ └── Random Access Memories
6363+│ └── Get Lucky ← streamable via HTTP
6464+├── Albums
6565+│ └── ...
6666+└── Tracks
6767+ └── ...
6868+```
6969+7070+Each track is served as a direct HTTP stream from the Rockbox GraphQL port
7171+(`http://<ip>:<graphql_port>/tracks/<id>/stream`). Control points can play
7272+individual tracks or enqueue them.
7373+7474+### Discovery flow
7575+7676+```
7777+rockboxd starts
7878+ └─ SSDP NOTIFY sent every 30 s to 239.255.255.250:1900
7979+ └─ HTTP server on upnp_server_port (default 7878) answers:
8080+ GET /device.xml → UPnP device description
8181+ POST /ContentDirectory → Browse / Search actions (SOAP)
8282+ GET /tracks/<id>/stream → audio file bytes
8383+```
8484+8585+A control point (e.g. BubbleUPnP) that receives the NOTIFY fetches
8686+`/device.xml`, then issues `Browse` actions to walk the tree.
8787+8888+---
8989+9090+## Feature 2 — MediaRenderer (AVTransport)
9191+9292+When `upnp_renderer_enabled = true`, Rockbox advertises itself as a
9393+`urn:schemas-upnp-org:device:MediaRenderer:1`. A control point can then push
9494+any URI to Rockbox and tell it to play.
9595+9696+### Push-play flow (control point → Rockbox)
9797+9898+```
9999+Control Point Rockbox (renderer)
100100+ │ │
101101+ │── SetAVTransportURI ─────────────>│ URL + DIDL-Lite metadata
102102+ │ │ (title, artist, album, album art)
103103+ │── Play ────────────────────────> │ start playback
104104+ │ │
105105+ │── Pause / Stop / Seek ──────────> │
106106+ │ │
107107+ │── GetPositionInfo ─────────────> │
108108+ │<─ TrackDuration, RelTime ─────────│
109109+```
110110+111111+**DIDL-Lite** (Digital Item Declaration Language Lite) is the XML format used
112112+to carry track metadata in `SetAVTransportURI`. Rockbox parses the
113113+`CurrentURIMetaData` field, XML-unescapes it, and extracts:
114114+115115+- `<dc:title>` — track title
116116+- `<upnp:artist>` — artist name
117117+- `<upnp:album>` — album name
118118+- `<upnp:albumArtURI>` — album art URL
119119+- `<res duration="H:MM:SS.mmm">` — track duration
120120+121121+Rockbox stores this and returns it in `GetPositionInfo` / `GetMediaInfo`
122122+responses so control points can display a progress bar.
123123+124124+### Supported AVTransport actions
125125+126126+| Action | Behaviour |
127127+|-----------------------|------------------------------------------------------------------------------------|
128128+| `SetAVTransportURI` | Store URI + parse DIDL-Lite metadata; open the stream in the Rockbox audio engine |
129129+| `Play` | Start or resume playback |
130130+| `Pause` | Pause/resume toggle |
131131+| `Stop` | Stop playback; clear stored metadata |
132132+| `Seek` | Seek to absolute time (REL_TIME target unit) |
133133+| `GetTransportInfo` | Return current transport state (PLAYING / PAUSED_PLAYBACK / STOPPED) |
134134+| `GetPositionInfo` | Return track URI, DIDL-Lite metadata, duration, elapsed time |
135135+| `GetMediaInfo` | Return current URI and DIDL-Lite metadata |
136136+137137+---
138138+139139+## Feature 3 — PCM / WAV output sink
140140+141141+When `audio_output = "upnp"`, Rockbox encodes its live PCM output as a
142142+continuous WAV stream and broadcasts it over HTTP, then commands an external
143143+UPnP renderer to play that stream.
144144+145145+### Architecture
146146+147147+```
148148+Rockbox audio engine (S16LE stereo PCM)
149149+ │
150150+ pcm_upnp_write() ← called per DMA chunk
151151+ │
152152+ BroadcastBuffer (4 MB ring)
153153+ │
154154+ HTTP server (:upnp_http_port)
155155+ GET /stream.wav ──────────────────────────────────→ UPnP renderer
156156+ (Kodi, VLC, etc.)
157157+ │
158158+ AVTransport SOAP (SetAVTransportURI + Play)
159159+ ┌───────────────────────────────────────────────────> upnp_renderer_url
160160+ │ CurrentURI = http://<local_ip>:<port>/stream.wav
161161+ │ CurrentURIMetaData = DIDL-Lite with:
162162+ │ title, artist, album, albumArtURI, duration
163163+ │ (art URL: http://<local_ip>:<graphql_port>/covers/<filename>)
164164+ │ (album art fetched from local SQLite library DB by track path)
165165+ └
166166+```
167167+168168+### Track-change metadata updates
169169+170170+A background task polls `current_track()` every 2 seconds. When the track path
171171+changes it sends a new `SetAVTransportURI` with updated DIDL-Lite metadata
172172+(`send_play = false` so the renderer does not restart the stream — the WAV
173173+HTTP connection is continuous). This keeps the renderer's "Now Playing" display
174174+accurate across track boundaries without interrupting audio.
175175+176176+### WAV stream format
177177+178178+```
179179+Content-Type: audio/wav
180180+Transfer-Encoding: chunked (infinite stream)
181181+Audio format: PCM S16LE, 2 channels, sample rate = upnp_http_port
182182+ standard 44-byte WAV header followed by raw PCM forever
183183+```
184184+185185+The `BroadcastBuffer` is a 4 MB lock-free ring. Multiple HTTP clients can
186186+connect simultaneously (each with an independent read cursor), lagging clients
187187+skip forward rather than blocking the writer.
188188+189189+---
190190+191191+## Settings reference (`~/.config/rockbox.org/settings.toml`)
192192+193193+### Audio output — PCM sink to an external renderer
194194+195195+```toml
196196+audio_output = "upnp"
197197+198198+# URL of the renderer's AVTransport controlURL (required for metadata push)
199199+upnp_renderer_url = "http://192.168.1.x:7777/AVTransport/control"
200200+201201+# Port for the WAV HTTP broadcast server (default: 7879)
202202+upnp_http_port = 7879
203203+```
204204+205205+To find `upnp_renderer_url`: on the renderer device look in its UPnP device
206206+description XML (`LOCATION` from SSDP), find the `AVTransport` service block,
207207+and read the `controlURL` element. Or enable the auto-scan (see below) and copy
208208+the URL logged at startup.
209209+210210+### Media Server — expose library to control points
211211+212212+```toml
213213+upnp_server_enabled = true
214214+upnp_server_port = 7878 # HTTP port for ContentDirectory + device.xml
215215+upnp_friendly_name = "Rockbox" # name shown in control points
216216+```
217217+218218+### MediaRenderer — let control points push media to Rockbox
219219+220220+```toml
221221+upnp_renderer_enabled = true
222222+upnp_renderer_port = 7880 # HTTP port for AVTransport + device.xml
223223+upnp_friendly_name = "Rockbox"
224224+```
225225+226226+### All UPnP settings at a glance
227227+228228+| Key | Type | Default | Description |
229229+|--------------------------|-----------|--------------|------------------------------------------------|
230230+| `audio_output` | string | `"builtin"` | Set to `"upnp"` to use the PCM sink |
231231+| `upnp_renderer_url` | string | — | AVTransport controlURL of the target renderer |
232232+| `upnp_http_port` | integer | `7879` | Port for the WAV broadcast HTTP server |
233233+| `upnp_server_enabled` | bool | `false` | Start the ContentDirectory media server |
234234+| `upnp_server_port` | integer | `7878` | HTTP port for the media server |
235235+| `upnp_renderer_enabled` | bool | `false` | Start the MediaRenderer |
236236+| `upnp_renderer_port` | integer | `7880` | HTTP port for the renderer |
237237+| `upnp_friendly_name` | string | `"Rockbox"` | Display name shown to control points |
238238+239239+---
240240+241241+## Using Kodi as a UPnP renderer
242242+243243+1. In Kodi: **Settings → Services → UPnP/DLNA** → enable "Allow remote control via UPnP".
244244+245245+2. Find Kodi's AVTransport URL. Either:
246246+ - Run `rockboxd` with `RUST_LOG=info` and look for
247247+ `upnp scan: found renderer "Kodi" av=http://...`
248248+ (Rockbox scans the LAN for renderers at startup).
249249+ - Or browse Kodi's device description at
250250+ `http://<kodi-ip>:1234/upnpms/device.xml` and find the
251251+ `AVTransport` `controlURL`.
252252+253253+3. Set `settings.toml`:
254254+ ```toml
255255+ audio_output = "upnp"
256256+ upnp_renderer_url = "http://192.168.1.x:1234/upnpms/event"
257257+ ```
258258+259259+4. Start `rockboxd` and play a track. Kodi will show the track title, artist,
260260+ album, and album art.
261261+262262+---
263263+264264+## LAN renderer auto-discovery
265265+266266+At startup, Rockbox sends an SSDP `M-SEARCH` for
267267+`urn:schemas-upnp-org:device:MediaRenderer:1` and logs all discovered
268268+renderers. The scan result is also available through the HTTP/gRPC API (device
269269+list endpoint), so the UI can offer a renderer picker.
270270+271271+Discovery sequence:
272272+273273+```
274274+rockboxd LAN
275275+ │── M-SEARCH (UDP multicast) ───>│
276276+ │<─ 200 OK LOCATION: http://... │ (one reply per renderer)
277277+ │── GET <LOCATION> ─────────────>│ fetch device description XML
278278+ │<─ device.xml ──────────────────│
279279+ │ parse: friendlyName, UDN, AVTransport controlURL
280280+ │ → Device { app: UPNP_DLNA, base_url: controlURL }
281281+```
282282+283283+---
284284+285285+## Crate layout
286286+287287+```
288288+src/
289289+ lib.rs — FFI exports (pcm_upnp_*), BroadcastBuffer, global state,
290290+ AVTransport SOAP client, track-change monitor
291291+ db.rs — SQLite helpers (open_pool, track_by_path, all_tracks, …)
292292+ didl.rs — DIDL-Lite XML parser (for incoming SetAVTransportURI)
293293+ format.rs — WAV header builder
294294+ pcm_server.rs — HTTP broadcast server (one goroutine per client)
295295+ renderer.rs — MediaRenderer HTTP handler (AVTransport SOAP endpoint)
296296+ scan.rs — SSDP M-SEARCH + device description probe
297297+ server.rs — ContentDirectory HTTP handler (Browse/Search SOAP)
298298+ ssdp.rs — SSDP NOTIFY announcer
299299+```