feat(devicemanager): Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors
This commit is contained in:
527
ts/dlna/dlna.classes.renderer.ts
Normal file
527
ts/dlna/dlna.classes.renderer.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.soapClient = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.soapClient.next(this.avTransportUrl);
|
||||
this.emit('playback:next');
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous track
|
||||
*/
|
||||
public async previous(): Promise<void> {
|
||||
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<number> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.getVolume(this.renderingControlUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume level
|
||||
*/
|
||||
public async setVolume(level: number): Promise<void> {
|
||||
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<boolean> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.getMute(this.renderingControlUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mute state
|
||||
*/
|
||||
public async setMute(muted: boolean): Promise<void> {
|
||||
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<boolean> {
|
||||
const newMuted = !this._currentMuted;
|
||||
await this.setMute(newMuted);
|
||||
return newMuted;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Status Information
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get transport info
|
||||
*/
|
||||
public async getTransportInfo(): Promise<IDlnaTransportInfo> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.getTransportInfo(this.avTransportUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get position info
|
||||
*/
|
||||
public async getPositionInfo(): Promise<IDlnaPositionInfo> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.getPositionInfo(this.avTransportUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media info
|
||||
*/
|
||||
public async getMediaInfo(): Promise<IDlnaMediaInfo> {
|
||||
if (!this.soapClient) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return this.soapClient.getMediaInfo(this.avTransportUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full playback state
|
||||
*/
|
||||
public async getPlaybackState(): Promise<IDlnaPlaybackState> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
468
ts/dlna/dlna.classes.server.ts
Normal file
468
ts/dlna/dlna.classes.server.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.soapClient = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
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<IDlnaBrowseResult> {
|
||||
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<IDlnaContentItem | null> {
|
||||
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<IDlnaBrowseResult> {
|
||||
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<IDlnaContentItem[]> {
|
||||
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<IDlnaServerStats> {
|
||||
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<IDlnaBrowseResult> {
|
||||
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<IDlnaBrowseResult> {
|
||||
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<IDlnaBrowseResult> {
|
||||
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<IDlnaBrowseResult> {
|
||||
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<IDlnaBrowseResult> {
|
||||
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';
|
||||
Reference in New Issue
Block a user