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