mlbserver 2021.10.0-1.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 (4) hide show
  1. package/README.md +62 -0
  2. package/index.js +1837 -0
  3. package/package.json +30 -0
  4. package/session.js +2263 -0
package/index.js ADDED
@@ -0,0 +1,1837 @@
1
+ #!/usr/bin/env node
2
+
3
+ // index.js sets up web server and listens for and responds to URL requests
4
+
5
+ // Required Node packages
6
+ const minimist = require('minimist')
7
+ const root = require('root')
8
+ const path = require('path')
9
+ const url = require('url')
10
+ const assert = require('assert')
11
+ var crypto = require('crypto')
12
+
13
+ // More required Node packages, for multiview streaming
14
+ const HLSServer = require('hls-server')
15
+ const http = require('http')
16
+ const httpAttach = require('http-attach')
17
+ const ffmpeg = require('fluent-ffmpeg')
18
+
19
+ // Declare our session class for API activity, from the included session.js file
20
+ const sessionClass = require('./session.js')
21
+
22
+ // Define some valid variable values, the first one being the default
23
+ const VALID_DATES = [ 'today', 'yesterday' ]
24
+ const YESTERDAY_UTC_HOURS = 14 // UTC hours (EST + 4) to change home page default date from yesterday to today
25
+ const VALID_MEDIA_TYPES = [ 'Video', 'Audio', 'Spanish' ]
26
+ const VALID_LINK_TYPES = [ 'Embed', 'Stream', 'Chromecast', 'Advanced' ]
27
+ const VALID_START_FROM = [ 'Beginning', 'Live' ]
28
+ const VALID_INNING_HALF = [ '', 'top', 'bottom' ]
29
+ const VALID_INNING_NUMBER = [ '', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12' ]
30
+ const VALID_SCORES = [ 'Hide', 'Show' ]
31
+ const VALID_RESOLUTIONS = [ 'adaptive', '720p60', '720p', '540p', '504p', '360p', 'none' ]
32
+ const DEFAULT_MULTIVIEW_RESOLUTION = '540p'
33
+ // Corresponding andwidths to display for above resolutions
34
+ const VALID_BANDWIDTHS = [ '', '6600k', '4160k', '2950k', '2120k', '1400k', '' ]
35
+ const VALID_AUDIO_TRACKS = [ 'all', 'English', 'English Radio', 'Radio Española', 'none' ]
36
+ const DEFAULT_MULTIVIEW_AUDIO_TRACK = 'English'
37
+ const VALID_SKIP = [ 'off', 'breaks', 'pitches' ]
38
+ const VALID_FORCE_VOD = [ 'off', 'on' ]
39
+
40
+ const SAMPLE_STREAM_URL = 'https://www.radiantmediaplayer.com/media/rmp-segment/bbb-abr-aes/playlist.m3u8'
41
+
42
+ // Basic command line arguments, if specified:
43
+ // --port or -p (primary port to run on; defaults to 9999 if not specified)
44
+ // --debug or -d (false if not specified)
45
+ // --version or -v (returns package version number)
46
+ // --logout or -l (logs out and clears session)
47
+ // --session or -s (clears session)
48
+ // --cache or -c (clears cache)
49
+ //
50
+ // Advanced command line arguments:
51
+ // --account_username (email address, default will use stored credentials or prompt user to enter them)
52
+ // --account_password (default will use stored credentials or prompt user to enter them)
53
+ // --multiview_port (port for multiview streaming; defaults to 1 more than primary port, or 10000)
54
+ // --multiview_path (where to create the folder for multiview encoded files; defaults to app directory)
55
+ // --ffmpeg_path (path to ffmpeg binary to use for multiview encoding; default downloads a binary using ffmpeg-static)
56
+ // --ffmpeg_encoder (ffmpeg video encoder to use for multiview; default is the software encoder libx264)
57
+ // --ffmpeg_logging (if present, logs all ffmpeg output -- useful for experimenting or troubleshooting)
58
+ // --page_username (username to protect pages; default is no protection)
59
+ // --page_password (password to protect pages; default is no protection)
60
+ var argv = minimist(process.argv, {
61
+ alias: {
62
+ p: 'port',
63
+ d: 'debug',
64
+ l: 'logout',
65
+ s: 'session',
66
+ c: 'cache',
67
+ v: 'version'
68
+ },
69
+ boolean: ['ffmpeg_logging', 'debug', 'logout', 'session', 'cache', 'version'],
70
+ string: ['port', 'account_username', 'account_password', 'multiview_port', 'multiview_path', 'ffmpeg_path', 'ffmpeg_encoder', 'page_username', 'page_password']
71
+ })
72
+
73
+ // Version
74
+ if (argv.version) return console.log(require('./package').version)
75
+
76
+ // Declare a session, pass arguments to it
77
+ var session = new sessionClass(argv)
78
+
79
+ // Clear cache (cache data, not images)
80
+ if (argv.cache) {
81
+ session.log('Clearing cache...')
82
+ session.clear_cache()
83
+ session = new sessionClass(argv)
84
+ }
85
+
86
+ // Clear session
87
+ if (argv.session) {
88
+ session.log('Clearing session data...')
89
+ session.clear_session_data()
90
+ session = new sessionClass(argv)
91
+ }
92
+
93
+ // Logout (also implies clearing session)
94
+ if (argv.logout) {
95
+ session.log('Logging out...')
96
+ session.logout()
97
+ if (!argv.session) {
98
+ session.clear_session_data()
99
+ }
100
+ session = new sessionClass(argv)
101
+ }
102
+
103
+ // Set FFMPEG path, download if necessary
104
+ const pathToFfmpeg = argv.ffmpeg_path || require('ffmpeg-static')
105
+ ffmpeg.setFfmpegPath(pathToFfmpeg)
106
+
107
+ // Set FFMPEG encoder, use libx264 if not specified
108
+ const ffmpegEncoder = argv.ffmpeg_encoder || 'libx264'
109
+
110
+ // Declare web server
111
+ var app = root()
112
+
113
+ // Get appname from directory
114
+ var appname = path.basename(__dirname)
115
+
116
+ // Multiview server variables
117
+ var hls_base = 'multiview'
118
+ var multiview_stream_name = 'master.m3u8'
119
+ if ( session.protection.content_protect ) multiview_stream_name += '?content_protect=' + session.protection.content_protect
120
+ var multiview_url_path = '/' + hls_base + '/' + multiview_stream_name
121
+ session.setMultiviewStreamURLPath(multiview_url_path)
122
+ var ffmpeg_command
123
+ var ffmpeg_status = false
124
+
125
+ // Start web server listening on port
126
+ // and also multiview server on its port (next one if not defined otherwise)
127
+ let port = argv.port || 9999
128
+ let multiview_port = argv.multiview_port || port + 1
129
+ session.setPorts(port, multiview_port)
130
+ app.listen(port, function(addr) {
131
+ session.log(appname + ' started at http://' + addr)
132
+ session.debuglog('multiview port ' + multiview_port)
133
+ session.log('multiview server started at http://' + addr.replace(':' + port, ':' + multiview_port) + multiview_url_path)
134
+ session.clear_multiview_files()
135
+ })
136
+ var multiview_app = http.createServer()
137
+ var hls = new HLSServer(multiview_app, {
138
+ path: '/' + hls_base,
139
+ dir: session.get_multiview_directory()
140
+ })
141
+ function corsMiddleware (req, res, next) {
142
+ res.setHeader('Access-Control-Allow-Origin', '*');
143
+ next()
144
+ }
145
+ httpAttach(multiview_app, corsMiddleware)
146
+ multiview_app.listen(multiview_port)
147
+
148
+ // Listen for stream requests
149
+ app.get('/stream.m3u8', async function(req, res) {
150
+ try {
151
+ session.log('stream.m3u8 request : ' + req.url)
152
+
153
+ if ( session.protection.content_protect ) {
154
+ if ( !req.query.content_protect || (req.query.content_protect != session.protection.content_protect) ) {
155
+ session.log('stream request rejected due to missing/invalid content_protect value')
156
+ res.end('')
157
+ return
158
+ }
159
+ }
160
+
161
+ let mediaId
162
+ let contentId
163
+ let streamURL
164
+ let options = {}
165
+ let urlArray = req.url.split('?')
166
+ if ( (urlArray.length == 1) || ((session.data.scan_mode == 'on') && req.query.team) || (!req.query.src && !req.query.highlight_src && !req.query.type && !req.query.id && !req.query.mediaId && !req.query.contentId) ) {
167
+ // load a sample encrypted HLS stream
168
+ session.log('loading sample stream')
169
+ options.resolution = 'adaptive'
170
+ streamURL = SAMPLE_STREAM_URL
171
+ } else {
172
+ if ( req.query.resolution && (options.resolution == 'best') ) {
173
+ options.resolution = VALID_RESOLUTIONS[1]
174
+ } else {
175
+ options.resolution = session.returnValidItem(req.query.resolution, VALID_RESOLUTIONS)
176
+ }
177
+ options.audio_track = session.returnValidItem(req.query.audio_track, VALID_AUDIO_TRACKS)
178
+ options.audio_url = req.query.audio_url || ''
179
+ options.force_vod = req.query.force_vod || VALID_FORCE_VOD[0]
180
+
181
+ options.inning_half = req.query.inning_half || VALID_INNING_HALF[0]
182
+ options.inning_number = req.query.inning_number || VALID_INNING_NUMBER[0]
183
+ options.skip = req.query.skip || VALID_SKIP[0]
184
+
185
+ if ( req.query.src ) {
186
+ streamURL = req.query.src
187
+ } else if ( req.query.highlight_src ) {
188
+ streamURL = req.query.highlight_src
189
+ } else if ( req.query.type && (req.query.type.toUpperCase() == 'BIGINNING') ) {
190
+ streamURL = await session.getBigInningStreamURL()
191
+ } else {
192
+ if ( req.query.contentId ) {
193
+ contentId = req.query.contentId
194
+ }
195
+ if ( req.query.mediaId ) {
196
+ mediaId = req.query.mediaId
197
+ } else if ( req.query.contentId ) {
198
+ mediaId = await session.getMediaIdFromContentId(contentId)
199
+ } else if ( req.query.team ) {
200
+ let mediaType = req.query.mediaType || VALID_MEDIA_TYPES[0]
201
+ let mediaInfo = await session.getMediaId(decodeURIComponent(req.query.team), mediaType, req.query.date, req.query.game)
202
+ if ( mediaInfo ) {
203
+ mediaId = mediaInfo.mediaId
204
+ contentId = mediaInfo.contentId
205
+ } else {
206
+ session.log('no matching game found ' + req.url)
207
+ }
208
+ }
209
+
210
+ if ( !mediaId ) {
211
+ session.log('failed to get mediaId : ' + req.url)
212
+ res.end('')
213
+ return
214
+ } else {
215
+ session.debuglog('mediaId : ' + mediaId)
216
+ streamURL = await session.getStreamURL(mediaId)
217
+ }
218
+ }
219
+ }
220
+
221
+ if (streamURL) {
222
+ session.debuglog('using streamURL : ' + streamURL)
223
+
224
+ if ( streamURL.indexOf('master_radio_') > 0 ) {
225
+ options.resolution = 'adaptive'
226
+ }
227
+
228
+ if ( req.query.audio_url && (req.query.audio_url != '') ) {
229
+ options.audio_url = await session.getAudioPlaylistURL(req.query.audio_url)
230
+ }
231
+
232
+ if ( (options.inning_half != VALID_INNING_HALF[0]) || (options.inning_number != VALID_INNING_NUMBER[0]) || (options.skip != VALID_SKIP[0]) ) {
233
+ if ( contentId ) {
234
+ options.contentId = contentId
235
+
236
+ let skip_adjust = req.query.skip_adjust || 0
237
+ let skip_types = []
238
+ if ( (options.inning_half != VALID_INNING_HALF[0]) || (options.inning_number != VALID_INNING_NUMBER[0]) ) {
239
+ skip_types.push('innings')
240
+ }
241
+ if ( options.skip != VALID_SKIP[0] ) {
242
+ skip_types.push(options.skip)
243
+ }
244
+ await session.getEventOffsets(contentId, skip_types, skip_adjust)
245
+ }
246
+ }
247
+
248
+ getMasterPlaylist(streamURL, req, res, options)
249
+ } else {
250
+ session.log('failed to get streamURL : ' + req.url)
251
+ res.end('')
252
+ return
253
+ }
254
+ } catch (e) {
255
+ session.log('stream request error : ' + e.message)
256
+ res.end('')
257
+ }
258
+ })
259
+
260
+ // Store previous keys, for return without decoding
261
+ var prevKeys = {}
262
+ var getKey = function(url, headers, cb) {
263
+ if ( (typeof prevKeys[url] !== 'undefined') && (typeof prevKeys[url].key !== 'undefined') ) {
264
+ return cb(null, prevKeys[url].key)
265
+ }
266
+
267
+ if ( typeof prevKeys[url] === 'undefined' ) prevKeys[url] = {}
268
+
269
+ session.debuglog('key request : ' + url)
270
+ requestRetry(url, headers, function(err, response) {
271
+ if (err) return cb(err)
272
+ prevKeys[url].key = response.body
273
+ cb(null, response.body)
274
+ })
275
+ }
276
+
277
+ // Default respond function, for adjusting content-length and updating CORS headers
278
+ var respond = function(proxy, res, body) {
279
+ delete proxy.headers['content-length']
280
+ delete proxy.headers['transfer-encoding']
281
+ delete proxy.headers['content-md5']
282
+ delete proxy.headers['connection']
283
+ delete proxy.headers['access-control-allow-credentials']
284
+
285
+ proxy.headers['content-length'] = body.length
286
+ proxy.headers['access-control-allow-origin'] = '*'
287
+
288
+ res.writeHead(proxy.statusCode, proxy.headers)
289
+ res.end(body)
290
+ }
291
+
292
+ // Retry request function, up to 2 times
293
+ var requestRetry = function(u, opts, cb) {
294
+ var tries = 2
295
+ var action = function() {
296
+ session.streamVideo(u, opts, tries, function(err, res) {
297
+ if (err) {
298
+ if ( tries < 2 ) session.log('try ' + (3 - tries) + ' for ' + u)
299
+ if (tries-- > 0) return setTimeout(action, 1000)
300
+ return cb(err)
301
+ }
302
+ cb(err, res)
303
+ })
304
+ }
305
+
306
+ action()
307
+ }
308
+
309
+
310
+ // Get the master playlist from the stream URL
311
+ function getMasterPlaylist(streamURL, req, res, options = {}) {
312
+ session.debuglog('getMasterPlaylist of streamURL : ' + streamURL)
313
+ var req = function () {
314
+ var headers = {}
315
+ requestRetry(streamURL, headers, function(err, response) {
316
+ if (err) return res.error(err)
317
+
318
+ session.debuglog(response.body)
319
+
320
+ var body = response.body.trim().split('\n')
321
+
322
+ let resolution = options.resolution || VALID_RESOLUTIONS[0]
323
+ let audio_track = options.audio_track || VALID_AUDIO_TRACKS[0]
324
+ let audio_url = options.audio_url || ''
325
+ let force_vod = options.force_vod || VALID_FORCE_VOD[0]
326
+
327
+ let inning_half = options.inning_half || VALID_INNING_HALF[0]
328
+ let inning_number = options.inning_number || VALID_INNING_NUMBER[0]
329
+ let skip = options.skip || VALID_SKIP[0]
330
+ let contentId = options.contentId || false
331
+
332
+ if ( (inning_number > 0) && (inning_half == VALID_INNING_HALF[0]) ) {
333
+ inning_half = VALID_INNING_HALF[1]
334
+ }
335
+
336
+ // Some variables for controlling audio/video stream selection, if specified
337
+ var video_track_matched = false
338
+ var audio_track_matched = false
339
+ var frame_rate = '29.97'
340
+ if ( (resolution != 'adaptive') && (resolution != 'none') ) {
341
+ if ( resolution.slice(4) === '60' ) {
342
+ frame_rate = '59.94'
343
+ }
344
+ resolution = resolution.slice(0, 3)
345
+ }
346
+
347
+ body = body
348
+ .map(function(line) {
349
+ let newurl = ''
350
+
351
+ // Omit keyframe tracks
352
+ if (line.indexOf('#EXT-X-I-FRAME-STREAM-INF:') === 0) {
353
+ return
354
+ }
355
+
356
+ // Omit captions track when no video is specified
357
+ if ( (resolution == 'none') && (line.indexOf('#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,') === 0) ) {
358
+ return
359
+ }
360
+
361
+ // Parse audio tracks to only include matching one, if specified
362
+ if (line.indexOf('#EXT-X-MEDIA:TYPE=AUDIO') === 0) {
363
+ if ( audio_track_matched ) return
364
+ if ( audio_url != '' ) {
365
+ audio_track_matched = true
366
+ return '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="Alternate Audio",AUTOSELECT=YES,DEFAULT=YES,URI="' + audio_url + '"'
367
+ }
368
+ if ( audio_track == 'none') return
369
+ if ( (resolution == 'none') && (line.indexOf(',URI=') < 0) ) return
370
+ if ( (audio_track != 'all') && ((line.indexOf('NAME="'+audio_track+'"') > 0) || (line.indexOf('NAME="'+audio_track.substring(0,audio_track.length-1)+'"') > 0)) ) {
371
+ audio_track_matched = true
372
+ line = line.replace('AUTOSELECT=NO','AUTOSELECT=YES')
373
+ if ( line.indexOf(',DEFAULT=YES') < 0 ) line = line.replace('AUTOSELECT=YES','AUTOSELECT=YES,DEFAULT=YES')
374
+ } else if ( (audio_track != 'all') && ((line.indexOf('NAME="'+audio_track+'"') === -1) || (line.indexOf('NAME="'+audio_track.substring(0,audio_track.length-1)+'"') === -1)) ) {
375
+ return
376
+ }
377
+ if (line.indexOf(',URI=') > 0) {
378
+ if ( line.match ) {
379
+ //var parsed = line.match(/URI="([^"]+)"?$/)
380
+ var parsed = line.match(',URI="([^"]+)"')
381
+ if ( parsed[1] ) {
382
+ newurl = 'playlist?url='+encodeURIComponent(url.resolve(streamURL, parsed[1].trim()))
383
+ if ( force_vod != VALID_FORCE_VOD[0] ) newurl += '&force_vod=on'
384
+ if ( inning_half != VALID_INNING_HALF[0] ) newurl += '&inning_half=' + inning_half
385
+ if ( inning_number != VALID_INNING_NUMBER[0] ) newurl += '&inning_number=' + inning_number
386
+ if ( skip != VALID_SKIP[0] ) newurl += '&skip=' + skip
387
+ if ( contentId ) newurl += '&contentId=' + contentId
388
+ if ( resolution == 'none' ) {
389
+ audio_track_matched = true
390
+ return line.replace(parsed[0],'') + "\n" + '#EXT-X-STREAM-INF:BANDWIDTH=50000,CODECS="mp4a.40.2",AUDIO="aac"' + "\n" + newurl
391
+ }
392
+ return line.replace(parsed[1],newurl)
393
+ }
394
+ }
395
+ }
396
+ }
397
+
398
+ // Parse video tracks to only include matching one, if specified
399
+ if (line.indexOf('#EXT-X-STREAM-INF:BANDWIDTH=') === 0) {
400
+ if ( resolution == 'none' ) {
401
+ return
402
+ } else {
403
+ if ( resolution === 'adaptive' ) {
404
+ return line
405
+ } else {
406
+ if (line.indexOf(resolution+',FRAME-RATE='+frame_rate) > 0) {
407
+ video_track_matched = true
408
+ return line
409
+ } else {
410
+ return
411
+ }
412
+ }
413
+ }
414
+ }
415
+
416
+ // Skip key in archive master playlists
417
+ if (line.indexOf('#EXT-X-SESSION-KEY:METHOD=AES-128') === 0) {
418
+ return
419
+ }
420
+
421
+ if (line[0] === '#') {
422
+ return line
423
+ }
424
+
425
+ if ( (resolution === 'adaptive') || (video_track_matched) ) {
426
+ video_track_matched = false
427
+ newurl = encodeURIComponent(url.resolve(streamURL, line.trim()))
428
+ if ( force_vod != VALID_FORCE_VOD[0] ) newurl += '&force_vod=on'
429
+ if ( inning_half != VALID_INNING_HALF[0] ) newurl += '&inning_half=' + inning_half
430
+ if ( inning_number != VALID_INNING_NUMBER[0] ) newurl += '&inning_number=' + inning_number
431
+ if ( skip != VALID_SKIP[0] ) newurl += '&skip=' + skip
432
+ if ( contentId ) newurl += '&contentId=' + contentId
433
+ return 'playlist?url='+newurl
434
+ }
435
+ })
436
+ .filter(function(line) {
437
+ return line
438
+ })
439
+ .join('\n')+'\n'
440
+
441
+ session.debuglog(body)
442
+ respond(response, res, Buffer.from(body))
443
+ })
444
+ }
445
+
446
+ return req()
447
+
448
+ requestRetry(streamURL, headers, function(err, res) {
449
+ if (err) return res.error(err)
450
+ req()
451
+ })
452
+ }
453
+
454
+
455
+ // Listen for playlist requests
456
+ app.get('/playlist', function(req, res) {
457
+ session.debuglog('playlist request : ' + req.url)
458
+
459
+ delete req.headers.host
460
+
461
+ var u = req.query.url
462
+ session.debuglog('playlist url : ' + u)
463
+
464
+ var force_vod = req.query.force_vod || VALID_FORCE_VOD[0]
465
+ var inning_half = req.query.inning_half || VALID_INNING_HALF[0]
466
+ var inning_number = req.query.inning_number || VALID_INNING_NUMBER[0]
467
+ var skip = req.query.skip || VALID_SKIP[0]
468
+ var contentId = req.query.contentId || false
469
+
470
+ var req = function () {
471
+ var headers = {}
472
+ requestRetry(u, headers, function(err, response) {
473
+ if (err) return res.error(err)
474
+
475
+ //session.debuglog(response.body)
476
+
477
+ var body = response.body.trim().split('\n')
478
+ var key
479
+ var iv
480
+
481
+ if ( (contentId) && ((inning_half != VALID_INNING_HALF[0]) || (inning_number != VALID_INNING_NUMBER[0]) || (skip != VALID_SKIP[0]))) {
482
+ // If inning offsets don't exist, we'll force those options off
483
+ if ( (typeof session.temp_cache[contentId] === 'undefined') || (typeof session.temp_cache[contentId].inning_offsets === 'undefined') ) {
484
+ inning_half = VALID_INNING_HALF[0]
485
+ inning_number = VALID_INNING_NUMBER[0]
486
+ skip = 'off'
487
+ } else {
488
+ var time_counter = 0.0
489
+ var skip_index = 1
490
+ var skip_next = false
491
+ var discontinuity = false
492
+
493
+ var offsets = session.temp_cache[contentId].event_offsets
494
+ }
495
+ }
496
+
497
+ body = body
498
+ .map(function(line) {
499
+ if ( ((skip != 'off') || (inning_half != VALID_INNING_HALF[0]) || (inning_number != VALID_INNING_NUMBER[0])) && (typeof session.temp_cache[contentId] !== 'undefined') && (typeof session.temp_cache[contentId].inning_offsets !== 'undefined') ) {
500
+ if ( skip_next ) {
501
+ skip_next = false
502
+ return null
503
+ }
504
+
505
+ if (line.indexOf('#EXTINF:') == 0) {
506
+ time_counter += parseFloat(line.substring(8, line.length-1))
507
+
508
+ if ( (inning_half != VALID_INNING_HALF[0]) || (inning_number != VALID_INNING_NUMBER[0]) ) {
509
+ let inning_index = 0
510
+ if ( inning_number > 0 ) {
511
+ inning_index = (inning_number * 2)
512
+ if ( inning_half == 'top' ) inning_index = inning_index - 1
513
+ }
514
+ if ( (typeof session.temp_cache[contentId].inning_offsets[inning_index] !== 'undefined') && (typeof session.temp_cache[contentId].inning_offsets[inning_index].start !== 'undefined') && (time_counter < session.temp_cache[contentId].inning_offsets[inning_index].start) ) {
515
+ session.debuglog('skipping ' + time_counter + ' before ' + session.temp_cache[contentId].inning_offsets[inning_index].start)
516
+ // Increment skip index if our offset is less than the inning start
517
+ if ( offsets && (offsets[skip_index]) && (offsets[skip_index].end) && (offsets[skip_index].end < session.temp_cache[contentId].inning_offsets[inning_index].start) ) {
518
+ skip_index++
519
+ }
520
+ skip_next = true
521
+ if ( discontinuity ) {
522
+ return null
523
+ } else {
524
+ discontinuity = true
525
+ return '#EXT-X-DISCONTINUITY'
526
+ }
527
+ } else {
528
+ session.debuglog('inning start time not found or duplicate request made, ignoring: ' + u)
529
+ inning_half = VALID_INNING_HALF[0]
530
+ inning_number = VALID_INNING_NUMBER[0]
531
+ }
532
+ }
533
+
534
+ if ( (skip != VALID_SKIP[0]) && (inning_half == VALID_INNING_HALF[0]) && (inning_number == VALID_INNING_NUMBER[0]) ) {
535
+ let skip_this = true
536
+ if ( (typeof offsets[skip_index] !== 'undefined') && (typeof offsets[skip_index].start !== 'undefined') && (typeof offsets[skip_index].end !== 'undefined') && (time_counter > offsets[skip_index].start) && (time_counter > offsets[skip_index].end) ) {
537
+ skip_index++
538
+ }
539
+ if ( (typeof offsets[skip_index] === 'undefined') || (typeof offsets[skip_index].start === 'undefined') || (typeof offsets[skip_index].end === 'undefined') || ((time_counter > offsets[skip_index].start) && (time_counter < offsets[skip_index].end)) ) {
540
+ session.debuglog('keeping ' + time_counter)
541
+ skip_this = false
542
+ } else {
543
+ session.debuglog('skipping ' + time_counter)
544
+ }
545
+ if ( skip_this ) {
546
+ skip_next = true
547
+ if ( discontinuity ) {
548
+ return null
549
+ } else {
550
+ discontinuity = true
551
+ return '#EXT-X-DISCONTINUITY'
552
+ }
553
+ } else {
554
+ discontinuity = false
555
+ }
556
+ }
557
+ }
558
+ }
559
+
560
+ if (line.indexOf('-KEY:METHOD=AES-128') > 0) {
561
+ var parsed = line.match(/URI="([^"]+)"(?:,IV=(.+))?$/)
562
+ if ( parsed ) {
563
+ if ( parsed[1].substr(0,4) == 'http' ) key = parsed[1]
564
+ else key = url.resolve(u, parsed[1])
565
+ if (parsed[2]) iv = parsed[2].slice(2).toLowerCase()
566
+ }
567
+ return null
568
+ }
569
+
570
+ if (line[0] === '#') return line
571
+
572
+ if ( key ) return 'ts?url='+encodeURIComponent(url.resolve(u, line.trim()))+'&key='+encodeURIComponent(key)+'&iv='+encodeURIComponent(iv)
573
+ else return 'ts?url='+encodeURIComponent(url.resolve(u, line.trim()))
574
+ })
575
+ .filter(function(line) {
576
+ return line
577
+ })
578
+ .join('\n')+'\n'
579
+
580
+ if ( force_vod != VALID_FORCE_VOD[0] ) body += '#EXT-X-ENDLIST' + '\n'
581
+ session.debuglog(body)
582
+ respond(response, res, Buffer.from(body))
583
+ })
584
+ }
585
+
586
+ return req()
587
+
588
+ requestRetry(u, headers, function(err, res) {
589
+ if (err) return res.error(err)
590
+ req()
591
+ })
592
+ })
593
+
594
+ // Listen for ts requests (video segments) and decode them
595
+ app.get('/ts', function(req, res) {
596
+ session.debuglog('ts request : ' + req.url)
597
+
598
+ delete req.headers.host
599
+
600
+ var u = req.query.url
601
+ session.debuglog('ts url : ' + u)
602
+
603
+ var headers = {encoding:null}
604
+
605
+ requestRetry(u, headers, function(err, response) {
606
+ if (err) return res.error(err)
607
+ if (!req.query.key) return respond(response, res, response.body)
608
+
609
+ //var ku = url.resolve(manifest, req.query.key)
610
+ var ku = req.query.key
611
+ getKey(ku, headers, function(err, key) {
612
+ if (err) return res.error(err)
613
+
614
+ var iv = Buffer.from(req.query.iv, 'hex')
615
+ session.debuglog('iv : 0x'+req.query.iv)
616
+
617
+ var dc = crypto.createDecipheriv('aes-128-cbc', key, iv)
618
+ var buffer = Buffer.concat([dc.update(response.body), dc.final()])
619
+
620
+ respond(response, res, buffer)
621
+ })
622
+ })
623
+ })
624
+
625
+ // Protect pages by password
626
+ async function protect(req, res) {
627
+ if (argv.page_username && argv.page_password) {
628
+ const reject = () => {
629
+ res.setHeader('www-authenticate', 'Basic')
630
+ res.error(401, ' Not Authorized')
631
+ return false
632
+ }
633
+
634
+ const authorization = req.headers.authorization
635
+
636
+ if(!authorization) {
637
+ return reject()
638
+ }
639
+
640
+ const [username, password] = Buffer.from(authorization.replace('Basic ', ''), 'base64').toString().split(':')
641
+
642
+ if(! (username === argv.page_username && password === argv.page_password)) {
643
+ return reject()
644
+ }
645
+ }
646
+ return true
647
+ }
648
+
649
+ // Server homepage, base URL
650
+ app.get('/', async function(req, res) {
651
+ try {
652
+ if ( ! (await protect(req, res)) ) return
653
+
654
+ session.debuglog('homepage request : ' + req.url)
655
+
656
+ let server = 'http://' + req.headers.host
657
+ let multiview_server = server.replace(':' + session.data.port, ':' + session.data.multiviewPort)
658
+
659
+ let gameDate = session.liveDate()
660
+ let todayUTCHours = session.getTodayUTCHours()
661
+ if ( req.query.date ) {
662
+ if ( req.query.date == VALID_DATES[1] ) {
663
+ gameDate = session.yesterdayDate()
664
+ } else if ( req.query.date != VALID_DATES[0] ) {
665
+ gameDate = req.query.date
666
+ }
667
+ } else {
668
+ let curDate = new Date()
669
+ let utcHours = curDate.getUTCHours()
670
+ if ( (utcHours >= todayUTCHours) && (utcHours < YESTERDAY_UTC_HOURS) ) {
671
+ gameDate = session.yesterdayDate()
672
+ }
673
+ }
674
+ var cache_data = await session.getDayData(gameDate)
675
+ var big_inning
676
+ if ( cache_data.dates && cache_data.dates[0] && cache_data.dates[0].games && (cache_data.dates[0].games.length > 0) ) {
677
+ big_inning = await session.getBigInningSchedule(gameDate)
678
+ }
679
+
680
+ var linkType = VALID_LINK_TYPES[0]
681
+ if ( req.query.linkType ) {
682
+ linkType = req.query.linkType
683
+ session.setLinkType(linkType)
684
+ }
685
+ var startFrom = VALID_START_FROM[0]
686
+ if ( req.query.startFrom ) {
687
+ startFrom = req.query.startFrom
688
+ }
689
+ var scores = VALID_SCORES[0]
690
+ if ( req.query.scores ) {
691
+ scores = req.query.scores
692
+ }
693
+ var mediaType = VALID_MEDIA_TYPES[0]
694
+ if ( req.query.mediaType ) {
695
+ mediaType = req.query.mediaType
696
+ }
697
+ var resolution = VALID_RESOLUTIONS[0]
698
+ if ( req.query.resolution ) {
699
+ resolution = req.query.resolution
700
+ }
701
+ var audio_track = VALID_AUDIO_TRACKS[0]
702
+ if ( req.query.audio_track ) {
703
+ audio_track = req.query.audio_track
704
+ }
705
+ var force_vod = VALID_FORCE_VOD[0]
706
+ if ( req.query.force_vod ) {
707
+ force_vod = req.query.force_vod
708
+ }
709
+ var inning_half = VALID_INNING_HALF[0]
710
+ if ( req.query.inning_half ) {
711
+ inning_half = req.query.inning_half
712
+ }
713
+ var inning_number = VALID_INNING_NUMBER[0]
714
+ if ( req.query.inning_number ) {
715
+ inning_number = req.query.inning_number
716
+ }
717
+ var skip = VALID_SKIP[0]
718
+ if ( req.query.skip ) {
719
+ skip = req.query.skip
720
+ }
721
+ var skip_adjust = 0
722
+ if ( req.query.skip_adjust ) {
723
+ skip_adjust = req.query.skip_adjust
724
+ }
725
+ // audio_url is disabled here, now used in multiview instead
726
+ /*var audio_url = ''
727
+ if ( req.query.audio_url ) {
728
+ audio_url = req.query.audio_url
729
+ }*/
730
+
731
+ var scan_mode = session.data.scan_mode
732
+ if ( req.query.scan_mode && (req.query.scan_mode != session.data.scan_mode) ) {
733
+ scan_mode = req.query.scan_mode
734
+ session.setScanMode(req.query.scan_mode)
735
+ }
736
+
737
+ var content_protect_a = ''
738
+ var content_protect_b = ''
739
+ if ( session.protection.content_protect ) {
740
+ content_protect_a = '?content_protect=' + session.protection.content_protect
741
+ content_protect_b = '&content_protect=' + session.protection.content_protect
742
+ }
743
+
744
+ var body = '<!DOCTYPE html><html><head><meta charset="UTF-8"><meta http-equiv="Content-type" content="text/html;charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"><title>' + appname + '</title><link rel="icon" href="favicon.svg' + content_protect_a + '"><style type="text/css">input[type=text],input[type=button]{-webkit-appearance:none;-webkit-border-radius:0}body{width:480px;color:lightgray;background-color:black;font-family:Arial,Helvetica,sans-serif;-webkit-text-size-adjust:none}a{color:darkgray}button{color:lightgray;background-color:black}button.default{color:black;background-color:lightgray}table{width:100%;pad}table,th,td{border:1px solid darkgray;border-collapse:collapse}th,td{padding:5px}.tinytext,textarea,input[type="number"]{font-size:.8em}textarea{width:380px}'
745
+
746
+ // Highlights CSS
747
+ //max-height:calc(100vh-110px);
748
+ body += '.modal{display:none;position:fixed;z-index:1;padding-top:100px;left:0;top:0;width:100%;height:100%;overflow:auto;-webkit-overflow-scrolling:touch;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)}.modal-content{background-color:#fefefe;margin:auto;padding:10px;border:1px solid #888;width:360px;color:black}#highlights a{color:black}.close{color:black;float:right;font-size:28px;font-weight:bold;}#highlights a:hover,#highlights a:focus,.close:hover,.close:focus{color:gray;text-decoration:none;cursor:pointer;}'
749
+
750
+ // Tooltip CSS
751
+ body += '.tooltip{position:relative;display:inline-block;border-bottom: 1px dotted gray;}.tooltip .tooltiptext{font-size:.8em;visibility:hidden;width:360px;background-color:gray;color:white;text-align:left;padding:5px;border-radius:6px;position:absolute;z-index:1;top:100%;left:75%;margin-left:-30px;}.tooltip:hover .tooltiptext{visibility:visible;}'
752
+
753
+ body += '</style><script type="text/javascript">' + "\n";
754
+
755
+ // Define option variables in page
756
+ body += 'var date="' + gameDate + '";var mediaType="' + mediaType + '";var resolution="' + resolution + '";var audio_track="' + audio_track + '";var force_vod="' + force_vod + '";var inning_half="' + inning_half + '";var inning_number="' + inning_number + '";var skip="' + skip + '";var skip_adjust="' + skip_adjust + '";var linkType="' + linkType + '";var startFrom="' + startFrom + '";var scores="' + scores + '";var scan_mode="' + scan_mode + '";' + "\n"
757
+ // audio_url is disabled here, now used in multiview instead
758
+ //body += 'var audio_url="' + audio_url + '";' + "\n"
759
+
760
+ // Reload function, called after options change
761
+ // audio_url is disabled here, now used in multiview instead
762
+ body += 'var defaultDate="' + session.liveDate() + '";var curDate=new Date();var utcHours=curDate.getUTCHours();if ((utcHours >= ' + todayUTCHours + ') && (utcHours < ' + YESTERDAY_UTC_HOURS + ')){defaultDate="' + session.yesterdayDate() + '"}function reload(){var newurl="/?";if (date != defaultDate){var urldate=date;if (date == "' + session.liveDate() + '"){urldate="today"}else if (date == "' + session.yesterdayDate() + '"){urldate="yesterday"}newurl+="date="+urldate+"&"}if (mediaType != "' + VALID_MEDIA_TYPES[0] + '"){newurl+="mediaType="+mediaType+"&"}if (mediaType=="Video"){if (resolution != "' + VALID_RESOLUTIONS[0] + '"){newurl+="resolution="+resolution+"&"}if (audio_track != "' + VALID_AUDIO_TRACKS[0] + '"){newurl+="audio_track="+encodeURIComponent(audio_track)+"&"}else if (resolution == "none"){newurl+="audio_track="+encodeURIComponent("' + VALID_AUDIO_TRACKS[2] + '")+"&"}/*if (audio_url != ""){newurl+="audio_url="+encodeURIComponent(audio_url)+"&"}*/if (inning_half != "' + VALID_INNING_HALF[0] + '"){newurl+="inning_half="+inning_half+"&"}if (inning_number != "' + VALID_INNING_NUMBER[0] + '"){newurl+="inning_number="+inning_number+"&"}if (skip != "' + VALID_SKIP[0] + '"){newurl+="skip="+skip+"&";if (skip_adjust != "0"){newurl+="skip_adjust="+skip_adjust+"&"}}}if (linkType != "' + VALID_LINK_TYPES[0] + '"){newurl+="linkType="+linkType+"&"}if (linkType=="Embed"){if (startFrom != "' + VALID_START_FROM[0] + '"){newurl+="startFrom="+startFrom+"&"}}if (linkType=="Stream"){if (force_vod != "' + VALID_FORCE_VOD[0] + '"){newurl+="force_vod="+force_vod+"&"}}if (scores != "' + VALID_SCORES[0] + '"){newurl+="scores="+scores+"&"}if (scan_mode != "' + session.data.scan_mode + '"){newurl+="scan_mode="+scan_mode+"&"}window.location=newurl.substring(0,newurl.length-1)}' + "\n"
763
+
764
+ // Ajax function for multiview and highlights
765
+ body += 'function makeGETRequest(url, callback){var request=new XMLHttpRequest();request.onreadystatechange=function(){if (request.readyState==4 && request.status==200){callback(request.responseText)}};request.open("GET", url);request.send();}' + "\n"
766
+
767
+ // Multiview functions
768
+ body += 'function parsemultiviewresponse(responsetext){if (responsetext == "started"){setTimeout(function(){document.getElementById("startmultiview").innerHTML="Restart";document.getElementById("stopmultiview").innerHTML="Stop"},15000)}else if (responsetext == "stopped"){setTimeout(function(){document.getElementById("stopmultiview").innerHTML="Stopped";document.getElementById("startmultiview").innerHTML="Start"},3000)}else{alert(responsetext)}}function addmultiview(e){for(var i=1;i<=4;i++){var valuefound = false;var oldvalue="";var newvalue=e.value;if(!e.checked){oldvalue=e.value;newvalue=""}if (document.getElementById("multiview" + i).value == oldvalue){document.getElementById("multiview" + i).value=newvalue;valuefound=true;break}}if(e.checked && !valuefound){e.checked=false}}function startmultiview(e){var count=0;var getstr="";for(var i=1;i<=4;i++){if (document.getElementById("multiview"+i).value != ""){count++;getstr+="streams="+encodeURIComponent(document.getElementById("multiview"+i).value)+"&sync="+encodeURIComponent(document.getElementById("sync"+i).value)+"&"}}if((count >= 1) && (count <= 4)){if (document.getElementById("faster").checked){getstr+="faster=true&dvr=true&"}else if (document.getElementById("dvr").checked){getstr+="dvr=true&"}if (document.getElementById("audio_url").value != ""){getstr+="audio_url="+encodeURIComponent(document.getElementById("audio_url").value)+"&";if (document.getElementById("audio_url_seek").value != "0"){getstr+="audio_url_seek="+encodeURIComponent(document.getElementById("audio_url_seek").value)}}e.innerHTML="starting...";makeGETRequest("/multiview?"+getstr, parsemultiviewresponse)}else{alert("Multiview requires between 1-4 streams to be selected")}return false}function stopmultiview(e){e.innerHTML="stopping...";makeGETRequest("/multiview", parsemultiviewresponse);return false}' + "\n"
769
+
770
+ // Function to switch URLs to stream URLs, where necessary
771
+ body += 'function stream_substitution(url){return url.replace(/\\/([a-zA-Z]+\.html)/,"/stream.m3u8")}' + "\n"
772
+
773
+ // Adds touch capability to hover tooltips
774
+ body += 'document.addEventListener("touchstart", function() {}, true);' + "\n"
775
+
776
+ body += '</script></head><body><h1>' + appname + '</h1>' + "\n"
777
+
778
+ body += '<p><span class="tooltip tinytext">Touch or hover over an option name for more details</span></p>' + "\n"
779
+
780
+ todayUTCHours -= 4
781
+ body += '<p><span class="tooltip">Date<span class="tooltiptext">"today" lasts until ' + todayUTCHours + ' AM EST. Home page will default to yesterday between ' + todayUTCHours + ' AM - ' + (YESTERDAY_UTC_HOURS - 4) + ' AM EST.</span></span>: <input type="date" id="gameDate" value="' + gameDate + '"/> '
782
+ for (var i = 0; i < VALID_DATES.length; i++) {
783
+ body += '<button '
784
+ if ( ((VALID_DATES[i] == 'today') && (gameDate == session.liveDate())) || ((VALID_DATES[i] == 'yesterday') && (gameDate == session.yesterdayDate())) ) body += 'class="default" '
785
+ body += 'onclick="date=\'' + VALID_DATES[i] + '\';reload()">' + VALID_DATES[i] + '</button> '
786
+ }
787
+ body += '</p>' + "\n" + '<p><span class="tinytext">Updated ' + session.getCacheUpdatedDate(gameDate) + '</span></p>' + "\n"
788
+
789
+ body += '<p><span class="tooltip">Media Type<span class="tooltiptext">Video is TV broadcasts, Audio is English radio, and Spanish is Spanish radio (not available for all games).</span></span>: '
790
+ for (var i = 0; i < VALID_MEDIA_TYPES.length; i++) {
791
+ body += '<button '
792
+ if ( mediaType == VALID_MEDIA_TYPES[i] ) body += 'class="default" '
793
+ body += 'onclick="mediaType=\'' + VALID_MEDIA_TYPES[i] + '\';reload()">' + VALID_MEDIA_TYPES[i] + '</button> '
794
+ }
795
+ body += '</p>' + "\n"
796
+
797
+ body += '<p><span class="tooltip">Link Type<span class="tooltiptext">Embed will play in your browser (with AirPlay support), Stream will give you a stream URL to open directly in media players like Kodi or VLC, Chromecast is a desktop browser-based casting site, and Advanced will play in your desktop browser with some extra tools and debugging information (Advanced may require you to disable mixed content blocking in your browser).<br><br>NOTE: Chromecast may not be able to resolve local domain names; if so, you can simply access this page using an IP address instead.</span></span>: '
798
+ for (var i = 0; i < VALID_LINK_TYPES.length; i++) {
799
+ body += '<button '
800
+ if ( linkType == VALID_LINK_TYPES[i] ) body += 'class="default" '
801
+ body += 'onclick="linkType=\'' + VALID_LINK_TYPES[i] + '\';reload()">' + VALID_LINK_TYPES[i] + '</button> '
802
+ }
803
+ body += '</p>' + "\n"
804
+
805
+ body += '<p>'
806
+ if ( linkType == 'Embed' ) {
807
+ body += '<span class="tooltip">Start From<span class="tooltiptext">For the embedded player only: Beginning will start playback at the beginning of the stream (may be 1 hour before game time for live games), and Live will start at the live point (if the event is live -- archive games should always start at the beginning). You can still seek anywhere.</span></span>: '
808
+ for (var i = 0; i < VALID_START_FROM.length; i++) {
809
+ body += '<button '
810
+ if ( startFrom == VALID_START_FROM[i] ) body += 'class="default" '
811
+ body += 'onclick="startFrom=\'' + VALID_START_FROM[i] + '\';reload()">' + VALID_START_FROM[i] + '</button> '
812
+ }
813
+ body += "\n"
814
+ }
815
+
816
+ if ( mediaType == 'Video' ) {
817
+ if ( linkType == 'Embed' ) {
818
+ body += 'or '
819
+ }
820
+ body += '<span class="tooltip">Inning<span class="tooltiptext">For video streams only: choose the inning to start with (and the score to display, if applicable). Inning number is relative -- for example, selecting inning 7 here will show inning 7 for scheduled 9 inning games, but inning 5 for scheduled 7 inning games, for example. If an inning number is specified, seeking to an earlier point will not be possible. Inning 0 (zero) should be the broadcast start time, if available. Default is the beginning of the stream. To use with radio, set the video track to "None".</span></span>: '
821
+ body += '<select id="inning_half" onchange="inning_half=this.value;reload()">'
822
+ for (var i = 0; i < VALID_INNING_HALF.length; i++) {
823
+ body += '<option value="' + VALID_INNING_HALF[i] + '"'
824
+ if ( inning_half == VALID_INNING_HALF[i] ) body += ' selected'
825
+ body += '>' + VALID_INNING_HALF[i] + '</option> '
826
+ }
827
+ body += '</select>' + "\n"
828
+
829
+ body += ' '
830
+ body += '<select id="inning_number" onchange="inning_number=this.value;reload()">'
831
+ for (var i = 0; i < VALID_INNING_NUMBER.length; i++) {
832
+ body += '<option value="' + VALID_INNING_NUMBER[i] + '"'
833
+ if ( inning_number == VALID_INNING_NUMBER[i] ) body += ' selected'
834
+ body += '>' + VALID_INNING_NUMBER[i] + '</option> '
835
+ }
836
+ body += '</select>'
837
+ }
838
+ body += '</p>' + "\n"
839
+
840
+ body += '<p><span class="tooltip">Scores<span class="tooltiptext">Choose whether to show scores on this web page. Combine this with the inning option to only show scores through the specified inning.</span></span>: '
841
+ for (var i = 0; i < VALID_SCORES.length; i++) {
842
+ body += '<button '
843
+ if ( scores == VALID_SCORES[i] ) body += 'class="default" '
844
+ body += 'onclick="scores=\'' + VALID_SCORES[i] + '\';reload()">' + VALID_SCORES[i] + '</button> '
845
+ }
846
+ body += '</p>' + "\n"
847
+
848
+ body += "<table>" + "\n"
849
+
850
+ // Rename some parameters before display links
851
+ var mediaFeedType = 'mediaFeedType'
852
+ var language = 'en'
853
+ if ( mediaType == 'Video' ) {
854
+ mediaType = 'MLBTV'
855
+ } else if ( mediaType == 'Spanish' ) {
856
+ mediaType = 'Audio'
857
+ language = 'es'
858
+ }
859
+ if ( mediaType == 'Audio' ) {
860
+ mediaFeedType = 'type'
861
+ }
862
+ linkType = linkType.toLowerCase()
863
+ let link = linkType + '.html'
864
+ if ( linkType == 'stream' ) {
865
+ link = linkType + '.m3u8'
866
+ } else {
867
+ force_vod = 'off'
868
+ }
869
+ var thislink = '/' + link
870
+
871
+ if ( (mediaType == 'MLBTV') && big_inning && big_inning.start ) {
872
+ body += '<tr><td>' + new Date(big_inning.start).toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + ' - ' + new Date(big_inning.end).toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + '</td><td>'
873
+ let currentDate = new Date()
874
+ let compareStart = new Date(big_inning.start)
875
+ compareStart.setMinutes(compareStart.getMinutes()-10)
876
+ let compareEnd = new Date(big_inning.end)
877
+ compareEnd.setHours(compareEnd.getHours()+1)
878
+ if ( (currentDate >= compareStart) && (currentDate < compareEnd) ) {
879
+ let querystring = '?type=biginning'
880
+ let multiviewquerystring = querystring + '&resolution=' + DEFAULT_MULTIVIEW_RESOLUTION
881
+ if ( resolution != VALID_RESOLUTIONS[0] ) querystring += '&resolution=' + resolution
882
+ querystring += content_protect_b
883
+ multiviewquerystring += content_protect_b
884
+ body += '<a href="' + thislink + querystring + '">Big Inning</a>'
885
+ body += '<input type="checkbox" value="' + server + '/stream.m3u8' + multiviewquerystring + '" onclick="addmultiview(this)">'
886
+ } else {
887
+ body += 'Big Inning'
888
+ }
889
+ body += '</td></tr>' + "\n"
890
+ }
891
+
892
+ for (var j = 0; j < cache_data.dates[0].games.length; j++) {
893
+ let game_started = false
894
+
895
+ let awayteam = cache_data.dates[0].games[j].teams['away'].team.abbreviation
896
+ let hometeam = cache_data.dates[0].games[j].teams['home'].team.abbreviation
897
+
898
+ let teams = awayteam + " @ " + hometeam
899
+ let pitchers = ""
900
+ let state = "<br/>"
901
+
902
+ if ( cache_data.dates[0].games[j].status.startTimeTBD == true ) {
903
+ state += "Time TBD"
904
+ } else {
905
+ let startTime = new Date(cache_data.dates[0].games[j].gameDate)
906
+ state += startTime.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true })
907
+ }
908
+
909
+ var abstractGameState = cache_data.dates[0].games[j].status.abstractGameState
910
+ var detailedState = cache_data.dates[0].games[j].status.detailedState
911
+
912
+ var scheduledInnings = '9'
913
+ if ( cache_data.dates[0].games[j].linescore && cache_data.dates[0].games[j].linescore.scheduledInnings ) {
914
+ scheduledInnings = cache_data.dates[0].games[j].linescore.scheduledInnings
915
+ }
916
+ var relative_inning = (inning_number - (9 - scheduledInnings))
917
+ relative_inning = relative_inning < 0 ? 0 : relative_inning
918
+ if ( (scores == 'Show') && (cache_data.dates[0].games[j].gameUtils.isLive || cache_data.dates[0].games[j].gameUtils.isFinal) && !cache_data.dates[0].games[j].gameUtils.isCancelled && !cache_data.dates[0].games[j].gameUtils.isPostponed ) {
919
+ let awayscore = ''
920
+ let homescore = ''
921
+ if ( (inning_number != VALID_INNING_NUMBER[0]) && cache_data.dates[0].games[j].linescore && cache_data.dates[0].games[j].linescore.innings ) {
922
+ awayscore = 0
923
+ homescore = 0
924
+ let display_inning = ''
925
+ for (var k = 0; k < cache_data.dates[0].games[j].linescore.innings.length; k++) {
926
+ if ( cache_data.dates[0].games[j].linescore.innings[k] ) {
927
+ if ( (cache_data.dates[0].games[j].linescore.innings[k].num < relative_inning) ) {
928
+ display_inning = 'T' + cache_data.dates[0].games[j].linescore.innings[k].num
929
+ if ( typeof cache_data.dates[0].games[j].linescore.innings[k].away.runs !== 'undefined' ) awayscore += cache_data.dates[0].games[j].linescore.innings[k].away.runs
930
+ if ( typeof cache_data.dates[0].games[j].linescore.innings[k].home.runs !== 'undefined' ) {
931
+ display_inning = 'B' + cache_data.dates[0].games[j].linescore.innings[k].num
932
+ homescore += cache_data.dates[0].games[j].linescore.innings[k].home.runs
933
+ if ( cache_data.dates[0].games[j].linescore.innings[k+1] ) {
934
+ display_inning = 'T' + (cache_data.dates[0].games[j].linescore.innings[k].num + 1)
935
+ }
936
+ }
937
+ } else if ( (inning_half == VALID_INNING_HALF[2]) && (cache_data.dates[0].games[j].linescore.innings[k].num == relative_inning) ) {
938
+ if ( typeof cache_data.dates[0].games[j].linescore.innings[k].away.runs !== 'undefined' ) {
939
+ display_inning = 'B' + cache_data.dates[0].games[j].linescore.innings[k].num
940
+ awayscore += cache_data.dates[0].games[j].linescore.innings[k].away.runs
941
+ }
942
+ } else {
943
+ break
944
+ }
945
+ } else {
946
+ break
947
+ }
948
+ }
949
+ if ( display_inning != '' ) {
950
+ state = "<br/>" + display_inning
951
+ }
952
+ } else {
953
+ awayscore = cache_data.dates[0].games[j].teams['away'].score
954
+ homescore = cache_data.dates[0].games[j].teams['home'].score
955
+ if ( cache_data.dates[0].games[j].gameUtils.isLive && !cache_data.dates[0].games[j].gameUtils.isFinal ) {
956
+ state = "<br/>" + cache_data.dates[0].games[j].linescore.inningHalf.substr(0,1) + cache_data.dates[0].games[j].linescore.currentInning
957
+ } else if ( cache_data.dates[0].games[j].gameUtils.isFinal ) {
958
+ state = "<br/>" + detailedState
959
+ }
960
+ if ( cache_data.dates[0].games[j].flags.perfectGame == true ) {
961
+ state += "<br/>Perfect Game"
962
+ } else if ( cache_data.dates[0].games[j].flags.noHitter == true ) {
963
+ state += "<br/>No-Hitter"
964
+ }
965
+ }
966
+ teams = awayteam + " " + awayscore + " @ " + hometeam + " " + homescore
967
+ } else if ( cache_data.dates[0].games[j].gameUtils.isCancelled || cache_data.dates[0].games[j].gameUtils.isPostponed || cache_data.dates[0].games[j].gameUtils.isSuspended ) {
968
+ state = "<br/>" + detailedState
969
+ } else if ( cache_data.dates[0].games[j].gameUtils.isDelayed ) {
970
+ state += "<br/>" + detailedState
971
+ }
972
+
973
+ if ( cache_data.dates[0].games[j].doubleHeader != 'N' ) {
974
+ state += "<br/>Game " + cache_data.dates[0].games[j].gameNumber
975
+ }
976
+ if ( cache_data.dates[0].games[j].description ) {
977
+ state += "<br/>" + cache_data.dates[0].games[j].description
978
+ }
979
+ if ( scheduledInnings != '9' ) {
980
+ state += "<br/>" + cache_data.dates[0].games[j].linescore.scheduledInnings + " inning game"
981
+ }
982
+ var resumeStatus = false
983
+ if ( cache_data.dates[0].games[j].resumeGameDate || cache_data.dates[0].games[j].resumedFromDate ) {
984
+ let resumeDate
985
+ if ( cache_data.dates[0].games[j].resumeGameDate ) {
986
+ resumeDate = new Date(cache_data.dates[0].games[j].resumeGameDate)
987
+ state += "<br/>Resuming on<br/>"
988
+ } else {
989
+ resumeDate = new Date(cache_data.dates[0].games[j].resumedFromDate)
990
+ state += "<br/>Resumed from<br/>"
991
+ }
992
+ state += resumeDate.toLocaleString('default', { month: 'long' }) + " " + (resumeDate.getDate()+1)
993
+ // Also show the status by the media links, if one of them is live
994
+ resumeStatus = 'archived'
995
+ if ( ((typeof cache_data.dates[0].games[j].content.media) != 'undefined') || ((typeof cache_data.dates[0].games[j].content.media.epg) != 'undefined') ) {
996
+ for (var k = 0; k < cache_data.dates[0].games[j].content.media.epg.length; k++) {
997
+ for (var x = 0; x < cache_data.dates[0].games[j].content.media.epg[k].items.length; x++) {
998
+ if ( cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaState && (cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaState == 'MEDIA_ON') ) {
999
+ resumeStatus = 'live'
1000
+ break
1001
+ }
1002
+ }
1003
+ if ( resumeStatus ) break
1004
+ }
1005
+ }
1006
+ }
1007
+
1008
+ if ( (cache_data.dates[0].games[j].teams['away'].probablePitcher && cache_data.dates[0].games[j].teams['away'].probablePitcher.lastName) || (cache_data.dates[0].games[j].teams['home'].probablePitcher && cache_data.dates[0].games[j].teams['home'].probablePitcher.lastName) ) {
1009
+ pitchers = "<br/>"
1010
+ if ( cache_data.dates[0].games[j].teams['away'].probablePitcher && cache_data.dates[0].games[j].teams['away'].probablePitcher.lastName ) {
1011
+ pitchers += '<a href="https://mlb.com/player/' + cache_data.dates[0].games[j].teams['away'].probablePitcher.nameSlug + '" target="_blank">' + cache_data.dates[0].games[j].teams['away'].probablePitcher.lastName + '</a>'
1012
+ } else {
1013
+ pitchers += 'TBD'
1014
+ }
1015
+ pitchers += ' vs '
1016
+ if ( cache_data.dates[0].games[j].teams['home'].probablePitcher && cache_data.dates[0].games[j].teams['home'].probablePitcher.lastName ) {
1017
+ pitchers += '<a href="https://mlb.com/player/' + cache_data.dates[0].games[j].teams['home'].probablePitcher.nameSlug + '" target="_blank">' +cache_data.dates[0].games[j].teams['home'].probablePitcher.lastName + '</a>'
1018
+ } else {
1019
+ pitchers += 'TBD'
1020
+ }
1021
+ }
1022
+
1023
+ body += "<tr><td>" + teams + pitchers + state + "</td>"
1024
+
1025
+ if ( ((typeof cache_data.dates[0].games[j].content.media) == 'undefined') || ((typeof cache_data.dates[0].games[j].content.media.epg) == 'undefined') ) {
1026
+ body += "<td></td>"
1027
+ } else {
1028
+ body += "<td>"
1029
+ for (var k = 0; k < cache_data.dates[0].games[j].content.media.epg.length; k++) {
1030
+ let epgTitle = cache_data.dates[0].games[j].content.media.epg[k].title
1031
+ if ( epgTitle == mediaType ) {
1032
+ let lastStation
1033
+ for (var x = 0; x < cache_data.dates[0].games[j].content.media.epg[k].items.length; x++) {
1034
+ if ( ((typeof cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaFeedType) == 'undefined') || (cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaFeedType.indexOf('IN_MARKET_') == -1) ) {
1035
+ if ( ((typeof cache_data.dates[0].games[j].content.media.epg[k].items[x].language) == 'undefined') || (cache_data.dates[0].games[j].content.media.epg[k].items[x].language == language) ) {
1036
+ let teamabbr
1037
+ if ( ((typeof cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaFeedType) != 'undefined') && (cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaFeedType == 'NATIONAL') ) {
1038
+ teamabbr = 'NATIONAL'
1039
+ } else {
1040
+ teamabbr = hometeam
1041
+ if ( cache_data.dates[0].games[j].content.media.epg[k].items[x][mediaFeedType] == 'AWAY' ) {
1042
+ teamabbr = awayteam
1043
+ }
1044
+ }
1045
+ let station = cache_data.dates[0].games[j].content.media.epg[k].items[x].callLetters
1046
+ if ( (cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaState == 'MEDIA_ON') || (cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaState == 'MEDIA_ARCHIVE') || cache_data.dates[0].games[j].gameUtils.isFinal ) {
1047
+ game_started = true
1048
+ let mediaId = cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaId
1049
+ if ( (mediaType == 'MLBTV') && session.cache.media && session.cache.media[mediaId] && session.cache.media[mediaId].blackout && session.cache.media[mediaId].blackoutExpiry && (new Date(session.cache.media[mediaId].blackoutExpiry) > new Date()) ) {
1050
+ body += teamabbr + ': <s>' + station + '</s>'
1051
+ } else {
1052
+ let querystring
1053
+ querystring = '?mediaId=' + mediaId
1054
+ let multiviewquerystring = querystring + '&resolution=' + DEFAULT_MULTIVIEW_RESOLUTION + '&audio_track=' + DEFAULT_MULTIVIEW_AUDIO_TRACK
1055
+ if ( linkType == 'embed' ) {
1056
+ if ( startFrom != 'Beginning' ) querystring += '&startFrom=' + startFrom
1057
+ }
1058
+ if ( mediaType == 'MLBTV' ) {
1059
+ if ( inning_half != VALID_INNING_HALF[0] ) querystring += '&inning_half=' + inning_half
1060
+ if ( inning_number != '' ) querystring += '&inning_number=' + relative_inning
1061
+ if ( skip != 'off' ) querystring += '&skip=' + skip
1062
+ if ( skip_adjust != '0' ) querystring += '&skip_adjust=' + skip_adjust
1063
+ if ( (inning_half != VALID_INNING_HALF[0]) || (inning_number != VALID_INNING_NUMBER[0]) || (skip != VALID_SKIP[0]) ) {
1064
+ let contentId = cache_data.dates[0].games[j].content.media.epg[k].items[x].contentId
1065
+ querystring += '&contentId=' + contentId
1066
+ }
1067
+ if ( resolution != VALID_RESOLUTIONS[0] ) querystring += '&resolution=' + resolution
1068
+ if ( audio_track != VALID_AUDIO_TRACKS[0] ) querystring += '&audio_track=' + encodeURIComponent(audio_track)
1069
+ // audio_url is disabled here, now used in multiview instead
1070
+ //if ( audio_url != '' ) querystring += '&audio_url=' + encodeURIComponent(audio_url)
1071
+ }
1072
+ if ( linkType == 'stream' ) {
1073
+ if ( cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaState == 'MEDIA_ON' ) {
1074
+ if ( force_vod != VALID_FORCE_VOD[0] ) querystring += '&force_vod=' + force_vod
1075
+ }
1076
+ }
1077
+ querystring += content_protect_b
1078
+ body += teamabbr + ': <a href="' + thislink + querystring + '">' + station + '</a>'
1079
+ if ( mediaType == 'MLBTV' ) {
1080
+ body += '<input type="checkbox" value="' + server + '/stream.m3u8' + multiviewquerystring + '" onclick="addmultiview(this)">'
1081
+ }
1082
+ if ( resumeStatus ) {
1083
+ if ( resumeStatus == 'live' ) {
1084
+ if ( cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaState == 'MEDIA_ARCHIVE' ) {
1085
+ body += '(1)'
1086
+ } else if ( cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaState == 'MEDIA_ON' ) {
1087
+ body += '(2)'
1088
+ }
1089
+ } else {
1090
+ if ( station == lastStation ) {
1091
+ body += '(2)'
1092
+ } else {
1093
+ body += '(1)'
1094
+ lastStation = station
1095
+ }
1096
+ }
1097
+ }
1098
+ body += ', '
1099
+ }
1100
+ } else {
1101
+ body += teamabbr + ': ' + station + ', '
1102
+ }
1103
+ }
1104
+ }
1105
+ }
1106
+ break
1107
+ }
1108
+ }
1109
+ if ( body.substr(-2) == ', ' ) {
1110
+ body = body.slice(0, -2)
1111
+ }
1112
+ if ( (mediaType == 'MLBTV') && (game_started) ) {
1113
+ body += '<br/><a href="javascript:showhighlights(\'' + cache_data.dates[0].games[j].gamePk + '\',\'' + gameDate + '\')">Highlights</a>'
1114
+ }
1115
+ if ( body.substr(-2) == ', ' ) {
1116
+ body = body.slice(0, -2)
1117
+ }
1118
+ body += "</td>"
1119
+ body += "</tr>" + "\n"
1120
+ }
1121
+ }
1122
+ body += "</table><br/>" + "\n"
1123
+
1124
+ // Rename parameter back before displaying further links
1125
+ if ( mediaType == 'MLBTV' ) {
1126
+ mediaType = 'Video'
1127
+ }
1128
+
1129
+ if ( mediaType == 'Video' ) {
1130
+ body += '<p><span class="tooltip">Video<span class="tooltiptext">For video streams only: you can manually specifiy a video track (resolution) to use. Adaptive will let your client choose. 720p60 is the best quality. 540p is default for multiview (see below).<br/><br/>None will allow to remove the video tracks, if you just want to listen to the audio while using the "start at inning" or "skip breaks" options enabled.</span></span>: '
1131
+ for (var i = 0; i < VALID_RESOLUTIONS.length; i++) {
1132
+ body += '<button '
1133
+ if ( resolution == VALID_RESOLUTIONS[i] ) body += 'class="default" '
1134
+ body += 'onclick="resolution=\'' + VALID_RESOLUTIONS[i] + '\';reload()">' + VALID_RESOLUTIONS[i]
1135
+ if ( VALID_BANDWIDTHS[i] != '' ) {
1136
+ body += '<br/><span class="tinytext">' + VALID_BANDWIDTHS[i] + '</span>'
1137
+ }
1138
+ body += '</button> '
1139
+ }
1140
+ body += '</p>' + "\n"
1141
+
1142
+ body += '<p><span class="tooltip">Audio<span class="tooltiptext">For video streams only: you can manually specifiy which audio track to include. Some media players can accept them all and let you choose. English is the TV broadcast audio, and the default for multiview (see below).<br/><br/>If you select "none" for video above, picking an audio track here will make it an audio-only feed that supports the inning start and skip breaks options.</span></span>: '
1143
+ for (var i = 0; i < VALID_AUDIO_TRACKS.length; i++) {
1144
+ body += '<button '
1145
+ if ( audio_track == VALID_AUDIO_TRACKS[i] ) body += 'class="default" '
1146
+ body += 'onclick="audio_track=\'' + VALID_AUDIO_TRACKS[i] + '\';reload()">' + VALID_AUDIO_TRACKS[i] + '</button> '
1147
+ }
1148
+ // audio_url is disabled here, now used in multiview instead
1149
+ //body += '<br/><span class="tooltip">or enter a separate audio stream URL<span class="tooltiptext">EXPERIMENTAL! May not actually work. For video streams only: you can also include a separate audio stream URL as an alternate audio track. This is useful if you want to pair the road radio feed with a national TV broadcast (which only includes home radio feeds by default).<br/><br/>After entering the audio stream URL, click the Update button to include it in the video links above; click the Reset button when done with this option.<br/><br/>Warning: does not support inning start or skip options.</span></span>: <span class="tinytext">(copy one from the <button onclick="mediaType=\'Audio\';reload()">Audio</button> page</a>)</span><br/><textarea id="audio_url" rows=2 cols=60 oninput="this.value=stream_substitution(this.value)">' + audio_url + '</textarea><br/><button onclick="audio_url=document.getElementById(\'audio_url\').value;reload()">Update Audio URL</button> <button onclick="audio_url=\'\';reload()">Reset Audio URL</button><br/>'
1150
+ body += '</p>' + "\n"
1151
+
1152
+ body += '<p><span class="tooltip">Skip<span class="tooltiptext">For video streams only (use the video "none" option above to apply it to audio streams): you can remove breaks or non-decision pitches from the stream (the latter is useful to make your own "condensed games").<br/><br/>NOTE: skip timings are only generated when the stream is loaded -- so for live games, it will only skip up to the time you loaded the stream.</span></span>: '
1153
+ for (var i = 0; i < VALID_SKIP.length; i++) {
1154
+ body += '<button '
1155
+ if ( skip == VALID_SKIP[i] ) body += 'class="default" '
1156
+ body += 'onclick="skip=\'' + VALID_SKIP[i] + '\';reload()">' + VALID_SKIP[i] + '</button> '
1157
+ }
1158
+ body += ' <span class="tooltip">Skip Adjust<span class="tooltiptext">Seconds to adjust the skip time video segments, if necessary. Try a negative number if the plays are ending before the video segments begin; use a positive number if the video segments are ending before the play happens.</span></span>: <input type="number" id="skip_adjust" value="' + skip_adjust + '" step="5" onchange="setTimeout(function(){skip_adjust=document.getElementById(\'skip_adjust\').value;reload()},750)" onblur="skip_adjust=this.value;reload()" style="vertical-align:top;font-size:.8em;width:3em"/>'
1159
+ body += '</p>' + "\n"
1160
+
1161
+ body += '<table><tr><td><table><tr><td>1</td><td>2</tr><tr><td>3</td><td>4</td></tr></table><td><span class="tooltip">Multiview / Alternate Audio / Sync<span class="tooltiptext">For video streams only: create a new live stream combining 1-4 separate video streams, using the layout shown at left (if more than 1 video stream is selected). Check the boxes next to feeds above to add/remove them, then click "Start" when ready, "Stop" when done watching, or "Restart" to stop and start with the currently selected streams. May take up to 15 seconds after starting before it is ready to play.<br/><br/>No video scaling is performed: defaults to 540p video for each stream, which can combine to make one 1080p stream. Audio defaults to English (TV) audio. If you specify a different audio track instead, you can use the box after each URL below to adjust the sync in seconds (use positive values if audio is early and the audio stream needs to be padded with silence at the beginning to line up with the video; negative values if audio is late, and audio needs to be trimmed from the beginning.)<br/><br/>TIP #1: You can enter just 1 video stream here, at any resolution, to take advantage of the audio sync or alternate audio features without using multiview -- a single video stream will not be re-encoded and will be presented at its full resolution.<br/><br/>TIP #2: You can also manually enter streams from other sources like <a href="https://www.npmjs.com/package/milbserver" target="_blank">milbserver</a> in the boxes below.<br/><br/>WARNING #1: if the mlbserver process dies or restarts while multiview is active, the ffmpeg encoding process will be orphaned and must be killed manually.<br/><br/>WARNING #2: If you did not specify a hardware encoder for ffmpeg on the command line, this will use your server CPU for encoding. Either way, your system may not be able to keep up with processing 4 video streams at once. Try fewer streams if you have perisistent trouble.</span></span>: <a id="startmultiview" href="" onclick="startmultiview(this);return false">Start'
1162
+ if ( ffmpeg_status ) body += 'ed'
1163
+ body += '</a> | <a id="stopmultiview" href="" onclick="stopmultiview(this);return false">Stop'
1164
+ if ( !ffmpeg_status ) body += 'ped'
1165
+ body += '</a><br/>' + "\n"
1166
+ body += '<span class="tinytext">(check boxes next to games to add, then click "Start";<br/>must click "Stop" link above when done, or manually kill ffmpeg)</span></td></tr><tr><td colspan="2">' + "\n"
1167
+ for (var i=1; i<=4; i++) {
1168
+ body += i + ': <textarea id="multiview' + i + '" rows=2 cols=60 oninput="this.value=stream_substitution(this.value)"></textarea>'
1169
+ body += '<input type="number" id="sync' + i + '" value="0.0" step=".1" style="vertical-align:top;font-size:.8em;width:3em"/>'
1170
+ body += '<br/>' + "\n"
1171
+ }
1172
+ body += '<input type="checkbox" id="dvr"/> <span class="tooltip">DVR: allow pausing/seeking multiview<span class="tooltiptext">If this is enabled, it will use more disk space but you will be able to pause and seek in the multiview stream. Not necessary if you are strictly watching live.</span></span><br/>'
1173
+ body += '<input type="checkbox" id="faster" onchange="if (this.checked){document.getElementById(\'dvr\').checked=true}"/> <span class="tooltip">Encode faster than real-time<span class="tooltiptext">Implies DVR. Not necessary for live streams (which are only delivered in real-time), but if you want to seek ahead in archive streams using multiview, you may want to enable this. WARNING: ffmpeg may approach 100% CPU usage if you use this while combining multiple archive video streams in multiview.</span></span><br/>' + "\n"
1174
+ body += '<hr><span class="tooltip">Alternate audio URL and sync<span class="tooltiptext">Optional: you can also include a separate audio-only URL as an additional alternate audio track. This is useful if you want to pair the road radio feed with a national TV broadcast (which only includes home radio feeds by default). Archive games will likely require a very large negative sync value, as the radio broadcasts may not be trimmed like the video archives.</span></span>:<br/><textarea id="audio_url" rows=2 cols=60 oninput="this.value=stream_substitution(this.value)"></textarea><input id="audio_url_seek" type="number" value="0" style="vertical-align:top;font-size:.8em;width:4em"/>'
1175
+ body += '<hr>Watch: <a href="/embed.html?src=' + encodeURIComponent(multiview_server + multiview_url_path) + '">Embed</a> | <a href="' + multiview_server + multiview_url_path + '">Stream</a> | <a href="/chromecast.html?src=' + encodeURIComponent(multiview_server + multiview_url_path) + '">Chromecast</a> | <a href="/advanced.html?src=' + encodeURIComponent(multiview_server + multiview_url_path) + '">Advanced</a><br/><span class="tinytext">Download: <a href="/kodi.strm?src=' + encodeURIComponent(multiview_server + multiview_url_path) + '">Kodi STRM file</a> (<a href="/kodi.strm?version=18&src=' + encodeURIComponent(multiview_server + multiview_url_path) + '">Leia/18</a>)</span>'
1176
+ body += '</td></tr></table><br/>' + "\n"
1177
+ }
1178
+
1179
+ if ( (linkType == 'stream') && (gameDate == session.liveDate()) ) {
1180
+ body += '<p><span class="tooltip">Force VOD<span class="tooltiptext">For streams only: if your client does not support seeking in mlbserver live streams, turning this on will make the stream look like a VOD stream instead, allowing the client to start at the beginning and allowing the user to seek within it. You will need to reload the stream to watch/view past the current time, though.</span></span>: '
1181
+ for (var i = 0; i < VALID_FORCE_VOD.length; i++) {
1182
+ body += '<button '
1183
+ if ( force_vod == VALID_FORCE_VOD[i] ) body += 'class="default" '
1184
+ body += 'onclick="force_vod=\'' + VALID_FORCE_VOD[i] + '\';reload()">' + VALID_FORCE_VOD[i] + '</button> '
1185
+ }
1186
+ body += '<span class="tinytext">(if client does not support seeking in live streams)</span></p>' + "\n"
1187
+ }
1188
+
1189
+ body += '<table><tr><td>' + "\n"
1190
+
1191
+ body += '<p><span class="tooltip">Live Channel Playlist and XMLTV Guide<span class="tooltiptext">Allows you to generate a M3U playlist of channels, and an XML file of guide listings for those channels, to import into TV/DVR/PVR software like Tvheadend or Jellyfin.<br/><br/>NOTE: May be helpful to specify a resolution above.</span></span>:</p>' + "\n"
1192
+
1193
+ body += '<p><span class="tooltip">Scan Mode<span class="tooltiptext">During setup, some TV/DVR/PVR software will attempt to load all stream URLs. Turning Scan Mode ON will return a sample stream for all stream requests, thus satisfying that software without overloading mlbserver or excluding streams which aren\'t currently live. Once the channels are set up, turning Scan Mode OFF will restore normal stream behavior.<br/><br/>WARNING: Be sure your TV/DVR/PVR software doesn\'t periodically scan all channels automatically or you might overload mlbserver.</span></span>: '
1194
+ let options = ['off', 'on']
1195
+ for (var i = 0; i < options.length; i++) {
1196
+ body += '<button '
1197
+ if ( scan_mode == options[i] ) body += 'class="default" '
1198
+ body += 'onclick="scan_mode=\'' + options[i] + '\';reload()">' + options[i] + '</button> '
1199
+ }
1200
+ body += ' <span class="tinytext">(ON plays sample for all stream requests)</span></p>' + "\n"
1201
+
1202
+ body += '<p>All: <a href="/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + content_protect_b + '">channels.m3u</a> and <a href="/guide.xml?mediaType=' + mediaType + content_protect_b + '">guide.xml</a></p>' + "\n"
1203
+
1204
+ body += '<p><span class="tooltip">By team<span class="tooltiptext">Including a team will include that team\'s broadcasts, not their opponent\'s broadcasts or national TV broadcasts.</span></span>: <a href="/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=ari' + content_protect_b + '">channels.m3u</a> and <a href="/guide.xml?mediaType=' + mediaType + '&includeTeams=ari' + content_protect_b + '">guide.xml</a></p>' + "\n"
1205
+
1206
+ body += '<p><span class="tooltip">Exclude a team + national<span class="tooltiptext">This is useful for excluding games you may be blacked out from. Excluding a team will exclude every game involving that team. National refers to USA national TV broadcasts.</span></span>: <a href="/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&excludeTeams=ari,national' + content_protect_b + '">channels.m3u</a> and <a href="/guide.xml?mediaType=' + mediaType + '&excludeTeams=ari,national' + content_protect_b + '">guide.xml</a></p>' + "\n"
1207
+
1208
+ body += '<p><span class="tooltip">Include (or exclude) Big Inning<span class="tooltiptext">Big Inning is the live look-in and highlights show.</span></span>: <a href="/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=biginning' + content_protect_b + '">channels.m3u</a> and <a href="/guide.xml?mediaType=' + mediaType + '&includeTeams=biginning' + content_protect_b + '">guide.xml</a></p>' + "\n"
1209
+
1210
+ body += '<p><span class="tooltip">Include (or exclude) Multiview<span class="tooltiptext">Requires starting and stopping the multiview stream from the web interface.</span></span>: <a href="/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=multiview' + content_protect_b + '">channels.m3u</a> and <a href="/guide.xml?mediaType=' + mediaType + '&includeTeams=multiview' + content_protect_b + '">guide.xml</a></p>' + "\n"
1211
+
1212
+ body += '</td></tr></table><br/>' + "\n"
1213
+
1214
+ body += '<table><tr><td>' + "\n"
1215
+ body += '<p>Example links:</p>' + "\n"
1216
+ body += '<p>' + "\n"
1217
+ let example_types = [ ['embed.html', 'Embed'], ['stream.m3u8', 'Stream'], ['chromecast.html', 'Chromecast'], ['kodi.strm', 'Kodi'] ]
1218
+
1219
+ let examples = [
1220
+ ['Team live video', '?team=ari&resolution=720p60'],
1221
+ ['Team live radio', '?team=ari&mediaType=Audio'],
1222
+ ['Catch-up/condensed', '?team=ari&resolution=720p60&skip=pitches&date=today'],
1223
+ ['Condensed yesterday', '?team=ari&resolution=720p60&skip=pitches&date=yesterday'],
1224
+ ['Same but DH game 2', '?team=ari&resolution=720p60&skip=pitches&date=yesterday&game=2'],
1225
+ ['Nat\'l game 1 today', '?team=national.1&resolution=720p60&date=today'],
1226
+ ['Nat\'l game 2 yesterday', '?team=national.2&resolution=720p60&date=yesterday']
1227
+ ]
1228
+
1229
+ for (var i=0; i<examples.length; i++) {
1230
+ body += '&bull; ' + examples[i][0] + ': '
1231
+ for (var j=0; j<example_types.length; j++) {
1232
+ body += '<a href="/' + example_types[j][0] + examples[i][1]
1233
+ body += content_protect_b
1234
+ body += '">' + example_types[j][1] + '</a>'
1235
+ if ( j < (example_types.length-1) ) {
1236
+ body += ' | '
1237
+ } else {
1238
+ body += '<br/>' + "\n"
1239
+ }
1240
+ }
1241
+ }
1242
+ body += '</p></td></tr></table><br/>' + "\n"
1243
+
1244
+ let media_center_link = '/live-stream-games/' + gameDate.replace(/-/g,'/') + '?linkType=' + linkType
1245
+ body += '<p><span class="tooltip">Media Center View<span class="tooltiptext">Allows you to use the MLB Media Center page format for nagivation. However, only the "Link Type" option is supported.</span></span>: <a href="' + media_center_link + '" target="_blank">Link</a></p>' + "\n"
1246
+
1247
+ body += '<p><span class="tooltip">Sample video<span class="tooltiptext">A sample stream. Useful for testing and troubleshooting.</span></span>: <a href="/embed.html' + content_protect_a + '">Embed</a> | <a href="/stream.m3u8' + content_protect_a + '">Stream</a> | <a href="/chromecast.html' + content_protect_a + '">Chromecast</a> | <a href="/advanced.html' + content_protect_a + '">Advanced</a></p>' + "\n"
1248
+
1249
+ body += '<p><span class="tooltip">Bookmarklets for MLB.com<span class="tooltiptext">If you watch at MLB.com, drag these bookmarklets to your bookmarks toolbar and use them to hide parts of the interface.</span></span>: <a href="javascript:(function(){let x=document.querySelector(\'#mlbtv-stats-panel\');if(x.style.display==\'none\'){x.style.display=\'initial\';}else{x.style.display=\'none\';}})();">Boxscore</a> | <a href="javascript:(function(){let x=document.querySelector(\'.mlbtv-header-container\');if(x.style.display==\'none\'){let y=document.querySelector(\'.mlbtv-players-container\');y.style.display=\'none\';x.style.display=\'initial\';setTimeout(function(){y.style.display=\'initial\';},15);}else{x.style.display=\'none\';}})();">Scoreboard</a> | <a href="javascript:(function(){let x=document.querySelector(\'.mlbtv-container--footer\');if(x.style.display==\'none\'){let y=document.querySelector(\'.mlbtv-players-container\');y.style.display=\'none\';x.style.display=\'initial\';setTimeout(function(){y.style.display=\'initial\';},15);}else{x.style.display=\'none\';}})();">Linescore</a> | <a href="javascript:(function(){let x=document.querySelector(\'#mlbtv-stats-panel\');if(x.style.display==\'none\'){x.style.display=\'initial\';}else{x.style.display=\'none\';}x=document.querySelector(\'.mlbtv-header-container\');if(x.style.display==\'none\'){x.style.display=\'initial\';}else{x.style.display=\'none\';}x=document.querySelector(\'.mlbtv-container--footer\');if(x.style.display==\'none\'){let y=document.querySelector(\'.mlbtv-players-container\');y.style.display=\'none\';x.style.display=\'initial\';setTimeout(function(){y.style.display=\'initial\';},15);}else{x.style.display=\'none\';}})();">All</a></p>' + "\n"
1250
+
1251
+ // Datepicker functions
1252
+ body += '<script>var datePicker=document.getElementById("gameDate");function changeDate(e){date=datePicker.value;reload()}function removeDate(e){datePicker.removeEventListener("change",changeDate,false);datePicker.addEventListener("blur",changeDate,false);if(e.keyCode===13){date=datePicker.value;reload()}}datePicker.addEventListener("change",changeDate,false);datePicker.addEventListener("keypress",removeDate,false)</script>' + "\n"
1253
+
1254
+ // Highlights modal defintion
1255
+ body += '<div id="myModal" class="modal"><div class="modal-content"><span class="close">&times;</span><div id="highlights"></div></div></div>'
1256
+
1257
+ // Highlights modal functions
1258
+ body += '<script type="text/javascript">var modal = document.getElementById("myModal");var highlightsModal = document.getElementById("highlights");var span = document.getElementsByClassName("close")[0];function parsehighlightsresponse(responsetext) { try { var highlights = JSON.parse(responsetext);var modaltext = "<ul>"; if (highlights && highlights[0]) { for (var i = 0; i < highlights.length; i++) { modaltext += "<li><a href=\'' + link + '?highlight_src=" + encodeURIComponent(highlights[i].playbacks[3].url) + "&resolution=" + resolution + "' + content_protect_b + '\'>" + highlights[i].headline + "</a><span class=\'tinytext\'> (<a href=\'" + highlights[i].playbacks[0].url + "\'>MP4</a>)</span></li>" } } else { modaltext += "No highlights available for this game.";}modaltext += "</ul>";highlightsModal.innerHTML = modaltext;modal.style.display = "block"} catch (e) { alert("Error processing highlights: " + e.message)}} function showhighlights(gamePk, gameDate) { makeGETRequest("/highlights?gamePk=" + gamePk + "&gameDate=" + gameDate, parsehighlightsresponse);return false} span.onclick = function() {modal.style.display = "none";}' + "\n"
1259
+ body += 'window.onclick = function(event) { if (event.target == modal) { modal.style.display = "none"; } }</script>' + "\n"
1260
+
1261
+ body += "</body></html>"
1262
+
1263
+ res.writeHead(200, {'Content-Type': 'text/html'});
1264
+ res.end(body)
1265
+ } catch (e) {
1266
+ session.log('home request error : ' + e.message)
1267
+ res.end('')
1268
+ }
1269
+ })
1270
+
1271
+ // Listen for OPTIONS requests and respond with CORS headers
1272
+ app.options('*', function(req, res) {
1273
+ session.debuglog('OPTIONS request : ' + req.url)
1274
+ var cors_headers = {
1275
+ 'access-control-allow-headers': 'Origin, X-Requested-With, Content-Type, accessToken, Authorization, Accept, Range',
1276
+ 'access-control-allow-origin': '*',
1277
+ 'access-control-allow-methods': 'GET, OPTIONS',
1278
+ 'access-control-max-age': 0
1279
+ }
1280
+ res.writeHead(204, cors_headers)
1281
+ res.end()
1282
+ return
1283
+ })
1284
+
1285
+ // Listen for live-stream-games (schedule) page requests, return the page after local url substitution
1286
+ app.get('/live-stream-games*', async function(req, res) {
1287
+ if ( ! (await protect(req, res)) ) return
1288
+
1289
+ session.debuglog('schedule request : ' + req.url)
1290
+
1291
+ // check for a linkType parameter in the url
1292
+ let linkType = 'embed'
1293
+ if ( req.query.linkType ) {
1294
+ linkType = req.query.linkType
1295
+ session.setLinkType(linkType)
1296
+ }
1297
+
1298
+ // use the link type to determine the local url to use
1299
+ var local_url = '/embed.html' // default to embedded player
1300
+ var content_protect = ''
1301
+ if ( linkType == 'stream' ) { // direct stream
1302
+ local_url = '/stream.m3u8'
1303
+ if ( session.protection.content_protect ) content_protect = '&content_protect=' + session.protection.content_protect
1304
+ } else { // other
1305
+ local_url = '/' + linkType + '.html'
1306
+ }
1307
+
1308
+ // remove our linkType parameter, if specified, from the url we will fetch remotely
1309
+ var remote_url = url.parse(req.url).pathname
1310
+
1311
+ let reqObj = {
1312
+ url: 'https://www.mlb.com' + remote_url,
1313
+ headers: {
1314
+ 'User-Agent': session.getUserAgent(),
1315
+ 'Origin': 'https://www.mlb.com',
1316
+ 'Accept-Encoding': 'gzip, deflate, br'
1317
+ },
1318
+ gzip: true
1319
+ }
1320
+
1321
+ var body = await session.httpGet(reqObj)
1322
+
1323
+ // a regex substitution to change existing links to local urls
1324
+ body = body.replace(/https:\/\/www.mlb.com\/tv\/g\d+\/[v]([a-zA-Z0-9-]+)/g,local_url+"?contentId=$1"+content_protect)
1325
+
1326
+ // hide popup to accept cookies
1327
+ body = body.replace(/www.googletagmanager.com/g,'0.0.0.0')
1328
+
1329
+ res.end(body)
1330
+ })
1331
+
1332
+ // Listen for embed request, respond with embedded hls.js player
1333
+ app.get('/embed.html', async function(req, res) {
1334
+ if ( ! (await protect(req, res)) ) return
1335
+
1336
+ session.log('embed.html request : ' + req.url)
1337
+
1338
+ delete req.headers.host
1339
+
1340
+ let startFrom = 'Beginning'
1341
+ if ( req.query.startFrom ) {
1342
+ startFrom = req.query.startFrom
1343
+ }
1344
+
1345
+ let video_url = '/stream.m3u8'
1346
+ if ( req.query.src ) {
1347
+ video_url = req.query.src
1348
+ } else {
1349
+ let urlArray = req.url.split('?')
1350
+ if ( (urlArray.length == 2) ) {
1351
+ video_url += '?' + urlArray[1]
1352
+ }
1353
+ }
1354
+ session.debuglog('embed src : ' + video_url)
1355
+
1356
+ // Adapted from https://hls-js.netlify.app/demo/basic-usage.html
1357
+ var body = '<html><head><meta charset="UTF-8"><meta http-equiv="Content-type" content="text/html;charset=UTF-8"><title>' + appname + ' player</title><link rel="icon" href="favicon.svg"><style type="text/css">input[type=text],input[type=button]{-webkit-appearance:none;-webkit-border-radius:0}body{background-color:black;color:lightgrey;font-family:Arial,Helvetica,sans-serif}video{width:100% !important;height:auto !important;max-width:1280px}input[type=number]::-webkit-inner-spin-button{opacity:1}button{color:lightgray;background-color:black}button.default{color:black;background-color:lightgray}</style><script>function goBack(){var prevPage=window.location.href;window.history.go(-1);setTimeout(function(){if(window.location.href==prevPage){window.location.href="/"}}, 500)}function toggleAudio(x){var elements=document.getElementsByClassName("audioButton");for(var i=0;i<elements.length;i++){elements[i].className="audioButton"}document.getElementById("audioButton"+x).className+=" default";hls.audioTrack=x}function changeTime(x){video.currentTime+=x}function changeRate(x){let newRate=Math.round((Number(document.getElementById("playback_rate").value)+x)*10)/10;if((newRate<=document.getElementById("playback_rate").max) && (newRate>=document.getElementById("playback_rate").min)){document.getElementById("playback_rate").value=newRate.toFixed(1);video.defaultPlaybackRate=video.playbackRate=document.getElementById("playback_rate").value}}function myKeyPress(e){if(e.key=="ArrowRight"){changeTime(10)}else if(e.key=="ArrowLeft"){changeTime(-10)}else if(e.key=="ArrowUp"){changeRate(0.1)}else if(e.key=="ArrowDown"){changeRate(-0.1)}}</script></head><body onkeydown="myKeyPress(event)"><script src="https://hls-js.netlify.app/dist/hls.js"></script><video id="video" controls></video><script>var video=document.getElementById("video");if(Hls.isSupported()){var hls=new Hls('
1358
+
1359
+ if ( startFrom != 'Live' ) {
1360
+ body += '{startPosition:0,liveSyncDuration:32400,liveMaxLatencyDuration:32410}'
1361
+ }
1362
+
1363
+ body += ');hls.loadSource("' + video_url + '");hls.attachMedia(video);hls.on(Hls.Events.MEDIA_ATTACHED,function(){video.muted=true;video.play()});hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, function(){var audioSpan=document.getElementById("audioSpan");var audioButtons="";for(var i=0;i<hls.audioTracks.length;i++){audioButtons+=\'<button id="audioButton\'+i+\'" class="audioButton\';if(i==0){audioButtons+=\' default\'}audioButtons+=\'" onclick="toggleAudio(\'+i+\')">\'+hls.audioTracks[i]["name"]+"</button> "}audioSpan.innerHTML=audioButtons})}else if(video.canPlayType("application/vnd.apple.mpegurl")){video.src="' + video_url + '";video.addEventListener("canplay",function(){video.play()})}</script><p>Skip: <button onclick="changeTime(-30)">- 30 s</button> <button onclick="changeTime(-10)">- 10 s</button> <button onclick="changeTime(10)">+ 10 s</button> <button onclick="changeTime(30)">+ 30 s</button> <button onclick="changeTime(90)">+ 90 s</button> <button onclick="changeTime(120)">+ 120 s</button> '
1364
+
1365
+ body += '<button onclick="changeTime(video.duration-10)">Latest</button> '
1366
+
1367
+ body += '<button id="airplay">AirPlay</button></p><p>Playback rate: <input type="number" value=1.0 min=0.1 max=16.0 step=0.1 id="playback_rate" size="8" style="width: 4em" onchange="video.defaultPlaybackRate=video.playbackRate=this.value"></p><p>Audio: <button onclick="video.muted=!video.muted">Toggle Mute</button> <span id="audioSpan"></span></p><p><button onclick="goBack()">Back</button></p><script>var airPlay=document.getElementById("airplay");if(window.WebKitPlaybackTargetAvailabilityEvent){video.addEventListener("webkitplaybacktargetavailabilitychanged",function(event){switch(event.availability){case "available":airPlay.style.display="inline";break;default:airPlay.style.display="none"}airPlay.addEventListener("click",function(){video.webkitShowPlaybackTargetPicker()})})}else{airPlay.style.display="none"}</script></body></html>'
1368
+ res.end(body)
1369
+ })
1370
+
1371
+ // Listen for advanced embed request, redirect to online demo hls.js player
1372
+ app.get('/advanced.html', async function(req, res) {
1373
+ if ( ! (await protect(req, res)) ) return
1374
+
1375
+ session.log('advanced embed request : ' + req.url)
1376
+
1377
+ let server = 'http://' + req.headers.host
1378
+
1379
+ let video_url = '/stream.m3u8'
1380
+ if ( req.query.src ) {
1381
+ video_url = req.query.src
1382
+ } else {
1383
+ let urlArray = req.url.split('?')
1384
+ if ( (urlArray.length == 2) ) {
1385
+ video_url += '?' + urlArray[1]
1386
+ }
1387
+ video_url = server + video_url
1388
+ }
1389
+ session.debuglog('advanced embed src : ' + video_url)
1390
+
1391
+ res.redirect('http://hls-js.netlify.app/demo/?src=' + encodeURIComponent(video_url))
1392
+ })
1393
+
1394
+ // Listen for Chromecast request, redirect to chromecast.link player
1395
+ app.get('/chromecast.html', async function(req, res) {
1396
+ if ( ! (await protect(req, res)) ) return
1397
+
1398
+ session.log('chromecast request : ' + req.url)
1399
+
1400
+ let server = 'http://' + req.headers.host
1401
+
1402
+ let video_url = '/stream.m3u8'
1403
+ if ( req.query.src ) {
1404
+ video_url = req.query.src
1405
+ } else {
1406
+ let urlArray = req.url.split('?')
1407
+ if ( (urlArray.length == 2) ) {
1408
+ video_url += '?' + urlArray[1]
1409
+ }
1410
+ video_url = server + video_url
1411
+ }
1412
+ session.debuglog('chromecast src : ' + video_url)
1413
+
1414
+ res.redirect('https://chromecast.link#title=' + appname + '&content=' + encodeURIComponent(video_url))
1415
+ })
1416
+
1417
+ // Listen for channels.m3u playlist request
1418
+ app.get('/channels.m3u', async function(req, res) {
1419
+ session.log('channels.m3u request : ' + req.url)
1420
+
1421
+ if ( session.protection.content_protect ) {
1422
+ if ( !req.query.content_protect || (req.query.content_protect != session.protection.content_protect) ) {
1423
+ session.log('playlist request rejected due to missing/invalid content_protect value')
1424
+ res.end('')
1425
+ return
1426
+ }
1427
+ }
1428
+
1429
+ let mediaType = 'Video'
1430
+ if ( req.query.mediaType ) {
1431
+ mediaType = req.query.mediaType
1432
+ }
1433
+
1434
+ let includeTeams = []
1435
+ if ( req.query.includeTeams ) {
1436
+ includeTeams = req.query.includeTeams.toUpperCase().split(',')
1437
+ }
1438
+ let excludeTeams = []
1439
+ if ( req.query.excludeTeams ) {
1440
+ excludeTeams = req.query.excludeTeams.toUpperCase().split(',')
1441
+ }
1442
+
1443
+ let server = 'http://' + req.headers.host
1444
+
1445
+ let resolution = 'best'
1446
+ if ( req.query.resolution ) {
1447
+ resolution = req.query.resolution
1448
+ }
1449
+
1450
+ let pipe = 'false'
1451
+ if ( req.query.pipe ) {
1452
+ pipe = req.query.pipe
1453
+ }
1454
+
1455
+ let startingChannelNumber = 1
1456
+ if ( req.query.startingChannelNumber ) {
1457
+ startingChannelNumber = req.query.startingChannelNumber
1458
+ }
1459
+
1460
+ var body = await session.getChannels(mediaType, includeTeams, excludeTeams, server, resolution, pipe, startingChannelNumber)
1461
+
1462
+ res.writeHead(200, {'Content-Type': 'audio/x-mpegurl'})
1463
+ res.end(body)
1464
+ })
1465
+
1466
+ // Listen for guide.xml request
1467
+ app.get('/guide.xml', async function(req, res) {
1468
+ session.log('guide.xml request : ' + req.url)
1469
+
1470
+ if ( session.protection.content_protect ) {
1471
+ if ( !req.query.content_protect || (req.query.content_protect != session.protection.content_protect) ) {
1472
+ session.log('xml request rejected due to missing/invalid content_protect value')
1473
+ res.end('')
1474
+ return
1475
+ }
1476
+ }
1477
+
1478
+ let mediaType = 'Video'
1479
+ if ( req.query.mediaType ) {
1480
+ mediaType = req.query.mediaType
1481
+ }
1482
+
1483
+ let includeTeams = []
1484
+ if ( req.query.includeTeams ) {
1485
+ includeTeams = req.query.includeTeams.toUpperCase().split(',')
1486
+ }
1487
+ let excludeTeams = []
1488
+ if ( req.query.excludeTeams ) {
1489
+ excludeTeams = req.query.excludeTeams.toUpperCase().split(',')
1490
+ }
1491
+
1492
+ let server = 'http://' + req.headers.host
1493
+
1494
+ var body = await session.getGuide(mediaType, includeTeams, excludeTeams, server)
1495
+
1496
+ res.end(body)
1497
+ })
1498
+
1499
+ // Listen for image requests
1500
+ app.get('/image.svg', async function(req, res) {
1501
+ session.debuglog('image request : ' + req.url)
1502
+
1503
+ if ( session.protection.content_protect ) {
1504
+ if ( !req.query.content_protect || (req.query.content_protect != session.protection.content_protect) ) {
1505
+ session.debuglog('image request rejected due to missing/invalid content_protect value')
1506
+ res.end('')
1507
+ return
1508
+ }
1509
+ }
1510
+
1511
+ let teamId = 'MLB'
1512
+ if ( req.query.teamId ) {
1513
+ teamId = req.query.teamId
1514
+ }
1515
+
1516
+ var body = await session.getImage(teamId)
1517
+
1518
+ res.writeHead(200, {'Content-Type': 'image/svg+xml'})
1519
+ res.end(body)
1520
+ })
1521
+
1522
+ // Listen for favicon requests
1523
+ app.get('/favicon.svg', async function(req, res) {
1524
+ session.debuglog('favicon request : ' + req.url)
1525
+
1526
+ if ( session.protection.content_protect ) {
1527
+ if ( !req.query.content_protect || (req.query.content_protect != session.protection.content_protect) ) {
1528
+ session.debuglog('image request rejected due to missing/invalid content_protect value')
1529
+ res.end('')
1530
+ return
1531
+ }
1532
+ }
1533
+
1534
+ var body = await session.getImage('MLB')
1535
+
1536
+ res.writeHead(200, {'Content-Type': 'image/svg+xml'})
1537
+ res.end(body)
1538
+ })
1539
+
1540
+ // Listen for highlights requests
1541
+ app.get('/highlights', async function(req, res) {
1542
+ if ( ! (await protect(req, res)) ) return
1543
+
1544
+ try {
1545
+ session.log('highlights request : ' + req.url)
1546
+
1547
+ let highlightsData = ''
1548
+ if ( req.query.gamePk && req.query.gameDate ) {
1549
+ highlightsData = await session.getHighlightsData(req.query.gamePk, req.query.gameDate)
1550
+ }
1551
+ res.end(JSON.stringify(highlightsData))
1552
+ } catch (e) {
1553
+ session.log('highlights request error : ' + e.message)
1554
+ res.end('')
1555
+ }
1556
+ })
1557
+
1558
+ // Listen for multiview requests
1559
+ app.get('/multiview', async function(req, res) {
1560
+ if ( ! (await protect(req, res)) ) return
1561
+
1562
+ try {
1563
+ session.log('multiview request : ' + req.url)
1564
+
1565
+ try {
1566
+ ffmpeg_command.kill()
1567
+ session.clear_multiview_files()
1568
+ } catch (e) {
1569
+ //session.debuglog('error killing multiview command:' + e.message)
1570
+ }
1571
+
1572
+ if ( req.query.streams ) {
1573
+ let sync = []
1574
+ if ( req.query.sync ) {
1575
+ sync = req.query.sync
1576
+ }
1577
+ let dvr = false
1578
+ if ( req.query.dvr ) {
1579
+ dvr = req.query.dvr
1580
+ }
1581
+ let faster = false
1582
+ if ( req.query.faster ) {
1583
+ faster = req.query.faster
1584
+ dvr = true
1585
+ }
1586
+ let audio_url = false
1587
+ if ( req.query.audio_url && (req.query.audio_url != '') ) {
1588
+ audio_url = req.query.audio_url
1589
+ }
1590
+ let audio_url_seek = false
1591
+ if ( req.query.audio_url_seek && (req.query.audio_url_seek != '0') ) {
1592
+ audio_url_seek = req.query.audio_url_seek
1593
+ }
1594
+ // Wait to restart it
1595
+ setTimeout(function() {
1596
+ res.end(start_multiview_stream(req.query.streams, sync, dvr, faster, audio_url, audio_url_seek))
1597
+ }, 5000)
1598
+ } else {
1599
+ res.end('stopped')
1600
+ }
1601
+ } catch (e) {
1602
+ session.log('multiview request error : ' + e.message)
1603
+ res.end('multiview request error, check log')
1604
+ }
1605
+ })
1606
+
1607
+ function start_multiview_stream(streams, sync, dvr, faster, audio_url, audio_url_seek) {
1608
+ try {
1609
+ ffmpeg_command = ffmpeg({ timeout: 432000 })
1610
+
1611
+ // If it's not already an array (only 1 parameter was passed in URL), convert it
1612
+ if ( !Array.isArray(streams) ) streams = [streams]
1613
+ if ( !Array.isArray(sync) ) sync = [sync]
1614
+
1615
+ // Max 4 streams
1616
+ var stream_count = Math.min(streams.length, 4)
1617
+
1618
+ var audio_present = []
1619
+ var complexFilter = []
1620
+ var xstack_inputs = []
1621
+ var xstack_layout = '0_0|w0_0'
1622
+ var map_audio = ''
1623
+
1624
+ // Video
1625
+ let video_output = '0'
1626
+ for (var i=0; i<stream_count; i++) {
1627
+ let url = streams[i]
1628
+
1629
+ // Stream URL for testing
1630
+ //url = SAMPLE_STREAM_URL
1631
+
1632
+ // Production
1633
+ ffmpeg_command.input(url)
1634
+ .addInputOption('-thread_queue_size', '4096')
1635
+
1636
+ if ( !faster ) ffmpeg_command.native()
1637
+
1638
+ // Only apply filters if more than 1 stream
1639
+ if ( stream_count > 1 ) {
1640
+ complexFilter.push({
1641
+ filter: 'setpts=PTS-STARTPTS',
1642
+ inputs: i+':v',
1643
+ outputs: 'v'+i
1644
+ })
1645
+ xstack_inputs.push('v'+i)
1646
+ }
1647
+
1648
+ // Check if audio is present
1649
+ if ( url.indexOf('audio_track=none') === -1 ) {
1650
+ audio_present.push(i)
1651
+ }
1652
+ }
1653
+
1654
+ // Alternate audio track, if provided
1655
+ if ( audio_url ) {
1656
+ ffmpeg_command.input(audio_url)
1657
+ .addInputOption('-thread_queue_size', '4096')
1658
+
1659
+ if ( !faster ) ffmpeg_command.native()
1660
+
1661
+ audio_present.push(stream_count)
1662
+ if ( audio_url_seek && (audio_url_seek != 0) ) {
1663
+
1664
+ if ( audio_url_seek > 0 ) {
1665
+ sync.push(audio_url_seek)
1666
+ } else if ( audio_url_seek < 0 ) {
1667
+ session.log('trimming audio for stream ' + stream_count + ' by ' + audio_url_seek + ' seconds')
1668
+ ffmpeg_command.addInputOption('-ss', (audio_url_seek * -1))
1669
+ }
1670
+ }
1671
+ }
1672
+
1673
+ // More video
1674
+ // Only apply filters if more than 1 stream
1675
+ if ( stream_count > 1 ) {
1676
+ video_output = 'out'
1677
+ if ( stream_count > 2 ) xstack_layout += '|0_h0'
1678
+ if ( stream_count > 3 ) xstack_layout += '|w0_h0'
1679
+ complexFilter.push({
1680
+ filter: 'xstack',
1681
+ options: { inputs:stream_count, layout: xstack_layout, fill:'black' },
1682
+ inputs: xstack_inputs,
1683
+ outputs: video_output
1684
+ })
1685
+ video_output = '[' + video_output + ']'
1686
+ }
1687
+
1688
+ // Audio
1689
+ for (var i=0; i<audio_present.length; i++) {
1690
+ let audio_input = audio_present[i] + ':a:0'
1691
+ let filter = ''
1692
+ if ( sync[audio_present[i]] ) {
1693
+ if ( sync[audio_present[i]] > 0 ) {
1694
+ session.log('delaying audio for stream ' + (audio_present[i]+1) + ' by ' + sync[audio_present[i]] + ' seconds')
1695
+ filter = 'adelay=' + (sync[i] * 1000) + ','
1696
+ } else if ( sync[audio_present[i]] < 0 ) {
1697
+ session.log('trimming audio for stream ' + (audio_present[i]+1) + ' by ' + sync[audio_present[i]] + ' seconds')
1698
+ filter = 'atrim=start=' + (sync[audio_present[i]] * -1) + 's,'
1699
+ }
1700
+ }
1701
+ // Resampling adds silence to preserve timestamps, and padding makes its length match the video track
1702
+ complexFilter.push({
1703
+ filter: 'aresample=async=1:first_pts=0,' + filter + 'asetpts=PTS-STARTPTS,apad',
1704
+ inputs: audio_input,
1705
+ outputs: 'out' + i
1706
+ })
1707
+ }
1708
+
1709
+ ffmpeg_command.complexFilter(complexFilter)
1710
+ ffmpeg_command.addOutputOption('-map', video_output + ':v')
1711
+
1712
+ var var_stream_map = 'v:0,agroup:aac'
1713
+ for (var i=0; i<audio_present.length; i++) {
1714
+ ffmpeg_command.addOutputOption('-map', '[out' + i + ']')
1715
+ var_stream_map += ' a:' + i + ',agroup:aac,language:ENG'
1716
+ if ( i == 0 ) {
1717
+ var_stream_map += ',default:yes'
1718
+ }
1719
+ }
1720
+
1721
+ // Default to keep only 12 segments (1 minute) on disk, unless dvr is specified
1722
+ var hls_list_size = 12
1723
+ var delete_segments = 'delete_segments+'
1724
+ if ( dvr ) {
1725
+ hls_list_size = 0
1726
+ delete_segments = ''
1727
+ }
1728
+
1729
+ if ( stream_count > 1 ) {
1730
+ // Only re-encode video if there is more than 1 video stream
1731
+ let bandwidth = 1040 * stream_count
1732
+ ffmpeg_command.addOutputOption('-c:v', ffmpegEncoder)
1733
+ .addOutputOption('-pix_fmt:v', 'yuv420p')
1734
+ .addOutputOption('-preset:v', 'superfast')
1735
+ .addOutputOption('-r:v', '30')
1736
+ .addOutputOption('-g:v', '150')
1737
+ .addOutputOption('-keyint_min:v', '150')
1738
+ .addOutputOption('-b:v', bandwidth.toString() + 'k')
1739
+ } else {
1740
+ // If only 1 video stream, just copy the video without re-encoding
1741
+ ffmpeg_command.addOutputOption('-c:v', 'copy')
1742
+ }
1743
+ ffmpeg_command.addOutputOption('-c:a', 'aac')
1744
+ .addOutputOption('-strict', 'experimental')
1745
+ .addOutputOption('-sn')
1746
+ .addOutputOption('-t', '6:00:00')
1747
+ .addOutputOption('-f', 'hls')
1748
+ .addOutputOption('-hls_time', '5')
1749
+ .addOutputOption('-hls_list_size', hls_list_size)
1750
+ .addOutputOption('-hls_allow_cache', '0')
1751
+ .addOutputOption('-hls_flags', delete_segments + 'independent_segments+discont_start+program_date_time')
1752
+
1753
+ if ( dvr ) {
1754
+ ffmpeg_command.addOutputOption('-hls_playlist_type', 'event')
1755
+ }
1756
+
1757
+ ffmpeg_command.addOutputOption('-start_number', '1')
1758
+ .addOutputOption('-hls_segment_filename', session.get_multiview_directory() + '/stream_%v_%d.ts')
1759
+ .addOutputOption('-var_stream_map', var_stream_map)
1760
+ .addOutputOption('-master_pl_name', multiview_stream_name)
1761
+ .addOutputOption('-y')
1762
+ .output(session.get_multiview_directory() + '/stream-%v.m3u8')
1763
+ .on('start', function() {
1764
+ session.log('multiview stream started')
1765
+ ffmpeg_status = true
1766
+ })
1767
+ .on('error', function(err) {
1768
+ session.log('multiview stream stopped: ' + err.message)
1769
+ ffmpeg_status = false
1770
+ })
1771
+ .on('end', function() {
1772
+ session.log('multiview stream ended')
1773
+ ffmpeg_status = false
1774
+ })
1775
+
1776
+ if ( argv.ffmpeg_logging ) {
1777
+ session.log('ffmpeg output logging enabled')
1778
+ ffmpeg_command.on('stderr', function(stderrLine) {
1779
+ session.log(stderrLine);
1780
+ })
1781
+ }
1782
+
1783
+ ffmpeg_command.run()
1784
+
1785
+ session.log('multiview stream command started')
1786
+
1787
+ return 'started'
1788
+ } catch (e) {
1789
+ session.log('multiview start error : ' + e.message)
1790
+ return 'multiview start error, check log'
1791
+ }
1792
+ }
1793
+
1794
+ // Listen for Kodi STRM file requests
1795
+ app.get('/kodi.strm', async function(req, res) {
1796
+ if ( ! (await protect(req, res)) ) return
1797
+
1798
+ try {
1799
+ session.log('kodi.strm request : ' + req.url)
1800
+
1801
+ let server = 'http://' + req.headers.host
1802
+
1803
+ let video_url = '/stream.m3u8'
1804
+ let file_name = 'kodi'
1805
+ if ( req.query.src ) {
1806
+ video_url = req.query.src
1807
+ } else {
1808
+ let urlArray = req.url.split('?')
1809
+ if ( (urlArray.length == 2) ) {
1810
+ video_url += '?' + urlArray[1]
1811
+ let paramArray = urlArray[1].split('=')
1812
+ for (var i=1; i<paramArray.length; i++) {
1813
+ let param = paramArray[i].split('&')
1814
+ file_name += '.' + param[0]
1815
+ }
1816
+ }
1817
+ video_url = server + video_url
1818
+ }
1819
+
1820
+ var inputstream_property_name = 'inputstreamaddon'
1821
+ if ( req.query.version && (req.query.version == '18') ) {
1822
+ inputstream_property_name = 'inputstream.adaptive'
1823
+ }
1824
+
1825
+ var body = '#KODIPROP:mimetype=application/vnd.apple.mpegurl' + "\n" + '#KODIPROP:' + inputstream_property_name + '=inputstream.adaptive' + "\n" + '#KODIPROP:inputstream.adaptive.manifest_type=hls' + "\n" + video_url
1826
+
1827
+ var download_headers = {
1828
+ 'Content-Disposition': 'attachment; filename="' + file_name + '.strm"'
1829
+ }
1830
+ res.writeHead(200, download_headers)
1831
+
1832
+ res.end(body)
1833
+ } catch (e) {
1834
+ session.log('kodi.strm request error : ' + e.message)
1835
+ res.end('kodi.strm request error, check log')
1836
+ }
1837
+ })