mlbserver 2021.10.0-1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +62 -0
  2. package/index.js +1837 -0
  3. package/package.json +30 -0
  4. package/session.js +2263 -0
package/session.js ADDED
@@ -0,0 +1,2263 @@
1
+ #!/usr/bin/env node
2
+
3
+ // session.js defines the session class which handles API activity
4
+
5
+ // Required Node packages for the session class
6
+ const fs = require('fs')
7
+ const path = require('path')
8
+ const readlineSync = require('readline-sync')
9
+ const FileCookieStore = require('tough-cookie-filestore')
10
+ const parseString = require('xml2js').parseString
11
+
12
+ // Define some file paths and names
13
+ const DATA_DIRECTORY = path.join(__dirname, 'data')
14
+ const CACHE_DIRECTORY = path.join(__dirname, 'cache')
15
+ const MULTIVIEW_DIRECTORY_NAME = 'multiview'
16
+
17
+ const CREDENTIALS_FILE = path.join(__dirname, 'credentials.json')
18
+ const PROTECTION_FILE = path.join(__dirname, 'protection.json')
19
+ const COOKIE_FILE = path.join(DATA_DIRECTORY, 'cookies.json')
20
+ const DATA_FILE = path.join(DATA_DIRECTORY, 'data.json')
21
+ const CACHE_FILE = path.join(CACHE_DIRECTORY, 'cache.json')
22
+
23
+ // Default user agent to use for API requests
24
+ const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:87.0) Gecko/20100101 Firefox/87.0'
25
+
26
+ // Other variables to use in API communications
27
+ const PLATFORM = "macintosh"
28
+ const BAM_SDK_VERSION = '4.3'
29
+ const BAM_TOKEN_URL = 'https://us.edge.bamgrid.com/token'
30
+
31
+ // Default date handling
32
+ const TODAY_UTC_HOURS = 8 // UTC hours (EST + 4) into tomorrow to still use today's date
33
+
34
+ const TEAM_IDS = {'ARI':'109','ATL':'144','BAL':'110','BOS':'111','CHC':'112','CWS':'145','CIN':'113','CLE':'114','COL':'115','DET':'116','HOU':'117','KCR':'118','LAA':'108','LAD':'119','MIA':'146','MIL':'158','MIN':'142','NYM':'121','NYY':'147','OAK':'133','PHI':'143','PIT':'134','STL':'138','SDP':'135','SFG':'137','SEA':'136','TBR':'139','TEX':'140','TOR':'141','WSH':'120'}
35
+
36
+ class sessionClass {
37
+ // Initialize the class
38
+ constructor(argv = {}) {
39
+ this.debug = argv.debug
40
+
41
+ // Read credentials from file, if present
42
+ this.credentials = this.readFileToJson(CREDENTIALS_FILE) || {}
43
+
44
+ // Check if account credentials were provided and if they are different from the stored credentials
45
+ if ( argv.account_username && argv.account_password && ((argv.account_username != this.credentials.account_username) || (argv.account_password != this.credentials.account_password)) ) {
46
+ this.debuglog('updating account credentials')
47
+ this.credentials.account_username = argv.account_username
48
+ this.credentials.account_password = argv.account_password
49
+ this.save_credentials()
50
+ this.clear_session_data()
51
+ } else {
52
+ // Prompt for credentials if they don't exist
53
+ if ( !this.credentials.account_username || !this.credentials.account_password ) {
54
+ this.debuglog('prompting for account credentials')
55
+ this.credentials.account_username = readlineSync.question('Enter account username (email address): ')
56
+ this.credentials.account_password = readlineSync.question('Enter account password: ', { hideEchoBack: true })
57
+ this.save_credentials()
58
+ this.clear_session_data()
59
+ }
60
+ }
61
+
62
+ // If page username/password protection is specified, retrieve or generate a random string of random length
63
+ // to protect non-page content (streams, playlists, guides, images)
64
+ this.protection = {}
65
+ if ( argv.page_username && argv.page_password ) {
66
+ // Read protection data from file, if present
67
+ this.protection = this.readFileToJson(PROTECTION_FILE) || {}
68
+
69
+ if ( !this.protection.content_protect ) {
70
+ this.log('generating new content protection value')
71
+ this.log('** YOU WILL NEED TO UPDATE ANY CONTENT URLS YOU HAVE COPIED OUTSIDE OF MLBSERVER **')
72
+ this.protection.content_protect = this.getRandomString(this.getRandomInteger(32,64))
73
+ this.save_protection()
74
+ }
75
+ }
76
+
77
+ // Create storage directories if they don't already exist
78
+ this.createDirectory(DATA_DIRECTORY)
79
+ this.createFile(COOKIE_FILE)
80
+
81
+ // Set multiview path
82
+ if ( argv.multiview_path ) {
83
+ this.multiview_path = path.join(argv.multiview_path, path.basename(__dirname))
84
+ this.createDirectory(this.multiview_path)
85
+ this.multiview_path = path.join(this.multiview_path, MULTIVIEW_DIRECTORY_NAME)
86
+ } else {
87
+ this.multiview_path = path.join(__dirname, MULTIVIEW_DIRECTORY_NAME)
88
+ }
89
+ this.createDirectory(this.multiview_path)
90
+
91
+ // Set up http requests with the cookie jar
92
+ this.request = require('request-promise')
93
+ this.jar = this.request.jar(new FileCookieStore(COOKIE_FILE))
94
+ this.request = this.request.defaults({timeout:15000, agent:false, jar: this.request.jar()})
95
+
96
+ // Load session data and cache from files
97
+ this.data = this.readFileToJson(DATA_FILE) || {}
98
+ this.cache = this.readFileToJson(CACHE_FILE) || {}
99
+
100
+ // Define empty temporary cache (for inning data)
101
+ this.temp_cache = {}
102
+
103
+ // Default scan_mode and linkType values
104
+ if ( !this.data.scan_mode ) {
105
+ this.setScanMode('on')
106
+ }
107
+ if ( !this.data.linkType ) {
108
+ this.setLinkType('embed')
109
+ }
110
+ }
111
+
112
+ // Store the ports, used for generating URLs
113
+ setPorts(port, multiviewPort) {
114
+ this.data.port = port
115
+ this.data.multiviewPort = multiviewPort
116
+ this.save_session_data()
117
+ }
118
+
119
+ // Set the scan_mode
120
+ // "on" will return the sample stream for all live channels.m3u stream requests
121
+ setScanMode(x) {
122
+ this.log('scan_mode set to ' + x)
123
+ this.data.scan_mode = x
124
+ this.save_session_data()
125
+ }
126
+
127
+ // Set the linkType
128
+ // used for storing the desired page type across throughout site navigation
129
+ setLinkType(x) {
130
+ this.data.linkType = x
131
+ this.save_session_data()
132
+ }
133
+
134
+ // Set the multiview stream URL path
135
+ setMultiviewStreamURLPath(url_path) {
136
+ this.data.multiviewStreamURLPath = url_path
137
+ this.save_session_data()
138
+ }
139
+
140
+ // Some basic self-explanatory functions
141
+ createDirectory(directoryPath) {
142
+ if (fs.existsSync(directoryPath) && !fs.lstatSync(directoryPath).isDirectory() ){
143
+ fs.unlinkSync(directoryPath);
144
+ }
145
+ if (!fs.existsSync(directoryPath)){
146
+ fs.mkdirSync(directoryPath);
147
+ }
148
+ }
149
+
150
+ createFile(filePath) {
151
+ if (!fs.existsSync(filePath)) {
152
+ fs.closeSync(fs.openSync(filePath, 'w'))
153
+ }
154
+ }
155
+
156
+ isValidJson(str) {
157
+ try {
158
+ JSON.parse(str);
159
+ } catch (e) {
160
+ return false;
161
+ }
162
+ return true;
163
+ }
164
+
165
+ readFileToJson(filePath) {
166
+ if (fs.existsSync(filePath)) {
167
+ return JSON.parse(fs.readFileSync(filePath))
168
+ }
169
+ }
170
+
171
+ writeJsonToFile(jsonStr, filePath) {
172
+ if (this.isValidJson(jsonStr)) {
173
+ fs.writeFileSync(filePath, jsonStr)
174
+ }
175
+ }
176
+
177
+ checkValidItem(item, obj) {
178
+ if (obj.includes(item)) {
179
+ return true
180
+ }
181
+ return false
182
+ }
183
+
184
+ returnValidItem(item, obj) {
185
+ if (!obj.includes(item)) return obj[0]
186
+ else return item
187
+ }
188
+
189
+ sortObj(obj) {
190
+ return Object.keys(obj).sort().reduce(function (result, key) {
191
+ result[key] = obj[key];
192
+ return result;
193
+ }, {});
194
+ }
195
+
196
+ localTimeString() {
197
+ let curDate = new Date()
198
+ return curDate.toLocaleString()
199
+ }
200
+
201
+ getTodayUTCHours() {
202
+ return TODAY_UTC_HOURS
203
+ }
204
+
205
+ getUserAgent() {
206
+ return USER_AGENT
207
+ }
208
+
209
+ // the live date is today's date, or if before a specified hour (UTC time), then use yesterday's date
210
+ liveDate(hour = TODAY_UTC_HOURS) {
211
+ let curDate = new Date()
212
+ if ( curDate.getUTCHours() < hour ) {
213
+ curDate.setDate(curDate.getDate()-1)
214
+ }
215
+ return curDate.toISOString().substring(0,10)
216
+ }
217
+
218
+ yesterdayDate() {
219
+ let curDate = new Date(this.liveDate())
220
+ curDate.setDate(curDate.getDate()-1)
221
+ return curDate.toISOString().substring(0,10)
222
+ }
223
+
224
+ convertDateToXMLTV(x) {
225
+ let newDate = String(x.getFullYear()) + String(x.getMonth() + 1).padStart(2, '0') + String(x.getDate()).padStart(2, '0') + String(x.getHours()).padStart(2, '0') + String(x.getMinutes()).padStart(2, '0') + String(x.getSeconds()).padStart(2, '0') + " "
226
+ let offset = x.getTimezoneOffset()
227
+ if ( offset > 0 ) {
228
+ newDate += "-"
229
+ } else {
230
+ newDate += "+"
231
+ }
232
+ newDate += String((offset / 60)).padStart(2, '0') + "00"
233
+ return newDate
234
+ }
235
+
236
+ getCacheUpdatedDate(dateString) {
237
+ return this.cache.dates[dateString].updated
238
+ }
239
+
240
+ setHighlightsCacheExpiry(cache_name, expiryDate) {
241
+ if ( !this.cache.highlights ) {
242
+ this.cache.highlights={}
243
+ }
244
+ if ( !this.cache.highlights[cache_name] ) {
245
+ this.cache.highlights[cache_name] = {}
246
+ }
247
+ this.cache.highlights[cache_name].highlightsCacheExpiry = expiryDate
248
+ this.save_cache_data()
249
+ }
250
+
251
+ setDateCacheExpiry(cache_name, expiryDate) {
252
+ if ( !this.cache.dates ) {
253
+ this.cache.dates={}
254
+ }
255
+ if ( !this.cache.dates[cache_name] ) {
256
+ this.cache.dates[cache_name] = {}
257
+ }
258
+ this.cache.dates[cache_name].dateCacheExpiry = expiryDate
259
+ this.cache.dates[cache_name].updated = this.localTimeString()
260
+ this.save_cache_data()
261
+ }
262
+
263
+ setAiringsCacheExpiry(cache_name, expiryDate) {
264
+ if ( !this.cache.airings ) {
265
+ this.cache.airings={}
266
+ }
267
+ if ( !this.cache.airings[cache_name] ) {
268
+ this.cache.airings[cache_name] = {}
269
+ }
270
+ this.cache.airings[cache_name].airingsCacheExpiry = expiryDate
271
+ this.save_cache_data()
272
+ }
273
+
274
+ setGamedayCacheExpiry(cache_name, expiryDate) {
275
+ if ( !this.cache.gameday ) {
276
+ this.cache.gameday={}
277
+ }
278
+ if ( !this.cache.gameday[cache_name] ) {
279
+ this.cache.gameday[cache_name] = {}
280
+ }
281
+ this.cache.gameday[cache_name].gamedayCacheExpiry = expiryDate
282
+ this.save_cache_data()
283
+ }
284
+
285
+ createContentCache(contentId) {
286
+ if ( !this.cache.content ) {
287
+ this.cache.content = {}
288
+ }
289
+ if ( !this.cache.content[contentId] ) {
290
+ this.cache.content[contentId] = {}
291
+ }
292
+ }
293
+
294
+ createMediaCache(mediaId) {
295
+ if ( !this.cache.media ) {
296
+ this.cache.media = {}
297
+ }
298
+ if ( !this.cache.media[mediaId] ) {
299
+ this.cache.media[mediaId] = {}
300
+ }
301
+ }
302
+
303
+ cacheMediaId(contentId, mediaId) {
304
+ this.createContentCache(contentId)
305
+ this.cache.content[contentId].mediaId = mediaId
306
+ this.save_cache_data()
307
+ }
308
+
309
+ cacheGamePk(contentId, gamePk) {
310
+ this.createContentCache(contentId)
311
+ this.cache.content[contentId].gamePk = gamePk
312
+ this.save_cache_data()
313
+ }
314
+
315
+ cacheStreamURL(mediaId, streamURL) {
316
+ this.createMediaCache(mediaId)
317
+ this.cache.media[mediaId].streamURL = streamURL
318
+ // Expire it in 1 minute
319
+ let seconds_to_expire = 60
320
+ this.cache.media[mediaId].streamURLExpiry = new Date(new Date().getTime() + seconds_to_expire * 1000)
321
+ this.save_cache_data()
322
+ }
323
+
324
+ markBlackoutError(mediaId) {
325
+ this.createMediaCache(mediaId)
326
+ this.log('saving blackout error to prevent repeated access attempts')
327
+ this.cache.media[mediaId].blackout = true
328
+ // Expire it in 1 hour
329
+ let seconds_to_expire = 60*60
330
+ this.cache.media[mediaId].blackoutExpiry = new Date(new Date().getTime() + seconds_to_expire * 1000)
331
+ this.save_cache_data()
332
+ }
333
+
334
+ log(msg) {
335
+ console.log(this.localTimeString() + ' ' + msg)
336
+ }
337
+
338
+ debuglog(msg) {
339
+ if (this.debug) this.log(msg)
340
+ }
341
+
342
+ halt(msg) {
343
+ this.log(msg)
344
+ process.exit(1)
345
+ }
346
+
347
+ logout() {
348
+ try {
349
+ fs.unlinkSync(CREDENTIALS_FILE)
350
+ } catch(e){
351
+ this.debuglog('credentials cannot be cleared or do not exist yet : ' + e.message)
352
+ }
353
+ }
354
+
355
+ clear_session_data() {
356
+ try {
357
+ fs.unlinkSync(COOKIE_FILE)
358
+ fs.unlinkSync(DATA_FILE)
359
+ } catch(e){
360
+ this.debuglog('session cannot be cleared or does not exist yet : ' + e.message)
361
+ }
362
+ }
363
+
364
+ clear_cache() {
365
+ try {
366
+ fs.unlinkSync(CACHE_FILE)
367
+ } catch(e){
368
+ this.debuglog('cache cannot be cleared or does not exist yet : ' + e.message)
369
+ }
370
+ }
371
+
372
+ get_multiview_directory() {
373
+ return this.multiview_path
374
+ }
375
+
376
+ clear_multiview_files() {
377
+ try {
378
+ if ( this.multiview_path ) {
379
+ fs.rmdir(this.multiview_path, { recursive: true }, (err) => {
380
+ if (err) throw err;
381
+
382
+ this.createDirectory(this.multiview_path)
383
+ })
384
+ }
385
+ } catch(e){
386
+ this.debuglog('recursive clear multiview files error: ' + e.message)
387
+ try {
388
+ if ( this.multiview_path ) {
389
+ fs.readdir(this.multiview_path, (err, files) => {
390
+ if (err) throw err
391
+
392
+ for (const file of files) {
393
+ fs.unlink(path.join(this.multiview_path, file), err => {
394
+ if (err) throw err
395
+ })
396
+ }
397
+ })
398
+ }
399
+ } catch(e){
400
+ this.debuglog('clear multiview files error : ' + e.message)
401
+ }
402
+ }
403
+ }
404
+
405
+ save_credentials() {
406
+ this.writeJsonToFile(JSON.stringify(this.credentials), CREDENTIALS_FILE)
407
+ this.debuglog('credentials saved to file')
408
+ }
409
+
410
+ save_protection() {
411
+ this.writeJsonToFile(JSON.stringify(this.protection), PROTECTION_FILE)
412
+ this.debuglog('protection data saved to file')
413
+ }
414
+
415
+ save_session_data() {
416
+ this.createDirectory(DATA_DIRECTORY)
417
+ this.writeJsonToFile(JSON.stringify(this.data), DATA_FILE)
418
+ this.debuglog('session data saved to file')
419
+ }
420
+
421
+ save_cache_data() {
422
+ this.createDirectory(CACHE_DIRECTORY)
423
+ this.writeJsonToFile(JSON.stringify(this.cache), CACHE_FILE)
424
+ this.debuglog('cache data saved to file')
425
+ }
426
+
427
+ save_json_cache_file(cache_name, cache_data) {
428
+ this.createDirectory(CACHE_DIRECTORY)
429
+ this.writeJsonToFile(JSON.stringify(cache_data), path.join(CACHE_DIRECTORY, cache_name+'.json'))
430
+ this.debuglog('cache file saved')
431
+ }
432
+
433
+ // Generate a random integer in a range
434
+ getRandomInteger(min, max) {
435
+ return Math.floor(Math.random() * (max - min) ) + min;
436
+ }
437
+
438
+ // Generate a random string of specified length
439
+ getRandomString(length) {
440
+ var s = ''
441
+ do {
442
+ s += Math.random().toString(36).substr(2);
443
+ } while (s.length < length)
444
+ s = s.substr(0, length)
445
+
446
+ return s
447
+ }
448
+
449
+ // Generic http GET request function
450
+ httpGet(reqObj) {
451
+ reqObj.jar = this.jar
452
+ return new Promise((resolve, reject) => {
453
+ this.request.get(reqObj)
454
+ .then(function(body) {
455
+ resolve(body)
456
+ })
457
+ .catch(function(e) {
458
+ console.error('http get failed : ' + e.message)
459
+ console.error(reqObj)
460
+ process.exit(1)
461
+ })
462
+ })
463
+ }
464
+
465
+ // Generic http POST request function
466
+ httpPost(reqObj) {
467
+ reqObj.jar = this.jar
468
+ return new Promise((resolve, reject) => {
469
+ this.request.post(reqObj)
470
+ .then(function(body) {
471
+ resolve(body)
472
+ })
473
+ .catch(function(e) {
474
+ console.error('http post failed : ' + e.message)
475
+ console.error(reqObj)
476
+ process.exit(1)
477
+ })
478
+ })
479
+ }
480
+
481
+ // request to use when fetching videos
482
+ streamVideo(u, opts, tries, cb) {
483
+ opts.jar = this.jar
484
+ opts.headers = {
485
+ 'Authorization': this.data.bamAccessToken,
486
+ 'User-Agent': USER_AGENT
487
+ }
488
+ this.request(u, opts, cb)
489
+ .catch(function(e) {
490
+ console.error('stream video failed : ' + e.message)
491
+ console.error(u)
492
+ if ( tries == 1 ) process.exit(1)
493
+ })
494
+ }
495
+
496
+ // request to use when fetching audio playlist URL
497
+ async getAudioPlaylistURL(url) {
498
+ var playlistURL
499
+ let reqObj = {
500
+ url: url,
501
+ headers: {
502
+ 'Authorization': this.data.bamAccessToken,
503
+ 'User-Agent': USER_AGENT
504
+ }
505
+ }
506
+ var response = await this.httpGet(reqObj)
507
+ var body = response.toString().trim().split('\n')
508
+ for (var i=0; i<body.length; i++) {
509
+ if ( body[i][0] != '#' ) {
510
+ playlistURL = body[i]
511
+ break
512
+ }
513
+ }
514
+ if ( playlistURL ) {
515
+ return playlistURL
516
+ } else {
517
+ session.log('Failed to find audio playlist URL from ' + url)
518
+ return ''
519
+ }
520
+ }
521
+
522
+ async getXApiKey() {
523
+ this.debuglog('getXApiKey')
524
+ if ( !this.data.xApiKey || !this.data.xApiKey ) {
525
+ await this.getApiKeys()
526
+ if ( this.data.xApiKey ) return this.data.xApiKey
527
+ } else {
528
+ return this.data.xApiKey
529
+ }
530
+ }
531
+
532
+ async getClientApiKey() {
533
+ this.debuglog('getClientApiKey')
534
+ if ( !this.data.clientApiKey ) {
535
+ await this.getApiKeys()
536
+ if ( this.data.clientApiKey ) return this.data.clientApiKey
537
+ } else {
538
+ return this.data.clientApiKey
539
+ }
540
+ }
541
+
542
+ // API call
543
+ async getApiKeys() {
544
+ this.debuglog('getApiKeys')
545
+ let reqObj = {
546
+ url: 'https://www.mlb.com/tv/g632102/',
547
+ headers: {
548
+ 'User-agent': USER_AGENT,
549
+ 'Origin': 'https://www.mlb.com',
550
+ 'Accept-Encoding': 'gzip, deflate, br'
551
+ },
552
+ gzip: true
553
+ }
554
+ var response = await this.httpGet(reqObj)
555
+ // disabled because it's very big!
556
+ //this.debuglog('getApiKeys response : ' + response)
557
+ var parsed = response.match('"x-api-key","value":"([^"]+)"')
558
+ if ( parsed[1] ) {
559
+ this.data.xApiKey = parsed[1]
560
+ this.save_session_data()
561
+ }
562
+ parsed = response.match('"clientApiKey":"([^"]+)"')
563
+ if ( parsed[1] ) {
564
+ this.data.clientApiKey = parsed[1]
565
+ this.save_session_data()
566
+ }
567
+ }
568
+
569
+ // API call
570
+ async getOktaClientId() {
571
+ this.debuglog('getOktaClientId')
572
+ if ( !this.data.oktaClientId ) {
573
+ this.debuglog('need to get oktaClientId')
574
+ let reqObj = {
575
+ url: 'https://www.mlbstatic.com/mlb.com/vendor/mlb-okta/mlb-okta.js',
576
+ headers: {
577
+ 'User-agent': USER_AGENT,
578
+ 'Origin': 'https://www.mlb.com',
579
+ 'Accept-Encoding': 'gzip, deflate, br'
580
+ },
581
+ gzip: true
582
+ }
583
+ var response = await this.httpGet(reqObj)
584
+ // disabled because it's very big!
585
+ //this.debuglog('getOktaClientId response : ' + response)
586
+ var parsed = response.match('production:{clientId:"([^"]+)",')
587
+ if ( parsed[1] ) {
588
+ this.data.oktaClientId = parsed[1]
589
+ this.save_session_data()
590
+ return this.data.oktaClientId
591
+ }
592
+ } else {
593
+ return this.data.oktaClientId
594
+ }
595
+ }
596
+
597
+ // API call
598
+ async getMediaIdFromContentId(contentId) {
599
+ this.debuglog('getMediaIdFromContentId from ' + contentId)
600
+ if ( this.cache.content && this.cache.content[contentId] && this.cache.content[contentId].mediaId ) {
601
+ this.debuglog('using cached mediaId')
602
+ return this.cache.content[contentId].mediaId
603
+ } else {
604
+ let cache_data = await this.getAiringsData(contentId)
605
+ let mediaId = cache_data.data.Airings[0].mediaId
606
+ this.cacheMediaId(contentId, mediaId)
607
+ return mediaId
608
+ }
609
+ }
610
+
611
+ // API call
612
+ async getGamePkFromContentId(contentId) {
613
+ this.debuglog('getGamePkFromContentId from ' + contentId)
614
+ if ( this.cache.content && this.cache.content[contentId] && this.cache.content[contentId].gamePk ) {
615
+ this.debuglog('using cached gamePk')
616
+ return this.cache.content[contentId].gamePk
617
+ } else {
618
+ let cache_data = await this.getAiringsData(contentId)
619
+ let gamePk = cache_data.data.Airings[0].partnerProgramId
620
+ this.cacheGamePk(contentId, gamePk)
621
+ return gamePk
622
+ }
623
+ }
624
+
625
+ // API call
626
+ async getStreamURL(mediaId) {
627
+ this.debuglog('getStreamURL from ' + mediaId)
628
+ if ( this.cache.media && this.cache.media[mediaId] && this.cache.media[mediaId].streamURL && this.cache.media[mediaId].streamURLExpiry && (Date.parse(this.cache.media[mediaId].streamURLExpiry) > new Date()) ) {
629
+ this.debuglog('using cached streamURL')
630
+ return this.cache.media[mediaId].streamURL
631
+ } else if ( this.cache.media && this.cache.media[mediaId] && this.cache.media[mediaId].blackout && this.cache.media[mediaId].blackoutExpiry && (Date.parse(this.cache.media[mediaId].blackoutExpiry) > new Date()) ) {
632
+ this.log('mediaId recently blacked out, skipping')
633
+ } else {
634
+ let playbackURL = 'https://edge.svcs.mlb.com/media/' + mediaId + '/scenarios/browser~csai'
635
+ let reqObj = {
636
+ url: playbackURL,
637
+ simple: false,
638
+ headers: {
639
+ 'Authorization': await this.getBamAccessToken() || this.halt('missing bamAccessToken'),
640
+ 'User-agent': USER_AGENT,
641
+ 'Accept': 'application/vnd.media-service+json; version=1',
642
+ 'x-bamsdk-version': BAM_SDK_VERSION,
643
+ 'x-bamsdk-platform': PLATFORM,
644
+ 'Origin': 'https://www.mlb.com',
645
+ 'Accept-Encoding': 'gzip, deflate, br',
646
+ 'Content-type': 'application/json'
647
+ },
648
+ gzip: true
649
+ }
650
+ var response = await this.httpGet(reqObj)
651
+ if ( this.isValidJson(response) ) {
652
+ this.debuglog('getStreamURL response : ' + response)
653
+ let obj = JSON.parse(response)
654
+ if ( obj.errors && (obj.errors[0] == 'blackout') ) {
655
+ this.log('blackout error')
656
+ this.markBlackoutError(mediaId)
657
+ } else {
658
+ this.debuglog('getStreamURL : ' + obj.stream.complete)
659
+ this.cacheStreamURL(mediaId, obj.stream.complete)
660
+ return obj.stream.complete
661
+ }
662
+ }
663
+ }
664
+ }
665
+
666
+ // API call
667
+ async getBamAccessToken() {
668
+ this.debuglog('getBamAccessToken')
669
+ if ( !this.data.bamAccessToken || !this.data.bamAccessTokenExpiry || (Date.parse(this.data.bamAccessTokenExpiry) < new Date()) ) {
670
+ this.debuglog('need to get new bamAccessToken')
671
+ let reqObj = {
672
+ url: BAM_TOKEN_URL,
673
+ headers: {
674
+ 'Authorization': 'Bearer ' + await this.getClientApiKey() || this.halt('missing clientApiKey'),
675
+ 'User-agent': USER_AGENT,
676
+ 'Accept': 'application/vnd.media-service+json; version=1',
677
+ 'x-bamsdk-version': BAM_SDK_VERSION,
678
+ 'x-bamsdk-platform': PLATFORM,
679
+ 'Origin': 'https://www.mlb.com',
680
+ 'Accept-Encoding': 'gzip, deflate, br',
681
+ 'Content-type': 'application/json'
682
+ },
683
+ form: {
684
+ 'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
685
+ 'platform': 'browser',
686
+ 'subject_token': await this.getEntitlementToken() || this.halt('missing EntitlementToken'),
687
+ 'subject_token_type': 'urn:bamtech:params:oauth:token-type:account'
688
+ },
689
+ gzip: true
690
+ }
691
+ var response = await this.httpPost(reqObj)
692
+ if ( this.isValidJson(response) ) {
693
+ let obj = JSON.parse(response)
694
+ this.debuglog('getBamAccessToken : ' + obj.access_token)
695
+ this.debuglog('getBamAccessToken expires in : ' + obj.expires_in)
696
+ this.data.bamAccessToken = obj.access_token
697
+ this.data.bamAccessTokenExpiry = new Date(new Date().getTime() + obj.expires_in * 1000)
698
+ this.save_session_data()
699
+ return this.data.bamAccessToken
700
+ }
701
+ } else {
702
+ return this.data.bamAccessToken
703
+ }
704
+ }
705
+
706
+ // API call
707
+ async getEntitlementToken() {
708
+ this.debuglog('getEntitlementToken')
709
+ let reqObj = {
710
+ url: 'https://media-entitlement.mlb.com/api/v3/jwt',
711
+ headers: {
712
+ 'Authorization': 'Bearer ' + await this.getOktaAccessToken() || this.halt('missing OktaAccessToken'),
713
+ 'Origin': 'https://www.mlb.com',
714
+ 'x-api-key': await this.getXApiKey() || this.halt('missing xApiKey'),
715
+ 'Accept-Encoding': 'gzip, deflate, br'
716
+ },
717
+ qs: {
718
+ 'os': PLATFORM,
719
+ 'did': await this.getDeviceId() || this.halt('missing deviceId'),
720
+ 'appname': 'mlbtv_web'
721
+ },
722
+ gzip: true
723
+ }
724
+ var response = await this.httpGet(reqObj)
725
+ this.debuglog('getEntitlementToken response : ' + response)
726
+ this.debuglog('getEntitlementToken : ' + response)
727
+ return response
728
+ }
729
+
730
+ async getDeviceId() {
731
+ this.debuglog('getDeviceId')
732
+ let reqObj = {
733
+ url: 'https://us.edge.bamgrid.com/session',
734
+ headers: {
735
+ 'Authorization': await this.getDeviceAccessToken() || this.halt('missing device_access_token'),
736
+ 'User-agent': USER_AGENT,
737
+ 'Origin': 'https://www.mlb.com',
738
+ 'Accept': 'application/vnd.session-service+json; version=1',
739
+ 'Accept-Encoding': 'gzip, deflate, br',
740
+ 'Accept-Language': 'en-US,en;q=0.5',
741
+ 'x-bamsdk-version': BAM_SDK_VERSION,
742
+ 'x-bamsdk-platform': PLATFORM,
743
+ 'Content-type': 'application/json',
744
+ 'TE': 'Trailers'
745
+ },
746
+ gzip: true
747
+ }
748
+ var response = await this.httpGet(reqObj)
749
+ if ( this.isValidJson(response) ) {
750
+ this.debuglog('getDeviceId response : ' + response)
751
+ let obj = JSON.parse(response)
752
+ this.debuglog('getDeviceId : ' + obj.device.id)
753
+ return obj.device.id
754
+ }
755
+ }
756
+
757
+ // API call
758
+ async getDeviceAccessToken() {
759
+ this.debuglog('getDeviceAccessToken')
760
+ let reqObj = {
761
+ url: BAM_TOKEN_URL,
762
+ headers: {
763
+ 'Authorization': 'Bearer ' + await this.getClientApiKey() || this.halt('missing clientApiKey'),
764
+ 'Origin': 'https://www.mlb.com'
765
+ },
766
+ form: {
767
+ 'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
768
+ 'latitude': '0',
769
+ 'longitude': '0',
770
+ 'platform': 'browser',
771
+ 'subject_token': await this.getDevicesAssertion() || this.halt('missing devicesAssertion'),
772
+ 'subject_token_type': 'urn:bamtech:params:oauth:token-type:device'
773
+ }
774
+ }
775
+ var response = await this.httpPost(reqObj)
776
+ if ( this.isValidJson(response) ) {
777
+ this.debuglog('getDeviceAccessToken response : ' + response)
778
+ let obj = JSON.parse(response)
779
+ this.debuglog('getDeviceAccessToken : ' + obj.access_token)
780
+ return obj.access_token
781
+ }
782
+ }
783
+
784
+ // API call
785
+ async getDevicesAssertion() {
786
+ this.debuglog('getDevicesAssertion')
787
+ let reqObj = {
788
+ url: 'https://us.edge.bamgrid.com/devices',
789
+ headers: {
790
+ 'Authorization': 'Bearer ' + await this.getClientApiKey() || this.halt('missing clientApiKey'),
791
+ 'Origin': 'https://www.mlb.com'
792
+ },
793
+ json: {
794
+ 'applicationRuntime': 'firefox',
795
+ 'attributes': {},
796
+ 'deviceFamily': 'browser',
797
+ 'deviceProfile': 'macosx'
798
+ }
799
+ }
800
+ var response = await this.httpPost(reqObj)
801
+ if ( response.assertion ) {
802
+ this.debuglog('getDevicesAssertion response : ' + response)
803
+ this.debuglog('getDevicesAssertion : ' + response.assertion)
804
+ return response.assertion
805
+ }
806
+ }
807
+
808
+ async getOktaAccessToken() {
809
+ this.debuglog('getOktaAccessToken')
810
+ let oktaAccessToken = await this.retrieveOktaAccessToken()
811
+ if ( oktaAccessToken ) return oktaAccessToken
812
+ else {
813
+ oktaAccessToken = await this.retrieveOktaAccessToken()
814
+ if ( oktaAccessToken ) return oktaAccessToken
815
+ }
816
+ }
817
+
818
+ // API call
819
+ async retrieveOktaAccessToken() {
820
+ this.debuglog('retrieveOktaAccessToken')
821
+ if ( !this.data.oktaAccessToken || !this.data.oktaAccessTokenExpiry || (Date.parse(this.data.oktaAccessTokenExpiry) < new Date()) ) {
822
+ this.debuglog('need to get new oktaAccessToken')
823
+ let state = this.getRandomString(64)
824
+ let nonce = this.getRandomString(64)
825
+ let reqObj = {
826
+ url: 'https://ids.mlb.com/oauth2/aus1m088yK07noBfh356/v1/authorize',
827
+ headers: {
828
+ 'user-agent': USER_AGENT,
829
+ 'accept-encoding': 'identity'
830
+ },
831
+ qs: {
832
+ 'client_id': await this.getOktaClientId() || this.halt('missing oktaClientId'),
833
+ 'redirect_uri': 'https://www.mlb.com/login',
834
+ 'response_type': 'id_token token',
835
+ 'response_mode': 'okta_post_message',
836
+ 'state': state,
837
+ 'nonce': nonce,
838
+ 'prompt': 'none',
839
+ 'sessionToken': await this.getAuthnSessionToken() || this.halt('missing authnSessionToken'),
840
+ 'scope': 'openid email'
841
+ }
842
+ }
843
+ var response = await this.httpGet(reqObj)
844
+ var str = response.toString()
845
+ this.debuglog('retrieveOktaAccessToken response : ' + str)
846
+ if ( str.match ) {
847
+ var errorParsed = str.match("data.error = 'login_required'")
848
+ if ( errorParsed && errorParsed[1] ) {
849
+ // Need to log in again
850
+ this.log('Logging in...')
851
+ this.data.authnSessionToken = null
852
+ this.save_session_data()
853
+ return false
854
+ } else {
855
+ var parsed_token = str.match("data.access_token = '([^']+)'")
856
+ var parsed_expiry = str.match("data.expires_in = '([^']+)'")
857
+ if ( parsed_token && parsed_token[1] && parsed_expiry && parsed_expiry[1] ) {
858
+ let oktaAccessToken = parsed_token[1].split('\\x2D').join('-')
859
+ let oktaAccessTokenExpiry = parsed_expiry[1]
860
+ this.debuglog('retrieveOktaAccessToken : ' + oktaAccessToken)
861
+ this.debuglog('retrieveOktaAccessToken expires in : ' + oktaAccessTokenExpiry)
862
+ this.data.oktaAccessToken = oktaAccessToken
863
+ this.data.oktaAccessTokenExpiry = new Date(new Date().getTime() + oktaAccessTokenExpiry * 1000)
864
+ this.save_session_data()
865
+ return this.data.oktaAccessToken
866
+ }
867
+ }
868
+ }
869
+ } else {
870
+ return this.data.oktaAccessToken
871
+ }
872
+ }
873
+
874
+ // API call
875
+ async getAuthnSessionToken() {
876
+ this.debuglog('getAuthnSessionToken')
877
+ if ( !this.data.authnSessionToken ) {
878
+ this.debuglog('need to get authnSessionToken')
879
+ let reqObj = {
880
+ url: 'https://ids.mlb.com/api/v1/authn',
881
+ headers: {
882
+ 'user-agent': USER_AGENT,
883
+ 'accept-encoding': 'identity',
884
+ 'content-type': 'application/json'
885
+ },
886
+ json: {
887
+ 'username': this.credentials.account_username || this.halt('missing account username'),
888
+ 'password': this.credentials.account_password || this.halt('missing account password'),
889
+ 'options': {
890
+ 'multiOptionalFactorEnroll': false,
891
+ 'warnBeforePasswordExpired': true
892
+ }
893
+ }
894
+ }
895
+ var response = await this.httpPost(reqObj)
896
+ if ( response.sessionToken ) {
897
+ this.debuglog('getAuthnSessionToken response : ' + JSON.stringify(response))
898
+ this.debuglog('getAuthnSessionToken : ' + response.sessionToken)
899
+ this.data.authnSessionToken = response.sessionToken
900
+ this.save_session_data()
901
+ return this.data.authnSessionToken
902
+ }
903
+ } else {
904
+ return this.data.authnSessionToken
905
+ }
906
+ }
907
+
908
+ // get mediaId for a live channel request
909
+ async getMediaId(team, mediaType, mediaDate, gameNumber) {
910
+ try {
911
+ this.debuglog('getMediaId')
912
+
913
+ var mediaFeedType = 'mediaFeedType'
914
+ if ( mediaType == 'Video' ) {
915
+ mediaType = 'MLBTV'
916
+ } else if ( mediaType == 'Audio' ) {
917
+ mediaFeedType = 'type'
918
+ }
919
+
920
+ let gameDate = this.liveDate()
921
+ if ( mediaDate == 'yesterday' ) {
922
+ gameDate = this.yesterdayDate()
923
+ } else if ( (mediaDate) && (mediaDate != 'today') ) {
924
+ gameDate = mediaDate
925
+ }
926
+
927
+ let mediaId = false
928
+ let contentId = false
929
+
930
+ // First check if cached day data is available and non-expired
931
+ // if not, just get data for this team
932
+ let cache_data
933
+ let cache_name = gameDate
934
+ let cache_file = path.join(CACHE_DIRECTORY, gameDate+'.json')
935
+ let currentDate = new Date()
936
+ if ( fs.existsSync(cache_file) && this.cache && this.cache.dates && this.cache.dates[cache_name] && this.cache.dates[cache_name].dateCacheExpiry && (currentDate <= new Date(this.cache.dates[cache_name].dateCacheExpiry)) ) {
937
+ cache_data = await this.getDayData(gameDate)
938
+ } else {
939
+ cache_data = await this.getDayData(gameDate, team)
940
+ }
941
+
942
+ if ( (cache_data.totalGamesInProgress > 0) || (mediaDate) ) {
943
+ let nationalCount = 0
944
+ for (var j = 0; j < cache_data.dates[0].games.length; j++) {
945
+ if ( mediaId ) break
946
+ if ( (typeof cache_data.dates[0].games[j] !== 'undefined') && cache_data.dates[0].games[j].content && cache_data.dates[0].games[j].content.media && cache_data.dates[0].games[j].content.media.epg ) {
947
+ for (var k = 0; k < cache_data.dates[0].games[j].content.media.epg.length; k++) {
948
+ if ( mediaId ) break
949
+ if ( cache_data.dates[0].games[j].content.media.epg[k].title == mediaType ) {
950
+ for (var x = 0; x < cache_data.dates[0].games[j].content.media.epg[k].items.length; x++) {
951
+ if ( (cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaState == 'MEDIA_ON') || ((mediaDate) && ((cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaState == 'MEDIA_ARCHIVE') || (cache_data.dates[0].games[j].status.abstractGameState == 'Final'))) ) {
952
+ 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) ) {
953
+ if ( (team.toUpperCase().indexOf('NATIONAL.') == 0) && (cache_data.dates[0].games[j].content.media.epg[k].items[x][mediaFeedType] == 'NATIONAL') ) {
954
+ nationalCount += 1
955
+ let nationalArray = team.split('.')
956
+ if ( (nationalArray.length == 2) && (nationalArray[1] == nationalCount) ) {
957
+ this.debuglog('matched national event')
958
+ mediaId = cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaId
959
+ contentId = cache_data.dates[0].games[j].content.media.epg[k].items[x].contentId
960
+ break
961
+ }
962
+ } else {
963
+ let teamType = cache_data.dates[0].games[j].content.media.epg[k].items[x][mediaFeedType].toLowerCase()
964
+ if ( (teamType != 'national') && (team.toUpperCase() == cache_data.dates[0].games[j].teams[teamType].team.abbreviation) ) {
965
+ if ( gameNumber && (gameNumber > 1) ) {
966
+ this.debuglog('matched team for game number 1')
967
+ gameNumber--
968
+ } else {
969
+ this.debuglog('matched team for event')
970
+ mediaId = cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaId
971
+ contentId = cache_data.dates[0].games[j].content.media.epg[k].items[x].contentId
972
+ break
973
+ }
974
+ }
975
+ }
976
+ }
977
+ }
978
+ }
979
+ }
980
+ }
981
+ }
982
+ }
983
+
984
+ if (mediaId) {
985
+ return { mediaId, contentId }
986
+ }
987
+ }
988
+ this.log('could not find mediaId')
989
+ } catch(e) {
990
+ this.log('getMediaId error : ' + e.message)
991
+ }
992
+ }
993
+
994
+ // get highlights for a game
995
+ async getHighlightsData(gamePk, gameDate) {
996
+ try {
997
+ this.debuglog('getHighlightsData for ' + gamePk)
998
+
999
+ let cache_data
1000
+ let cache_name = 'h' + gamePk
1001
+ let cache_file = path.join(CACHE_DIRECTORY, cache_name+'.json')
1002
+ let currentDate = new Date()
1003
+ if ( !fs.existsSync(cache_file) || !this.cache || !this.cache.highlights || !this.cache.highlights[cache_name] || !this.cache.highlights[cache_name].highlightsCacheExpiry || (currentDate > new Date(this.cache.highlights[cache_name].highlightsCacheExpiry)) ) {
1004
+ let reqObj = {
1005
+ url: 'https://statsapi.mlb.com/api/v1/game/' + gamePk + '/content',
1006
+ headers: {
1007
+ 'User-agent': USER_AGENT,
1008
+ 'Origin': 'https://www.mlb.com',
1009
+ 'Accept-Encoding': 'gzip, deflate, br',
1010
+ 'Content-type': 'application/json'
1011
+ },
1012
+ gzip: true
1013
+ }
1014
+ var response = await this.httpGet(reqObj)
1015
+ if ( this.isValidJson(response) ) {
1016
+ //this.debuglog(response)
1017
+ cache_data = JSON.parse(response)
1018
+ this.save_json_cache_file(cache_name, cache_data)
1019
+
1020
+ // Default cache period is 1 hour from now
1021
+ let oneHourFromNow = new Date()
1022
+ oneHourFromNow.setHours(oneHourFromNow.getHours()+1)
1023
+ let cacheExpiry = oneHourFromNow
1024
+
1025
+ let today = this.liveDate()
1026
+ let yesterday = this.yesterdayDate()
1027
+ if ( gameDate == today ) {
1028
+ if ( (cache_data.media) && (cache_data.media.epg) ) {
1029
+ for (var i = 0; i < cache_data.media.epg.length; i++) {
1030
+ if ( cache_data.media.epg[i].items && cache_data.media.epg[i].items[0] && cache_data.media.epg[i].items[0].mediaState && (cache_data.media.epg[i].items[0].mediaState == 'MEDIA_ON') ) {
1031
+ this.debuglog('setting cache expiry to 5 minute due to in progress games')
1032
+ currentDate.setMinutes(currentDate.getMinutes()+5)
1033
+ cacheExpiry = currentDate
1034
+ break
1035
+ }
1036
+ }
1037
+ }
1038
+ } else if ( gameDate < today ) {
1039
+ this.debuglog('1+ days old, setting cache expiry to forever')
1040
+ cacheExpiry = new Date(8640000000000000)
1041
+ }
1042
+
1043
+ // finally save the setting
1044
+ this.setHighlightsCacheExpiry(cache_name, cacheExpiry)
1045
+ } else {
1046
+ this.log('error : invalid json from url ' + getObj.url)
1047
+ }
1048
+ } else {
1049
+ this.debuglog('using cached highlight data')
1050
+ cache_data = this.readFileToJson(cache_file)
1051
+ }
1052
+ if (cache_data && cache_data.highlights && cache_data.highlights.highlights && cache_data.highlights.highlights.items) {
1053
+ var array = cache_data.highlights.highlights.items
1054
+ return array.sort(this.GetSortOrder('date'))
1055
+ }
1056
+ } catch(e) {
1057
+ this.log('getHighlightsData error : ' + e.message)
1058
+ }
1059
+ }
1060
+
1061
+ GetSortOrder(prop) {
1062
+ return function(a, b) {
1063
+ if (a[prop] > b[prop]) {
1064
+ return 1
1065
+ } else if (a[prop] < b[prop]) {
1066
+ return -1
1067
+ }
1068
+ return 0
1069
+ }
1070
+ }
1071
+
1072
+ // get data for a day, either from cache or an API call
1073
+ async getDayData(dateString, team = false) {
1074
+ try {
1075
+ let cache_data
1076
+ let cache_name = dateString
1077
+ let url = 'https://bdfed.stitch.mlbinfra.com/bdfed/transform-mlb-scoreboard?stitch_env=prod&sortTemplate=2&sportId=1&startDate=' + dateString + '&endDate=' + dateString + '&gameType=E&&gameType=S&&gameType=R&&gameType=F&&gameType=D&&gameType=L&&gameType=W&&gameType=A&language=en&leagueId=104&&leagueId=103&contextTeamId='
1078
+ if ( team ) {
1079
+ cache_name = team.toUpperCase() + dateString
1080
+ url = 'http://statsapi.mlb.com/api/v1/schedule?sportId=1&teamId=' + TEAM_IDS[team.toUpperCase()] + '&startDate=' + dateString + '&endDate=' + dateString + '&gameType=&gamePk=&hydrate=team,game(content(media(epg)))'
1081
+ }
1082
+ this.debuglog('getDayData for ' + cache_name)
1083
+ let cache_file = path.join(CACHE_DIRECTORY, cache_name+'.json')
1084
+ let currentDate = new Date()
1085
+ if ( !fs.existsSync(cache_file) || !this.cache || !this.cache.dates || !this.cache.dates[cache_name] || !this.cache.dates[cache_name].dateCacheExpiry || (currentDate > new Date(this.cache.dates[cache_name].dateCacheExpiry)) ) {
1086
+ let reqObj = {
1087
+ url: url,
1088
+ headers: {
1089
+ 'User-agent': USER_AGENT,
1090
+ 'Origin': 'https://www.mlb.com',
1091
+ 'Accept-Encoding': 'gzip, deflate, br',
1092
+ 'Content-type': 'application/json'
1093
+ },
1094
+ gzip: true
1095
+ }
1096
+ var response = await this.httpGet(reqObj)
1097
+ if ( this.isValidJson(response) ) {
1098
+ //this.debuglog(response)
1099
+ cache_data = JSON.parse(response)
1100
+ this.save_json_cache_file(cache_name, cache_data)
1101
+
1102
+ // Default cache period is 1 hour from now
1103
+ let oneHourFromNow = new Date()
1104
+ oneHourFromNow.setHours(oneHourFromNow.getHours()+1)
1105
+ let cacheExpiry = oneHourFromNow
1106
+
1107
+ let today = this.liveDate()
1108
+ let yesterday = this.yesterdayDate()
1109
+ if ( dateString == today ) {
1110
+ let finals = false
1111
+ for (var i = 0; i < cache_data.dates[0].games.length; i++) {
1112
+ if ( ((cache_data.dates[0].games[i].status.abstractGameState == 'Live') && (cache_data.dates[0].games[i].status.detailedState.indexOf('Suspended') != 0)) || ((cache_data.dates[0].games[i].status.startTimeTBD == true) && (cache_data.dates[0].games[i].status.abstractGameState != 'Final') && (i > 0) && (cache_data.dates[0].games[i-1].status.abstractGameState == 'Final')) ) {
1113
+ this.debuglog('setting cache expiry to 1 minute due to in progress games or upcoming TBD game')
1114
+ currentDate.setMinutes(currentDate.getMinutes()+1)
1115
+ cacheExpiry = currentDate
1116
+ break
1117
+ } else if ( cache_data.dates[0].games[i].status.abstractGameState == 'Final' ) {
1118
+ finals = true
1119
+ } else if ( (finals == false) && (cache_data.dates[0].games[i].status.startTimeTBD == false) ) {
1120
+ let nextGameDate = new Date(cache_data.dates[0].games[i].gameDate)
1121
+ nextGameDate.setHours(nextGameDate.getHours()-1)
1122
+ this.debuglog('setting cache expiry to 1 hour before next live game')
1123
+ cacheExpiry = nextGameDate
1124
+ break
1125
+ }
1126
+ }
1127
+ } else if ( dateString > today ) {
1128
+ this.debuglog('1+ days in the future, setting cache expiry to tomorrow')
1129
+ let tomorrowDate = new Date(today)
1130
+ tomorrowDate.setDate(tomorrowDate.getDate()+1)
1131
+ let utcHours = 10
1132
+ tomorrowDate.setHours(tomorrowDate.getHours()+utcHours)
1133
+ cacheExpiry = tomorrowDate
1134
+ } else if ( dateString < yesterday ) {
1135
+ this.debuglog('2+ days old, setting cache expiry to forever')
1136
+ cacheExpiry = new Date(8640000000000000)
1137
+ }
1138
+
1139
+ // finally save the setting
1140
+ this.setDateCacheExpiry(cache_name, cacheExpiry)
1141
+ } else {
1142
+ this.log('error : invalid json from url ' + getObj.url)
1143
+ }
1144
+ } else {
1145
+ this.debuglog('using cached date data')
1146
+ cache_data = this.readFileToJson(cache_file)
1147
+ }
1148
+ if (cache_data) {
1149
+ return cache_data
1150
+ }
1151
+ } catch(e) {
1152
+ this.log('getDayData error : ' + e.message)
1153
+ }
1154
+ }
1155
+
1156
+ // get data for 3 weeks, either from cache or an API call
1157
+ async getWeeksData() {
1158
+ try {
1159
+ this.debuglog('getWeeksData')
1160
+
1161
+ // use 5 AM UTC time as the threshold to advance 1 day
1162
+ let utcHours = 5
1163
+
1164
+ let cache_data
1165
+ let cache_name = 'week'
1166
+ let cache_file = path.join(CACHE_DIRECTORY, cache_name + '.json')
1167
+ let currentDate = new Date()
1168
+ if ( !fs.existsSync(cache_file) || !this.cache || !this.cache.weekCacheExpiry || (currentDate > new Date(this.cache.weekCacheExpiry)) ) {
1169
+ let startDate = this.liveDate(utcHours)
1170
+ let endDate = new Date(startDate)
1171
+ endDate.setDate(endDate.getDate()+20)
1172
+ endDate = endDate.toISOString().substring(0,10)
1173
+ let reqObj = {
1174
+ url: 'https://bdfed.stitch.mlbinfra.com/bdfed/transform-mlb-scoreboard?stitch_env=prod&sortTemplate=2&sportId=1&startDate=' + startDate + '&endDate=' + endDate + '&gameType=E&&gameType=S&&gameType=R&&gameType=F&&gameType=D&&gameType=L&&gameType=W&&gameType=A&language=en&leagueId=104&&leagueId=103&contextTeamId=',
1175
+ headers: {
1176
+ 'User-agent': USER_AGENT,
1177
+ 'Origin': 'https://www.mlb.com',
1178
+ 'Accept-Encoding': 'gzip, deflate, br',
1179
+ 'Content-type': 'application/json'
1180
+ },
1181
+ gzip: true
1182
+ }
1183
+ var response = await this.httpGet(reqObj)
1184
+ if ( this.isValidJson(response) ) {
1185
+ //this.debuglog(response)
1186
+ cache_data = JSON.parse(response)
1187
+ this.save_json_cache_file(cache_name, cache_data)
1188
+ this.debuglog('setting channels cache expiry to next day')
1189
+ let nextDate = new Date(startDate)
1190
+ nextDate.setDate(nextDate.getDate()+1)
1191
+ nextDate.setHours(nextDate.getHours()+utcHours)
1192
+ this.cache.weekCacheExpiry = nextDate
1193
+ this.save_cache_data()
1194
+ } else {
1195
+ this.log('error : invalid json from url ' + getObj.url)
1196
+ }
1197
+ } else {
1198
+ this.debuglog('using cached channel data')
1199
+ cache_data = this.readFileToJson(cache_file)
1200
+ }
1201
+ if (cache_data) {
1202
+ return cache_data
1203
+ }
1204
+ } catch(e) {
1205
+ this.log('getWeeksData error : ' + e.message)
1206
+ }
1207
+ }
1208
+
1209
+ // get live channels in M3U format
1210
+ async getChannels(mediaType, includeTeams, excludeTeams, server, resolution, pipe, startingChannelNumber) {
1211
+ try {
1212
+ this.debuglog('getChannels')
1213
+
1214
+ var mediaFeedType = 'mediaFeedType'
1215
+ if ( mediaType == 'Video' ) {
1216
+ mediaType = 'MLBTV'
1217
+ } else if ( mediaType == 'Audio' ) {
1218
+ mediaFeedType = 'type'
1219
+ }
1220
+
1221
+ let cache_data = await this.getWeeksData()
1222
+ if (cache_data) {
1223
+ var channels = {}
1224
+ var nationalChannels = {}
1225
+ let prevDateIndex = {MLBTV:-1,Audio:-1}
1226
+ for (var i = 0; i < cache_data.dates.length; i++) {
1227
+ let dateIndex = {MLBTV:i,Audio:i}
1228
+ let nationalCounter = {MLBTV:0,Audio:0}
1229
+ for (var j = 0; j < cache_data.dates[i].games.length; j++) {
1230
+ if ( cache_data.dates[i].games[j].content && cache_data.dates[i].games[j].content.media && cache_data.dates[i].games[j].content.media.epg ) {
1231
+ for (var k = 0; k < cache_data.dates[i].games[j].content.media.epg.length; k++) {
1232
+ let mediaTitle = cache_data.dates[i].games[j].content.media.epg[k].title
1233
+ if ( mediaType == mediaTitle ) {
1234
+ for (var x = 0; x < cache_data.dates[i].games[j].content.media.epg[k].items.length; x++) {
1235
+ if ( ((((typeof cache_data.dates[i].games[j].content.media.epg[k].items[x].mediaFeedType) == 'undefined') || (cache_data.dates[i].games[j].content.media.epg[k].items[x].mediaFeedType.indexOf('IN_MARKET_') == -1)) && (((typeof cache_data.dates[i].games[j].content.media.epg[k].items[x].language) == 'undefined') || (cache_data.dates[i].games[j].content.media.epg[k].items[x].language != 'es'))) ) {
1236
+ let teamType = cache_data.dates[i].games[j].content.media.epg[k].items[x][mediaFeedType]
1237
+ let team
1238
+ let opponent_team
1239
+ if ( teamType == 'NATIONAL' ) {
1240
+ team = cache_data.dates[i].games[j].teams['home'].team.abbreviation
1241
+ opponent_team = cache_data.dates[i].games[j].teams['away'].team.abbreviation
1242
+
1243
+ if ( dateIndex[mediaTitle] > prevDateIndex[mediaTitle] ) {
1244
+ prevDateIndex[mediaTitle] = dateIndex[mediaTitle]
1245
+ nationalCounter[mediaTitle] = 1
1246
+ } else {
1247
+ nationalCounter[mediaTitle] += 1
1248
+ }
1249
+ } else {
1250
+ teamType = teamType.toLowerCase()
1251
+ let opponent_teamType = 'away'
1252
+ if ( teamType == 'away' ) {
1253
+ opponent_teamType = 'home'
1254
+ }
1255
+ team = cache_data.dates[i].games[j].teams[teamType].team.abbreviation
1256
+ opponent_team = cache_data.dates[i].games[j].teams[opponent_teamType].team.abbreviation
1257
+ }
1258
+ if ( (excludeTeams.length > 0) && (excludeTeams.includes(team) || excludeTeams.includes(opponent_team) || excludeTeams.includes(teamType)) ) {
1259
+ continue
1260
+ } else if ( (includeTeams.length == 0) || includeTeams.includes(team) || includeTeams.includes(teamType) ) {
1261
+ if ( (teamType == 'NATIONAL') && ((includeTeams.length == 0) || ((includeTeams.length > 0) && includeTeams.includes(teamType))) ) {
1262
+ team = teamType + '.' + nationalCounter[mediaTitle]
1263
+ }
1264
+ let channelid = mediaType + '.' + team
1265
+ let channelMediaType = mediaType
1266
+ if ( mediaType == 'MLBTV' ) {
1267
+ channelMediaType = 'Video'
1268
+ }
1269
+ let stream = server + '/stream.m3u8?team=' + encodeURIComponent(team) + '&mediaType=' + channelMediaType
1270
+ if ( channelMediaType == 'Video' ) {
1271
+ stream += '&resolution=' + resolution
1272
+ }
1273
+ if ( this.protection.content_protect ) stream += '&content_protect=' + this.protection.content_protect
1274
+ if ( pipe == 'true' ) {
1275
+ stream = 'pipe://ffmpeg -hide_banner -loglevel fatal -i "' + stream + '" -map 0:v -map 0:a -c copy -metadata service_provider="MLBTV" -metadata service_name="' + channelid + '" -f mpegts pipe:1'
1276
+ }
1277
+ let icon = server
1278
+ if ( (teamType == 'NATIONAL') && ((includeTeams.length == 0) || ((includeTeams.length > 0) && includeTeams.includes(teamType))) ) {
1279
+ icon += '/image.svg?teamId=MLB'
1280
+ if ( this.protection.content_protect ) icon += '&content_protect=' + this.protection.content_protect
1281
+ nationalChannels[channelid] = {}
1282
+ nationalChannels[channelid].channellogo = icon
1283
+ nationalChannels[channelid].stream = stream
1284
+ nationalChannels[channelid].mediatype = mediaType
1285
+ } else {
1286
+ icon += '/image.svg?teamId=' + cache_data.dates[i].games[j].content.media.epg[k].items[x].mediaFeedSubType
1287
+ if ( this.protection.content_protect ) icon += '&content_protect=' + this.protection.content_protect
1288
+ channels[channelid] = {}
1289
+ channels[channelid].channellogo = icon
1290
+ channels[channelid].stream = stream
1291
+ channels[channelid].mediatype = mediaType
1292
+ }
1293
+ }
1294
+ }
1295
+ }
1296
+ }
1297
+ }
1298
+ }
1299
+ }
1300
+ }
1301
+ channels = this.sortObj(channels)
1302
+ channels = Object.assign(channels, nationalChannels)
1303
+
1304
+ // Big Inning
1305
+ if ( mediaType == 'MLBTV' ) {
1306
+ if ( (excludeTeams.length > 0) && excludeTeams.includes('BIGINNING') ) {
1307
+ // do nothing
1308
+ } else if ( (includeTeams.length == 0) || includeTeams.includes('BIGINNING') ) {
1309
+ let extraChannels = {}
1310
+ let channelid = mediaType + '.BIGINNING'
1311
+ let icon = server + '/image.svg?teamId=MLB'
1312
+ if ( this.protection.content_protect ) icon += '&content_protect=' + this.protection.content_protect
1313
+ let stream = server + '/stream.m3u8?type=biginning&mediaType=Video&resolution=' + resolution
1314
+ if ( this.protection.content_protect ) stream += '&content_protect=' + this.protection.content_protect
1315
+ if ( pipe == 'true' ) {
1316
+ stream = 'pipe://ffmpeg -hide_banner -loglevel fatal -i "' + stream + '" -map 0:v -map 0:a -c copy -metadata service_provider="MLBTV" -metadata service_name="' + channelid + '" -f mpegts pipe:1'
1317
+ }
1318
+ extraChannels[channelid] = {}
1319
+ extraChannels[channelid].channellogo = icon
1320
+ extraChannels[channelid].stream = stream
1321
+ extraChannels[channelid].mediatype = mediaType
1322
+ channels = Object.assign(channels, extraChannels)
1323
+ }
1324
+ }
1325
+
1326
+ // Multiview
1327
+ if ( (mediaType == 'MLBTV') && (typeof this.data.multiviewStreamURLPath !== 'undefined') ) {
1328
+ if ( (excludeTeams.length > 0) && excludeTeams.includes('MULTIVIEW') ) {
1329
+ // do nothing
1330
+ } else if ( (includeTeams.length == 0) || includeTeams.includes('MULTIVIEW') ) {
1331
+ let extraChannels = {}
1332
+ let channelid = mediaType + '.MULTIVIEW'
1333
+ let icon = server + '/image.svg?teamId=MLB'
1334
+ if ( this.protection.content_protect ) icon += '&content_protect=' + this.protection.content_protect
1335
+ let stream = server.replace(':' + this.data.port, ':' + this.data.multiviewPort) + this.data.multiviewStreamURLPath
1336
+ if ( pipe == 'true' ) {
1337
+ stream = 'pipe://ffmpeg -hide_banner -loglevel fatal -i "' + stream + '" -map 0:v -map 0:a -c copy -metadata service_provider="MLBTV" -metadata service_name="' + channelid + '" -f mpegts pipe:1'
1338
+ }
1339
+ extraChannels[channelid] = {}
1340
+ extraChannels[channelid].channellogo = icon
1341
+ extraChannels[channelid].stream = stream
1342
+ extraChannels[channelid].mediatype = mediaType
1343
+ channels = Object.assign(channels, extraChannels)
1344
+ }
1345
+ }
1346
+
1347
+ let channelnumber = startingChannelNumber
1348
+ var body = '#EXTM3U' + "\n"
1349
+ //body += '#EXTINF:-1 CUID="MLBSERVER.SAMPLE.VIDEO" tvg-id="MLBSERVER.SAMPLE.VIDEO" tvg-name="MLBSERVER.SAMPLE.VIDEO",MLBSERVER SAMPLE VIDEO' + "\n"
1350
+ //body += '/stream.m3u8' + "\n"
1351
+ for (const [key, value] of Object.entries(channels)) {
1352
+ body += '#EXTINF:-1 CUID="' + key + '" channelID="' + key + '" tvg-num="1.' + channelnumber + '" tvg-chno="1.' + channelnumber + '" tvg-id="' + key + '" tvg-name="' + key + '" tvg-logo="' + value.channellogo + '" group-title="' + value.mediatype + '",' + key + "\n"
1353
+ body += value.stream + "\n"
1354
+ channelnumber++
1355
+ }
1356
+ return body
1357
+ }
1358
+ } catch(e) {
1359
+ this.log('getChannels error : ' + e.message)
1360
+ }
1361
+ }
1362
+
1363
+ // get guide.xml file, in XMLTV format
1364
+ async getGuide(mediaType, includeTeams, excludeTeams, server) {
1365
+ try {
1366
+ this.debuglog('getGuide')
1367
+
1368
+ var mediaFeedType = 'mediaFeedType'
1369
+ if ( mediaType == 'Video' ) {
1370
+ mediaType = 'MLBTV'
1371
+ } else if ( mediaType == 'Audio' ) {
1372
+ mediaFeedType = 'type'
1373
+ }
1374
+
1375
+ let cache_data = await this.getWeeksData()
1376
+ if (cache_data) {
1377
+ var channels = {}
1378
+ var programs = ""
1379
+ let prevDateIndex = {MLBTV:-1,Audio:-1}
1380
+ for (var i = 0; i < cache_data.dates.length; i++) {
1381
+ let dateIndex = {MLBTV:i,Audio:i}
1382
+ let nationalCounter = {MLBTV:0,Audio:0}
1383
+ for (var j = 0; j < cache_data.dates[i].games.length; j++) {
1384
+ if ( cache_data.dates[i].games[j].content && cache_data.dates[i].games[j].content.media && cache_data.dates[i].games[j].content.media.epg ) {
1385
+ for (var k = 0; k < cache_data.dates[i].games[j].content.media.epg.length; k++) {
1386
+ let mediaTitle = cache_data.dates[i].games[j].content.media.epg[k].title
1387
+ if ( mediaTitle == mediaType ) {
1388
+ for (var x = 0; x < cache_data.dates[i].games[j].content.media.epg[k].items.length; x++) {
1389
+ if ( ((((typeof cache_data.dates[i].games[j].content.media.epg[k].items[x].mediaFeedType) == 'undefined') || (cache_data.dates[i].games[j].content.media.epg[k].items[x].mediaFeedType.indexOf('IN_MARKET_') == -1)) && (((typeof cache_data.dates[i].games[j].content.media.epg[k].items[x].language) == 'undefined') || (cache_data.dates[i].games[j].content.media.epg[k].items[x].language != 'es'))) ) {
1390
+ let teamType = cache_data.dates[i].games[j].content.media.epg[k].items[x][mediaFeedType]
1391
+ let team
1392
+ let opponent_team
1393
+ if ( teamType == 'NATIONAL' ) {
1394
+ team = cache_data.dates[i].games[j].teams['home'].team.abbreviation
1395
+ opponent_team = cache_data.dates[i].games[j].teams['away'].team.abbreviation
1396
+
1397
+ if ( dateIndex[mediaTitle] > prevDateIndex[mediaTitle] ) {
1398
+ prevDateIndex[mediaTitle] = dateIndex[mediaTitle]
1399
+ nationalCounter[mediaTitle] = 1
1400
+ } else {
1401
+ nationalCounter[mediaTitle] += 1
1402
+ }
1403
+ } else {
1404
+ teamType = teamType.toLowerCase()
1405
+ let opponent_teamType = 'away'
1406
+ if ( teamType == 'away' ) {
1407
+ opponent_teamType = 'home'
1408
+ }
1409
+ team = cache_data.dates[i].games[j].teams[teamType].team.abbreviation
1410
+ opponent_team = cache_data.dates[i].games[j].teams[opponent_teamType].team.abbreviation
1411
+ }
1412
+ if ( (excludeTeams.length > 0) && (excludeTeams.includes(team) || excludeTeams.includes(opponent_team) || excludeTeams.includes(teamType)) ) {
1413
+ continue
1414
+ } else if ( (includeTeams.length == 0) || includeTeams.includes(team) || includeTeams.includes(teamType) ) {
1415
+ let icon = server
1416
+ if ( (teamType == 'NATIONAL') && ((includeTeams.length == 0) || ((includeTeams.length > 0) && includeTeams.includes(teamType))) ) {
1417
+ team = teamType + '.' + nationalCounter[mediaTitle]
1418
+ icon += '/image.svg?teamId=MLB'
1419
+ } else {
1420
+ icon += '/image.svg?teamId=' + cache_data.dates[i].games[j].content.media.epg[k].items[x].mediaFeedSubType
1421
+ }
1422
+ if ( this.protection.content_protect ) icon += '&content_protect=' + this.protection.content_protect
1423
+ let channelid = mediaType + '.' + team
1424
+ channels[channelid] = {}
1425
+ channels[channelid].name = channelid
1426
+ channels[channelid].icon = icon
1427
+
1428
+ let title = 'MLB Baseball: ' + cache_data.dates[i].games[j].teams['away'].team.teamName + ' at ' + cache_data.dates[i].games[j].teams['home'].team.teamName + ' (' + cache_data.dates[i].games[j].content.media.epg[k].items[x].callLetters
1429
+ if ( mediaType == 'Audio' ) {
1430
+ title += ' Radio'
1431
+ }
1432
+ title += ')'
1433
+
1434
+ let description = ''
1435
+ if ( cache_data.dates[i].games[j].doubleHeader != 'N' ) {
1436
+ description += 'Game ' + cache_data.dates[i].games[j].gameNumber + '. '
1437
+ }
1438
+ if ( (cache_data.dates[i].games[j].teams['away'].probablePitcher && cache_data.dates[i].games[j].teams['away'].probablePitcher.fullName) || (cache_data.dates[i].games[j].teams['home'].probablePitcher && cache_data.dates[i].games[j].teams['home'].probablePitcher.fullName) ) {
1439
+ if ( cache_data.dates[i].games[j].teams['away'].probablePitcher && cache_data.dates[i].games[j].teams['away'].probablePitcher.fullName ) {
1440
+ description += cache_data.dates[i].games[j].teams['away'].probablePitcher.fullName
1441
+ } else {
1442
+ description += 'TBD'
1443
+ }
1444
+ description += ' vs. '
1445
+ if ( cache_data.dates[i].games[j].teams['home'].probablePitcher && cache_data.dates[i].games[j].teams['home'].probablePitcher.fullName ) {
1446
+ description += cache_data.dates[i].games[j].teams['home'].probablePitcher.fullName
1447
+ } else {
1448
+ description += 'TBD'
1449
+ }
1450
+ description += '. '
1451
+ }
1452
+
1453
+ let gameDate = new Date(cache_data.dates[i].games[j].gameDate)
1454
+ let gameHours = 3
1455
+ // Handle suspended, TBD, and doubleheaders
1456
+ if ( cache_data.dates[i].games[j].status.resumedFrom ) {
1457
+ gameHours = 1
1458
+ if ( cache_data.dates[i].games[j].description ) {
1459
+ description += cache_data.dates[i].games[j].description
1460
+ } else {
1461
+ description += 'Resumption of suspended game.'
1462
+ }
1463
+ gameDate = new Date(cache_data.dates[i].games[j].gameDate)
1464
+ gameDate.setHours(gameDate.getHours()+1)
1465
+ } else if ( (cache_data.dates[i].games[j].status.startTimeTBD == true) && (cache_data.dates[i].games[j].doubleHeader == 'Y') && (cache_data.dates[i].games[j].gameNumber == 2) ) {
1466
+ description += 'Start time TBD.'
1467
+ gameDate = new Date(cache_data.dates[i].games[j-1].gameDate)
1468
+ gameDate.setHours(gameDate.getHours()+4)
1469
+ } else if ( cache_data.dates[i].games[j].status.startTimeTBD == true ) {
1470
+ continue
1471
+ }
1472
+ let start = this.convertDateToXMLTV(gameDate)
1473
+ gameDate.setHours(gameDate.getHours()+gameHours)
1474
+ let stop = this.convertDateToXMLTV(gameDate)
1475
+
1476
+ programs += "\n" + ' <programme channel="' + channelid + '" start="' + start + '" stop="' + stop + '">' + "\n" +
1477
+ ' <title lang="en">' + title + '</title>' + "\n" +
1478
+ ' <desc lang="en">' + description.trim() + '</desc>' + "\n" +
1479
+ ' <category lang="en">Sports</category>' + "\n" +
1480
+ ' <icon src="' + icon + '"></icon>' + "\n" +
1481
+ ' </programme>'
1482
+ }
1483
+ }
1484
+ }
1485
+ }
1486
+ }
1487
+ }
1488
+ }
1489
+ }
1490
+
1491
+ // Big Inning
1492
+ if ( mediaType == 'MLBTV' ) {
1493
+ if ( (excludeTeams.length > 0) && excludeTeams.includes('BIGINNING') ) {
1494
+ // do nothing
1495
+ } else if ( (includeTeams.length == 0) || includeTeams.includes('BIGINNING') ) {
1496
+ let icon = server + '/image.svg?teamId=MLB'
1497
+ if ( this.protection.content_protect ) icon += '&content_protect=' + this.protection.content_protect
1498
+ let channelid = mediaType + '.BIGINNING'
1499
+ channels[channelid] = {}
1500
+ channels[channelid].name = channelid
1501
+ channels[channelid].icon = icon
1502
+
1503
+ let title = 'MLB Big Inning'
1504
+ let description = 'Live look-ins and big moments from around the league'
1505
+
1506
+ await this.getBigInningSchedule()
1507
+ for (var i = 0; i < cache_data.dates.length; i++) {
1508
+ let gameDate = cache_data.dates[i].date
1509
+ if ( this.cache.bigInningSchedule[gameDate] && this.cache.bigInningSchedule[gameDate].start ) {
1510
+ let start = this.convertDateToXMLTV(new Date(this.cache.bigInningSchedule[gameDate].start))
1511
+ let stop = this.convertDateToXMLTV(new Date(this.cache.bigInningSchedule[gameDate].end))
1512
+
1513
+ programs += "\n" + ' <programme channel="' + channelid + '" start="' + start + '" stop="' + stop + '">' + "\n" +
1514
+ ' <title lang="en">' + title + '</title>' + "\n" +
1515
+ ' <desc lang="en">' + description.trim() + '</desc>' + "\n" +
1516
+ ' <category lang="en">Sports</category>' + "\n" +
1517
+ ' <icon src="' + icon + '"></icon>' + "\n" +
1518
+ ' </programme>'
1519
+ }
1520
+ }
1521
+ }
1522
+ }
1523
+
1524
+ // Multiview
1525
+ if ( (mediaType == 'MLBTV') && (typeof this.data.multiviewStreamURL !== 'undefined') ) {
1526
+ if ( (excludeTeams.length > 0) && excludeTeams.includes('MULTIVIEW') ) {
1527
+ // do nothing
1528
+ } else if ( (includeTeams.length == 0) || includeTeams.includes('MULTIVIEW') ) {
1529
+ let icon = server + '/image.svg?teamId=MLB'
1530
+ if ( this.protection.content_protect ) icon += '&content_protect=' + this.protection.content_protect
1531
+ let channelid = mediaType + '.MULTIVIEW'
1532
+ channels[channelid] = {}
1533
+ channels[channelid].name = channelid
1534
+ channels[channelid].icon = icon
1535
+
1536
+ let title = 'MLB Multiview'
1537
+ let description = 'Watch up to 4 games at once. Requires starting the multiview stream in the web interface first, and stopping it when done.'
1538
+
1539
+ for (var i = 0; i < cache_data.dates.length; i++) {
1540
+ let gameDate = new Date(cache_data.dates[i].date + 'T00:00:00.000')
1541
+ let start = this.convertDateToXMLTV(gameDate)
1542
+ gameDate.setDate(gameDate.getDate()+1)
1543
+ let stop = this.convertDateToXMLTV(gameDate)
1544
+
1545
+ programs += "\n" + ' <programme channel="' + channelid + '" start="' + start + '" stop="' + stop + '">' + "\n" +
1546
+ ' <title lang="en">' + title + '</title>' + "\n" +
1547
+ ' <desc lang="en">' + description.trim() + '</desc>' + "\n" +
1548
+ ' <category lang="en">Sports</category>' + "\n" +
1549
+ ' <icon src="' + icon + '"></icon>' + "\n" +
1550
+ ' </programme>'
1551
+ }
1552
+ }
1553
+ }
1554
+
1555
+ var body = '<?xml version="1.0" encoding="UTF-8"?>' + "\n" +
1556
+ '<!DOCTYPE tv SYSTEM "xmltv.dd">' + "\n" +
1557
+ ' <tv generator-info-name="mlbserver" source-info-name="mlbserver">'
1558
+ for (const [key, value] of Object.entries(channels)) {
1559
+ body += "\n" + ' <channel id="' + key + '">' + "\n" +
1560
+ ' <display-name>' + value.name + '</display-name>' + "\n" +
1561
+ ' <icon src="' + value.icon + '"></icon>' + "\n" +
1562
+ ' </channel>'
1563
+ }
1564
+ body += programs + "\n" + ' </tv>'
1565
+
1566
+ return body
1567
+ }
1568
+ } catch(e) {
1569
+ this.log('getGuide error : ' + e.message)
1570
+ }
1571
+ }
1572
+
1573
+ // Get image from cache or request
1574
+ async getImage(teamId) {
1575
+ this.debuglog('getImage ' + teamId)
1576
+ let imagePath = path.join(CACHE_DIRECTORY, teamId + '.svg')
1577
+ if ( fs.existsSync(imagePath) ) {
1578
+ this.debuglog('using cached image for ' + teamId)
1579
+ return fs.readFileSync(imagePath)
1580
+ } else {
1581
+ this.debuglog('requesting new image for ' + teamId)
1582
+ let imageURL = 'https://www.mlbstatic.com/team-logos/' + teamId + '.svg'
1583
+ if ( teamId == 'MLB' ) {
1584
+ imageURL = 'https://www.mlbstatic.com/team-logos/league-on-dark/1.svg'
1585
+ }
1586
+ let reqObj = {
1587
+ url: imageURL,
1588
+ headers: {
1589
+ 'User-agent': USER_AGENT,
1590
+ 'origin': 'https://www.mlb.com'
1591
+ }
1592
+ }
1593
+ var response = await this.httpGet(reqObj)
1594
+ if ( response ) {
1595
+ this.debuglog('getImage response : ' + response)
1596
+ fs.writeFileSync(imagePath, response)
1597
+ } else {
1598
+ this.debuglog('failed to get image for ' + teamId)
1599
+ }
1600
+ }
1601
+ }
1602
+
1603
+ // Get airings data for a game
1604
+ async getAiringsData(contentId, gamePk = false) {
1605
+ try {
1606
+ this.debuglog('getAiringsData')
1607
+
1608
+ let cache_data
1609
+ let cache_name = contentId
1610
+ let cache_file = path.join(CACHE_DIRECTORY, contentId+'.json')
1611
+ let url = 'https://search-api-mlbtv.mlb.com/svc/search/v2/graphql/persisted/query/core/Airings'
1612
+ let qs = { variables: '%7B%22contentId%22%3A%22' + contentId + '%22%7D' }
1613
+ if ( gamePk ) {
1614
+ url = 'https://search-api-mlbtv.mlb.com/svc/search/v2/graphql/persisted/query/core/Airings?variables={%22partnerProgramIds%22%3A[%22' + gamePk + '%22]}'
1615
+ qs = {}
1616
+ }
1617
+ let currentDate = new Date()
1618
+ if ( !fs.existsSync(cache_file) || !this.cache || !this.cache.airings || !this.cache.airings[cache_name] || !this.cache.airings[cache_name].airingsCacheExpiry || (currentDate > new Date(this.cache.airings[cache_name].airingsCacheExpiry)) ) {
1619
+ let reqObj = {
1620
+ url: url,
1621
+ qs: qs,
1622
+ headers: {
1623
+ 'Accept': 'application/json',
1624
+ 'X-BAMSDK-Version': BAM_SDK_VERSION,
1625
+ 'X-BAMSDK-Platform': PLATFORM,
1626
+ 'User-Agent': USER_AGENT,
1627
+ 'Origin': 'https://www.mlb.com',
1628
+ 'Accept-Encoding': 'gzip, deflate, br',
1629
+ 'Content-type': 'application/json'
1630
+ },
1631
+ gzip: true
1632
+ }
1633
+ var response = await this.httpGet(reqObj)
1634
+ if ( this.isValidJson(response) ) {
1635
+ this.debuglog(response)
1636
+ cache_data = JSON.parse(response)
1637
+ if ( gamePk ) {
1638
+ this.debuglog('searching for alternate airing with break data')
1639
+ let most_milestones = 0
1640
+ let most_milestones_index = -1
1641
+ let offset_index = 1
1642
+ let previous_offset
1643
+ let previous_startDatetime
1644
+ let new_startDatetime
1645
+ let new_offset
1646
+ for ( var i=0; i<cache_data.data.Airings.length; i++ ) {
1647
+ if ( cache_data.data.Airings[i].milestones ) {
1648
+ if ( cache_data.data.Airings[i].contentId && (cache_data.data.Airings[i].contentId == contentId) ) {
1649
+ if ( cache_data.data.Airings[i].milestones[0].milestoneTime[0].type == 'offset' ) offset_index = 0
1650
+ previous_offset = cache_data.data.Airings[i].milestones[0].milestoneTime[offset_index].start
1651
+ previous_startDatetime = new Date(cache_data.data.Airings[i].milestones[0].milestoneTime[(offset_index == 0 ? 1 : 0)].startDatetime)
1652
+ continue
1653
+ }
1654
+ if ( cache_data.data.Airings[i].milestones.length > most_milestones ) {
1655
+ most_milestones = cache_data.data.Airings[i].milestones.length
1656
+ most_milestones_index = i
1657
+ }
1658
+ }
1659
+ }
1660
+ if ( most_milestones_index && previous_startDatetime ) {
1661
+ this.debuglog('found alternate airing with break data')
1662
+ let temp_airing = cache_data.data.Airings[most_milestones_index]
1663
+
1664
+ offset_index = 1
1665
+ if ( temp_airing.milestones[0].milestoneTime[0].type == 'offset' ) offset_index = 0
1666
+ new_offset = temp_airing.milestones[0].milestoneTime[offset_index].start
1667
+ new_startDatetime = new Date(temp_airing.milestones[0].milestoneTime[(offset_index == 0 ? 1 : 0)].startDatetime)
1668
+
1669
+ let offset_adjust = (new_startDatetime / 1000) - (previous_startDatetime/1000) - previous_offset - new_offset
1670
+ this.debuglog('adjusting breaks by ' + offset_adjust)
1671
+
1672
+ for ( var j=0; j<temp_airing.milestones.length; j++ ) {
1673
+ offset_index = 1
1674
+ if ( temp_airing.milestones[j].milestoneTime[0].type == 'offset' ) offset_index = 0
1675
+ temp_airing.milestones[j].milestoneTime[offset_index].start += offset_adjust
1676
+ }
1677
+
1678
+ cache_data.data.Airings = [{}]
1679
+ cache_data.data.Airings[0] = temp_airing
1680
+ }
1681
+ }
1682
+ this.save_json_cache_file(contentId, cache_data)
1683
+
1684
+ // Default cache period is 1 hour from now
1685
+ let oneHourFromNow = new Date()
1686
+ oneHourFromNow.setHours(oneHourFromNow.getHours()+1)
1687
+ let cacheExpiry = oneHourFromNow
1688
+
1689
+ let today = this.liveDate()
1690
+ let game_date = new Date(cache_data.data.Airings[0].startDate)
1691
+ let compare_date = game_date.getFullYear() + '-' + (game_date.getMonth()+1).toString().padStart(2, '0') + '-' + game_date.getDate().toString().padStart(2, '0')
1692
+
1693
+ if ( compare_date == today ) {
1694
+ if ( (cache_data.data.Airings[0].mediaConfig.productType == 'LIVE') || ((typeof cache_data.data.Airings[0].milestones !== 'undefined') && (typeof cache_data.data.Airings[0].milestones[0] !== 'undefined') && (typeof cache_data.data.Airings[0].milestones[0].milestoneTime !== 'undefined') && (typeof cache_data.data.Airings[0].milestones[0].milestoneTime[0].start !== 'undefined') && ((cache_data.data.Airings[0].milestones[0].milestoneTime[0].start > 20*60) || (cache_data.data.Airings[0].milestones[0].milestoneTime[1].start > 20*60))) ) {
1695
+ this.debuglog('setting cache expiry to 5 minutes for live or untrimmed games today')
1696
+ currentDate.setMinutes(currentDate.getMinutes()+5)
1697
+ cacheExpiry = currentDate
1698
+ }
1699
+ } else if ( compare_date < today ) {
1700
+ this.debuglog('setting cache expiry to forever for past games')
1701
+ cacheExpiry = new Date(8640000000000000)
1702
+ }
1703
+
1704
+ // finally save the setting
1705
+ this.setAiringsCacheExpiry(cache_name, cacheExpiry)
1706
+ } else {
1707
+ this.log('error : invalid json from url ' + getObj.url)
1708
+ }
1709
+ } else {
1710
+ this.debuglog('using cached airings data')
1711
+ cache_data = this.readFileToJson(cache_file)
1712
+ }
1713
+ if (cache_data) {
1714
+ return cache_data
1715
+ }
1716
+ } catch(e) {
1717
+ this.log('getAiringsData error : ' + e.message)
1718
+ }
1719
+ }
1720
+
1721
+ // Get gameday data for a game (play and pitch data)
1722
+ async getGamedayData(contentId) {
1723
+ try {
1724
+ this.debuglog('getGamedayData')
1725
+
1726
+ let gamePk = await this.getGamePkFromContentId(contentId)
1727
+
1728
+ let cache_data
1729
+ let cache_name = 'g' + gamePk
1730
+ let cache_file = path.join(CACHE_DIRECTORY, cache_name+'.json')
1731
+ let currentDate = new Date()
1732
+ if ( !fs.existsSync(cache_file) || !this.cache || !this.cache.gameday || !this.cache.gameday[cache_name] || !this.cache.gameday[cache_name].gamedayCacheExpiry || (currentDate > new Date(this.cache.gameday[cache_name].gamedayCacheExpiry)) ) {
1733
+ let reqObj = {
1734
+ url: 'http://statsapi.mlb.com/api/v1.1/game/' + gamePk + '/feed/live',
1735
+ headers: {
1736
+ 'User-agent': USER_AGENT,
1737
+ 'Origin': 'https://www.mlb.com',
1738
+ 'Accept-Encoding': 'gzip, deflate, br',
1739
+ 'Content-type': 'application/json'
1740
+ },
1741
+ gzip: true
1742
+ }
1743
+ var response = await this.httpGet(reqObj)
1744
+ if ( this.isValidJson(response) ) {
1745
+ this.debuglog(response)
1746
+ cache_data = JSON.parse(response)
1747
+ this.save_json_cache_file(cache_name, cache_data)
1748
+
1749
+ // Default cache period is 1 hour from now
1750
+ let oneHourFromNow = new Date()
1751
+ oneHourFromNow.setHours(oneHourFromNow.getHours()+1)
1752
+ let cacheExpiry = oneHourFromNow
1753
+
1754
+ if ( (cache_data.gameData.status.abstractGameState == 'Live') && (cache_data.gameData.status.detailedState.indexOf('Suspended') != 0) ) {
1755
+ this.debuglog('setting cache expiry to 5 minutes for live game')
1756
+ currentDate.setMinutes(currentDate.getMinutes()+5)
1757
+ cacheExpiry = currentDate
1758
+ } else {
1759
+ let today = this.liveDate()
1760
+
1761
+ if ( cache_data.gameData.datetime.officialDate < today ) {
1762
+ this.debuglog('setting cache expiry to forever for past games')
1763
+ cacheExpiry = new Date(8640000000000000)
1764
+ }
1765
+ }
1766
+
1767
+ // finally save the setting
1768
+ this.setGamedayCacheExpiry(cache_name, cacheExpiry)
1769
+ } else {
1770
+ this.log('error : invalid response from url ' + getObj.url)
1771
+ }
1772
+ } else {
1773
+ this.debuglog('using cached gameday data')
1774
+ cache_data = this.readFileToJson(cache_file)
1775
+ }
1776
+ if (cache_data) {
1777
+ return cache_data
1778
+ }
1779
+ } catch(e) {
1780
+ this.log('getGamedayData error : ' + e.message)
1781
+ }
1782
+ }
1783
+
1784
+ // Get broadcast start timestamp
1785
+ async getBroadcastStart(contentId) {
1786
+ try {
1787
+ this.debuglog('getBroadcastStart')
1788
+
1789
+ if ( !this.temp_cache[contentId] ) {
1790
+ this.temp_cache[contentId] = {}
1791
+ }
1792
+
1793
+ let cache_data = await this.getAiringsData(contentId)
1794
+ // If VOD and we have fewer than 2 milestones, use the gamePk to look for more milestones in a different airing
1795
+ if ( cache_data.data.Airings[0].mediaConfig.productType && (cache_data.data.Airings[0].mediaConfig.productType == 'VOD') && cache_data.data.Airings[0].milestones && (cache_data.data.Airings[0].milestones.length < 2) ) {
1796
+ this.log('too few milestones, looking for more')
1797
+ this.cache.airings[contentId] = {}
1798
+ cache_data = await this.getAiringsData(contentId, cache_data.data.Airings[0].partnerProgramId)
1799
+ }
1800
+
1801
+ let broadcast_start_offset
1802
+ let broadcast_start_timestamp
1803
+
1804
+ if ( cache_data.data.Airings[0].milestones ) {
1805
+ for (var j = 0; j < cache_data.data.Airings[0].milestones.length; j++) {
1806
+ if ( cache_data.data.Airings[0].milestones[j].milestoneType == 'BROADCAST_START' ) {
1807
+ let offset_index = 1
1808
+ let offset
1809
+ if ( cache_data.data.Airings[0].milestones[j].milestoneTime[0].type == 'offset' ) {
1810
+ offset_index = 0
1811
+ }
1812
+ broadcast_start_offset = cache_data.data.Airings[0].milestones[j].milestoneTime[offset_index].start
1813
+
1814
+ // Broadcast start
1815
+ broadcast_start_timestamp = new Date(cache_data.data.Airings[0].milestones[j].milestoneTime[(offset_index == 0 ? 1 : 0)].startDatetime)
1816
+ this.debuglog('broadcast start time detected as ' + broadcast_start_timestamp)
1817
+ this.debuglog('offset detected as ' + broadcast_start_offset)
1818
+ broadcast_start_timestamp.setSeconds(broadcast_start_timestamp.getSeconds()-broadcast_start_offset)
1819
+ this.debuglog('new start time is ' + broadcast_start_timestamp)
1820
+ break
1821
+ }
1822
+ }
1823
+ }
1824
+
1825
+ if ( broadcast_start_offset && broadcast_start_timestamp ) {
1826
+ return { broadcast_start_offset, broadcast_start_timestamp }
1827
+ }
1828
+ } catch(e) {
1829
+ this.log('getBroadcastStart error : ' + e.message)
1830
+ }
1831
+ }
1832
+
1833
+ // Get event offsets into temporary cache
1834
+ async getEventOffsets(contentId, skip_types, skip_adjust = 0) {
1835
+ try {
1836
+ this.debuglog('getEventOffsets')
1837
+
1838
+ if ( skip_adjust != 0 ) session.log('manual adjustment of ' + skip_adjust + ' seconds being applied')
1839
+
1840
+ // Get the broadcast start time first -- event times will be relative to this
1841
+ let broadcast_start = await this.getBroadcastStart(contentId)
1842
+ let broadcast_start_offset = broadcast_start.broadcast_start_offset
1843
+ let broadcast_start_timestamp = broadcast_start.broadcast_start_timestamp
1844
+ this.debuglog('broadcast start detected as ' + broadcast_start_timestamp + ', offset ' + broadcast_start_offset)
1845
+
1846
+ let cache_data = await this.getGamedayData(contentId)
1847
+
1848
+ // There are the events to ignore, if we're skipping breaks
1849
+ let break_types = ['Game Advisory', 'Pitching Substitution', 'Offensive Substitution', 'Defensive Sub', 'Defensive Switch', 'Runner Placed On Base']
1850
+
1851
+ // There are the events to keep, in addition to the last event of each at-bat, if we're skipping pitches
1852
+ let action_types = ['Wild Pitch', 'Passed Ball', 'Stolen Base', 'Caught Stealing', 'Pickoff', 'Out', 'Balk', 'Defensive Indiff']
1853
+
1854
+ let inning_offsets = [{start:broadcast_start_offset}]
1855
+ let event_offsets = [{start:0}]
1856
+ let last_event = 0
1857
+ let default_event_duration = 15
1858
+
1859
+ // Pad times by these amounts
1860
+ let pad_start = 0
1861
+ let pad_end = 15
1862
+ let pad_adjust = 20
1863
+
1864
+ // Inning counters
1865
+ let last_inning = 0
1866
+ let last_inning_half = ''
1867
+
1868
+ // Loop through all plays
1869
+ for (var i=0; i < cache_data.liveData.plays.allPlays.length; i++) {
1870
+
1871
+ // If requested, calculate inning offsets
1872
+ if ( skip_types.includes('innings') ) {
1873
+ // Look for a change from our inning counters
1874
+ if ( cache_data.liveData.plays.allPlays[i].about && cache_data.liveData.plays.allPlays[i].about.inning && ((cache_data.liveData.plays.allPlays[i].about.inning != last_inning) || (cache_data.liveData.plays.allPlays[i].about.halfInning != last_inning_half)) ) {
1875
+ let inning_index = cache_data.liveData.plays.allPlays[i].about.inning * 2
1876
+ // top
1877
+ if ( cache_data.liveData.plays.allPlays[i].about.halfInning == 'top' ) {
1878
+ inning_index = inning_index - 1
1879
+ }
1880
+ if ( typeof inning_offsets[inning_index] === 'undefined' ) inning_offsets.push({})
1881
+ for (var j=0; j < cache_data.liveData.plays.allPlays[i].playEvents.length; j++) {
1882
+ if ( cache_data.liveData.plays.allPlays[i].playEvents[j].details && cache_data.liveData.plays.allPlays[i].playEvents[j].details.event && (break_types.some(v => cache_data.liveData.plays.allPlays[i].playEvents[j].details.event.includes(v))) ) {
1883
+ // ignore break events
1884
+ } else {
1885
+ inning_offsets[inning_index].start = ((new Date(cache_data.liveData.plays.allPlays[i].playEvents[j].startTime) - broadcast_start_timestamp) / 1000) - pad_start + skip_adjust
1886
+ break
1887
+ }
1888
+ }
1889
+ // Update inning counters
1890
+ last_inning = cache_data.liveData.plays.allPlays[i].about.inning
1891
+ last_inning_half = cache_data.liveData.plays.allPlays[i].about.halfInning
1892
+ }
1893
+ }
1894
+
1895
+ // Get event offsets, if necessary
1896
+ if ( skip_types.includes('breaks') || skip_types.includes('pitches') ) {
1897
+
1898
+ // Loop through play events, looking for actions
1899
+ let actions = []
1900
+ for (var j=0; j < cache_data.liveData.plays.allPlays[i].playEvents.length; j++) {
1901
+ // If skipping breaks, everything is an action except break types
1902
+ // otherwise, only action types are included (skipping pitches)
1903
+ if ( skip_types.includes('breaks') ) {
1904
+ if ( cache_data.liveData.plays.allPlays[i].playEvents[j].details && cache_data.liveData.plays.allPlays[i].playEvents[j].details.event && (break_types.some(v => cache_data.liveData.plays.allPlays[i].playEvents[j].details.event.includes(v))) ) {
1905
+ // ignore break events
1906
+ } else {
1907
+ actions.push(j)
1908
+ }
1909
+ } else if ( cache_data.liveData.plays.allPlays[i].playEvents[j].details && cache_data.liveData.plays.allPlays[i].playEvents[j].details.event && (action_types.some(v => cache_data.liveData.plays.allPlays[i].playEvents[j].details.event.includes(v))) ) {
1910
+ actions.push(j)
1911
+ }
1912
+ }
1913
+
1914
+ // Process breaks
1915
+ if ( skip_types.includes('breaks') ) {
1916
+ let this_event = {}
1917
+ let event_in_atbat = false
1918
+ for (var x=0; x < actions.length; x++) {
1919
+ let this_pad_start = 0
1920
+ let this_pad_end = 0
1921
+ // Once we define each event's start time, we won't change it
1922
+ if ( typeof this_event.start === 'undefined' ) {
1923
+ this_event.start = ((new Date(cache_data.liveData.plays.allPlays[i].playEvents[actions[x]].startTime) - broadcast_start_timestamp) / 1000) - pad_start + skip_adjust
1924
+ // For events within at-bats, adjust the padding
1925
+ if ( event_in_atbat ) {
1926
+ this_event.start -= pad_adjust
1927
+ }
1928
+ }
1929
+ // Update the end time, if available
1930
+ if ( cache_data.liveData.plays.allPlays[i].playEvents[actions[x]].endTime ) {
1931
+ this_event.end = ((new Date(cache_data.liveData.plays.allPlays[i].playEvents[actions[x]].endTime) - broadcast_start_timestamp) / 1000) + pad_end + skip_adjust
1932
+ // Otherwise use the start time to estimate the end time
1933
+ } else {
1934
+ this_event.end = ((new Date(cache_data.liveData.plays.allPlays[i].playEvents[actions[x]].startTime) - broadcast_start_timestamp) / 1000) + this_pad_end + skip_adjust + default_event_duration
1935
+ }
1936
+ // Check if we have skipped a play event (indicating a break inside an at-bat), in which case push this event and start another one
1937
+ if ( (x > 0) && (actions[x] > (actions[x-1]+1)) && (typeof this_event.end !== 'undefined') ) {
1938
+ // For events within at-bats, adjust the padding
1939
+ event_in_atbat = true
1940
+ this_event.end += pad_adjust
1941
+ event_offsets.push(this_event)
1942
+ this_event = {}
1943
+ }
1944
+ }
1945
+ // Once we've finished our loop through a play's events, push the event as long as we got an end time
1946
+ if ( typeof this_event.end !== 'undefined' ) {
1947
+ event_offsets.push(this_event)
1948
+ }
1949
+ } else if ( skip_types.includes('pitches') ) {
1950
+ // If we're skipping pitches, but we didn't detect any action events, use the last play event
1951
+ if ( (cache_data.liveData.plays.allPlays[i].playEvents.length > 0) && ((actions.length == 0) || (actions[(actions.length-1)] < (cache_data.liveData.plays.allPlays[i].playEvents.length-1))) ) {
1952
+ actions.push(cache_data.liveData.plays.allPlays[i].playEvents.length-1)
1953
+ }
1954
+ // Loop through the actions
1955
+ for (var x=0; x < actions.length; x++) {
1956
+ let this_event = {}
1957
+ let this_pad_start = pad_start
1958
+ let this_pad_end = pad_end
1959
+ // For events within at-bats, adjust the padding
1960
+ if ( x < (actions.length-1) ) {
1961
+ this_pad_start += pad_adjust
1962
+ this_pad_end -= pad_adjust
1963
+ }
1964
+ this_event.start = ((new Date(cache_data.liveData.plays.allPlays[i].playEvents[actions[x]].startTime) - broadcast_start_timestamp) / 1000) - this_pad_start + skip_adjust
1965
+ // If play event end time is available, set it and push this event
1966
+ if ( cache_data.liveData.plays.allPlays[i].playEvents[actions[x]].endTime ) {
1967
+ this_event.end = ((new Date(cache_data.liveData.plays.allPlays[i].playEvents[actions[x]].endTime) - broadcast_start_timestamp) / 1000) + this_pad_end + skip_adjust
1968
+ // Otherwise use the start time to estimate the end time
1969
+ } else {
1970
+ this_event.end = ((new Date(cache_data.liveData.plays.allPlays[i].playEvents[actions[x]].startTime) - broadcast_start_timestamp) / 1000) + this_pad_end + skip_adjust + default_event_duration
1971
+ }
1972
+ event_offsets.push(this_event)
1973
+ }
1974
+ }
1975
+ }
1976
+ }
1977
+
1978
+ if ( skip_types.includes('innings') ) {
1979
+ this.debuglog('inning offsets: ' + JSON.stringify(inning_offsets))
1980
+ }
1981
+ this.temp_cache[contentId].inning_offsets = inning_offsets
1982
+
1983
+ if ( skip_types.includes('breaks') || skip_types.includes('pitches') ) {
1984
+ this.debuglog('event offsets: ' + JSON.stringify(event_offsets))
1985
+ }
1986
+ this.temp_cache[contentId].event_offsets = event_offsets
1987
+
1988
+ return true
1989
+ } catch(e) {
1990
+ this.log('getEventOffsets error : ' + e.message)
1991
+ }
1992
+ }
1993
+
1994
+ // Get Big Inning schedule, if available
1995
+ async getBigInningSchedule(dateString = false) {
1996
+ try {
1997
+ this.debuglog('getBigInningSchedule')
1998
+
1999
+ let currentDate = new Date()
2000
+ if ( !this.cache || !this.cache.bigInningScheduleCacheExpiry || (currentDate > new Date(this.cache.bigInningScheduleCacheExpiry)) ) {
2001
+ if ( !this.cache.bigInningSchedule ) this.cache.bigInningSchedule = {}
2002
+ let reqObj = {
2003
+ url: 'https://www.mlb.com/live-stream-games/big-inning',
2004
+ headers: {
2005
+ 'User-Agent': USER_AGENT,
2006
+ 'Origin': 'https://www.mlb.com',
2007
+ 'Referer': 'https://www.mlb.com',
2008
+ 'Accept-Encoding': 'gzip, deflate, br'
2009
+ },
2010
+ gzip: true
2011
+ }
2012
+ var response = await this.httpGet(reqObj)
2013
+ if ( response ) {
2014
+ // disabled because it's very big!
2015
+ //this.debuglog(response)
2016
+ // break HTML into array based on table rows
2017
+ var rows = response.split('<tr>')
2018
+ // start iterating at 2 (after header row)
2019
+ for (var i=2; i<rows.length; i++) {
2020
+ // split HTML row into array with columns
2021
+ let cols = rows[i].split('<td>')
2022
+
2023
+ // define some variables that persist for each row
2024
+ let parts
2025
+ let year
2026
+ let month
2027
+ let day
2028
+ let this_datestring
2029
+ let add_date = 0
2030
+ let d
2031
+
2032
+ for (var j=1; j<cols.length; j++) {
2033
+ // split on closing bracket to get column text at resulting array index 0
2034
+ let col = cols[j].split('<')
2035
+ switch(j){
2036
+ // first column is date
2037
+ case 1:
2038
+ // split date into array
2039
+ parts = col[0].split(' ')
2040
+ year = parts[2]
2041
+ // get month index, zero-based
2042
+ month = new Date(Date.parse(parts[0] +" 1, 2021")).getMonth()
2043
+ day = parts[1].substring(0,parts[1].length-3)
2044
+ this_datestring = new Date(year, month, day).toISOString().substring(0,10)
2045
+ this.cache.bigInningSchedule[this_datestring] = {}
2046
+ // increment month index (not zero-based)
2047
+ month += 1
2048
+ break
2049
+ // remaining columns are times
2050
+ default:
2051
+ let hour
2052
+ let minute = '00'
2053
+ let ampm
2054
+ // if time has colon, split into array on that to get hour and minute parts
2055
+ if ( col[0].indexOf(':') > 0 ) {
2056
+ parts = col[0].split(':')
2057
+ hour = parseInt(parts[0])
2058
+ minute = parts[1].substring(0,2)
2059
+ } else {
2060
+ hour = parseInt(col[0].substring(0,col[0].length-2))
2061
+ }
2062
+ ampm = col[0].substring(col[0].length-2,col[0].length)
2063
+ // convert hour to 24-hour format
2064
+ if ( (ampm == 'PM') || ((hour == 12) && (ampm == 'AM')) ) {
2065
+ hour += 12
2066
+ }
2067
+ // these times are EDT so add 4 for UTC
2068
+ hour += 4
2069
+ // if hour is beyond 23, note we will have to add 1 day
2070
+ if ( hour > 23 ) {
2071
+ add_date = 1
2072
+ hour -= 24
2073
+ }
2074
+
2075
+ d = new Date(this_datestring + 'T' + hour.toString().padStart(2, '0') + ':' + minute.toString().padStart(2, '0') + ':00.000+00:00')
2076
+ d.setDate(d.getDate()+add_date)
2077
+ switch(j){
2078
+ // 2nd column is start time
2079
+ case 2:
2080
+ this.cache.bigInningSchedule[this_datestring].start = d
2081
+ break
2082
+ // 3rd column is end time
2083
+ case 3:
2084
+ this.cache.bigInningSchedule[this_datestring].end = d
2085
+ break
2086
+ }
2087
+ break
2088
+ }
2089
+ }
2090
+ }
2091
+ this.debuglog(JSON.stringify(this.cache.bigInningSchedule))
2092
+
2093
+ // Default cache period is 1 day from now
2094
+ let oneDayFromNow = new Date()
2095
+ oneDayFromNow.setDate(oneDayFromNow.getDate()+1)
2096
+ let cacheExpiry = oneDayFromNow
2097
+ this.cache.bigInningScheduleCacheExpiry = cacheExpiry
2098
+
2099
+ this.save_cache_data()
2100
+ } else {
2101
+ this.log('error : invalid response from url ' + getObj.url)
2102
+ }
2103
+ } else {
2104
+ this.debuglog('using cached big inning schedule')
2105
+ }
2106
+ // If we requested the schedule for a specific date, and it exists, return it
2107
+ if ( dateString ) {
2108
+ if ( this.cache.bigInningSchedule && this.cache.bigInningSchedule[dateString] ) {
2109
+ return this.cache.bigInningSchedule[dateString]
2110
+ }
2111
+ }
2112
+ } catch(e) {
2113
+ this.log('getBigInningSchedule error : ' + e.message)
2114
+ }
2115
+ }
2116
+
2117
+ // Get Big Inning URL, used to determine the stream URL if available
2118
+ async getBigInningURL() {
2119
+ try {
2120
+ this.debuglog('getBigInningURL')
2121
+
2122
+ let cache_data
2123
+ let currentDate = new Date()
2124
+ if ( !this.cache || !this.cache.bigInningURLCacheExpiry || (currentDate > new Date(this.cache.bigInningURLCacheExpiry)) ) {
2125
+ let reqObj = {
2126
+ url: 'https://dapi.cms.mlbinfra.com/v2/content/en-us/vsmcontents/live-now-mlb-big-inning',
2127
+ headers: {
2128
+ 'User-Agent': USER_AGENT,
2129
+ 'Origin': 'https://www.mlb.com',
2130
+ 'Referer': 'https://www.mlb.com',
2131
+ 'Content-Type': 'application/json',
2132
+ 'Accept-Encoding': 'gzip, deflate, br'
2133
+ },
2134
+ gzip: true
2135
+ }
2136
+ var response = await this.httpGet(reqObj)
2137
+ if ( this.isValidJson(response) ) {
2138
+ this.debuglog(response)
2139
+ cache_data = JSON.parse(response)
2140
+
2141
+ // Default cache period is 1 day from now
2142
+ let oneDayFromNow = new Date()
2143
+ oneDayFromNow.setDate(oneDayFromNow.getDate()+1)
2144
+ let cacheExpiry = oneDayFromNow
2145
+
2146
+ let today = this.liveDate()
2147
+
2148
+ if ( cache_data.title && (cache_data.title == 'LIVE NOW: MLB Big Inning') ) {
2149
+ this.debuglog('active big inning url')
2150
+ this.cache.bigInningURL = cache_data.references.video[0].fields.url
2151
+
2152
+ if ( this.cache.bigInningSchedule[today] && (currentDate < this.cache.bigInningSchedule[today].end ) ) {
2153
+ let scheduledEnd = new Date(this.cache.bigInningSchedule[today].end)
2154
+ scheduledEnd.setHours(scheduledEnd.getHours()+1)
2155
+ this.debuglog('setting cache expiry to scheduled end plus 1 hour: ' + scheduledEnd)
2156
+ cacheExpiry = scheduledEnd
2157
+ } else {
2158
+ // if it's not in our schedule, we can find the end time by parsing the time from the slug text, then adding the duration
2159
+ let slug_array = cache_data.references.video[0].slug.split('-')
2160
+ let et_hour = parseInt(slug_array[0])
2161
+ let et_minute
2162
+ if ( slug_array[1].indexOf('pm') ) {
2163
+ et_minute = slug_array[1].replace('pm','')
2164
+ et_hour += 12
2165
+ if ( et_hour == 24 ) et_hour = 0
2166
+ } else {
2167
+ et_minute = slug_array[1].replace('am','')
2168
+ }
2169
+ let scheduledEnd = new Date(today + 'T' + et_hour.toString().padStart(2,'0') + ':' + et_minute.padStart(2,'0') + ':00.000-04:00')
2170
+
2171
+ let duration_array = cache_data.references.video[0].fields.duration.split(':')
2172
+ scheduledEnd.setHours(scheduledEnd.getHours()+(parseInt(duration_array[0])-1))
2173
+ scheduledEnd.setMinutes(scheduledEnd.getMinutes()+parseInt(duration_array[1]))
2174
+
2175
+ this.debuglog('setting cache expiry to duration: ' + scheduledEnd.toISOString())
2176
+ cacheExpiry = scheduledEnd
2177
+ }
2178
+ } else {
2179
+ this.debuglog('no active big inning url')
2180
+ this.cache.bigInningURL = ''
2181
+ // check when next big inning is scheduled to start, within 5 days
2182
+ let counter = 5
2183
+ let checkDate = today
2184
+ await this.getBigInningSchedule()
2185
+ while ( counter < 5 ) {
2186
+ if ( this.cache.bigInningSchedule[checkDate] && (currentDate < this.cache.bigInningSchedule[checkDate].start ) ) {
2187
+ this.debuglog('setting cache expiry to next scheduled start: ' + this.cache.bigInningSchedule[checkDate].start)
2188
+ cacheExpiry = this.cache.bigInningSchedule[checkDate].start
2189
+ break
2190
+ }
2191
+ checkDate = new Date(checkDate).setDate(checkDate.getDate()+1).toISOString().substring(0,10)
2192
+ counter++
2193
+ }
2194
+ }
2195
+
2196
+ // finally save the setting
2197
+ this.cache.bigInningURLCacheExpiry = cacheExpiry
2198
+ this.save_cache_data()
2199
+ } else {
2200
+ this.log('error : invalid json from url ' + getObj.url)
2201
+ }
2202
+ }
2203
+ if ( this.cache.bigInningURL != '' ) {
2204
+ return this.cache.bigInningURL
2205
+ }
2206
+ } catch(e) {
2207
+ this.log('getBigInningURL error : ' + e.message)
2208
+ }
2209
+ }
2210
+
2211
+ // Get Big Inning stream URL
2212
+ async getBigInningStreamURL() {
2213
+ this.debuglog('getBigInningStreamURL')
2214
+ if ( this.cache.bigInningStreamURL && this.cache.bigInningURLExpiry && (currentDate < new Date(this.cache.bigInningURLCacheExpiry)) ) {
2215
+ this.log('using cached bigInningStreamURL')
2216
+ return this.cache.bigInningStreamURL
2217
+ } else {
2218
+ var playbackURL = await this.getBigInningURL()
2219
+ if ( !playbackURL ) {
2220
+ this.debuglog('no active big inning url')
2221
+ } else {
2222
+ this.debuglog('getBigInningStreamURL from ' + playbackURL)
2223
+ let reqObj = {
2224
+ url: playbackURL,
2225
+ simple: false,
2226
+ headers: {
2227
+ 'Authorization': 'Bearer ' + await this.getOktaAccessToken() || this.halt('missing OktaAccessToken'),
2228
+ 'User-agent': USER_AGENT,
2229
+ 'Accept': '*/*',
2230
+ 'Origin': 'https://www.mlb.com',
2231
+ 'Referer': 'https://www.mlb.com/',
2232
+ 'Accept-Encoding': 'gzip, deflate, br'
2233
+ },
2234
+ gzip: true
2235
+ }
2236
+ var response = await this.httpGet(reqObj)
2237
+ if ( this.isValidJson(response) ) {
2238
+ this.debuglog('getBigInningStreamURL response : ' + response)
2239
+ let obj = JSON.parse(response)
2240
+ if ( obj.success && (obj.success == true) ) {
2241
+ this.debuglog('found bigInningStreamURL : ' + obj.data[0].value)
2242
+ this.cache.bigInningStreamURL = obj.data[0].value
2243
+ this.save_cache_data()
2244
+ return this.cache.bigInningStreamURL
2245
+ } else {
2246
+ this.log('getBigInningStreamURL error')
2247
+ this.log(obj.errorCode)
2248
+ this.log(obj.message)
2249
+ return
2250
+ }
2251
+ } else if ( response.startsWith('#EXTM3U') ) {
2252
+ this.debuglog('getBigInningStreamURL is bigInningURL : ' + playbackURL)
2253
+ this.cache.bigInningStreamURL = playbackURL
2254
+ this.save_cache_data()
2255
+ return this.cache.bigInningStreamURL
2256
+ }
2257
+ }
2258
+ }
2259
+ }
2260
+
2261
+ }
2262
+
2263
+ module.exports = sessionClass