469 lines
11 KiB
TypeScript
469 lines
11 KiB
TypeScript
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';
|