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