Files
devicemanager/ts/protocols/protocol.upnp.ts

628 lines
18 KiB
TypeScript
Raw Permalink Normal View History

import * as plugins from '../plugins.js';
/**
* UPnP service types for DLNA
*/
export const UPNP_SERVICE_TYPES = {
AVTransport: 'urn:schemas-upnp-org:service:AVTransport:1',
RenderingControl: 'urn:schemas-upnp-org:service:RenderingControl:1',
ConnectionManager: 'urn:schemas-upnp-org:service:ConnectionManager:1',
ContentDirectory: 'urn:schemas-upnp-org:service:ContentDirectory:1',
};
/**
* UPnP device types for DLNA
*/
export const UPNP_DEVICE_TYPES = {
MediaRenderer: 'urn:schemas-upnp-org:device:MediaRenderer:1',
MediaServer: 'urn:schemas-upnp-org:device:MediaServer:1',
};
/**
* DLNA transport state
*/
export type TDlnaTransportState =
| 'STOPPED'
| 'PLAYING'
| 'PAUSED_PLAYBACK'
| 'TRANSITIONING'
| 'NO_MEDIA_PRESENT';
/**
* DLNA transport status
*/
export type TDlnaTransportStatus = 'OK' | 'ERROR_OCCURRED';
/**
* Position info from AVTransport
*/
export interface IDlnaPositionInfo {
track: number;
trackDuration: string;
trackMetadata: string;
trackUri: string;
relativeTime: string;
absoluteTime: string;
relativeCount: number;
absoluteCount: number;
}
/**
* Transport info from AVTransport
*/
export interface IDlnaTransportInfo {
state: TDlnaTransportState;
status: TDlnaTransportStatus;
speed: string;
}
/**
* Media info from AVTransport
*/
export interface IDlnaMediaInfo {
nrTracks: number;
mediaDuration: string;
currentUri: string;
currentUriMetadata: string;
nextUri: string;
nextUriMetadata: string;
playMedium: string;
recordMedium: string;
writeStatus: string;
}
/**
* Content item from ContentDirectory
*/
export interface IDlnaContentItem {
id: string;
parentId: string;
title: string;
class: string;
restricted: boolean;
res?: {
url: string;
protocolInfo: string;
size?: number;
duration?: string;
resolution?: string;
bitrate?: number;
}[];
albumArtUri?: string;
artist?: string;
album?: string;
genre?: string;
date?: string;
childCount?: number;
}
/**
* Browse result from ContentDirectory
*/
export interface IDlnaBrowseResult {
items: IDlnaContentItem[];
numberReturned: number;
totalMatches: number;
updateId: number;
}
/**
* UPnP SOAP client for DLNA operations
*/
export class UpnpSoapClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl.replace(/\/$/, '');
}
/**
* Make a SOAP request to a UPnP service
*/
public async soapAction(
controlUrl: string,
serviceType: string,
action: string,
args: Record<string, string | number> = {}
): Promise<string> {
// Build SOAP body
let argsXml = '';
for (const [key, value] of Object.entries(args)) {
const escapedValue = this.escapeXml(String(value));
argsXml += `<${key}>${escapedValue}</${key}>`;
}
const soapBody = `<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:${action} xmlns:u="${serviceType}">
${argsXml}
</u:${action}>
</s:Body>
</s:Envelope>`;
const fullUrl = controlUrl.startsWith('http') ? controlUrl : `${this.baseUrl}${controlUrl}`;
const response = await fetch(fullUrl, {
method: 'POST',
headers: {
'Content-Type': 'text/xml; charset=utf-8',
'SOAPACTION': `"${serviceType}#${action}"`,
},
body: soapBody,
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`SOAP request failed (${response.status}): ${text}`);
}
return response.text();
}
/**
* Escape XML special characters
*/
private escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Unescape XML special characters
*/
public unescapeXml(str: string): string {
return str
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&');
}
/**
* Extract value from SOAP response
*/
public extractValue(xml: string, tag: string): string {
const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i');
const match = xml.match(regex);
return match ? match[1].trim() : '';
}
/**
* Extract multiple values from SOAP response
*/
public extractValues(xml: string, tags: string[]): Record<string, string> {
const result: Record<string, string> = {};
for (const tag of tags) {
result[tag] = this.extractValue(xml, tag);
}
return result;
}
// ============================================================================
// AVTransport Actions
// ============================================================================
/**
* Set the URI to play
*/
public async setAVTransportURI(
controlUrl: string,
uri: string,
metadata: string = ''
): Promise<void> {
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'SetAVTransportURI', {
InstanceID: 0,
CurrentURI: uri,
CurrentURIMetaData: metadata,
});
}
/**
* Set next URI to play
*/
public async setNextAVTransportURI(
controlUrl: string,
uri: string,
metadata: string = ''
): Promise<void> {
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'SetNextAVTransportURI', {
InstanceID: 0,
NextURI: uri,
NextURIMetaData: metadata,
});
}
/**
* Play
*/
public async play(controlUrl: string, speed: string = '1'): Promise<void> {
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Play', {
InstanceID: 0,
Speed: speed,
});
}
/**
* Pause
*/
public async pause(controlUrl: string): Promise<void> {
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Pause', {
InstanceID: 0,
});
}
/**
* Stop
*/
public async stop(controlUrl: string): Promise<void> {
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Stop', {
InstanceID: 0,
});
}
/**
* Seek
*/
public async seek(controlUrl: string, target: string, unit: string = 'REL_TIME'): Promise<void> {
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Seek', {
InstanceID: 0,
Unit: unit,
Target: target,
});
}
/**
* Next track
*/
public async next(controlUrl: string): Promise<void> {
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Next', {
InstanceID: 0,
});
}
/**
* Previous track
*/
public async previous(controlUrl: string): Promise<void> {
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Previous', {
InstanceID: 0,
});
}
/**
* Get position info
*/
public async getPositionInfo(controlUrl: string): Promise<IDlnaPositionInfo> {
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetPositionInfo', {
InstanceID: 0,
});
const values = this.extractValues(response, [
'Track', 'TrackDuration', 'TrackMetaData', 'TrackURI',
'RelTime', 'AbsTime', 'RelCount', 'AbsCount',
]);
return {
track: parseInt(values['Track']) || 0,
trackDuration: values['TrackDuration'] || '0:00:00',
trackMetadata: this.unescapeXml(values['TrackMetaData'] || ''),
trackUri: values['TrackURI'] || '',
relativeTime: values['RelTime'] || '0:00:00',
absoluteTime: values['AbsTime'] || 'NOT_IMPLEMENTED',
relativeCount: parseInt(values['RelCount']) || 0,
absoluteCount: parseInt(values['AbsCount']) || 0,
};
}
/**
* Get transport info
*/
public async getTransportInfo(controlUrl: string): Promise<IDlnaTransportInfo> {
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetTransportInfo', {
InstanceID: 0,
});
const values = this.extractValues(response, [
'CurrentTransportState', 'CurrentTransportStatus', 'CurrentSpeed',
]);
return {
state: (values['CurrentTransportState'] || 'STOPPED') as TDlnaTransportState,
status: (values['CurrentTransportStatus'] || 'OK') as TDlnaTransportStatus,
speed: values['CurrentSpeed'] || '1',
};
}
/**
* Get media info
*/
public async getMediaInfo(controlUrl: string): Promise<IDlnaMediaInfo> {
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetMediaInfo', {
InstanceID: 0,
});
const values = this.extractValues(response, [
'NrTracks', 'MediaDuration', 'CurrentURI', 'CurrentURIMetaData',
'NextURI', 'NextURIMetaData', 'PlayMedium', 'RecordMedium', 'WriteStatus',
]);
return {
nrTracks: parseInt(values['NrTracks']) || 0,
mediaDuration: values['MediaDuration'] || '0:00:00',
currentUri: values['CurrentURI'] || '',
currentUriMetadata: this.unescapeXml(values['CurrentURIMetaData'] || ''),
nextUri: values['NextURI'] || '',
nextUriMetadata: this.unescapeXml(values['NextURIMetaData'] || ''),
playMedium: values['PlayMedium'] || 'NONE',
recordMedium: values['RecordMedium'] || 'NOT_IMPLEMENTED',
writeStatus: values['WriteStatus'] || 'NOT_IMPLEMENTED',
};
}
// ============================================================================
// RenderingControl Actions
// ============================================================================
/**
* Get volume
*/
public async getVolume(controlUrl: string, channel: string = 'Master'): Promise<number> {
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'GetVolume', {
InstanceID: 0,
Channel: channel,
});
const volume = this.extractValue(response, 'CurrentVolume');
return parseInt(volume) || 0;
}
/**
* Set volume
*/
public async setVolume(controlUrl: string, volume: number, channel: string = 'Master'): Promise<void> {
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'SetVolume', {
InstanceID: 0,
Channel: channel,
DesiredVolume: Math.max(0, Math.min(100, volume)),
});
}
/**
* Get mute state
*/
public async getMute(controlUrl: string, channel: string = 'Master'): Promise<boolean> {
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'GetMute', {
InstanceID: 0,
Channel: channel,
});
const mute = this.extractValue(response, 'CurrentMute');
return mute === '1' || mute.toLowerCase() === 'true';
}
/**
* Set mute state
*/
public async setMute(controlUrl: string, muted: boolean, channel: string = 'Master'): Promise<void> {
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'SetMute', {
InstanceID: 0,
Channel: channel,
DesiredMute: muted ? 1 : 0,
});
}
// ============================================================================
// ContentDirectory Actions
// ============================================================================
/**
* Browse content directory
*/
public async browse(
controlUrl: string,
objectId: string = '0',
browseFlag: 'BrowseDirectChildren' | 'BrowseMetadata' = 'BrowseDirectChildren',
filter: string = '*',
startIndex: number = 0,
requestCount: number = 100,
sortCriteria: string = ''
): Promise<IDlnaBrowseResult> {
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.ContentDirectory, 'Browse', {
ObjectID: objectId,
BrowseFlag: browseFlag,
Filter: filter,
StartingIndex: startIndex,
RequestedCount: requestCount,
SortCriteria: sortCriteria,
});
const values = this.extractValues(response, ['Result', 'NumberReturned', 'TotalMatches', 'UpdateID']);
const resultXml = this.unescapeXml(values['Result'] || '');
const items = this.parseDidlResult(resultXml);
return {
items,
numberReturned: parseInt(values['NumberReturned']) || items.length,
totalMatches: parseInt(values['TotalMatches']) || items.length,
updateId: parseInt(values['UpdateID']) || 0,
};
}
/**
* Search content directory
*/
public async search(
controlUrl: string,
containerId: string,
searchCriteria: string,
filter: string = '*',
startIndex: number = 0,
requestCount: number = 100,
sortCriteria: string = ''
): Promise<IDlnaBrowseResult> {
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.ContentDirectory, 'Search', {
ContainerID: containerId,
SearchCriteria: searchCriteria,
Filter: filter,
StartingIndex: startIndex,
RequestedCount: requestCount,
SortCriteria: sortCriteria,
});
const values = this.extractValues(response, ['Result', 'NumberReturned', 'TotalMatches', 'UpdateID']);
const resultXml = this.unescapeXml(values['Result'] || '');
const items = this.parseDidlResult(resultXml);
return {
items,
numberReturned: parseInt(values['NumberReturned']) || items.length,
totalMatches: parseInt(values['TotalMatches']) || items.length,
updateId: parseInt(values['UpdateID']) || 0,
};
}
/**
* Parse DIDL-Lite result XML
*/
private parseDidlResult(xml: string): IDlnaContentItem[] {
const items: IDlnaContentItem[] = [];
// Match container and item elements
const elementRegex = /<(container|item)[^>]*>([\s\S]*?)<\/\1>/gi;
let match;
while ((match = elementRegex.exec(xml)) !== null) {
const elementXml = match[0];
const elementType = match[1];
// Extract attributes
const idMatch = elementXml.match(/id="([^"]*)"/);
const parentIdMatch = elementXml.match(/parentID="([^"]*)"/);
const restrictedMatch = elementXml.match(/restricted="([^"]*)"/);
const childCountMatch = elementXml.match(/childCount="([^"]*)"/);
const item: IDlnaContentItem = {
id: idMatch?.[1] || '',
parentId: parentIdMatch?.[1] || '',
title: this.extractTagContent(elementXml, 'dc:title'),
class: this.extractTagContent(elementXml, 'upnp:class'),
restricted: restrictedMatch?.[1] !== '0',
childCount: childCountMatch ? parseInt(childCountMatch[1]) : undefined,
};
// Extract resources
const resMatches = elementXml.match(/<res[^>]*>([^<]*)<\/res>/gi);
if (resMatches) {
item.res = resMatches.map((resXml) => {
const protocolInfo = resXml.match(/protocolInfo="([^"]*)"/)?.[1] || '';
const size = resXml.match(/size="([^"]*)"/)?.[1];
const duration = resXml.match(/duration="([^"]*)"/)?.[1];
const resolution = resXml.match(/resolution="([^"]*)"/)?.[1];
const bitrate = resXml.match(/bitrate="([^"]*)"/)?.[1];
const urlMatch = resXml.match(/>([^<]+)</);
return {
url: urlMatch?.[1] || '',
protocolInfo,
size: size ? parseInt(size) : undefined,
duration,
resolution,
bitrate: bitrate ? parseInt(bitrate) : undefined,
};
});
}
// Extract optional metadata
const albumArt = this.extractTagContent(elementXml, 'upnp:albumArtURI');
if (albumArt) item.albumArtUri = albumArt;
const artist = this.extractTagContent(elementXml, 'dc:creator') ||
this.extractTagContent(elementXml, 'upnp:artist');
if (artist) item.artist = artist;
const album = this.extractTagContent(elementXml, 'upnp:album');
if (album) item.album = album;
const genre = this.extractTagContent(elementXml, 'upnp:genre');
if (genre) item.genre = genre;
const date = this.extractTagContent(elementXml, 'dc:date');
if (date) item.date = date;
items.push(item);
}
return items;
}
/**
* Extract content from XML tag (handles namespaced tags)
*/
private extractTagContent(xml: string, tag: string): string {
// Handle both with and without namespace prefix
const tagName = tag.includes(':') ? tag.split(':')[1] : tag;
const regex = new RegExp(`<(?:[^:]*:)?${tagName}[^>]*>([^<]*)<\/(?:[^:]*:)?${tagName}>`, 'i');
const match = xml.match(regex);
return match ? match[1].trim() : '';
}
// ============================================================================
// Utility Methods
// ============================================================================
/**
* Generate DIDL-Lite metadata for a media URL
*/
public generateDidlMetadata(
title: string,
url: string,
mimeType: string = 'video/mp4'
): string {
const protocolInfo = `http-get:*:${mimeType}:*`;
return `<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
<item id="0" parentID="-1" restricted="1">
<dc:title>${this.escapeXml(title)}</dc:title>
<upnp:class>object.item.videoItem</upnp:class>
<res protocolInfo="${protocolInfo}">${this.escapeXml(url)}</res>
</item>
</DIDL-Lite>`;
}
/**
* Convert duration string to seconds
*/
public durationToSeconds(duration: string): number {
if (!duration || duration === 'NOT_IMPLEMENTED') return 0;
const parts = duration.split(':');
if (parts.length !== 3) return 0;
const hours = parseInt(parts[0]) || 0;
const minutes = parseInt(parts[1]) || 0;
const seconds = parseFloat(parts[2]) || 0;
return hours * 3600 + minutes * 60 + seconds;
}
/**
* Convert seconds to duration string
*/
public secondsToDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
}