import * as plugins from '../plugins.js'; import { Device } from '../abstract/device.abstract.js'; import { UpnpSoapClient, UPNP_SERVICE_TYPES, type TDlnaTransportState, type IDlnaTransportInfo, type IDlnaPositionInfo, type IDlnaMediaInfo, } from './dlna.classes.upnp.js'; import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js'; import type { ISsdpDevice, ISsdpService } from '../discovery/discovery.classes.ssdp.js'; /** * DLNA Renderer device info */ export interface IDlnaRendererInfo extends IDeviceInfo { type: 'dlna-renderer'; friendlyName: string; modelName: string; modelNumber?: string; manufacturer: string; udn: string; iconUrl?: string; supportsVolume: boolean; supportsSeek: boolean; } /** * Playback state */ export interface IDlnaPlaybackState { state: TDlnaTransportState; volume: number; muted: boolean; currentUri: string; currentTrack: { title: string; artist?: string; album?: string; duration: number; position: number; albumArtUri?: string; }; } /** * DLNA Media Renderer device * Represents a device that can play media (TV, speaker, etc.) */ export class DlnaRenderer extends Device { private soapClient: UpnpSoapClient | null = null; private avTransportUrl: string = ''; private renderingControlUrl: string = ''; private baseUrl: string = ''; private _friendlyName: string; private _modelName: string = ''; private _modelNumber?: string; private _udn: string = ''; private _iconUrl?: string; private _supportsVolume: boolean = true; private _supportsSeek: boolean = true; private _currentState: TDlnaTransportState = 'STOPPED'; private _currentVolume: number = 0; private _currentMuted: boolean = false; constructor( info: IDeviceInfo, options: { friendlyName: string; baseUrl: string; avTransportUrl?: string; renderingControlUrl?: string; modelName?: string; modelNumber?: string; udn?: string; iconUrl?: string; }, retryOptions?: IRetryOptions ) { super(info, retryOptions); this._friendlyName = options.friendlyName; this.baseUrl = options.baseUrl; this.avTransportUrl = options.avTransportUrl || '/AVTransport/control'; this.renderingControlUrl = options.renderingControlUrl || '/RenderingControl/control'; this._modelName = options.modelName || ''; this._modelNumber = options.modelNumber; this._udn = options.udn || ''; this._iconUrl = options.iconUrl; } // Getters public get friendlyName(): string { return this._friendlyName; } public get modelName(): string { return this._modelName; } public get modelNumber(): string | undefined { return this._modelNumber; } public get udn(): string { return this._udn; } public get iconUrl(): string | undefined { return this._iconUrl; } public get supportsVolume(): boolean { return this._supportsVolume; } public get supportsSeek(): boolean { return this._supportsSeek; } public get currentState(): TDlnaTransportState { return this._currentState; } public get currentVolume(): number { return this._currentVolume; } public get currentMuted(): boolean { return this._currentMuted; } /** * Connect to renderer */ protected async doConnect(): Promise { this.soapClient = new UpnpSoapClient(this.baseUrl); // Test connection by getting transport info try { await this.getTransportInfo(); } catch (error) { this.soapClient = null; throw error; } // Try to get volume (may not be supported) try { this._currentVolume = await this.getVolume(); this._supportsVolume = true; } catch { this._supportsVolume = false; } } /** * Disconnect */ protected async doDisconnect(): Promise { this.soapClient = null; } /** * Refresh status */ public async refreshStatus(): Promise { if (!this.soapClient) { throw new Error('Not connected'); } const [transport, volume, muted] = await Promise.all([ this.getTransportInfo(), this._supportsVolume ? this.getVolume() : Promise.resolve(0), this._supportsVolume ? this.getMute() : Promise.resolve(false), ]); this._currentState = transport.state; this._currentVolume = volume; this._currentMuted = muted; this.emit('status:updated', this.getDeviceInfo()); } // ============================================================================ // Playback Control // ============================================================================ /** * Set media URI to play */ public async setAVTransportURI(uri: string, metadata?: string): Promise { if (!this.soapClient) { throw new Error('Not connected'); } const meta = metadata || this.soapClient.generateDidlMetadata('Media', uri); await this.soapClient.setAVTransportURI(this.avTransportUrl, uri, meta); this.emit('media:loaded', { uri }); } /** * Play current media */ public async play(uri?: string, metadata?: string): Promise { if (!this.soapClient) { throw new Error('Not connected'); } if (uri) { await this.setAVTransportURI(uri, metadata); } await this.soapClient.play(this.avTransportUrl); this._currentState = 'PLAYING'; this.emit('playback:started'); } /** * Pause playback */ public async pause(): Promise { if (!this.soapClient) { throw new Error('Not connected'); } await this.soapClient.pause(this.avTransportUrl); this._currentState = 'PAUSED_PLAYBACK'; this.emit('playback:paused'); } /** * Stop playback */ public async stop(): Promise { if (!this.soapClient) { throw new Error('Not connected'); } await this.soapClient.stop(this.avTransportUrl); this._currentState = 'STOPPED'; this.emit('playback:stopped'); } /** * Seek to position */ public async seek(seconds: number): Promise { if (!this.soapClient) { throw new Error('Not connected'); } const target = this.soapClient.secondsToDuration(seconds); await this.soapClient.seek(this.avTransportUrl, target, 'REL_TIME'); this.emit('playback:seeked', { position: seconds }); } /** * Next track */ public async next(): Promise { if (!this.soapClient) { throw new Error('Not connected'); } await this.soapClient.next(this.avTransportUrl); this.emit('playback:next'); } /** * Previous track */ public async previous(): Promise { if (!this.soapClient) { throw new Error('Not connected'); } await this.soapClient.previous(this.avTransportUrl); this.emit('playback:previous'); } // ============================================================================ // Volume Control // ============================================================================ /** * Get volume level */ public async getVolume(): Promise { if (!this.soapClient) { throw new Error('Not connected'); } return this.soapClient.getVolume(this.renderingControlUrl); } /** * Set volume level */ public async setVolume(level: number): Promise { if (!this.soapClient) { throw new Error('Not connected'); } await this.soapClient.setVolume(this.renderingControlUrl, level); this._currentVolume = level; this.emit('volume:changed', { volume: level }); } /** * Get mute state */ public async getMute(): Promise { if (!this.soapClient) { throw new Error('Not connected'); } return this.soapClient.getMute(this.renderingControlUrl); } /** * Set mute state */ public async setMute(muted: boolean): Promise { if (!this.soapClient) { throw new Error('Not connected'); } await this.soapClient.setMute(this.renderingControlUrl, muted); this._currentMuted = muted; this.emit('mute:changed', { muted }); } /** * Toggle mute */ public async toggleMute(): Promise { const newMuted = !this._currentMuted; await this.setMute(newMuted); return newMuted; } // ============================================================================ // Status Information // ============================================================================ /** * Get transport info */ public async getTransportInfo(): Promise { if (!this.soapClient) { throw new Error('Not connected'); } return this.soapClient.getTransportInfo(this.avTransportUrl); } /** * Get position info */ public async getPositionInfo(): Promise { if (!this.soapClient) { throw new Error('Not connected'); } return this.soapClient.getPositionInfo(this.avTransportUrl); } /** * Get media info */ public async getMediaInfo(): Promise { if (!this.soapClient) { throw new Error('Not connected'); } return this.soapClient.getMediaInfo(this.avTransportUrl); } /** * Get full playback state */ public async getPlaybackState(): Promise { if (!this.soapClient) { throw new Error('Not connected'); } const [transport, position, media, volume, muted] = await Promise.all([ this.getTransportInfo(), this.getPositionInfo(), this.getMediaInfo(), this._supportsVolume ? this.getVolume() : Promise.resolve(0), this._supportsVolume ? this.getMute() : Promise.resolve(false), ]); // Parse metadata for track info const trackMeta = this.parseTrackMetadata(position.trackMetadata); return { state: transport.state, volume, muted, currentUri: media.currentUri, currentTrack: { title: trackMeta.title || 'Unknown', artist: trackMeta.artist, album: trackMeta.album, duration: this.soapClient.durationToSeconds(position.trackDuration), position: this.soapClient.durationToSeconds(position.relativeTime), albumArtUri: trackMeta.albumArtUri, }, }; } /** * Parse track metadata from DIDL-Lite */ private parseTrackMetadata(metadata: string): { title?: string; artist?: string; album?: string; albumArtUri?: string; } { if (!metadata) return {}; const extractTag = (xml: string, tag: string): string | undefined => { const regex = new RegExp(`<(?:[^:]*:)?${tag}[^>]*>([^<]*)<\/(?:[^:]*:)?${tag}>`, 'i'); const match = xml.match(regex); return match ? match[1].trim() : undefined; }; return { title: extractTag(metadata, 'title'), artist: extractTag(metadata, 'creator') || extractTag(metadata, 'artist'), album: extractTag(metadata, 'album'), albumArtUri: extractTag(metadata, 'albumArtURI'), }; } /** * Get device info */ public getDeviceInfo(): IDlnaRendererInfo { return { id: this.id, name: this.name, type: 'dlna-renderer', address: this.address, port: this.port, status: this.status, friendlyName: this._friendlyName, modelName: this._modelName, modelNumber: this._modelNumber, manufacturer: this.manufacturer || '', udn: this._udn, iconUrl: this._iconUrl, supportsVolume: this._supportsVolume, supportsSeek: this._supportsSeek, }; } /** * Create from SSDP discovery */ public static fromSsdpDevice( ssdpDevice: ISsdpDevice, retryOptions?: IRetryOptions ): DlnaRenderer | null { if (!ssdpDevice.description) { return null; } const desc = ssdpDevice.description; // Find AVTransport and RenderingControl URLs const avTransport = desc.services.find((s) => s.serviceType.includes('AVTransport') ); const renderingControl = desc.services.find((s) => s.serviceType.includes('RenderingControl') ); if (!avTransport) { return null; // Not a media renderer } // Build base URL const baseUrl = new URL(ssdpDevice.location); const baseUrlStr = `${baseUrl.protocol}//${baseUrl.host}`; // Get icon URL let iconUrl: string | undefined; if (desc.icons && desc.icons.length > 0) { const bestIcon = desc.icons.sort((a, b) => b.width - a.width)[0]; iconUrl = bestIcon.url.startsWith('http') ? bestIcon.url : `${baseUrlStr}${bestIcon.url}`; } const info: IDeviceInfo = { id: `dlna-renderer:${desc.UDN}`, name: desc.friendlyName, type: 'dlna-renderer', address: ssdpDevice.address, port: ssdpDevice.port, status: 'unknown', manufacturer: desc.manufacturer, model: desc.modelName, }; return new DlnaRenderer( info, { friendlyName: desc.friendlyName, baseUrl: baseUrlStr, avTransportUrl: avTransport.controlURL, renderingControlUrl: renderingControl?.controlURL, modelName: desc.modelName, modelNumber: desc.modelNumber, udn: desc.UDN, iconUrl, }, retryOptions ); } }