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