Skip to content

Commit ad9fa8a

Browse files
committed
feat: initial @sonn-audio/node-upnp module
UPnP AV toolkit with zero runtime deps. Three app-agnostic frameworks: - UpnpMediaServer (inject a ContentProvider) - UpnpMediaRenderer (inject a RendererHandler) - DlnaControlPoint (push to an external renderer, with device-quirk handling) plus a shared SsdpAdvertiser and the underlying SSDP/SOAP/DIDL-Lite/GENA/ description/SCPD/ID3 primitives. Dual CJS+ESM build, typed. Includes a runnable demo consumer and a release workflow (npm publish --provenance on release).
0 parents  commit ad9fa8a

21 files changed

Lines changed: 4256 additions & 0 deletions

File tree

.github/workflows/release.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Release
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
jobs:
8+
publish:
9+
if: startsWith(github.event.release.tag_name, 'v')
10+
runs-on: ubuntu-latest
11+
permissions:
12+
contents: read
13+
id-token: write
14+
15+
steps:
16+
- name: Checkout
17+
uses: actions/checkout@v4
18+
19+
- name: Use Node.js
20+
uses: actions/setup-node@v4
21+
with:
22+
node-version: 20
23+
registry-url: https://registry.npmjs.org
24+
25+
- name: Install dependencies
26+
run: npm ci
27+
28+
- name: Build
29+
run: npm run build
30+
31+
- name: Ensure tag matches package version
32+
env:
33+
RELEASE_TAG: ${{ github.event.release.tag_name }}
34+
run: |
35+
PKG_VERSION=$(node -p "require('./package.json').version")
36+
TAG=${RELEASE_TAG#v}
37+
if [ "$PKG_VERSION" != "$TAG" ]; then
38+
echo "Package version $PKG_VERSION does not match tag v$TAG"
39+
exit 1
40+
fi
41+
42+
- name: Publish to npm
43+
env:
44+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
45+
run: npm publish --provenance --access public

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
dist
2+
node_modules
3+
tsconfig.tsbuildinfo

README.md

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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

examples/demo-server.mjs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// A self-contained consumer of @sonn-audio/node-upnp — proves the module is usable
2+
// by an app that knows nothing about lox-audioserver. It stands up a UPnP
3+
// MediaServer (browse a tiny made-up catalogue) and a MediaRenderer (accept a cast
4+
// and "play" it by logging), both on one HTTP server and one SSDP advertiser.
5+
//
6+
// npm run build && node examples/demo-server.mjs
7+
//
8+
// Then point any DLNA control point (BubbleUPnP, VLC → "demo server", Hi-Fi app) at
9+
// the LAN. Browsing shows two tracks; casting to "Demo Renderer" logs the URI.
10+
11+
import http from 'node:http';
12+
import os from 'node:os';
13+
import {
14+
SsdpAdvertiser,
15+
UpnpMediaServer,
16+
UpnpMediaRenderer,
17+
ROOT_OBJECT_ID,
18+
} from '../dist/esm/index.js';
19+
20+
const PORT = 7799;
21+
22+
// Pick a LAN IPv4 so LOCATION URLs are reachable by other devices.
23+
function lanIp() {
24+
for (const addrs of Object.values(os.networkInterfaces())) {
25+
for (const a of addrs ?? []) {
26+
if (a.family === 'IPv4' && !a.internal) return a.address;
27+
}
28+
}
29+
return '127.0.0.1';
30+
}
31+
const HOST = lanIp();
32+
const base = (p) => `http://${HOST}:${PORT}${p}`;
33+
34+
const logger = {
35+
debug: (m, meta) => console.log('[dbg]', m, meta ?? ''),
36+
info: (m, meta) => console.log('[inf]', m, meta ?? ''),
37+
warn: (m, meta) => console.warn('[wrn]', m, meta ?? ''),
38+
};
39+
40+
// ── A made-up content catalogue (the app's own model, mapped to DIDL shapes) ──────
41+
const TRACKS = [
42+
{
43+
id: 'track/1',
44+
title: 'Ocean Drive',
45+
artist: 'Demo Artist',
46+
album: 'Neutral Sessions',
47+
url: base('/media/track-1.mp3'),
48+
},
49+
{
50+
id: 'track/2',
51+
title: 'Night Shift',
52+
artist: 'Demo Artist',
53+
album: 'Neutral Sessions',
54+
url: base('/media/track-2.mp3'),
55+
},
56+
];
57+
58+
/** ContentProvider: the module asks, the app answers with neutral DIDL shapes. */
59+
const provider = {
60+
async browse(objectId, offset, limit) {
61+
if (objectId === ROOT_OBJECT_ID) {
62+
const objects = TRACKS.slice(offset, offset + limit).map((t) => ({
63+
id: t.id,
64+
parentId: ROOT_OBJECT_ID,
65+
title: t.title,
66+
artist: t.artist,
67+
album: t.album,
68+
upnpClass: 'object.item.audioItem.musicTrack',
69+
resources: [{ url: t.url, protocolInfo: 'http-get:*:audio/mpeg:*' }],
70+
}));
71+
return { objects, total: TRACKS.length };
72+
}
73+
return { objects: [], total: 0 };
74+
},
75+
async browseMetadata(objectId) {
76+
const t = TRACKS.find((x) => x.id === objectId);
77+
if (!t) return null;
78+
return {
79+
id: t.id,
80+
parentId: ROOT_OBJECT_ID,
81+
title: t.title,
82+
artist: t.artist,
83+
album: t.album,
84+
upnpClass: 'object.item.audioItem.musicTrack',
85+
resources: [{ url: t.url, protocolInfo: 'http-get:*:audio/mpeg:*' }],
86+
};
87+
},
88+
};
89+
90+
const advertiser = new SsdpAdvertiser({ serverHeader: 'demo/1.0 UPnP/1.0', logger });
91+
92+
const server = new UpnpMediaServer({
93+
udn: 'uuid:demo-server-0000-0000-000000000001',
94+
friendlyName: () => 'Demo Server',
95+
baseUrl: () => base('/dms'),
96+
provider,
97+
logger,
98+
});
99+
100+
const renderer = new UpnpMediaRenderer({
101+
udn: 'uuid:demo-renderer-0000-0000-00000001',
102+
friendlyName: () => 'Demo Renderer',
103+
baseUrl: () => base('/dmr'),
104+
handler: {
105+
onSetUri: (uri, meta) => console.log('renderer ← SetURI', uri, meta?.title ?? ''),
106+
onPlay: (uri, at) => console.log('renderer ▶ PLAY', uri, at != null ? `@${at}s` : ''),
107+
onPause: () => console.log('renderer ⏸ PAUSE'),
108+
onStop: () => console.log('renderer ⏹ STOP'),
109+
onVolume: (v) => console.log('renderer 🔊', v),
110+
},
111+
logger,
112+
});
113+
114+
// ── One HTTP server routes to both devices by path prefix ────────────────────────
115+
const httpServer = http.createServer((req, res) => {
116+
const url = new URL(req.url ?? '/', base(''));
117+
const path = url.pathname;
118+
if (path.startsWith('/dms/')) return void server.handle(req, res, path.slice('/dms/'.length));
119+
if (path.startsWith('/dmr/')) return void renderer.handle(req, res, path.slice('/dmr/'.length));
120+
if (path.startsWith('/media/')) {
121+
// The app serves its own audio however it likes. For the demo, 404 (no real bytes).
122+
res.writeHead(404, { 'Content-Type': 'text/plain' });
123+
return void res.end('demo: no audio bytes');
124+
}
125+
res.writeHead(404);
126+
res.end();
127+
});
128+
129+
httpServer.listen(PORT, async () => {
130+
console.log(`demo UPnP host on http://${HOST}:${PORT}`);
131+
console.log(` MediaServer device.xml → ${base('/dms/device.xml')}`);
132+
console.log(` MediaRenderer device.xml → ${base('/dmr/device.xml')}`);
133+
advertiser.addDevice({
134+
udn: server.udn,
135+
...server.deviceTypeAndServices(),
136+
location: () => base('/dms/device.xml'),
137+
});
138+
advertiser.addDevice({
139+
udn: renderer.udn,
140+
...renderer.deviceTypeAndServices(),
141+
location: () => base('/dmr/device.xml'),
142+
});
143+
await advertiser.start();
144+
console.log('SSDP advertising; discoverable by any DLNA control point.');
145+
});
146+
147+
process.on('SIGINT', async () => {
148+
await advertiser.stop();
149+
httpServer.close();
150+
renderer.dispose();
151+
process.exit(0);
152+
});

0 commit comments

Comments
 (0)