@edenware/dlnacasts 1.0.0

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.
package/dist/index.mjs ADDED
@@ -0,0 +1,390 @@
1
+ import MediaRenderer from 'upnp-mediarenderer-client'
2
+ import { EventEmitter } from 'events'
3
+ import parallel from 'run-parallel'
4
+ import { parseString } from 'xml2js'
5
+ import { Client as SSDP } from 'node-ssdp'
6
+ import { isIP } from 'node:net'
7
+ import * as http from 'node:http'
8
+ const os = require('os')
9
+ const agent = new http.Agent({ keepAlive: true, maxSockets: 5 })
10
+
11
+ const SERVICE_TYPE = 'urn:schemas-upnp-org:device:MediaRenderer:1';
12
+ import thunky from 'thunky'
13
+
14
+ const noop = () => {}
15
+
16
+ export default (options = {}) => {
17
+ const log = options.log ? console.debug : () => {}
18
+
19
+ // Find local IP
20
+ const interfaces = os.networkInterfaces()
21
+ let localIP = '192.168.1.8' // fallback
22
+ for (const name of Object.keys(interfaces)) {
23
+ for (const iface of interfaces[name]) {
24
+ if (iface.family === 'IPv4' && !iface.internal) {
25
+ localIP = iface.address
26
+ break
27
+ }
28
+ }
29
+ if (localIP !== '192.168.1.8') break
30
+ }
31
+ log('Local IP:', localIP)
32
+
33
+ const that = new EventEmitter()
34
+ const casts = {}
35
+ const ssdp = SSDP ? new SSDP({
36
+ unicastHost: localIP,
37
+ log: true
38
+ }) : null
39
+
40
+ that.players = []
41
+
42
+ const emit = (cst) => {
43
+ if (!cst || !cst.host || cst.emitted) return
44
+ cst.emitted = true
45
+
46
+ const player = new EventEmitter()
47
+ let getStatus = undefined
48
+ let stopped = false // Flag para rastrear se stop foi chamado
49
+ let playFailed = false // Flag para rastrear se play falhou
50
+
51
+ const connect = thunky(function reconnect (cb) {
52
+ const client = new MediaRenderer(player.xml)
53
+
54
+ client.on('error', (err) => {
55
+ log('[DLNACASTS] Client error, clearing player.client:', err)
56
+ try { clearInterval(getStatus) } catch(e) {}
57
+ player.client = undefined
58
+ player.emit('error', err)
59
+ })
60
+
61
+ client.on('loading', (err) => {
62
+ player.emit('loading', err)
63
+ })
64
+
65
+ client.on('close', () => {
66
+ log('[DLNACASTS] Client closed, clearing player.client')
67
+ try { clearInterval(getStatus) } catch(e) {}
68
+ player.client = undefined
69
+ connect = thunky(reconnect) // Reset thunky para permitir nova reconexão
70
+ })
71
+
72
+ player.client = client
73
+ cb(null, player.client)
74
+ })
75
+
76
+ const parseTime = (time) => {
77
+ if (!time || time.indexOf(':') === -1) return 0
78
+ const parts = time.split(':').map(Number)
79
+ return parts[0] * 3600 + parts[1] * 60 + parts[2]
80
+ }
81
+
82
+ player.name = cst.name
83
+ player.host = cst.host
84
+ player.xml = cst.xml
85
+ player._status = {}
86
+ player.MAX_VOLUME = 100
87
+
88
+ player.connect = connect
89
+
90
+ player.close = (cb = noop) => {
91
+ log('[DLNACASTS] Closing player, clearing player.client')
92
+ try { clearInterval(getStatus) } catch(e) {}
93
+ if (player.client) {
94
+ for (let e of ["error", "status", "loading", "close"]) {
95
+ player.client.removeAllListeners(e)
96
+ }
97
+ player.client = undefined
98
+ }
99
+ stopped = true // Marcar que stop foi chamado
100
+ cb()
101
+ }
102
+
103
+ player.play = (url, opts, cb = noop) => {
104
+ if (typeof opts === 'function') return player.play(url, null, opts)
105
+ if (!opts) opts = {}
106
+ if (!url) return player.resume(cb)
107
+
108
+ stopped = false // Resetar stopped ao chamar play
109
+ playFailed = false // Resetar playFailed ao chamar play
110
+ player.subtitles = opts.subtitles
111
+
112
+ connect((err, p) => {
113
+ if (err) {
114
+ log('[DLNACASTS] Connect failed in play:', err)
115
+ playFailed = true // Marcar que play falhou
116
+ return cb(err)
117
+ }
118
+
119
+ try { clearInterval(getStatus) } catch(e) {}
120
+
121
+ const media = {
122
+ autoplay: opts.autoPlay !== false,
123
+ contentType: opts.type || 'video/mp4',
124
+ metadata: opts.metadata || {
125
+ title: opts.title || '',
126
+ type: 'video', // can be 'video', 'audio' or 'image'
127
+ subtitlesUrl: player.subtitles && player.subtitles.length ? player.subtitles[0] : null
128
+ }
129
+ }
130
+ if (opts.dlnaFeatures) {
131
+ media.dlnaFeatures = opts.dlnaFeatures || 'DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01500000000000000000000000000000'
132
+ //media.dlnaFeatures = opts.dlnaFeatures; // for LG WebOS 'DLNA.ORG_OP=01;DLNA.ORG_FLAGS=01100000000000000000000000000000' allows seeking
133
+ }
134
+
135
+ let callback = cb
136
+ if (opts.seek) {
137
+ callback = (err) => {
138
+ if (err) {
139
+ playFailed = true // Marcar que play falhou
140
+ return cb(err)
141
+ }
142
+ player.seek(opts.seek, cb)
143
+ }
144
+ }
145
+
146
+ getStatus = setInterval(() => {
147
+ if (stopped || playFailed) {
148
+ log('[DLNACASTS] Skipping getStatus: stopped=%s, playFailed=%s', stopped, playFailed)
149
+ return
150
+ }
151
+ if (!player.client) {
152
+ log('[DLNACASTS] player.client is undefined in getStatus, attempting to reconnect')
153
+ connect((err) => {
154
+ if (err) {
155
+ log('[DLNACASTS] Reconnect failed in getStatus:', err)
156
+ playFailed = true // Marcar falha se reconexão falhar
157
+ return
158
+ }
159
+ log('[DLNACASTS] Reconnected successfully in getStatus')
160
+ player.client.callAction('AVTransport', 'GetTransportInfo', {
161
+ InstanceID: player.client.instanceId
162
+ }, (err, res) => {
163
+ if (err) return
164
+ const newStatus = res.CurrentTransportState
165
+ if (newStatus !== player._status.playerState) {
166
+ player._status.playerState = newStatus
167
+ player.status((err, status) => {
168
+ if (err) return
169
+ player.emit('status', status)
170
+ })
171
+ }
172
+ })
173
+ })
174
+ return
175
+ }
176
+ player.client.callAction('AVTransport', 'GetTransportInfo', {
177
+ InstanceID: player.client.instanceId
178
+ }, (err, res) => {
179
+ if (err) return
180
+ const newStatus = res.CurrentTransportState
181
+ if (newStatus !== player._status.playerState) {
182
+ player._status.playerState = newStatus
183
+ player.status((err, status) => {
184
+ if (err) return
185
+ player.emit('status', status)
186
+ })
187
+ }
188
+ })
189
+ }, 1000)
190
+
191
+ p.load(url, media, callback)
192
+ })
193
+ }
194
+
195
+ player.resume = (cb = noop) => {
196
+ player.client.play(cb)
197
+ }
198
+
199
+ player.pause = (cb = noop) => {
200
+ player.client.pause(cb)
201
+ }
202
+
203
+ player.stop = (cb = noop) => {
204
+ try { clearInterval(getStatus) } catch(e) {}
205
+ stopped = true // Marcar que stop foi chamado
206
+ player.client.stop(cb)
207
+ }
208
+
209
+ player.status = (cb = noop) => {
210
+ if (stopped || playFailed) {
211
+ log('[DLNACASTS] Skipping status: stopped=%s, playFailed=%s', stopped, playFailed)
212
+ return cb(null, player._status)
213
+ }
214
+ if (!player.client) {
215
+ log('[DLNACASTS] player.client is undefined in status, attempting to reconnect')
216
+ connect((err) => {
217
+ if (err) {
218
+ log('[DLNACASTS] Reconnect failed in status:', err)
219
+ playFailed = true // Marcar falha se reconexão falhar
220
+ return cb(err)
221
+ }
222
+ log('[DLNACASTS] Reconnected successfully in status')
223
+ parallel({
224
+ currentTime: (acb) => {
225
+ player.client.callAction('AVTransport', 'GetPositionInfo', {
226
+ InstanceID: player.client.instanceId
227
+ }, (err, res) => {
228
+ if (err) return acb()
229
+ acb(null, parseTime(res.AbsTime) | parseTime(res.RelTime))
230
+ })
231
+ },
232
+ volume: (acb) => {
233
+ player.getVolume(acb)
234
+ }
235
+ }, (err, results) => {
236
+ log('dlnacasts player.status results: %o', results)
237
+ player._status.currentTime = results.currentTime
238
+ player._status.volume = { level: results.volume / player.MAX_VOLUME }
239
+ return cb(err, player._status)
240
+ })
241
+ })
242
+ return
243
+ }
244
+
245
+ parallel({
246
+ currentTime: (acb) => {
247
+ player.client.callAction('AVTransport', 'GetPositionInfo', {
248
+ InstanceID: player.client.instanceId
249
+ }, (err, res) => {
250
+ if (err) return acb()
251
+ acb(null, parseTime(res.AbsTime) | parseTime(res.RelTime))
252
+ })
253
+ },
254
+ volume: (acb) => {
255
+ player.getVolume(acb)
256
+ }
257
+ }, (err, results) => {
258
+ log('dlnacasts player.status results: %o', results)
259
+ player._status.currentTime = results.currentTime
260
+ player._status.volume = { level: results.volume / player.MAX_VOLUME }
261
+ return cb(err, player._status)
262
+ })
263
+ }
264
+
265
+ player.getVolume = (cb) => {
266
+ player.client.callAction('RenderingControl', 'GetVolume', {
267
+ InstanceID: player.client.instanceId,
268
+ Channel: 'Master'
269
+ }, (err, res) => {
270
+ if (err) return cb()
271
+ cb(null, res.CurrentVolume ? parseInt(res.CurrentVolume) : 0)
272
+ })
273
+ }
274
+
275
+ player.setVolume = (vol, cb = noop) => {
276
+ player.client.callAction('RenderingControl', 'SetVolume', {
277
+ InstanceID: player.client.instanceId,
278
+ Channel: 'Master',
279
+ DesiredVolume: vol
280
+ }, cb)
281
+ }
282
+
283
+ player.request = (target, action, data, cb = noop) => {
284
+ if (data.InstanceID === null) {
285
+ data.InstanceID = player.client.instanceId
286
+ }
287
+ player.client.callAction(target, action, data, cb)
288
+ }
289
+
290
+ player.seek = (time, cb = noop) => {
291
+ player.client.seek(time, cb)
292
+ }
293
+
294
+ that.players.push(player)
295
+ that.emit('update', player)
296
+ }
297
+
298
+ if (ssdp) {
299
+ // Response handler moved to update()
300
+ }
301
+
302
+ that.validate = (name, host, xml) => {
303
+ if (!casts[name]) {
304
+ http.get(xml, { agent }, res => {
305
+ const {statusCode} = res
306
+ if (statusCode == 200) {
307
+ if (!casts[name]) {
308
+ casts[name] = {name, host, xml}
309
+ emit(casts[name])
310
+ } else if (isIP(casts[name].host) != 4 && isIP(host) == 4) {
311
+ casts[name].host = host
312
+ casts[name].xml = xml
313
+ emit(casts[name])
314
+ }
315
+ }
316
+ res.resume()
317
+ }).on('error', e => {})
318
+ }
319
+ }
320
+
321
+ that.update = () => {
322
+ log('[DLNACASTS] querying ssdp')
323
+ if (ssdp) {
324
+ const tasks = []
325
+ const responseHandler = (headers, statusCode, info) => {
326
+ if (!headers.LOCATION) return
327
+ if (headers.ST !== SERVICE_TYPE) return
328
+
329
+ tasks.push((cb) => {
330
+ http.get(headers.LOCATION, { agent }, (res) => {
331
+ if (res.statusCode !== 200) return cb()
332
+ let body = ''
333
+ res.on('data', (chunk) => { body += chunk })
334
+ res.on('end', () => {
335
+ parseString(body, {explicitArray: false, explicitRoot: false},
336
+ (err, service) => {
337
+ if (err) return cb()
338
+ if (!service.device) return cb()
339
+
340
+ log('[DLNACASTS] ssdp device:', service.device)
341
+
342
+ const name = service.device.friendlyName
343
+
344
+ if (!name) return cb()
345
+
346
+ const host = info.address
347
+ const xml = headers.LOCATION
348
+
349
+ if (!casts[name]) {
350
+ casts[name] = {name: name, host: host, xml: xml}
351
+ emit(casts[name])
352
+ } else if (casts[name] && !casts[name].host) {
353
+ casts[name].host = host
354
+ casts[name].xml = xml
355
+ emit(casts[name])
356
+ }
357
+ cb()
358
+ })
359
+ })
360
+ }).on('error', () => cb())
361
+ })
362
+ }
363
+ ssdp.on('response', responseHandler)
364
+ ssdp.search(SERVICE_TYPE)
365
+ setTimeout(() => {
366
+ ssdp.removeListener('response', responseHandler)
367
+ parallel(tasks, () => {})
368
+ }, 10000)
369
+ }
370
+ }
371
+
372
+ that.on('removeListener', () => {
373
+ if (ssdp && that.listenerCount('update') === 0) {
374
+ ssdp.stop()
375
+ }
376
+ })
377
+
378
+ that.destroy = () => {
379
+ log('[DLNACASTS] destroying ssdp...')
380
+ if (ssdp) {
381
+ ssdp.stop()
382
+ }
383
+ }
384
+
385
+ that.close = () => {
386
+ that.removeAllListeners('update')
387
+ }
388
+
389
+ return that
390
+ }