Imported from jsnjack/streambox (
AGENTS.md). Install upstream withnpx skills add jsnjack/streambox. Copyright stays with the author.
AGENTS.md
See AGENTS.universal.md and AGENTS.go.md for universal conventions. Refresh:
make standards
Overview
streambox is a minimal DLNA/UPnP media server for local-network video
playback on TVs and tablets. It scans a directory, advertises itself via SSDP,
serves files over HTTP, and exposes a small web UI for managing the library
(watch history, delete, refresh, restart the systemd unit). The server holds
no persistent identity — every process start is a fresh device.
Architecture
main.go Thin entry point — delegates to cmd.Execute.
cmd/
root.go Root cobra command: loads config, wires server +
ssdp, handles signals.
config.go `streambox config init` subcommand.
logger.go slog setup; defines LevelTrace and initLogger.
Fan-out handler: stderr (INFO or DEBUG) and an
optional trace file at TRACE level, set
independently by --debug and --trace.
internal/
config/
config.go TOML schema, defaults, loader. DefaultConfig is
the template written by `config init`.
loglevel/
loglevel.go Exports the custom slog `LevelTrace` constant so
any subsystem can log at trace level without
importing cmd.
media/
library.go Recursive scan, in-memory object index, title
cleaner, fsnotify-based change watcher.
flatten.go Optional watcher that moves video files out of
newly-detected subfolders into the root once
the subfolder's contents stop changing.
history.go Bounded watch history with undo buffer.
server/
server.go UPnP device + service descriptors,
ContentDirectory SOAP, file serving,
event subscriptions, web UI templates,
auto-regen state machine.
ssdp/
ssdp.go SSDP multicast discovery: NOTIFY broadcasts on
all physical IPv4 interfaces, M-SEARCH replies,
10s alive cadence, byebye-on-shutdown.
Key Flows
- Startup —
runServeloads defaults, overlays the TOML file (if any), then overlays CLI flags. Scans the media directory. Generates a fresh random UUID for this run and seedsSystemUpdateIDfromtime.Now().Unix()(spec-compliant baseline — a returning client that cached a value can't mistake it for "nothing changed"). Starts HTTP + SSDP goroutines, blocks on SIGINT/SIGTERM. - DLNA browse — TV sends SOAP
Browseto/contentdirectory/control.Server.browsewalksmedia.Library, builds a DIDL-Lite XML fragment, and returns it wrapped in a SOAP envelope. - File playback — TV fetches
/files/<id>.serveFileresolves the ID, records it inWatchHistory, and streams the file withhttp.ServeContent(range support, MIME, DLNA headers). File IDs are stable hashes of the on-disk path — survive restarts so partial playback can resume. - Library invalidation + auto-regen — fsnotify create/remove/rename
events debounced 2 s. On fire:
Library.Reloadrescans,BumpUpdateIDincrementsSystemUpdateIDand (a) NOTIFYs subscribers, (b) marks an auto-regen pending. A background ticker (every 5 s) fires the pending regen once a 30 s cooldown has elapsed: byebye-old + alive-new over SSDP, new in-memory UUID. TVs then see a brand-new device on their next discovery and fetch a fresh state. - Web UI delete —
/ui/deleteremoves the file from disk and reloads the library for an immediate UI refresh. The fsnotify-driven bump above handles TV notifications ~2 s later (deduplicated single event). - UPnP eventing — TVs SUBSCRIBE to
/contentdirectory/eventsand/connectionmanager/events. Each SUBSCRIBE with a CALLBACK header creates a SID; an unknown-SID renewal returns 412 so the control point must re-SUBSCRIBE; failed NOTIFYs evict the subscription on first failure (no 30-min ghost callbacks).
Build & Run
make check # full validation gate (fmt+vet+build+test+lint)
make build # multi-arch binaries under bin/
./streambox # INFO+ on stderr
./streambox --debug # DEBUG+ on stderr
./streambox --trace # INFO+ on stderr AND TRACE+ in /tmp/streambox.log
./streambox --debug --trace # DEBUG+ on stderr AND TRACE+ in /tmp/streambox.log
./streambox --media ~/Videos # override media dir
./streambox --version # print stamped version
./streambox config init # write default config to UserConfigDir
--debug and --trace are independent: stderr level reflects --debug,
the trace file (truncated on every start) reflects --trace. The "ready"
line streambox ready url=… name=… is always emitted at INFO so the
terminal shows readiness regardless of where logs go.
Smoke test: from another host on the same LAN, curl http://<host>:8080/ui
and verify the directory listing renders.
Configuration
- File location:
os.UserConfigDir()/streambox/config.toml(typically~/.config/streambox/config.toml, respects$XDG_CONFIG_HOME). - Format: TOML. Schema in
internal/config/config.go(Configstruct). - Override order (lowest → highest): defaults → TOML file → CLI flags.
--config/-coverrides the auto-detected file location.
No persistent state files. Identity (UUID, SystemUpdateID) is regenerated on every process start.
Design Decisions
- No persistent identity. The server has no
uuidorupdateidfile. Every start gets a fresh random UUID;SystemUpdateIDis seeded fromtime.Now().Unix(). This is paired with auto-regen, which churns the UUID on library changes anyway — persistent identity would be dead weight. TVs simply see "the StreamBox is a new device" each time, do fresh discovery + subscription, and end up with current state. - Auto-regen on library change. A bump (fsnotify-driven) marks
regenPending=true; a background ticker firesOnAutoRegenafter a 30 s cooldown.OnAutoRegencallsssdp.UpdateIdentity, which multicasts ssdp:byebye for the old UUID and ssdp:alive for the new one. SSDP alives also continue at the regular 10 s cadence, so a TV that wakes within ~10 s of the alive burst still picks up the new device. - fsnotify with 2 s debounce. Bulk file operations fire many events; rescanning per event would thrash. The debounce coalesces bursts.
- All physical IPv4 interfaces for SSDP. Virtual interfaces (docker, veth, virbr, tun/tap) are filtered — they cause spurious NOTIFY traffic and occasional TV duplicate-device bugs.
- LG TV cache workaround (
recent_buckets). LG TVs cache directory listings aggressively per-container-ID. Auto-regen helps via fresh device identity, but if a TV stays on a folder it has already cached, it won't re-Browse it. Settingrecent_buckets = NcreatesRecent 1,Recent 2, …, each a distinct container ID — navigating to an unvisited bucket forces a fresh fetch. - Manual "Regenerate UUID" button. Same flow as auto-regen; kept as an emergency lever for stuck clients.
- Fan-out logging via
slog. A single multi-handler dispatches each record to stderr (INFO or DEBUG depending on--debug) and, if--traceis set, also to/tmp/streambox.logat TRACE level. Both destinations are filtered independently.
Gotchas
Library.Reloadconstructs a freshLibraryand copies its fields under the write lock — callers keep their*Librarypointer.Watchreturns once watchers are wired and runs the loop in a goroutine. An error fromfsnotifyafter startup logs and continues (the function has already returned).- The
Recentvirtual folder reuses item IDs fromAll.parentCtxin DIDL output is the container being browsed, not the item's real parent, so back-navigation from insideRecentworks on TVs. flattenmode only watches direct children of the media root. Events for deeper paths are ignored on purpose.- Auto-regen uses
regenCheckInterval = 5 sticker +regenCooldown = 30 s; worst-case latency from a library change to actual regen is ~debounce + cooldown + checkInterval≈ 37 s. - The auto-regen
OnAutoRegencallback is nil-checked rather than asserted — tests construct aServerwithout it.