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 = {} ): Promise { // Build SOAP body let argsXml = ''; for (const [key, value] of Object.entries(args)) { const escapedValue = this.escapeXml(String(value)); argsXml += `<${key}>${escapedValue}`; } const soapBody = ` ${argsXml} `; 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, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Unescape XML special characters */ public unescapeXml(str: string): string { return str .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&'); } /** * Extract value from SOAP response */ public extractValue(xml: string, tag: string): string { const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)`, 'i'); const match = xml.match(regex); return match ? match[1].trim() : ''; } /** * Extract multiple values from SOAP response */ public extractValues(xml: string, tags: string[]): Record { const result: Record = {}; 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 { 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 { 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 { await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Play', { InstanceID: 0, Speed: speed, }); } /** * Pause */ public async pause(controlUrl: string): Promise { await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Pause', { InstanceID: 0, }); } /** * Stop */ public async stop(controlUrl: string): Promise { await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Stop', { InstanceID: 0, }); } /** * Seek */ public async seek(controlUrl: string, target: string, unit: string = 'REL_TIME'): Promise { await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Seek', { InstanceID: 0, Unit: unit, Target: target, }); } /** * Next track */ public async next(controlUrl: string): Promise { await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Next', { InstanceID: 0, }); } /** * Previous track */ public async previous(controlUrl: string): Promise { await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Previous', { InstanceID: 0, }); } /** * Get position info */ public async getPositionInfo(controlUrl: string): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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>/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(/>([^<]+)]*>([^<]*)<\/(?:[^:]*:)?${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 ` ${this.escapeXml(title)} object.item.videoItem ${this.escapeXml(url)} `; } /** * 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')}`; } }