Skip to content

Commit f8cd12a

Browse files
committed
feat(controlpoint): ask a renderer which formats it accepts (v0.4.0)
A UPnP renderer publishes what it can decode as protocolInfo entries in its ConnectionManager's Sink list, and asking is the only way to know whether a stream will play before sending it: a renderer handed something it cannot decode does not complain, it plays silence. Without this, a caller can only guess or be configured. - discovery now resolves the ConnectionManager control URL alongside AVTransport and RenderingControl; the service parsing was already generic, so this is one more lookup. - DlnaControlPoint.getSinkContentTypes() invokes GetProtocolInfo and returns the MIME types, cached — the sink list is a property of the device, not of a session. - parseSinkContentTypes() is exported and takes the *third colon-separated field* of each entry rather than searching the raw string, so `audio/flac` cannot be found inside some other token, and it keeps parameterised types (`audio/L16;rate=44100`) intact. Returns null — not an empty array — when the device has no ConnectionManager, does not answer, or answers unparseably. "It told us nothing" and "it accepts nothing" must not read the same to a caller deciding what to send.
1 parent f791538 commit f8cd12a

4 files changed

Lines changed: 107 additions & 2 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@sonn-audio/node-upnp",
3-
"version": "0.3.1",
3+
"version": "0.4.0",
44
"description": "UPnP AV toolkit: SSDP, SOAP, DIDL-Lite, GENA, plus a MediaRenderer server, a control-point client, and a MediaServer framework. Protocol only — inject your own content and playback.",
55
"license": "MIT",
66
"repository": {

src/controlpoint/controlPoint.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ export class DlnaControlPoint {
5656
private host: string;
5757
private controlUrl?: string;
5858
private renderingControlUrl?: string;
59+
private connectionManagerUrl?: string;
60+
/** Cached answer to GetProtocolInfo; the sink list is a property of the device, not of a session. */
61+
private sinkContentTypes?: string[] | null;
5962
private discoveryPromise?: Promise<boolean>;
6063
private readonly autoDiscover: boolean;
6164
private readonly deviceName: string;
@@ -181,6 +184,67 @@ export class DlnaControlPoint {
181184
return this.enqueueResult(() => this.invokeAction('Stop', this.buildStopBody()));
182185
}
183186

187+
/**
188+
* The MIME types this renderer says it can play, from ConnectionManager::GetProtocolInfo's `Sink`.
189+
*
190+
* A UPnP renderer publishes what it accepts as protocolInfo entries — `http-get:*:audio/flac:*` —
191+
* and asking is the only way to know whether a stream will play before sending it. A renderer that
192+
* cannot decode what it is given does not complain; it plays silence.
193+
*
194+
* Returns `null` when the device has no ConnectionManager, does not answer, or answers with
195+
* something unparseable — deliberately not an empty array, because "it told us nothing" and "it
196+
* accepts nothing" must not read the same to a caller deciding what to send. Cached after the first
197+
* answer.
198+
*/
199+
public async getSinkContentTypes(): Promise<string[] | null> {
200+
if (this.sinkContentTypes !== undefined) {
201+
return this.sinkContentTypes;
202+
}
203+
if (!(await this.ensureEndpoints()) || !this.connectionManagerUrl) {
204+
this.log?.debug?.('no ConnectionManager endpoint; sink formats unknown', { host: this.host });
205+
return null;
206+
}
207+
const body =
208+
'<?xml version="1.0" encoding="utf-8"?>' +
209+
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"' +
210+
' s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' +
211+
'<s:Body><u:GetProtocolInfo xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1"/></s:Body>' +
212+
'</s:Envelope>';
213+
const controller = new AbortController();
214+
this.controllers.add(controller);
215+
const timeout = setTimeout(() => controller.abort(), this.commandTimeoutMs);
216+
timeout.unref();
217+
try {
218+
const response = await fetch(this.connectionManagerUrl, {
219+
method: 'POST',
220+
headers: {
221+
'Content-Type': 'text/xml; charset="utf-8"',
222+
SOAPAction: '"urn:schemas-upnp-org:service:ConnectionManager:1#GetProtocolInfo"',
223+
},
224+
body,
225+
signal: controller.signal,
226+
});
227+
const text = await response.text();
228+
if (!response.ok) {
229+
this.log?.debug?.('GetProtocolInfo failed', { host: this.host, status: response.status });
230+
return null;
231+
}
232+
const types = parseSinkContentTypes(text);
233+
this.sinkContentTypes = types;
234+
this.log?.info?.('renderer sink formats', { host: this.host, count: types?.length ?? 0, types });
235+
return types;
236+
} catch (err) {
237+
this.log?.debug?.('GetProtocolInfo error', {
238+
host: this.host,
239+
message: err instanceof Error ? err.message : String(err),
240+
});
241+
return null;
242+
} finally {
243+
clearTimeout(timeout);
244+
this.controllers.delete(controller);
245+
}
246+
}
247+
184248
public async setVolume(percent: number): Promise<boolean> {
185249
if (!(await this.ensureEndpoints())) {
186250
return false;
@@ -580,6 +644,9 @@ export class DlnaControlPoint {
580644
if (info.renderingControlEventUrl) {
581645
this.renderingControlEventUrl = info.renderingControlEventUrl;
582646
}
647+
if (info.connectionManagerUrl) {
648+
this.connectionManagerUrl = info.connectionManagerUrl;
649+
}
583650
this.log?.info?.('DLNA discovery completed', {
584651
host: this.host,
585652
controlUrl: this.controlUrl,
@@ -611,3 +678,33 @@ export class DlnaControlPoint {
611678
});
612679
}
613680
}
681+
682+
/**
683+
* Pull the MIME types out of a GetProtocolInfo response's `Sink`.
684+
*
685+
* The list is comma-separated protocolInfo entries, each `protocol:network:mime:additional` — so the
686+
* third colon-separated field is the format. Entries a renderer publishes for other protocols
687+
* (rtsp-rtp-udp, internal) keep their own MIME field, which is why the whole field is taken rather
688+
* than a substring match on the raw string: `audio/flac` must not be found inside
689+
* `application/x-flac-container`.
690+
*/
691+
export function parseSinkContentTypes(soapResponse: string): string[] | null {
692+
const sink = /<Sink>([\s\S]*?)<\/Sink>/i.exec(soapResponse)?.[1];
693+
if (!sink) {
694+
return null;
695+
}
696+
const decoded = sink
697+
.replace(/&amp;/g, '&')
698+
.replace(/&lt;/g, '<')
699+
.replace(/&gt;/g, '>')
700+
.replace(/&quot;/g, '"');
701+
const types = new Set<string>();
702+
for (const entry of decoded.split(',')) {
703+
const fields = entry.trim().split(':');
704+
const mime = fields[2]?.trim().toLowerCase();
705+
if (mime && mime.includes('/')) {
706+
types.add(mime);
707+
}
708+
}
709+
return types.size ? [...types] : null;
710+
}

src/controlpoint/discovery.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ export interface DlnaEndpointInfo {
1010
avTransportEventUrl?: string;
1111
/** GENA event subscription endpoint for RenderingControl (volume / mute). */
1212
renderingControlEventUrl?: string;
13+
/**
14+
* ConnectionManager control endpoint. This is where a renderer publishes the formats it accepts
15+
* (`GetProtocolInfo` → `Sink`), which is the only way to know whether a stream will play before
16+
* sending it.
17+
*/
18+
connectionManagerUrl?: string;
1319
friendlyName?: string;
1420
descriptionUrl?: string;
1521
}
@@ -347,12 +353,14 @@ function parseDeviceDescription(xml: string, location: string): DlnaEndpointInfo
347353
};
348354
const avTransport = selectService(services, 'avtransport');
349355
const rendering = selectService(services, 'renderingcontrol');
356+
const connectionManager = selectService(services, 'connectionmanager');
350357
return {
351358
friendlyName: extractTag(xml, 'friendlyName'),
352359
controlUrl: getUrl(avTransport?.controlUrl),
353360
renderingControlUrl: getUrl(rendering?.controlUrl),
354361
avTransportEventUrl: getUrl(avTransport?.eventSubUrl),
355362
renderingControlEventUrl: getUrl(rendering?.eventSubUrl),
363+
connectionManagerUrl: getUrl(connectionManager?.controlUrl),
356364
};
357365
}
358366

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export type {
4949
ParsedSearchCriteria,
5050
} from './mediaserver/mediaServer.js';
5151

52-
export { DlnaControlPoint } from './controlpoint/controlPoint.js';
52+
export { DlnaControlPoint, parseSinkContentTypes } from './controlpoint/controlPoint.js';
5353
export type { DlnaControlPointOptions } from './controlpoint/controlPoint.js';
5454

5555
// ── Control-point discovery ─────────────────────────────────────────────────────

0 commit comments

Comments
 (0)