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.
- package/README.md +62 -0
- package/index.js +1837 -0
- package/package.json +30 -0
- 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 += '• ' + 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">×</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
|
+
})
|