@ecobridge.xyz/devicemanager 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/.gitea/workflows/default_nottags.yaml +66 -0
  2. package/.gitea/workflows/default_tags.yaml +124 -0
  3. package/.vscode/launch.json +11 -0
  4. package/.vscode/settings.json +26 -0
  5. package/changelog.md +91 -0
  6. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  7. package/dist_ts/00_commitinfo_data.js +9 -0
  8. package/dist_ts/device/device.classes.device.d.ts +205 -0
  9. package/dist_ts/device/device.classes.device.js +344 -0
  10. package/dist_ts/devicemanager.classes.devicemanager.d.ts +210 -0
  11. package/dist_ts/devicemanager.classes.devicemanager.js +835 -0
  12. package/dist_ts/discovery/discovery.classes.homeassistant.d.ts +101 -0
  13. package/dist_ts/discovery/discovery.classes.homeassistant.js +313 -0
  14. package/dist_ts/discovery/discovery.classes.mdns.d.ts +90 -0
  15. package/dist_ts/discovery/discovery.classes.mdns.js +291 -0
  16. package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +64 -0
  17. package/dist_ts/discovery/discovery.classes.networkscanner.js +535 -0
  18. package/dist_ts/discovery/discovery.classes.ssdp.d.ts +112 -0
  19. package/dist_ts/discovery/discovery.classes.ssdp.js +255 -0
  20. package/dist_ts/factories/index.d.ts +190 -0
  21. package/dist_ts/factories/index.js +350 -0
  22. package/dist_ts/features/feature.abstract.d.ts +118 -0
  23. package/dist_ts/features/feature.abstract.js +172 -0
  24. package/dist_ts/features/feature.camera.d.ts +95 -0
  25. package/dist_ts/features/feature.camera.js +162 -0
  26. package/dist_ts/features/feature.climate.d.ts +174 -0
  27. package/dist_ts/features/feature.climate.js +328 -0
  28. package/dist_ts/features/feature.cover.d.ts +121 -0
  29. package/dist_ts/features/feature.cover.js +217 -0
  30. package/dist_ts/features/feature.fan.d.ts +127 -0
  31. package/dist_ts/features/feature.fan.js +235 -0
  32. package/dist_ts/features/feature.light.d.ts +156 -0
  33. package/dist_ts/features/feature.light.js +296 -0
  34. package/dist_ts/features/feature.lock.d.ts +103 -0
  35. package/dist_ts/features/feature.lock.js +170 -0
  36. package/dist_ts/features/feature.playback.d.ts +98 -0
  37. package/dist_ts/features/feature.playback.js +191 -0
  38. package/dist_ts/features/feature.power.d.ts +79 -0
  39. package/dist_ts/features/feature.power.js +173 -0
  40. package/dist_ts/features/feature.print.d.ts +78 -0
  41. package/dist_ts/features/feature.print.js +226 -0
  42. package/dist_ts/features/feature.scan.d.ts +79 -0
  43. package/dist_ts/features/feature.scan.js +315 -0
  44. package/dist_ts/features/feature.sensor.d.ts +95 -0
  45. package/dist_ts/features/feature.sensor.js +147 -0
  46. package/dist_ts/features/feature.snmp.d.ts +88 -0
  47. package/dist_ts/features/feature.snmp.js +175 -0
  48. package/dist_ts/features/feature.switch.d.ts +75 -0
  49. package/dist_ts/features/feature.switch.js +126 -0
  50. package/dist_ts/features/feature.volume.d.ts +104 -0
  51. package/dist_ts/features/feature.volume.js +186 -0
  52. package/dist_ts/features/index.d.ts +19 -0
  53. package/dist_ts/features/index.js +23 -0
  54. package/dist_ts/helpers/helpers.iprange.d.ts +35 -0
  55. package/dist_ts/helpers/helpers.iprange.js +151 -0
  56. package/dist_ts/helpers/helpers.retry.d.ts +25 -0
  57. package/dist_ts/helpers/helpers.retry.js +69 -0
  58. package/dist_ts/index.d.ts +15 -0
  59. package/dist_ts/index.js +40 -0
  60. package/dist_ts/interfaces/feature.interfaces.d.ts +255 -0
  61. package/dist_ts/interfaces/feature.interfaces.js +6 -0
  62. package/dist_ts/interfaces/homeassistant.interfaces.d.ts +550 -0
  63. package/dist_ts/interfaces/homeassistant.interfaces.js +111 -0
  64. package/dist_ts/interfaces/index.d.ts +330 -0
  65. package/dist_ts/interfaces/index.js +16 -0
  66. package/dist_ts/interfaces/smarthome.interfaces.d.ts +282 -0
  67. package/dist_ts/interfaces/smarthome.interfaces.js +7 -0
  68. package/dist_ts/paths.d.ts +1 -0
  69. package/dist_ts/paths.js +3 -0
  70. package/dist_ts/plugins.d.ts +23 -0
  71. package/dist_ts/plugins.js +30 -0
  72. package/dist_ts/protocols/index.d.ts +12 -0
  73. package/dist_ts/protocols/index.js +21 -0
  74. package/dist_ts/protocols/protocol.escl.d.ts +58 -0
  75. package/dist_ts/protocols/protocol.escl.js +364 -0
  76. package/dist_ts/protocols/protocol.homeassistant.d.ts +248 -0
  77. package/dist_ts/protocols/protocol.homeassistant.js +611 -0
  78. package/dist_ts/protocols/protocol.ipp.d.ts +45 -0
  79. package/dist_ts/protocols/protocol.ipp.js +284 -0
  80. package/dist_ts/protocols/protocol.nut.d.ts +160 -0
  81. package/dist_ts/protocols/protocol.nut.js +364 -0
  82. package/dist_ts/protocols/protocol.sane.d.ts +104 -0
  83. package/dist_ts/protocols/protocol.sane.js +597 -0
  84. package/dist_ts/protocols/protocol.snmp.d.ts +133 -0
  85. package/dist_ts/protocols/protocol.snmp.js +333 -0
  86. package/dist_ts/protocols/protocol.upnp.d.ts +207 -0
  87. package/dist_ts/protocols/protocol.upnp.js +442 -0
  88. package/dist_ts/protocols/protocol.upssnmp.d.ts +160 -0
  89. package/dist_ts/protocols/protocol.upssnmp.js +263 -0
  90. package/npmextra.json +24 -0
  91. package/package.json +39 -0
  92. package/readme.hints.md +115 -0
  93. package/readme.md +624 -0
  94. package/test/test.ts +261 -0
  95. package/ts/00_commitinfo_data.ts +8 -0
  96. package/ts/device/device.classes.device.ts +454 -0
  97. package/ts/devicemanager.classes.devicemanager.ts +1219 -0
  98. package/ts/discovery/discovery.classes.homeassistant.ts +376 -0
  99. package/ts/discovery/discovery.classes.mdns.ts +347 -0
  100. package/ts/discovery/discovery.classes.networkscanner.ts +648 -0
  101. package/ts/discovery/discovery.classes.ssdp.ts +357 -0
  102. package/ts/factories/index.ts +696 -0
  103. package/ts/features/feature.abstract.ts +251 -0
  104. package/ts/features/feature.camera.ts +214 -0
  105. package/ts/features/feature.climate.ts +407 -0
  106. package/ts/features/feature.cover.ts +278 -0
  107. package/ts/features/feature.fan.ts +296 -0
  108. package/ts/features/feature.light.ts +369 -0
  109. package/ts/features/feature.lock.ts +223 -0
  110. package/ts/features/feature.playback.ts +246 -0
  111. package/ts/features/feature.power.ts +231 -0
  112. package/ts/features/feature.print.ts +297 -0
  113. package/ts/features/feature.scan.ts +370 -0
  114. package/ts/features/feature.sensor.ts +202 -0
  115. package/ts/features/feature.snmp.ts +235 -0
  116. package/ts/features/feature.switch.ts +170 -0
  117. package/ts/features/feature.volume.ts +256 -0
  118. package/ts/features/index.ts +25 -0
  119. package/ts/helpers/helpers.iprange.ts +172 -0
  120. package/ts/helpers/helpers.retry.ts +100 -0
  121. package/ts/index.ts +165 -0
  122. package/ts/interfaces/feature.interfaces.ts +359 -0
  123. package/ts/interfaces/homeassistant.interfaces.ts +666 -0
  124. package/ts/interfaces/index.ts +421 -0
  125. package/ts/interfaces/smarthome.interfaces.ts +421 -0
  126. package/ts/paths.ts +5 -0
  127. package/ts/plugins.ts +41 -0
  128. package/ts/protocols/index.ts +59 -0
  129. package/ts/protocols/protocol.escl.ts +439 -0
  130. package/ts/protocols/protocol.homeassistant.ts +737 -0
  131. package/ts/protocols/protocol.ipp.ts +329 -0
  132. package/ts/protocols/protocol.nut.ts +471 -0
  133. package/ts/protocols/protocol.sane.ts +694 -0
  134. package/ts/protocols/protocol.snmp.ts +439 -0
  135. package/ts/protocols/protocol.upnp.ts +627 -0
  136. package/ts/protocols/protocol.upssnmp.ts +377 -0
  137. package/tsconfig.json +12 -0
@@ -0,0 +1,627 @@
1
+ import * as plugins from '../plugins.js';
2
+
3
+ /**
4
+ * UPnP service types for DLNA
5
+ */
6
+ export const UPNP_SERVICE_TYPES = {
7
+ AVTransport: 'urn:schemas-upnp-org:service:AVTransport:1',
8
+ RenderingControl: 'urn:schemas-upnp-org:service:RenderingControl:1',
9
+ ConnectionManager: 'urn:schemas-upnp-org:service:ConnectionManager:1',
10
+ ContentDirectory: 'urn:schemas-upnp-org:service:ContentDirectory:1',
11
+ };
12
+
13
+ /**
14
+ * UPnP device types for DLNA
15
+ */
16
+ export const UPNP_DEVICE_TYPES = {
17
+ MediaRenderer: 'urn:schemas-upnp-org:device:MediaRenderer:1',
18
+ MediaServer: 'urn:schemas-upnp-org:device:MediaServer:1',
19
+ };
20
+
21
+ /**
22
+ * DLNA transport state
23
+ */
24
+ export type TDlnaTransportState =
25
+ | 'STOPPED'
26
+ | 'PLAYING'
27
+ | 'PAUSED_PLAYBACK'
28
+ | 'TRANSITIONING'
29
+ | 'NO_MEDIA_PRESENT';
30
+
31
+ /**
32
+ * DLNA transport status
33
+ */
34
+ export type TDlnaTransportStatus = 'OK' | 'ERROR_OCCURRED';
35
+
36
+ /**
37
+ * Position info from AVTransport
38
+ */
39
+ export interface IDlnaPositionInfo {
40
+ track: number;
41
+ trackDuration: string;
42
+ trackMetadata: string;
43
+ trackUri: string;
44
+ relativeTime: string;
45
+ absoluteTime: string;
46
+ relativeCount: number;
47
+ absoluteCount: number;
48
+ }
49
+
50
+ /**
51
+ * Transport info from AVTransport
52
+ */
53
+ export interface IDlnaTransportInfo {
54
+ state: TDlnaTransportState;
55
+ status: TDlnaTransportStatus;
56
+ speed: string;
57
+ }
58
+
59
+ /**
60
+ * Media info from AVTransport
61
+ */
62
+ export interface IDlnaMediaInfo {
63
+ nrTracks: number;
64
+ mediaDuration: string;
65
+ currentUri: string;
66
+ currentUriMetadata: string;
67
+ nextUri: string;
68
+ nextUriMetadata: string;
69
+ playMedium: string;
70
+ recordMedium: string;
71
+ writeStatus: string;
72
+ }
73
+
74
+ /**
75
+ * Content item from ContentDirectory
76
+ */
77
+ export interface IDlnaContentItem {
78
+ id: string;
79
+ parentId: string;
80
+ title: string;
81
+ class: string;
82
+ restricted: boolean;
83
+ res?: {
84
+ url: string;
85
+ protocolInfo: string;
86
+ size?: number;
87
+ duration?: string;
88
+ resolution?: string;
89
+ bitrate?: number;
90
+ }[];
91
+ albumArtUri?: string;
92
+ artist?: string;
93
+ album?: string;
94
+ genre?: string;
95
+ date?: string;
96
+ childCount?: number;
97
+ }
98
+
99
+ /**
100
+ * Browse result from ContentDirectory
101
+ */
102
+ export interface IDlnaBrowseResult {
103
+ items: IDlnaContentItem[];
104
+ numberReturned: number;
105
+ totalMatches: number;
106
+ updateId: number;
107
+ }
108
+
109
+ /**
110
+ * UPnP SOAP client for DLNA operations
111
+ */
112
+ export class UpnpSoapClient {
113
+ private baseUrl: string;
114
+
115
+ constructor(baseUrl: string) {
116
+ this.baseUrl = baseUrl.replace(/\/$/, '');
117
+ }
118
+
119
+ /**
120
+ * Make a SOAP request to a UPnP service
121
+ */
122
+ public async soapAction(
123
+ controlUrl: string,
124
+ serviceType: string,
125
+ action: string,
126
+ args: Record<string, string | number> = {}
127
+ ): Promise<string> {
128
+ // Build SOAP body
129
+ let argsXml = '';
130
+ for (const [key, value] of Object.entries(args)) {
131
+ const escapedValue = this.escapeXml(String(value));
132
+ argsXml += `<${key}>${escapedValue}</${key}>`;
133
+ }
134
+
135
+ const soapBody = `<?xml version="1.0" encoding="utf-8"?>
136
+ <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
137
+ <s:Body>
138
+ <u:${action} xmlns:u="${serviceType}">
139
+ ${argsXml}
140
+ </u:${action}>
141
+ </s:Body>
142
+ </s:Envelope>`;
143
+
144
+ const fullUrl = controlUrl.startsWith('http') ? controlUrl : `${this.baseUrl}${controlUrl}`;
145
+
146
+ const response = await fetch(fullUrl, {
147
+ method: 'POST',
148
+ headers: {
149
+ 'Content-Type': 'text/xml; charset=utf-8',
150
+ 'SOAPACTION': `"${serviceType}#${action}"`,
151
+ },
152
+ body: soapBody,
153
+ signal: AbortSignal.timeout(10000),
154
+ });
155
+
156
+ if (!response.ok) {
157
+ const text = await response.text();
158
+ throw new Error(`SOAP request failed (${response.status}): ${text}`);
159
+ }
160
+
161
+ return response.text();
162
+ }
163
+
164
+ /**
165
+ * Escape XML special characters
166
+ */
167
+ private escapeXml(str: string): string {
168
+ return str
169
+ .replace(/&/g, '&amp;')
170
+ .replace(/</g, '&lt;')
171
+ .replace(/>/g, '&gt;')
172
+ .replace(/"/g, '&quot;')
173
+ .replace(/'/g, '&apos;');
174
+ }
175
+
176
+ /**
177
+ * Unescape XML special characters
178
+ */
179
+ public unescapeXml(str: string): string {
180
+ return str
181
+ .replace(/&lt;/g, '<')
182
+ .replace(/&gt;/g, '>')
183
+ .replace(/&quot;/g, '"')
184
+ .replace(/&apos;/g, "'")
185
+ .replace(/&amp;/g, '&');
186
+ }
187
+
188
+ /**
189
+ * Extract value from SOAP response
190
+ */
191
+ public extractValue(xml: string, tag: string): string {
192
+ const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i');
193
+ const match = xml.match(regex);
194
+ return match ? match[1].trim() : '';
195
+ }
196
+
197
+ /**
198
+ * Extract multiple values from SOAP response
199
+ */
200
+ public extractValues(xml: string, tags: string[]): Record<string, string> {
201
+ const result: Record<string, string> = {};
202
+ for (const tag of tags) {
203
+ result[tag] = this.extractValue(xml, tag);
204
+ }
205
+ return result;
206
+ }
207
+
208
+ // ============================================================================
209
+ // AVTransport Actions
210
+ // ============================================================================
211
+
212
+ /**
213
+ * Set the URI to play
214
+ */
215
+ public async setAVTransportURI(
216
+ controlUrl: string,
217
+ uri: string,
218
+ metadata: string = ''
219
+ ): Promise<void> {
220
+ await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'SetAVTransportURI', {
221
+ InstanceID: 0,
222
+ CurrentURI: uri,
223
+ CurrentURIMetaData: metadata,
224
+ });
225
+ }
226
+
227
+ /**
228
+ * Set next URI to play
229
+ */
230
+ public async setNextAVTransportURI(
231
+ controlUrl: string,
232
+ uri: string,
233
+ metadata: string = ''
234
+ ): Promise<void> {
235
+ await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'SetNextAVTransportURI', {
236
+ InstanceID: 0,
237
+ NextURI: uri,
238
+ NextURIMetaData: metadata,
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Play
244
+ */
245
+ public async play(controlUrl: string, speed: string = '1'): Promise<void> {
246
+ await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Play', {
247
+ InstanceID: 0,
248
+ Speed: speed,
249
+ });
250
+ }
251
+
252
+ /**
253
+ * Pause
254
+ */
255
+ public async pause(controlUrl: string): Promise<void> {
256
+ await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Pause', {
257
+ InstanceID: 0,
258
+ });
259
+ }
260
+
261
+ /**
262
+ * Stop
263
+ */
264
+ public async stop(controlUrl: string): Promise<void> {
265
+ await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Stop', {
266
+ InstanceID: 0,
267
+ });
268
+ }
269
+
270
+ /**
271
+ * Seek
272
+ */
273
+ public async seek(controlUrl: string, target: string, unit: string = 'REL_TIME'): Promise<void> {
274
+ await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Seek', {
275
+ InstanceID: 0,
276
+ Unit: unit,
277
+ Target: target,
278
+ });
279
+ }
280
+
281
+ /**
282
+ * Next track
283
+ */
284
+ public async next(controlUrl: string): Promise<void> {
285
+ await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Next', {
286
+ InstanceID: 0,
287
+ });
288
+ }
289
+
290
+ /**
291
+ * Previous track
292
+ */
293
+ public async previous(controlUrl: string): Promise<void> {
294
+ await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Previous', {
295
+ InstanceID: 0,
296
+ });
297
+ }
298
+
299
+ /**
300
+ * Get position info
301
+ */
302
+ public async getPositionInfo(controlUrl: string): Promise<IDlnaPositionInfo> {
303
+ const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetPositionInfo', {
304
+ InstanceID: 0,
305
+ });
306
+
307
+ const values = this.extractValues(response, [
308
+ 'Track', 'TrackDuration', 'TrackMetaData', 'TrackURI',
309
+ 'RelTime', 'AbsTime', 'RelCount', 'AbsCount',
310
+ ]);
311
+
312
+ return {
313
+ track: parseInt(values['Track']) || 0,
314
+ trackDuration: values['TrackDuration'] || '0:00:00',
315
+ trackMetadata: this.unescapeXml(values['TrackMetaData'] || ''),
316
+ trackUri: values['TrackURI'] || '',
317
+ relativeTime: values['RelTime'] || '0:00:00',
318
+ absoluteTime: values['AbsTime'] || 'NOT_IMPLEMENTED',
319
+ relativeCount: parseInt(values['RelCount']) || 0,
320
+ absoluteCount: parseInt(values['AbsCount']) || 0,
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Get transport info
326
+ */
327
+ public async getTransportInfo(controlUrl: string): Promise<IDlnaTransportInfo> {
328
+ const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetTransportInfo', {
329
+ InstanceID: 0,
330
+ });
331
+
332
+ const values = this.extractValues(response, [
333
+ 'CurrentTransportState', 'CurrentTransportStatus', 'CurrentSpeed',
334
+ ]);
335
+
336
+ return {
337
+ state: (values['CurrentTransportState'] || 'STOPPED') as TDlnaTransportState,
338
+ status: (values['CurrentTransportStatus'] || 'OK') as TDlnaTransportStatus,
339
+ speed: values['CurrentSpeed'] || '1',
340
+ };
341
+ }
342
+
343
+ /**
344
+ * Get media info
345
+ */
346
+ public async getMediaInfo(controlUrl: string): Promise<IDlnaMediaInfo> {
347
+ const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetMediaInfo', {
348
+ InstanceID: 0,
349
+ });
350
+
351
+ const values = this.extractValues(response, [
352
+ 'NrTracks', 'MediaDuration', 'CurrentURI', 'CurrentURIMetaData',
353
+ 'NextURI', 'NextURIMetaData', 'PlayMedium', 'RecordMedium', 'WriteStatus',
354
+ ]);
355
+
356
+ return {
357
+ nrTracks: parseInt(values['NrTracks']) || 0,
358
+ mediaDuration: values['MediaDuration'] || '0:00:00',
359
+ currentUri: values['CurrentURI'] || '',
360
+ currentUriMetadata: this.unescapeXml(values['CurrentURIMetaData'] || ''),
361
+ nextUri: values['NextURI'] || '',
362
+ nextUriMetadata: this.unescapeXml(values['NextURIMetaData'] || ''),
363
+ playMedium: values['PlayMedium'] || 'NONE',
364
+ recordMedium: values['RecordMedium'] || 'NOT_IMPLEMENTED',
365
+ writeStatus: values['WriteStatus'] || 'NOT_IMPLEMENTED',
366
+ };
367
+ }
368
+
369
+ // ============================================================================
370
+ // RenderingControl Actions
371
+ // ============================================================================
372
+
373
+ /**
374
+ * Get volume
375
+ */
376
+ public async getVolume(controlUrl: string, channel: string = 'Master'): Promise<number> {
377
+ const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'GetVolume', {
378
+ InstanceID: 0,
379
+ Channel: channel,
380
+ });
381
+
382
+ const volume = this.extractValue(response, 'CurrentVolume');
383
+ return parseInt(volume) || 0;
384
+ }
385
+
386
+ /**
387
+ * Set volume
388
+ */
389
+ public async setVolume(controlUrl: string, volume: number, channel: string = 'Master'): Promise<void> {
390
+ await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'SetVolume', {
391
+ InstanceID: 0,
392
+ Channel: channel,
393
+ DesiredVolume: Math.max(0, Math.min(100, volume)),
394
+ });
395
+ }
396
+
397
+ /**
398
+ * Get mute state
399
+ */
400
+ public async getMute(controlUrl: string, channel: string = 'Master'): Promise<boolean> {
401
+ const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'GetMute', {
402
+ InstanceID: 0,
403
+ Channel: channel,
404
+ });
405
+
406
+ const mute = this.extractValue(response, 'CurrentMute');
407
+ return mute === '1' || mute.toLowerCase() === 'true';
408
+ }
409
+
410
+ /**
411
+ * Set mute state
412
+ */
413
+ public async setMute(controlUrl: string, muted: boolean, channel: string = 'Master'): Promise<void> {
414
+ await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'SetMute', {
415
+ InstanceID: 0,
416
+ Channel: channel,
417
+ DesiredMute: muted ? 1 : 0,
418
+ });
419
+ }
420
+
421
+ // ============================================================================
422
+ // ContentDirectory Actions
423
+ // ============================================================================
424
+
425
+ /**
426
+ * Browse content directory
427
+ */
428
+ public async browse(
429
+ controlUrl: string,
430
+ objectId: string = '0',
431
+ browseFlag: 'BrowseDirectChildren' | 'BrowseMetadata' = 'BrowseDirectChildren',
432
+ filter: string = '*',
433
+ startIndex: number = 0,
434
+ requestCount: number = 100,
435
+ sortCriteria: string = ''
436
+ ): Promise<IDlnaBrowseResult> {
437
+ const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.ContentDirectory, 'Browse', {
438
+ ObjectID: objectId,
439
+ BrowseFlag: browseFlag,
440
+ Filter: filter,
441
+ StartingIndex: startIndex,
442
+ RequestedCount: requestCount,
443
+ SortCriteria: sortCriteria,
444
+ });
445
+
446
+ const values = this.extractValues(response, ['Result', 'NumberReturned', 'TotalMatches', 'UpdateID']);
447
+
448
+ const resultXml = this.unescapeXml(values['Result'] || '');
449
+ const items = this.parseDidlResult(resultXml);
450
+
451
+ return {
452
+ items,
453
+ numberReturned: parseInt(values['NumberReturned']) || items.length,
454
+ totalMatches: parseInt(values['TotalMatches']) || items.length,
455
+ updateId: parseInt(values['UpdateID']) || 0,
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Search content directory
461
+ */
462
+ public async search(
463
+ controlUrl: string,
464
+ containerId: string,
465
+ searchCriteria: string,
466
+ filter: string = '*',
467
+ startIndex: number = 0,
468
+ requestCount: number = 100,
469
+ sortCriteria: string = ''
470
+ ): Promise<IDlnaBrowseResult> {
471
+ const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.ContentDirectory, 'Search', {
472
+ ContainerID: containerId,
473
+ SearchCriteria: searchCriteria,
474
+ Filter: filter,
475
+ StartingIndex: startIndex,
476
+ RequestedCount: requestCount,
477
+ SortCriteria: sortCriteria,
478
+ });
479
+
480
+ const values = this.extractValues(response, ['Result', 'NumberReturned', 'TotalMatches', 'UpdateID']);
481
+
482
+ const resultXml = this.unescapeXml(values['Result'] || '');
483
+ const items = this.parseDidlResult(resultXml);
484
+
485
+ return {
486
+ items,
487
+ numberReturned: parseInt(values['NumberReturned']) || items.length,
488
+ totalMatches: parseInt(values['TotalMatches']) || items.length,
489
+ updateId: parseInt(values['UpdateID']) || 0,
490
+ };
491
+ }
492
+
493
+ /**
494
+ * Parse DIDL-Lite result XML
495
+ */
496
+ private parseDidlResult(xml: string): IDlnaContentItem[] {
497
+ const items: IDlnaContentItem[] = [];
498
+
499
+ // Match container and item elements
500
+ const elementRegex = /<(container|item)[^>]*>([\s\S]*?)<\/\1>/gi;
501
+ let match;
502
+
503
+ while ((match = elementRegex.exec(xml)) !== null) {
504
+ const elementXml = match[0];
505
+ const elementType = match[1];
506
+
507
+ // Extract attributes
508
+ const idMatch = elementXml.match(/id="([^"]*)"/);
509
+ const parentIdMatch = elementXml.match(/parentID="([^"]*)"/);
510
+ const restrictedMatch = elementXml.match(/restricted="([^"]*)"/);
511
+ const childCountMatch = elementXml.match(/childCount="([^"]*)"/);
512
+
513
+ const item: IDlnaContentItem = {
514
+ id: idMatch?.[1] || '',
515
+ parentId: parentIdMatch?.[1] || '',
516
+ title: this.extractTagContent(elementXml, 'dc:title'),
517
+ class: this.extractTagContent(elementXml, 'upnp:class'),
518
+ restricted: restrictedMatch?.[1] !== '0',
519
+ childCount: childCountMatch ? parseInt(childCountMatch[1]) : undefined,
520
+ };
521
+
522
+ // Extract resources
523
+ const resMatches = elementXml.match(/<res[^>]*>([^<]*)<\/res>/gi);
524
+ if (resMatches) {
525
+ item.res = resMatches.map((resXml) => {
526
+ const protocolInfo = resXml.match(/protocolInfo="([^"]*)"/)?.[1] || '';
527
+ const size = resXml.match(/size="([^"]*)"/)?.[1];
528
+ const duration = resXml.match(/duration="([^"]*)"/)?.[1];
529
+ const resolution = resXml.match(/resolution="([^"]*)"/)?.[1];
530
+ const bitrate = resXml.match(/bitrate="([^"]*)"/)?.[1];
531
+ const urlMatch = resXml.match(/>([^<]+)</);
532
+
533
+ return {
534
+ url: urlMatch?.[1] || '',
535
+ protocolInfo,
536
+ size: size ? parseInt(size) : undefined,
537
+ duration,
538
+ resolution,
539
+ bitrate: bitrate ? parseInt(bitrate) : undefined,
540
+ };
541
+ });
542
+ }
543
+
544
+ // Extract optional metadata
545
+ const albumArt = this.extractTagContent(elementXml, 'upnp:albumArtURI');
546
+ if (albumArt) item.albumArtUri = albumArt;
547
+
548
+ const artist = this.extractTagContent(elementXml, 'dc:creator') ||
549
+ this.extractTagContent(elementXml, 'upnp:artist');
550
+ if (artist) item.artist = artist;
551
+
552
+ const album = this.extractTagContent(elementXml, 'upnp:album');
553
+ if (album) item.album = album;
554
+
555
+ const genre = this.extractTagContent(elementXml, 'upnp:genre');
556
+ if (genre) item.genre = genre;
557
+
558
+ const date = this.extractTagContent(elementXml, 'dc:date');
559
+ if (date) item.date = date;
560
+
561
+ items.push(item);
562
+ }
563
+
564
+ return items;
565
+ }
566
+
567
+ /**
568
+ * Extract content from XML tag (handles namespaced tags)
569
+ */
570
+ private extractTagContent(xml: string, tag: string): string {
571
+ // Handle both with and without namespace prefix
572
+ const tagName = tag.includes(':') ? tag.split(':')[1] : tag;
573
+ const regex = new RegExp(`<(?:[^:]*:)?${tagName}[^>]*>([^<]*)<\/(?:[^:]*:)?${tagName}>`, 'i');
574
+ const match = xml.match(regex);
575
+ return match ? match[1].trim() : '';
576
+ }
577
+
578
+ // ============================================================================
579
+ // Utility Methods
580
+ // ============================================================================
581
+
582
+ /**
583
+ * Generate DIDL-Lite metadata for a media URL
584
+ */
585
+ public generateDidlMetadata(
586
+ title: string,
587
+ url: string,
588
+ mimeType: string = 'video/mp4'
589
+ ): string {
590
+ const protocolInfo = `http-get:*:${mimeType}:*`;
591
+
592
+ 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/">
593
+ <item id="0" parentID="-1" restricted="1">
594
+ <dc:title>${this.escapeXml(title)}</dc:title>
595
+ <upnp:class>object.item.videoItem</upnp:class>
596
+ <res protocolInfo="${protocolInfo}">${this.escapeXml(url)}</res>
597
+ </item>
598
+ </DIDL-Lite>`;
599
+ }
600
+
601
+ /**
602
+ * Convert duration string to seconds
603
+ */
604
+ public durationToSeconds(duration: string): number {
605
+ if (!duration || duration === 'NOT_IMPLEMENTED') return 0;
606
+
607
+ const parts = duration.split(':');
608
+ if (parts.length !== 3) return 0;
609
+
610
+ const hours = parseInt(parts[0]) || 0;
611
+ const minutes = parseInt(parts[1]) || 0;
612
+ const seconds = parseFloat(parts[2]) || 0;
613
+
614
+ return hours * 3600 + minutes * 60 + seconds;
615
+ }
616
+
617
+ /**
618
+ * Convert seconds to duration string
619
+ */
620
+ public secondsToDuration(seconds: number): string {
621
+ const hours = Math.floor(seconds / 3600);
622
+ const minutes = Math.floor((seconds % 3600) / 60);
623
+ const secs = Math.floor(seconds % 60);
624
+
625
+ return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
626
+ }
627
+ }