[READ-ONLY] Mirror of https://github.com/mrgnw/dlna-rs. Single-binary DLNA media server in Rust. Auto-discovered by VLC on Apple TV.
0

Configure Feed

Select the types of activity you want to include in your feed.

design: dlna-rs MVP spec

author
Morgan Williams
date (May 9, 2026, 11:25 PM +0200) commit f26be734
+235
+235
docs/superpowers/specs/2026-05-09-dlna-rs-design.md
··· 1 + # dlna-rs — design 2 + 3 + A single-binary DLNA/UPnP media server in Rust that exposes local folders to LAN devices (target: VLC on AppleTV). Includes an embedded Svelte web UI for managing the advertised folders. 4 + 5 + ## Goals 6 + 7 + - One binary, run from a terminal. 8 + - Auto-discovered by VLC on AppleTV via UPnP/DLNA. 9 + - Configurable via `~/.config/dlna-rs/config.styx` and CLI flags. 10 + - Multiple folders, each with optional recursion. 11 + - `/status` endpoint with top-level folder names and file counts. 12 + - Tiny embedded web UI to add/remove folders, with a server-side folder browser modal. 13 + 14 + ## Non-goals (MVP) 15 + 16 + Transcoding, auth, thumbnails, subtitles handling, NFO/metadata parsing, watcher-based live re-indexing. 17 + 18 + ## Architecture 19 + 20 + Three concurrent subsystems, all spawned on tokio in the same process: 21 + 22 + 1. **SSDP responder** — UDP multicast on `239.255.255.250:1900`. Provided by `upnp-serve` (rqbit). 23 + 2. **HTTP server (axum)** — bound to `0.0.0.0:<port>` (default `9119`). Routes: 24 + - UPnP routes (`/device.xml`, `/scpd.xml`, `/soap`, …) — provided by `upnp-serve` router. 25 + - `/stream/{file_id}` — our handler, Range + DLNA headers. 26 + - `/api/*` — admin JSON API. 27 + - `/status` — curl-friendly status text/JSON. 28 + - `/` — embedded Svelte SPA (rust-embed fallback). 29 + 3. **Library** — shared `Arc<RwLock<Library>>` holding folders + cached counts + `file_id ↔ PathBuf` map. 30 + 31 + All three subsystems share the `Library` via `Arc`. SSDP and HTTP both come from `upnp-serve`; we extend the axum router with our own routes before serving. 32 + 33 + ``` 34 + ┌──────────────────────────┐ 35 + AppleTV VLC ──▶│ SSDP responder (UDP 1900)│ (upnp-serve) 36 + └──────────────────────────┘ 37 + ┌──────────────────────────┐ 38 + AppleTV VLC ──▶│ UPnP HTTP │ (upnp-serve router) 39 + │ /device.xml, /scpd.xml │ 40 + │ /soap (ContentDir) │ 41 + ├──────────────────────────┤ 42 + AppleTV VLC ──▶│ /stream/<id> (Range) │ (our axum routes) 43 + │ /status │ 44 + Web browser ──▶│ /api/folders, /api/browse│ 45 + │ / (embedded Svelte SPA) │ 46 + └──────────────────────────┘ 47 + :9119 (single port) 48 + ``` 49 + 50 + ## Crate layout 51 + 52 + ``` 53 + src/ 54 + main.rs — clap CLI, load config, spawn tasks, graceful shutdown 55 + config.rs — styx parse/serialize, atomic write (tmp+rename) 56 + library.rs — Library, Folder, file_id allocation, walkdir count 57 + browse.rs — impl ContentDirectoryBrowseProvider (lazy readdir) 58 + stream.rs — GET /stream/{file_id} with Range + DLNA headers 59 + api.rs — admin JSON: /api/folders, /api/browse, /api/status, /status 60 + web.rs — rust-embed fallback handler for `/` 61 + web/ — Svelte 5 source (SPA, vite build to web/dist) 62 + ``` 63 + 64 + ## Library 65 + 66 + ```rust 67 + struct Library { 68 + folders: Vec<Folder>, 69 + files: HashMap<FileId, PathBuf>, // stable id → path 70 + by_path: HashMap<PathBuf, FileId>, // reverse, for stable ids 71 + next_id: u64, 72 + } 73 + 74 + struct Folder { 75 + path: PathBuf, 76 + recursive: bool, 77 + file_count: usize, // last computed; updated on add and on /folders/refresh 78 + } 79 + ``` 80 + 81 + - `FileId` = monotonic u64 (or stable hash of canonical path — TBD; monotonic is simpler). 82 + - `add_folder(path, recursive)`: canonicalize, walk with `walkdir` honoring `recursive`, count files (not dirs), persist to config. 83 + - `remove_folder(path)`: drop from list, prune ids whose paths are under it, persist. 84 + - `browse(object_id)`: invoked by the ContentDirectoryBrowseProvider. Translates DLNA object IDs to filesystem paths. Lazy `read_dir`; allocates a `FileId` for any new file encountered and returns a UPnP item with `<res>` URL pointing at `/stream/{file_id}`. 85 + 86 + ## DLNA object id scheme 87 + 88 + `upnp-serve`'s `ContentDirectoryBrowseProvider` is given an opaque `object_id`. We use a single u64 id space; the `Library` maps each id to either a file or directory `PathBuf` plus a kind tag: 89 + 90 + ```rust 91 + enum Entry { File(PathBuf), Dir(PathBuf) } 92 + files: HashMap<u64, Entry> 93 + ``` 94 + 95 + - `"0"` = root (lists each configured folder as a child container). 96 + - Any other id is the stringified u64 from the map. 97 + 98 + Ids are allocated on demand the first time `browse` encounters a path (file or directory). Stable for the process lifetime; not persisted across restarts (acceptable — VLC re-browses on reconnect). 99 + 100 + ## Streaming handler 101 + 102 + `GET /stream/{file_id}` mirrors rqbit's behavior: 103 + 104 + - Look up path; 404 if unknown. 105 + - `Accept-Ranges: bytes` always. 106 + - Parse `Range` header (`http-range-header` or hand-rolled). Multi-range is **not** supported — first range only or 416. 107 + - 200 full, or 206 partial with `Content-Range: bytes {s}-{e}/{total}` and `Content-Length`. 108 + - `Content-Type` via `mime_guess::from_path`. 109 + - DLNA headers: 110 + - `transferMode.dlna.org: Streaming` 111 + - `contentFeatures.dlna.org: DLNA.ORG_OP=01;DLNA.ORG_CI=0` 112 + - Body via `tokio_util::io::ReaderStream` with 64 KiB chunks; `tokio::fs::File::seek` to start. 113 + 114 + ## JSON API 115 + 116 + ``` 117 + GET /status → text/plain, one line per folder 118 + GET /api/status → JSON: { friendly_name, port, folders: [...] } 119 + GET /api/folders → [{ path, recursive, file_count }] 120 + POST /api/folders { path, recursive } → 201 {folder} 121 + DELETE /api/folders { path } → 204 122 + POST /api/folders/refresh { path } → 200 { file_count } 123 + GET /api/browse?path=… → { path, parent: path|null, entries: [{name, is_dir}] } 124 + ``` 125 + 126 + `/api/browse` lists immediate children of an absolute server path; entries are sorted directories-first, then alphabetical. Symlinks are followed but never escape root checks (no chroot — by design, this is a local LAN tool the user runs themselves). 127 + 128 + `POST /api/folders` and `DELETE /api/folders` rewrite the config file on success (atomic tmp-rename). 129 + 130 + ## Config 131 + 132 + `~/.config/dlna-rs/config.styx` (override with `--config <path>`). 133 + 134 + ```styx 135 + port: 9119 136 + friendly_name: "dlna-rs ({hostname})" 137 + 138 + folders: [ 139 + { path: "/Users/m/Movies", recursive: true } 140 + { path: "/Volumes/external/tv", recursive: true } 141 + ] 142 + ``` 143 + 144 + CLI flags (clap): 145 + 146 + ``` 147 + --config <PATH> default: ~/.config/dlna-rs/config.styx 148 + --port <PORT> override config 149 + --friendly-name <NAME> override config 150 + --folder <PATH> repeatable; appended to config folders (recursive=true) 151 + --folder-flat <PATH> repeatable; appended (recursive=false) 152 + ``` 153 + 154 + `{hostname}` is templated at startup via the `hostname` crate. Missing config file = create with defaults on first save. 155 + 156 + ## Web UI (Svelte 5 SPA) 157 + 158 + Built with vite to `web/dist/`, embedded at compile time via `rust-embed`. Served at `/`. 159 + 160 + Single page. Plain `fetch` to the JSON API. Svelte 5 runes (`$state`, `$derived`, no `$effect`). 161 + 162 + Layout (per design-data: narrow ~720px, dense, no decoration): 163 + 164 + ``` 165 + dlna-rs 9119 · friendly-name 166 + ───────────────────────────────────────────────────────────────── 167 + /Users/m/Movies ↻ 423 files recursive ✕ 168 + /Volumes/external/tv ↻ 88 files recursive ✕ 169 + + add folder… 170 + ``` 171 + 172 + Add-folder modal: 173 + 174 + - Fetches `/api/browse?path=$HOME` initially. 175 + - Breadcrumbs across the top, list of subdirectories below. 176 + - "Select this folder" button + recursive checkbox. 177 + - POSTs to `/api/folders`, closes on success. 178 + 179 + `↻` re-runs `/api/folders/refresh`. `✕` calls `DELETE /api/folders` after a `confirm()`. 180 + 181 + No SvelteKit; pure SPA. Build script: `cd web && bun install && bun run build`. Cargo build runs this via `build.rs`. 182 + 183 + ## Dependencies (Cargo.toml) 184 + 185 + ```toml 186 + [dependencies] 187 + tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "signal", "sync"] } 188 + axum = "0.7" 189 + tower = "0.5" 190 + clap = { version = "4", features = ["derive"] } 191 + serde = { version = "1", features = ["derive"] } 192 + serde_json = "1" 193 + walkdir = "2" 194 + mime_guess = "2" 195 + rust-embed = "8" 196 + anyhow = "1" 197 + tracing = "0.1" 198 + tracing-subscriber = { version = "0.3", features = ["env-filter"] } 199 + hostname = "0.4" 200 + url = "2" 201 + percent-encoding = "2" 202 + http-range-header = "0.4" 203 + styx = "…" # pin latest 204 + upnp-serve = { git = "https://github.com/ikatson/rqbit", rev = "<pinned-rev>" } 205 + tokio-util = { version = "0.7", features = ["io"] } 206 + ``` 207 + 208 + Pin the rqbit revision (specific commit hash) to avoid surprise breakage. 209 + 210 + ## Failure modes & handling 211 + 212 + - **Port already in use** → exit with clear error, suggest `--port`. 213 + - **SSDP bind fails** (no multicast) → log warning, continue HTTP-only (still useful via direct URL in VLC). 214 + - **Folder no longer exists** at startup → log warning, keep entry, file_count = 0, browse returns empty container. 215 + - **File deleted between browse and stream** → 404. 216 + - **Config file unparseable** → exit with clear error and the parse position. 217 + - **Folder add via API points at non-existent path** → 400 with message. 218 + - **Concurrent config writes** → serialized through the `Library`'s lock; atomic rename means readers never see torn files. 219 + 220 + ## Testing strategy 221 + 222 + MVP is exploratory; primary verification is "does VLC on AppleTV auto-detect and play?". In addition: 223 + 224 + - Unit: config parse/serialize round-trip; range header parsing; `Library` add/remove/refresh. 225 + - Integration (axum): `GET /api/folders`, `POST /api/folders`, `DELETE`, `GET /api/browse`, Range request on a fixture file. 226 + - Manual: launch with a real folder, browse from VLC desktop and AppleTV. 227 + 228 + ## Out of scope (MVP) 229 + 230 + - Authentication / TLS / rate-limiting. 231 + - Transcoding, thumbnails, subtitle muxing. 232 + - Persistent file_id across restarts. 233 + - Live filesystem watching. 234 + - Multi-user / multi-library. 235 + - Cross-platform packaging (macOS dev only for MVP).