|
| 1 | +# @sonn-audio/node-upnp |
| 2 | + |
| 3 | +A UPnP AV toolkit for Node — **protocol only, you inject your own content and |
| 4 | +playback.** Zero runtime dependencies. |
| 5 | + |
| 6 | +It gives you three drop-in frameworks plus the lower-level primitives they're |
| 7 | +built from. Each framework is free of any app-specific content or playback model: |
| 8 | +you supply those through a small interface, and the module handles SSDP discovery, |
| 9 | +SOAP control, GENA eventing, DIDL-Lite and the device/SCPD descriptions. |
| 10 | + |
| 11 | +| You want to be a… | Use | You provide | |
| 12 | +| --- | --- | --- | |
| 13 | +| **MediaServer** (others browse & pull your content) | `UpnpMediaServer` | a `ContentProvider` | |
| 14 | +| **MediaRenderer** (others cast to you) | `UpnpMediaRenderer` | a `RendererHandler` | |
| 15 | +| **Control point** (you push to an external renderer) | `DlnaControlPoint` | a stream URI + DIDL | |
| 16 | + |
| 17 | +All devices you expose share one `SsdpAdvertiser` — a single `:1900` UDP socket |
| 18 | +announces every one of them and answers `M-SEARCH`. |
| 19 | + |
| 20 | +## Install |
| 21 | + |
| 22 | +```sh |
| 23 | +npm install @sonn-audio/node-upnp |
| 24 | +``` |
| 25 | + |
| 26 | +Ships CommonJS (`require`) and ESM (`import`) builds plus `.d.ts` types. Node 18+ |
| 27 | +(uses global `fetch`, `AbortController`). |
| 28 | + |
| 29 | +## Be a MediaServer |
| 30 | + |
| 31 | +Answer `Browse` over your own catalogue by mapping it onto neutral DIDL shapes. |
| 32 | +The module never sees your content model. |
| 33 | + |
| 34 | +```ts |
| 35 | +import http from 'node:http'; |
| 36 | +import { SsdpAdvertiser, UpnpMediaServer, ROOT_OBJECT_ID } from '@sonn-audio/node-upnp'; |
| 37 | + |
| 38 | +const provider = { |
| 39 | + async browse(objectId, offset, limit) { |
| 40 | + if (objectId !== ROOT_OBJECT_ID) return { objects: [], total: 0 }; |
| 41 | + return { |
| 42 | + objects: [{ |
| 43 | + id: 'track/1', |
| 44 | + parentId: ROOT_OBJECT_ID, |
| 45 | + title: 'Ocean Drive', |
| 46 | + artist: 'Demo Artist', |
| 47 | + upnpClass: 'object.item.audioItem.musicTrack', |
| 48 | + resources: [{ url: 'http://host/media/1.mp3', protocolInfo: 'http-get:*:audio/mpeg:*' }], |
| 49 | + }], |
| 50 | + total: 1, |
| 51 | + }; |
| 52 | + }, |
| 53 | +}; |
| 54 | + |
| 55 | +const server = new UpnpMediaServer({ |
| 56 | + udn: 'uuid:...', |
| 57 | + friendlyName: () => 'My Server', |
| 58 | + baseUrl: () => 'http://192.168.1.10:7799/dms', |
| 59 | + provider, |
| 60 | +}); |
| 61 | + |
| 62 | +// Route requests under your base path to the framework: |
| 63 | +const httpServer = http.createServer((req, res) => { |
| 64 | + const path = new URL(req.url, 'http://x').pathname; |
| 65 | + if (path.startsWith('/dms/')) server.handle(req, res, path.slice('/dms/'.length)); |
| 66 | +}); |
| 67 | +httpServer.listen(7799); |
| 68 | + |
| 69 | +const ssdp = new SsdpAdvertiser(); |
| 70 | +ssdp.addDevice({ udn: server.udn, ...server.deviceTypeAndServices(), |
| 71 | + location: () => 'http://192.168.1.10:7799/dms/device.xml' }); |
| 72 | +await ssdp.start(); |
| 73 | +``` |
| 74 | + |
| 75 | +`ContentProvider`: |
| 76 | + |
| 77 | +```ts |
| 78 | +interface ContentProvider { |
| 79 | + browse(objectId: string, offset: number, limit: number): Promise<BrowseResult>; |
| 80 | + browseMetadata?(objectId: string): Promise<DidlContainer | DidlItem | null>; |
| 81 | +} |
| 82 | +``` |
| 83 | + |
| 84 | +Return `DidlContainer` for browsable folders, `DidlItem` (with `resources`) for |
| 85 | +playable tracks. The module builds and escapes the DIDL-Lite for you. |
| 86 | + |
| 87 | +## Be a MediaRenderer |
| 88 | + |
| 89 | +Accept a cast from any control point and drive your own engine. |
| 90 | + |
| 91 | +```ts |
| 92 | +import { UpnpMediaRenderer } from '@sonn-audio/node-upnp'; |
| 93 | + |
| 94 | +const renderer = new UpnpMediaRenderer({ |
| 95 | + udn: 'uuid:...', |
| 96 | + friendlyName: () => 'My Renderer', |
| 97 | + baseUrl: () => 'http://192.168.1.10:7799/dmr', |
| 98 | + handler: { |
| 99 | + onSetUri: (uri, meta) => console.log('will play', uri, meta?.title), |
| 100 | + onPlay: (uri, atSec) => myEngine.play(uri, atSec), |
| 101 | + onPause: () => myEngine.pause(), |
| 102 | + onStop: () => myEngine.stop(), |
| 103 | + onSeek: (sec) => myEngine.seek(sec), |
| 104 | + onVolume: (pct) => myEngine.setVolume(pct), |
| 105 | + // Optional: report your real position so controllers show an accurate timeline. |
| 106 | + getPosition: () => ({ elapsed: myEngine.elapsed(), duration: myEngine.duration() }), |
| 107 | + }, |
| 108 | +}); |
| 109 | +// Route /dmr/* to renderer.handle(req, res, sub) and advertise it on the SsdpAdvertiser. |
| 110 | +``` |
| 111 | + |
| 112 | +The renderer answers `GetTransportInfo` / `GetPositionInfo`, pushes GENA |
| 113 | +`LastChange` events so controllers reflect play/pause and a timeline, and handles |
| 114 | +volume/mute. When your engine changes state outside UPnP, call |
| 115 | +`renderer.reflectTransportState(...)` / `renderer.reflectVolume(...)` to push it |
| 116 | +back to subscribers. |
| 117 | + |
| 118 | +## Be a control point (push to an external renderer) |
| 119 | + |
| 120 | +Drive an external AVTransport renderer — the inverse of the above. This carries |
| 121 | +the hard-won device-quirk handling: silent-timeout-as-accepted on |
| 122 | +`SetAVTransportURI`, a `701 TRANSITIONING` retry on `Play`, and strict command |
| 123 | +serialization so overlapping Stop/SetURI/Play can't interleave and wedge a device. |
| 124 | + |
| 125 | +```ts |
| 126 | +import { DlnaControlPoint, buildDidl } from '@sonn-audio/node-upnp'; |
| 127 | + |
| 128 | +const cp = new DlnaControlPoint({ host: '192.168.1.42' }); // or { controlUrl } / autoDiscover |
| 129 | +const didl = buildDidl([{ |
| 130 | + id: '0', parentId: '-1', title: 'Ocean Drive', |
| 131 | + resources: [{ url: streamUri, protocolInfo: 'http-get:*:audio/mpeg:DLNA.ORG_PN=MP3' }], |
| 132 | +}]); |
| 133 | + |
| 134 | +await cp.setUri(streamUri, didl); // full Stop → SetURI → Play, with the quirks handled |
| 135 | +await cp.setVolume(40); |
| 136 | + |
| 137 | +// Optional: receive the renderer's own state (knob turns, app-side play/pause): |
| 138 | +await cp.subscribeEvents({ |
| 139 | + onTransport: (e) => console.log('device state', e.transportState), |
| 140 | + onRendering: (e) => console.log('device volume', e.volume), |
| 141 | +}, myLanIp); |
| 142 | +``` |
| 143 | + |
| 144 | +## Lower-level primitives |
| 145 | + |
| 146 | +Exported for building device shapes the frameworks don't cover: |
| 147 | + |
| 148 | +- **SSDP** — `SsdpAdvertiser`, `resolveDlnaEndpoints`, `discoverDlnaDevices` |
| 149 | +- **DIDL-Lite** — `buildDidl`, `buildItemElement`, `buildContainerElement`, |
| 150 | + `parseDidlObject`, `readDidlField`, `readDidlDuration` |
| 151 | +- **SOAP** — `escapeXml`, `parseSoapAction`, `extractTag`, `buildSoapResponse`, |
| 152 | + `buildSoapRequest`, `buildSoapFault`, `extractFaultCode` |
| 153 | +- **GENA** — `DlnaEventSubscriber` |
| 154 | +- **Descriptions** — `buildDeviceDescription`, `SERVICE_TYPES`, `DEVICE_TYPES`, and |
| 155 | + the standard SCPD constants (`AV_TRANSPORT_SCPD`, `CONTENT_DIRECTORY_SCPD`, …) |
| 156 | +- **ID3** — `buildId3v2Tag` (prepend now-playing tags to a tagless MP3 stream so a |
| 157 | + pulling renderer reads title/artist from the audio itself) |
| 158 | + |
| 159 | +Inject an optional `logger` (`{ debug?, info?, warn?, error? }`) into any of them; |
| 160 | +omit it and the module is silent. |
| 161 | + |
| 162 | +## Runnable demo |
| 163 | + |
| 164 | +[`examples/demo-server.mjs`](examples/demo-server.mjs) stands up a MediaServer and |
| 165 | +a MediaRenderer on one HTTP server + one advertiser, using a made-up two-track |
| 166 | +catalogue. Build, run, then point any DLNA control point at your LAN: |
| 167 | + |
| 168 | +```sh |
| 169 | +npm run build && node examples/demo-server.mjs |
| 170 | +``` |
| 171 | + |
| 172 | +## License |
| 173 | + |
| 174 | +MIT |
0 commit comments