Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion docs/features/dlna.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ Object IDs are **string prefixes**, routed by [`cds.rs`](../../src-tauri/crates/
0/track/<id> Single-track BrowseMetadata payload
```

Every id that a `BrowseDirectChildren` can return also answers `BrowseMetadata` with exactly one object: controllers read a folder's metadata before opening it, or before queueing it whole, and an empty answer shows as an empty folder. Every container carries a `childCount` (index-backed `COUNT` subqueries; an album counts only its available tracks, as its listing does). The `parentID` a `BrowseMetadata` reports:

| Object | `parentID` | Why |
| ----------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `0` | `-1` | The root, per the spec. |
| `0/artists`, `0/albums` | `0` | |
| `0/artists/<id>` | `0/artists` | |
| `0/albums/<id>` | `0/albums` | Also listed under its artist, but its id lives under `0/albums`, which reaches it whatever the album's artist row says. |
| `0/track/<id>` | `0/albums/<id>` | Its album, the only container that lists it; `0` for a track without an album. |

An unknown id, or one whose row is gone, still answers an empty DIDL rather than a fault.

Pagination via `StartingIndex` + `RequestedCount` → SQL `LIMIT/OFFSET`, capped at `MAX_PAGE_SIZE = 500`. `RequestedCount = 0` (the spec's "all") folds to the same cap so a misbehaving controller can't pull 50k tracks into one DIDL document.

## DIDL-Lite items
Expand All @@ -67,6 +79,16 @@ The `transferMode.dlna.org: Streaming` and `contentFeatures.dlna.org` headers on

The device UUID is `Uuid::new_v5(NAMESPACE, server_name)` so controllers see the same `uuid:` URN across launches even when the LAN IP changes — no on-disk persistence needed.

## Which address is advertised

A desktop often carries a VPN, WSL, Hyper-V or Docker adapter next to its real LAN, frequently listed first, and an address on one of those is one a receiver cannot reach. So nothing advertises a single address picked once:

- **SSDP** joins the multicast group on **every** IPv4 interface (one that refuses the membership is skipped; only refusing them all fails). Each NOTIFY batch goes out of each interface with that interface's own address in `LOCATION`, and an M-SEARCH reply names the address on the requester's subnet — the preferred address when none matches.
- **HTTP** — `URLBase` in `/description.xml`, and every stream and cover URL in a Browse result, is built from the local address the request arrived on (`http::LocalAddr`, recorded through axum's `ConnectInfo`). The address a controller connected to is reachable from it by construction, whichever interface that is.
- **The preferred address** — the URL shown in Settings, and the fallback above — comes from [`lan_addrs`](../../src-tauri/crates/app/src/dlna/mod.rs), which ranks every non-loopback IPv4 address: a private address on a running, physical interface first; then a non-private one; then anything point-to-point, down, or named like a VPN / VM / container adapter (`vEthernet (…)`, `docker0`, `br-…`, `virbr0`, `tailscale0`, `wg0`, …); link-local (`169.254.x.x`) last. The name match is a heuristic, so it only demotes, never drops: a host whose only address looks virtual is still served on it. Within a rank the OS order holds.

The interface list is read once when the server starts. After a network change (new Wi-Fi, a VPN coming up), toggle the server off and on to pick it up. There is no setting to pin an interface.

## Range streaming

`/stream/<track_id>` parses the `Range:` header, replies with `206 Partial Content` + `Content-Range`, and pipes the file through `tokio_util::io::ReaderStream::with_capacity(64 KiB)`. `take(length)` caps the reader so we never overshoot the window even if the controller closes early.
Expand All @@ -76,7 +98,7 @@ The device UUID is `Uuid::new_v5(NAMESPACE, server_name)` so controllers see the
Failures inside the worker are surfaced through `DlnaStatus.last_error` so the Settings UI can display them inline:

- Bind failure (port in use, no privilege) → `"bind 0.0.0.0:1234: …"` and the server stays in the stopped state.
- SSDP socket failure → HTTP keeps serving for controllers that already know the LOCATION; only auto-discovery breaks. Surfaced as `"SSDP: …"`.
- SSDP socket failure → HTTP keeps serving for controllers that already know the LOCATION; only auto-discovery breaks. Surfaced as `"SSDP: …"` — including `"SSDP: no IPv4 network interface"` when the host has no address to advertise, rather than announcing `0.0.0.0`.

## Limitations / not implemented yet

Expand Down
2 changes: 2 additions & 0 deletions docs/features/mpd.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Persisted in the global `app_setting` table — the listener is process-wide, no

The server binds every interface, matching [DLNA](dlna.md). Settled in #471 for two reasons.

A client connects to us, so no address is advertised on the wire; the one in `MpdStatus.bound_address` is only what Settings displays. It is the DLNA server's preferred address ([which one](dlna.md#which-address-is-advertised)): a real LAN interface ahead of a VPN, WSL, Hyper-V or Docker adapter listed before it.

**It matches what we already ship.** [`dlna/mod.rs`](../../src-tauri/crates/app/src/dlna/mod.rs) binds `0.0.0.0` and the DLNA HTTP layer has no authentication at all (UPnP has no such concept). WaveFlow therefore already exposes an opt-in, default-off, LAN-bound, zero-auth service — one that exposes strictly _more_ than MPD control does:

| | DLNA | MPD |
Expand Down
Loading
Loading