import * as plugins from '../plugins.js'; import { Device } from '../abstract/device.abstract.js'; import { UpnpSoapClient, UPNP_SERVICE_TYPES, type IDlnaContentItem, type IDlnaBrowseResult, } from './dlna.classes.upnp.js'; import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js'; import type { ISsdpDevice, ISsdpService } from '../discovery/discovery.classes.ssdp.js'; /** * DLNA Server device info */ export interface IDlnaServerInfo extends IDeviceInfo { type: 'dlna-server'; friendlyName: string; modelName: string; modelNumber?: string; manufacturer: string; udn: string; iconUrl?: string; contentCount?: number; } /** * Content directory statistics */ export interface IDlnaServerStats { totalItems: number; audioItems: number; videoItems: number; imageItems: number; containers: number; } /** * DLNA Media Server device * Represents a device that serves media content (NAS, media library, etc.) */ export class DlnaServer extends Device { private soapClient: UpnpSoapClient | null = null; private contentDirectoryUrl: string = ''; private baseUrl: string = ''; private _friendlyName: string; private _modelName: string = ''; private _modelNumber?: string; private _udn: string = ''; private _iconUrl?: string; private _contentCount?: number; constructor( info: IDeviceInfo, options: { friendlyName: string; baseUrl: string; contentDirectoryUrl?: string; modelName?: string; modelNumber?: string; udn?: string; iconUrl?: string; }, retryOptions?: IRetryOptions ) { super(info, retryOptions); this._friendlyName = options.friendlyName; this.baseUrl = options.baseUrl; this.contentDirectoryUrl = options.contentDirectoryUrl || '/ContentDirectory/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 contentCount(): number | undefined { return this._contentCount; } /** * Connect to server */ protected async doConnect(): Promise { this.soapClient = new UpnpSoapClient(this.baseUrl); // Test connection by browsing root try { const root = await this.browse('0', 0, 1); this._contentCount = root.totalMatches; } catch (error) { this.soapClient = null; throw error; } } /** * Disconnect */ protected async doDisconnect(): Promise { this.soapClient = null; } /** * Refresh status */ public async refreshStatus(): Promise { if (!this.soapClient) { throw new Error('Not connected'); } const root = await this.browse('0', 0, 1); this._contentCount = root.totalMatches; this.emit('status:updated', this.getDeviceInfo()); } // ============================================================================ // Content Directory Browsing // ============================================================================ /** * Browse content directory */ public async browse( objectId: string = '0', startIndex: number = 0, requestCount: number = 100 ): Promise { if (!this.soapClient) { throw new Error('Not connected'); } return this.soapClient.browse( this.contentDirectoryUrl, objectId, 'BrowseDirectChildren', '*', startIndex, requestCount ); } /** * Get metadata for a specific item */ public async getMetadata(objectId: string): Promise { if (!this.soapClient) { throw new Error('Not connected'); } const result = await this.soapClient.browse( this.contentDirectoryUrl, objectId, 'BrowseMetadata', '*', 0, 1 ); return result.items[0] || null; } /** * Search content directory */ public async search( containerId: string, searchCriteria: string, startIndex: number = 0, requestCount: number = 100 ): Promise { if (!this.soapClient) { throw new Error('Not connected'); } return this.soapClient.search( this.contentDirectoryUrl, containerId, searchCriteria, '*', startIndex, requestCount ); } /** * Browse all items recursively (up to limit) */ public async browseAll( objectId: string = '0', limit: number = 1000 ): Promise { const allItems: IDlnaContentItem[] = []; let startIndex = 0; const batchSize = 100; while (allItems.length < limit) { const result = await this.browse(objectId, startIndex, batchSize); allItems.push(...result.items); if (result.items.length < batchSize || allItems.length >= result.totalMatches) { break; } startIndex += result.items.length; } return allItems.slice(0, limit); } /** * Get content statistics */ public async getStats(): Promise { const stats: IDlnaServerStats = { totalItems: 0, audioItems: 0, videoItems: 0, imageItems: 0, containers: 0, }; // Browse root to get counts const root = await this.browseAll('0', 500); for (const item of root) { stats.totalItems++; if (item.class.includes('container')) { stats.containers++; } else if (item.class.includes('audioItem')) { stats.audioItems++; } else if (item.class.includes('videoItem')) { stats.videoItems++; } else if (item.class.includes('imageItem')) { stats.imageItems++; } } return stats; } // ============================================================================ // Content Access // ============================================================================ /** * Get stream URL for content item */ public getStreamUrl(item: IDlnaContentItem): string | null { if (!item.res || item.res.length === 0) { return null; } // Return first resource URL return item.res[0].url; } /** * Get best quality stream URL */ public getBestStreamUrl(item: IDlnaContentItem, preferredType?: string): string | null { if (!item.res || item.res.length === 0) { return null; } // Sort by bitrate (highest first) const sorted = [...item.res].sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0)); // If preferred type specified, try to find matching if (preferredType) { const preferred = sorted.find((r) => r.protocolInfo.toLowerCase().includes(preferredType.toLowerCase()) ); if (preferred) return preferred.url; } return sorted[0].url; } /** * Get album art URL for item */ public getAlbumArtUrl(item: IDlnaContentItem): string | null { if (item.albumArtUri) { // Resolve relative URLs if (!item.albumArtUri.startsWith('http')) { return `${this.baseUrl}${item.albumArtUri}`; } return item.albumArtUri; } return null; } // ============================================================================ // Search Helpers // ============================================================================ /** * Search for audio items by title */ public async searchAudio( title: string, startIndex: number = 0, requestCount: number = 100 ): Promise { const criteria = `dc:title contains "${title}" and upnp:class derivedfrom "object.item.audioItem"`; return this.search('0', criteria, startIndex, requestCount); } /** * Search for video items by title */ public async searchVideo( title: string, startIndex: number = 0, requestCount: number = 100 ): Promise { const criteria = `dc:title contains "${title}" and upnp:class derivedfrom "object.item.videoItem"`; return this.search('0', criteria, startIndex, requestCount); } /** * Search by artist */ public async searchByArtist( artist: string, startIndex: number = 0, requestCount: number = 100 ): Promise { const criteria = `dc:creator contains "${artist}" or upnp:artist contains "${artist}"`; return this.search('0', criteria, startIndex, requestCount); } /** * Search by album */ public async searchByAlbum( album: string, startIndex: number = 0, requestCount: number = 100 ): Promise { const criteria = `upnp:album contains "${album}"`; return this.search('0', criteria, startIndex, requestCount); } /** * Search by genre */ public async searchByGenre( genre: string, startIndex: number = 0, requestCount: number = 100 ): Promise { const criteria = `upnp:genre contains "${genre}"`; return this.search('0', criteria, startIndex, requestCount); } // ============================================================================ // Device Info // ============================================================================ /** * Get device info */ public getDeviceInfo(): IDlnaServerInfo { return { id: this.id, name: this.name, type: 'dlna-server', 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, contentCount: this._contentCount, }; } /** * Create from SSDP discovery */ public static fromSsdpDevice( ssdpDevice: ISsdpDevice, retryOptions?: IRetryOptions ): DlnaServer | null { if (!ssdpDevice.description) { return null; } const desc = ssdpDevice.description; // Find ContentDirectory URL const contentDirectory = desc.services.find((s) => s.serviceType.includes('ContentDirectory') ); if (!contentDirectory) { return null; // Not a media server } // 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-server:${desc.UDN}`, name: desc.friendlyName, type: 'dlna-server', address: ssdpDevice.address, port: ssdpDevice.port, status: 'unknown', manufacturer: desc.manufacturer, model: desc.modelName, }; return new DlnaServer( info, { friendlyName: desc.friendlyName, baseUrl: baseUrlStr, contentDirectoryUrl: contentDirectory.controlURL, modelName: desc.modelName, modelNumber: desc.modelNumber, udn: desc.UDN, iconUrl, }, retryOptions ); } } // Re-export content types export type { IDlnaContentItem, IDlnaBrowseResult } from './dlna.classes.upnp.js';