# Phase 3 — Smart TV casting via DLNA/UPnP
## Context
Phase 1 (HTTP relay) and Phase 2 (Chromecast) are shipped. The relay in
`cast-relay.js` already serves a standard fMP4 stream over HTTP — any
DLNA-capable TV can play from that URL. What is missing is *discovery*
(finding the TV) and *control* (telling it to play).
## What exists
| Layer | File | Role |
|-------|------|------|
| Relay | `cast-relay.js` | HTTP server, BoxAccumulator, ring buffer, `finish()` for clean EOS |
| Chromecast | `cast-chromecast.js` | mDNS discovery, CASTV2 connect/reload/disconnect |
| IPC | `main.js` lines 743-795 | `cast:*` + `cast:chromecast:*` handlers |
| Bridge | `preload.js` | `cast.start/push/stop/finish/status/discover` + chromecast methods |
| Platform | `platform.js` | `cast.*` namespace, `capabilities.lanCast` gate |
| UI | `app.js` VideoPlayer | Cast button, device picker, seek-aware restart, generation counter |
The relay is device-agnostic — Chromecast and DLNA share it. Phase 3 adds a
second device backend alongside `cast-chromecast.js`, not a second relay.
## Protocol
DLNA media rendering uses two protocols:
1. **SSDP** (Simple Service Discovery Protocol) — UDP multicast on
`239.255.255.250:1900`. The client sends `M-SEARCH` for
`urn:schemas-upnp-org:device:MediaRenderer:1` and collects responses
(location URL → XML device description → friendly name, icon, services).
2. **UPnP AV** — SOAP over HTTP to the `AVTransport:1` service on the TV.
Key actions:
- `SetAVTransportURI(uri, metadata)` — point the TV at the relay URL.
`metadata` is a DIDL-Lite XML snippet with content type, title, etc.
- `Play` / `Stop` / `Pause`
- `Seek(target)` — if the TV supports it (many don't for live streams)
- `GetTransportInfo` — current state (PLAYING, STOPPED, etc.)
## Implementation plan
### 1. `cast-dlna.js` (~150-200 lines)
```
class CastDLNA {
constructor()
async discover(): Device[]
// Send M-SEARCH, collect SSDP responses for 6 seconds
// Fetch each device's XML description
// Parse friendly name + AVTransport control URL
// Return [{ id, name, host, controlUrl }]
async play(device, mediaUrl): void
// POST SOAP SetAVTransportURI to device.controlUrl
// POST SOAP Play
async stop(): void
// POST SOAP Stop to connected device
async getStatus(): { state, ... }
// POST SOAP GetTransportInfo
getConnectedDevice(): { name } | null
}
```
**Dependencies:** None required. SSDP is raw UDP, UPnP AV is HTTP+SOAP —
both can be done with Node builtins (`dgram`, `http`, `xml` parsing with a
small helper or regex for the limited DIDL-Lite we generate). Avoid pulling
`node-ssdp` or `upnp-client` — they're heavy and unmaintained. Keep it
self-contained like `cast-chromecast.js`.
SSDP implementation notes:
- Bind UDP socket to `0.0.0.0` (not the LAN IP — multicast responses come
back to the sending port)
- Send to `239.255.255.250:1900`
- `MX: 3` (max wait), `ST: urn:schemas-upnp-org:device:MediaRenderer:1`
- Parse `LOCATION:` header from responses, fetch the XML, extract
`friendlyName` and the `AVTransport` `controlURL`
UPnP AV SOAP envelope (SetAVTransportURI):
```xml
0
{mediaUrl}
{didlLite}
```
### 2. IPC channels
Add to `main.js`:
- `cast:dlna:discover` → `castDLNA.discover()`
- `cast:dlna:play` → `castDLNA.play(device, mediaUrl)`
- `cast:dlna:stop` → `castDLNA.stop()`
- `cast:dlna:status` → `castDLNA.getStatus()`
### 3. Bridge + platform
Add to `preload.js` `cast` object:
- `dlnaDiscover`, `dlnaPlay`, `dlnaStop`, `dlnaStatus`
Add to `platform.js` `cast` namespace:
- Same four methods
### 4. UI changes
The device picker already lists Chromecast devices. Extend it:
- Run `cast:discover` (Chromecast mDNS) and `cast:dlna:discover` (SSDP) in
parallel
- Display both in the picker, with a type indicator (cast icon vs TV icon)
- On click: if Chromecast → existing `chromecastConnect` flow; if DLNA →
`cast.start()` (relay) then `dlnaPlay(device, relayUrl)`
- Seek restart: same pattern as Chromecast — `cast.stop()`, `cast.start()`
with new init segment, then `dlnaPlay()` with the new URL (DLNA has no
`reload`, it's just SetAVTransportURI + Play again)
- `onStreamEnd` `finish()` should work as-is — the TV sees HTTP EOF
### 5. Firewall
SSDP needs UDP 1900 (outbound multicast + inbound responses). The relay
ports (TCP 19550-19553) are already opened for Chromecast. Add to the
firewall instructions:
```
sudo firewall-cmd --zone=FedoraWorkstation --add-port=1900/udp
```
## Key differences from Chromecast
| | Chromecast | DLNA/Smart TV |
|---|---|---|
| Discovery | mDNS (bonjour-service) | SSDP (raw UDP) |
| Control | CASTV2 (TLS, protobuf) | UPnP AV (HTTP, SOAP/XML) |
| Connection | Persistent (TLS socket) | Stateless (one SOAP call per action) |
| Reload | `player.load()` on same session | `SetAVTransportURI` + `Play` (new URL) |
| Dependencies | `castv2-client`, `bonjour-service` | None (Node builtins) |
| Status | Continuous (`player.on('status')`) | Polling (`GetTransportInfo`) |
The stateless nature of UPnP AV is actually simpler — no persistent
connection to manage, no error handler that can call `_cleanup()` and lose
state. Each action is a self-contained HTTP request.
## Edge cases to handle
- **TV goes to sleep during playback** — `GetTransportInfo` returns
`STOPPED` or the HTTP request times out. Surface this in the UI.
- **Multiple AVTransport services** — some TVs expose more than one. Pick
the first, or the one whose `serviceId` contains `AVTransport`.
- **Content-Type negotiation** — some TVs reject `video/mp4` for live
streams. The DIDL-Lite metadata should specify
`protocolInfo="http-get:*:video/mp4:*"`.
- **No seek support** — most TVs don't support `Seek` on live/progressive
streams. The seek-restart pattern (stop → new URL → play) handles this.
## Testing
Samsung, LG, and Sony TVs are the most common DLNA renderers. VLC also
acts as a DLNA renderer (`vlc --play-and-exit ` or via its DLNA
renderer mode) and is useful for development without a physical TV.
## Estimated scope
- `cast-dlna.js`: ~150-200 lines (SSDP + UPnP AV, no deps)
- `main.js`: ~20 lines (4 IPC handlers)
- `preload.js`: ~5 lines
- `platform.js`: ~15 lines
- `app.js`: ~30 lines (parallel discovery, device type routing)
- Total: ~250 lines of new code