628 lines
18 KiB
TypeScript
628 lines
18 KiB
TypeScript
|
|
import * as plugins from '../plugins.js';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* UPnP service types for DLNA
|
||
|
|
*/
|
||
|
|
export const UPNP_SERVICE_TYPES = {
|
||
|
|
AVTransport: 'urn:schemas-upnp-org:service:AVTransport:1',
|
||
|
|
RenderingControl: 'urn:schemas-upnp-org:service:RenderingControl:1',
|
||
|
|
ConnectionManager: 'urn:schemas-upnp-org:service:ConnectionManager:1',
|
||
|
|
ContentDirectory: 'urn:schemas-upnp-org:service:ContentDirectory:1',
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* UPnP device types for DLNA
|
||
|
|
*/
|
||
|
|
export const UPNP_DEVICE_TYPES = {
|
||
|
|
MediaRenderer: 'urn:schemas-upnp-org:device:MediaRenderer:1',
|
||
|
|
MediaServer: 'urn:schemas-upnp-org:device:MediaServer:1',
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* DLNA transport state
|
||
|
|
*/
|
||
|
|
export type TDlnaTransportState =
|
||
|
|
| 'STOPPED'
|
||
|
|
| 'PLAYING'
|
||
|
|
| 'PAUSED_PLAYBACK'
|
||
|
|
| 'TRANSITIONING'
|
||
|
|
| 'NO_MEDIA_PRESENT';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* DLNA transport status
|
||
|
|
*/
|
||
|
|
export type TDlnaTransportStatus = 'OK' | 'ERROR_OCCURRED';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Position info from AVTransport
|
||
|
|
*/
|
||
|
|
export interface IDlnaPositionInfo {
|
||
|
|
track: number;
|
||
|
|
trackDuration: string;
|
||
|
|
trackMetadata: string;
|
||
|
|
trackUri: string;
|
||
|
|
relativeTime: string;
|
||
|
|
absoluteTime: string;
|
||
|
|
relativeCount: number;
|
||
|
|
absoluteCount: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Transport info from AVTransport
|
||
|
|
*/
|
||
|
|
export interface IDlnaTransportInfo {
|
||
|
|
state: TDlnaTransportState;
|
||
|
|
status: TDlnaTransportStatus;
|
||
|
|
speed: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Media info from AVTransport
|
||
|
|
*/
|
||
|
|
export interface IDlnaMediaInfo {
|
||
|
|
nrTracks: number;
|
||
|
|
mediaDuration: string;
|
||
|
|
currentUri: string;
|
||
|
|
currentUriMetadata: string;
|
||
|
|
nextUri: string;
|
||
|
|
nextUriMetadata: string;
|
||
|
|
playMedium: string;
|
||
|
|
recordMedium: string;
|
||
|
|
writeStatus: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Content item from ContentDirectory
|
||
|
|
*/
|
||
|
|
export interface IDlnaContentItem {
|
||
|
|
id: string;
|
||
|
|
parentId: string;
|
||
|
|
title: string;
|
||
|
|
class: string;
|
||
|
|
restricted: boolean;
|
||
|
|
res?: {
|
||
|
|
url: string;
|
||
|
|
protocolInfo: string;
|
||
|
|
size?: number;
|
||
|
|
duration?: string;
|
||
|
|
resolution?: string;
|
||
|
|
bitrate?: number;
|
||
|
|
}[];
|
||
|
|
albumArtUri?: string;
|
||
|
|
artist?: string;
|
||
|
|
album?: string;
|
||
|
|
genre?: string;
|
||
|
|
date?: string;
|
||
|
|
childCount?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Browse result from ContentDirectory
|
||
|
|
*/
|
||
|
|
export interface IDlnaBrowseResult {
|
||
|
|
items: IDlnaContentItem[];
|
||
|
|
numberReturned: number;
|
||
|
|
totalMatches: number;
|
||
|
|
updateId: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* UPnP SOAP client for DLNA operations
|
||
|
|
*/
|
||
|
|
export class UpnpSoapClient {
|
||
|
|
private baseUrl: string;
|
||
|
|
|
||
|
|
constructor(baseUrl: string) {
|
||
|
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Make a SOAP request to a UPnP service
|
||
|
|
*/
|
||
|
|
public async soapAction(
|
||
|
|
controlUrl: string,
|
||
|
|
serviceType: string,
|
||
|
|
action: string,
|
||
|
|
args: Record<string, string | number> = {}
|
||
|
|
): Promise<string> {
|
||
|
|
// Build SOAP body
|
||
|
|
let argsXml = '';
|
||
|
|
for (const [key, value] of Object.entries(args)) {
|
||
|
|
const escapedValue = this.escapeXml(String(value));
|
||
|
|
argsXml += `<${key}>${escapedValue}</${key}>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
const soapBody = `<?xml version="1.0" encoding="utf-8"?>
|
||
|
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||
|
|
<s:Body>
|
||
|
|
<u:${action} xmlns:u="${serviceType}">
|
||
|
|
${argsXml}
|
||
|
|
</u:${action}>
|
||
|
|
</s:Body>
|
||
|
|
</s:Envelope>`;
|
||
|
|
|
||
|
|
const fullUrl = controlUrl.startsWith('http') ? controlUrl : `${this.baseUrl}${controlUrl}`;
|
||
|
|
|
||
|
|
const response = await fetch(fullUrl, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {
|
||
|
|
'Content-Type': 'text/xml; charset=utf-8',
|
||
|
|
'SOAPACTION': `"${serviceType}#${action}"`,
|
||
|
|
},
|
||
|
|
body: soapBody,
|
||
|
|
signal: AbortSignal.timeout(10000),
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
const text = await response.text();
|
||
|
|
throw new Error(`SOAP request failed (${response.status}): ${text}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.text();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Escape XML special characters
|
||
|
|
*/
|
||
|
|
private escapeXml(str: string): string {
|
||
|
|
return str
|
||
|
|
.replace(/&/g, '&')
|
||
|
|
.replace(/</g, '<')
|
||
|
|
.replace(/>/g, '>')
|
||
|
|
.replace(/"/g, '"')
|
||
|
|
.replace(/'/g, ''');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Unescape XML special characters
|
||
|
|
*/
|
||
|
|
public unescapeXml(str: string): string {
|
||
|
|
return str
|
||
|
|
.replace(/</g, '<')
|
||
|
|
.replace(/>/g, '>')
|
||
|
|
.replace(/"/g, '"')
|
||
|
|
.replace(/'/g, "'")
|
||
|
|
.replace(/&/g, '&');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Extract value from SOAP response
|
||
|
|
*/
|
||
|
|
public extractValue(xml: string, tag: string): string {
|
||
|
|
const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i');
|
||
|
|
const match = xml.match(regex);
|
||
|
|
return match ? match[1].trim() : '';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Extract multiple values from SOAP response
|
||
|
|
*/
|
||
|
|
public extractValues(xml: string, tags: string[]): Record<string, string> {
|
||
|
|
const result: Record<string, string> = {};
|
||
|
|
for (const tag of tags) {
|
||
|
|
result[tag] = this.extractValue(xml, tag);
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// AVTransport Actions
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Set the URI to play
|
||
|
|
*/
|
||
|
|
public async setAVTransportURI(
|
||
|
|
controlUrl: string,
|
||
|
|
uri: string,
|
||
|
|
metadata: string = ''
|
||
|
|
): Promise<void> {
|
||
|
|
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'SetAVTransportURI', {
|
||
|
|
InstanceID: 0,
|
||
|
|
CurrentURI: uri,
|
||
|
|
CurrentURIMetaData: metadata,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Set next URI to play
|
||
|
|
*/
|
||
|
|
public async setNextAVTransportURI(
|
||
|
|
controlUrl: string,
|
||
|
|
uri: string,
|
||
|
|
metadata: string = ''
|
||
|
|
): Promise<void> {
|
||
|
|
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'SetNextAVTransportURI', {
|
||
|
|
InstanceID: 0,
|
||
|
|
NextURI: uri,
|
||
|
|
NextURIMetaData: metadata,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Play
|
||
|
|
*/
|
||
|
|
public async play(controlUrl: string, speed: string = '1'): Promise<void> {
|
||
|
|
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Play', {
|
||
|
|
InstanceID: 0,
|
||
|
|
Speed: speed,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Pause
|
||
|
|
*/
|
||
|
|
public async pause(controlUrl: string): Promise<void> {
|
||
|
|
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Pause', {
|
||
|
|
InstanceID: 0,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Stop
|
||
|
|
*/
|
||
|
|
public async stop(controlUrl: string): Promise<void> {
|
||
|
|
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Stop', {
|
||
|
|
InstanceID: 0,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Seek
|
||
|
|
*/
|
||
|
|
public async seek(controlUrl: string, target: string, unit: string = 'REL_TIME'): Promise<void> {
|
||
|
|
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Seek', {
|
||
|
|
InstanceID: 0,
|
||
|
|
Unit: unit,
|
||
|
|
Target: target,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Next track
|
||
|
|
*/
|
||
|
|
public async next(controlUrl: string): Promise<void> {
|
||
|
|
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Next', {
|
||
|
|
InstanceID: 0,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Previous track
|
||
|
|
*/
|
||
|
|
public async previous(controlUrl: string): Promise<void> {
|
||
|
|
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Previous', {
|
||
|
|
InstanceID: 0,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get position info
|
||
|
|
*/
|
||
|
|
public async getPositionInfo(controlUrl: string): Promise<IDlnaPositionInfo> {
|
||
|
|
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetPositionInfo', {
|
||
|
|
InstanceID: 0,
|
||
|
|
});
|
||
|
|
|
||
|
|
const values = this.extractValues(response, [
|
||
|
|
'Track', 'TrackDuration', 'TrackMetaData', 'TrackURI',
|
||
|
|
'RelTime', 'AbsTime', 'RelCount', 'AbsCount',
|
||
|
|
]);
|
||
|
|
|
||
|
|
return {
|
||
|
|
track: parseInt(values['Track']) || 0,
|
||
|
|
trackDuration: values['TrackDuration'] || '0:00:00',
|
||
|
|
trackMetadata: this.unescapeXml(values['TrackMetaData'] || ''),
|
||
|
|
trackUri: values['TrackURI'] || '',
|
||
|
|
relativeTime: values['RelTime'] || '0:00:00',
|
||
|
|
absoluteTime: values['AbsTime'] || 'NOT_IMPLEMENTED',
|
||
|
|
relativeCount: parseInt(values['RelCount']) || 0,
|
||
|
|
absoluteCount: parseInt(values['AbsCount']) || 0,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get transport info
|
||
|
|
*/
|
||
|
|
public async getTransportInfo(controlUrl: string): Promise<IDlnaTransportInfo> {
|
||
|
|
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetTransportInfo', {
|
||
|
|
InstanceID: 0,
|
||
|
|
});
|
||
|
|
|
||
|
|
const values = this.extractValues(response, [
|
||
|
|
'CurrentTransportState', 'CurrentTransportStatus', 'CurrentSpeed',
|
||
|
|
]);
|
||
|
|
|
||
|
|
return {
|
||
|
|
state: (values['CurrentTransportState'] || 'STOPPED') as TDlnaTransportState,
|
||
|
|
status: (values['CurrentTransportStatus'] || 'OK') as TDlnaTransportStatus,
|
||
|
|
speed: values['CurrentSpeed'] || '1',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get media info
|
||
|
|
*/
|
||
|
|
public async getMediaInfo(controlUrl: string): Promise<IDlnaMediaInfo> {
|
||
|
|
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetMediaInfo', {
|
||
|
|
InstanceID: 0,
|
||
|
|
});
|
||
|
|
|
||
|
|
const values = this.extractValues(response, [
|
||
|
|
'NrTracks', 'MediaDuration', 'CurrentURI', 'CurrentURIMetaData',
|
||
|
|
'NextURI', 'NextURIMetaData', 'PlayMedium', 'RecordMedium', 'WriteStatus',
|
||
|
|
]);
|
||
|
|
|
||
|
|
return {
|
||
|
|
nrTracks: parseInt(values['NrTracks']) || 0,
|
||
|
|
mediaDuration: values['MediaDuration'] || '0:00:00',
|
||
|
|
currentUri: values['CurrentURI'] || '',
|
||
|
|
currentUriMetadata: this.unescapeXml(values['CurrentURIMetaData'] || ''),
|
||
|
|
nextUri: values['NextURI'] || '',
|
||
|
|
nextUriMetadata: this.unescapeXml(values['NextURIMetaData'] || ''),
|
||
|
|
playMedium: values['PlayMedium'] || 'NONE',
|
||
|
|
recordMedium: values['RecordMedium'] || 'NOT_IMPLEMENTED',
|
||
|
|
writeStatus: values['WriteStatus'] || 'NOT_IMPLEMENTED',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// RenderingControl Actions
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get volume
|
||
|
|
*/
|
||
|
|
public async getVolume(controlUrl: string, channel: string = 'Master'): Promise<number> {
|
||
|
|
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'GetVolume', {
|
||
|
|
InstanceID: 0,
|
||
|
|
Channel: channel,
|
||
|
|
});
|
||
|
|
|
||
|
|
const volume = this.extractValue(response, 'CurrentVolume');
|
||
|
|
return parseInt(volume) || 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Set volume
|
||
|
|
*/
|
||
|
|
public async setVolume(controlUrl: string, volume: number, channel: string = 'Master'): Promise<void> {
|
||
|
|
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'SetVolume', {
|
||
|
|
InstanceID: 0,
|
||
|
|
Channel: channel,
|
||
|
|
DesiredVolume: Math.max(0, Math.min(100, volume)),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get mute state
|
||
|
|
*/
|
||
|
|
public async getMute(controlUrl: string, channel: string = 'Master'): Promise<boolean> {
|
||
|
|
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'GetMute', {
|
||
|
|
InstanceID: 0,
|
||
|
|
Channel: channel,
|
||
|
|
});
|
||
|
|
|
||
|
|
const mute = this.extractValue(response, 'CurrentMute');
|
||
|
|
return mute === '1' || mute.toLowerCase() === 'true';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Set mute state
|
||
|
|
*/
|
||
|
|
public async setMute(controlUrl: string, muted: boolean, channel: string = 'Master'): Promise<void> {
|
||
|
|
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'SetMute', {
|
||
|
|
InstanceID: 0,
|
||
|
|
Channel: channel,
|
||
|
|
DesiredMute: muted ? 1 : 0,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// ContentDirectory Actions
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Browse content directory
|
||
|
|
*/
|
||
|
|
public async browse(
|
||
|
|
controlUrl: string,
|
||
|
|
objectId: string = '0',
|
||
|
|
browseFlag: 'BrowseDirectChildren' | 'BrowseMetadata' = 'BrowseDirectChildren',
|
||
|
|
filter: string = '*',
|
||
|
|
startIndex: number = 0,
|
||
|
|
requestCount: number = 100,
|
||
|
|
sortCriteria: string = ''
|
||
|
|
): Promise<IDlnaBrowseResult> {
|
||
|
|
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.ContentDirectory, 'Browse', {
|
||
|
|
ObjectID: objectId,
|
||
|
|
BrowseFlag: browseFlag,
|
||
|
|
Filter: filter,
|
||
|
|
StartingIndex: startIndex,
|
||
|
|
RequestedCount: requestCount,
|
||
|
|
SortCriteria: sortCriteria,
|
||
|
|
});
|
||
|
|
|
||
|
|
const values = this.extractValues(response, ['Result', 'NumberReturned', 'TotalMatches', 'UpdateID']);
|
||
|
|
|
||
|
|
const resultXml = this.unescapeXml(values['Result'] || '');
|
||
|
|
const items = this.parseDidlResult(resultXml);
|
||
|
|
|
||
|
|
return {
|
||
|
|
items,
|
||
|
|
numberReturned: parseInt(values['NumberReturned']) || items.length,
|
||
|
|
totalMatches: parseInt(values['TotalMatches']) || items.length,
|
||
|
|
updateId: parseInt(values['UpdateID']) || 0,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Search content directory
|
||
|
|
*/
|
||
|
|
public async search(
|
||
|
|
controlUrl: string,
|
||
|
|
containerId: string,
|
||
|
|
searchCriteria: string,
|
||
|
|
filter: string = '*',
|
||
|
|
startIndex: number = 0,
|
||
|
|
requestCount: number = 100,
|
||
|
|
sortCriteria: string = ''
|
||
|
|
): Promise<IDlnaBrowseResult> {
|
||
|
|
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.ContentDirectory, 'Search', {
|
||
|
|
ContainerID: containerId,
|
||
|
|
SearchCriteria: searchCriteria,
|
||
|
|
Filter: filter,
|
||
|
|
StartingIndex: startIndex,
|
||
|
|
RequestedCount: requestCount,
|
||
|
|
SortCriteria: sortCriteria,
|
||
|
|
});
|
||
|
|
|
||
|
|
const values = this.extractValues(response, ['Result', 'NumberReturned', 'TotalMatches', 'UpdateID']);
|
||
|
|
|
||
|
|
const resultXml = this.unescapeXml(values['Result'] || '');
|
||
|
|
const items = this.parseDidlResult(resultXml);
|
||
|
|
|
||
|
|
return {
|
||
|
|
items,
|
||
|
|
numberReturned: parseInt(values['NumberReturned']) || items.length,
|
||
|
|
totalMatches: parseInt(values['TotalMatches']) || items.length,
|
||
|
|
updateId: parseInt(values['UpdateID']) || 0,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Parse DIDL-Lite result XML
|
||
|
|
*/
|
||
|
|
private parseDidlResult(xml: string): IDlnaContentItem[] {
|
||
|
|
const items: IDlnaContentItem[] = [];
|
||
|
|
|
||
|
|
// Match container and item elements
|
||
|
|
const elementRegex = /<(container|item)[^>]*>([\s\S]*?)<\/\1>/gi;
|
||
|
|
let match;
|
||
|
|
|
||
|
|
while ((match = elementRegex.exec(xml)) !== null) {
|
||
|
|
const elementXml = match[0];
|
||
|
|
const elementType = match[1];
|
||
|
|
|
||
|
|
// Extract attributes
|
||
|
|
const idMatch = elementXml.match(/id="([^"]*)"/);
|
||
|
|
const parentIdMatch = elementXml.match(/parentID="([^"]*)"/);
|
||
|
|
const restrictedMatch = elementXml.match(/restricted="([^"]*)"/);
|
||
|
|
const childCountMatch = elementXml.match(/childCount="([^"]*)"/);
|
||
|
|
|
||
|
|
const item: IDlnaContentItem = {
|
||
|
|
id: idMatch?.[1] || '',
|
||
|
|
parentId: parentIdMatch?.[1] || '',
|
||
|
|
title: this.extractTagContent(elementXml, 'dc:title'),
|
||
|
|
class: this.extractTagContent(elementXml, 'upnp:class'),
|
||
|
|
restricted: restrictedMatch?.[1] !== '0',
|
||
|
|
childCount: childCountMatch ? parseInt(childCountMatch[1]) : undefined,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Extract resources
|
||
|
|
const resMatches = elementXml.match(/<res[^>]*>([^<]*)<\/res>/gi);
|
||
|
|
if (resMatches) {
|
||
|
|
item.res = resMatches.map((resXml) => {
|
||
|
|
const protocolInfo = resXml.match(/protocolInfo="([^"]*)"/)?.[1] || '';
|
||
|
|
const size = resXml.match(/size="([^"]*)"/)?.[1];
|
||
|
|
const duration = resXml.match(/duration="([^"]*)"/)?.[1];
|
||
|
|
const resolution = resXml.match(/resolution="([^"]*)"/)?.[1];
|
||
|
|
const bitrate = resXml.match(/bitrate="([^"]*)"/)?.[1];
|
||
|
|
const urlMatch = resXml.match(/>([^<]+)</);
|
||
|
|
|
||
|
|
return {
|
||
|
|
url: urlMatch?.[1] || '',
|
||
|
|
protocolInfo,
|
||
|
|
size: size ? parseInt(size) : undefined,
|
||
|
|
duration,
|
||
|
|
resolution,
|
||
|
|
bitrate: bitrate ? parseInt(bitrate) : undefined,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Extract optional metadata
|
||
|
|
const albumArt = this.extractTagContent(elementXml, 'upnp:albumArtURI');
|
||
|
|
if (albumArt) item.albumArtUri = albumArt;
|
||
|
|
|
||
|
|
const artist = this.extractTagContent(elementXml, 'dc:creator') ||
|
||
|
|
this.extractTagContent(elementXml, 'upnp:artist');
|
||
|
|
if (artist) item.artist = artist;
|
||
|
|
|
||
|
|
const album = this.extractTagContent(elementXml, 'upnp:album');
|
||
|
|
if (album) item.album = album;
|
||
|
|
|
||
|
|
const genre = this.extractTagContent(elementXml, 'upnp:genre');
|
||
|
|
if (genre) item.genre = genre;
|
||
|
|
|
||
|
|
const date = this.extractTagContent(elementXml, 'dc:date');
|
||
|
|
if (date) item.date = date;
|
||
|
|
|
||
|
|
items.push(item);
|
||
|
|
}
|
||
|
|
|
||
|
|
return items;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Extract content from XML tag (handles namespaced tags)
|
||
|
|
*/
|
||
|
|
private extractTagContent(xml: string, tag: string): string {
|
||
|
|
// Handle both with and without namespace prefix
|
||
|
|
const tagName = tag.includes(':') ? tag.split(':')[1] : tag;
|
||
|
|
const regex = new RegExp(`<(?:[^:]*:)?${tagName}[^>]*>([^<]*)<\/(?:[^:]*:)?${tagName}>`, 'i');
|
||
|
|
const match = xml.match(regex);
|
||
|
|
return match ? match[1].trim() : '';
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Utility Methods
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate DIDL-Lite metadata for a media URL
|
||
|
|
*/
|
||
|
|
public generateDidlMetadata(
|
||
|
|
title: string,
|
||
|
|
url: string,
|
||
|
|
mimeType: string = 'video/mp4'
|
||
|
|
): string {
|
||
|
|
const protocolInfo = `http-get:*:${mimeType}:*`;
|
||
|
|
|
||
|
|
return `<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
||
|
|
<item id="0" parentID="-1" restricted="1">
|
||
|
|
<dc:title>${this.escapeXml(title)}</dc:title>
|
||
|
|
<upnp:class>object.item.videoItem</upnp:class>
|
||
|
|
<res protocolInfo="${protocolInfo}">${this.escapeXml(url)}</res>
|
||
|
|
</item>
|
||
|
|
</DIDL-Lite>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Convert duration string to seconds
|
||
|
|
*/
|
||
|
|
public durationToSeconds(duration: string): number {
|
||
|
|
if (!duration || duration === 'NOT_IMPLEMENTED') return 0;
|
||
|
|
|
||
|
|
const parts = duration.split(':');
|
||
|
|
if (parts.length !== 3) return 0;
|
||
|
|
|
||
|
|
const hours = parseInt(parts[0]) || 0;
|
||
|
|
const minutes = parseInt(parts[1]) || 0;
|
||
|
|
const seconds = parseFloat(parts[2]) || 0;
|
||
|
|
|
||
|
|
return hours * 3600 + minutes * 60 + seconds;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Convert seconds to duration string
|
||
|
|
*/
|
||
|
|
public secondsToDuration(seconds: number): string {
|
||
|
|
const hours = Math.floor(seconds / 3600);
|
||
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||
|
|
const secs = Math.floor(seconds % 60);
|
||
|
|
|
||
|
|
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||
|
|
}
|
||
|
|
}
|