feat(devicemanager): Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors
This commit is contained in:
627
ts/protocols/protocol.upnp.ts
Normal file
627
ts/protocols/protocol.upnp.ts
Normal file
@@ -0,0 +1,627 @@
|
||||
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')}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user