Files
devicemanager/ts/dlna/dlna.classes.server.ts

469 lines
11 KiB
TypeScript
Raw Normal View History

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';