mlbserver 2026.4.1-2 → 2026.4.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,6 +50,7 @@ Basic command line or Docker environment options:
50
50
  --session or -s (clears session)
51
51
  --cache or -c (clears cache)
52
52
  --env or -e (use environment variables instead of command line arguments; automatically applied in the Docker image)
53
+ --login_page or -lp (false if not specified, login is done via a login page and uses cookies)
53
54
  ```
54
55
 
55
56
  Advanced command line or Docker environment options:
@@ -68,6 +69,7 @@ Advanced command line or Docker environment options:
68
69
  --page_username (username to protect pages; default is no protection)
69
70
  --page_password (password to protect pages; default is no protection)
70
71
  --content_protect (specify the content protection key to include as a URL parameter, if page protection is enabled)
72
+ --login_page (optional, enable login page authentication; default is false, uses browswer popup when unset)
71
73
  --gamechanger_delay (specify extra delay for the gamechanger switches in 10 second increments, default is 0)
72
74
  --data_directory (defaults to installed application directory; in the Docker image, this defaults to /mlbserver/data_directory for mapping persistent storage)
73
75
  ```
@@ -15,6 +15,7 @@ services:
15
15
  #- page_username=
16
16
  #- page_password=
17
17
  #- content_protect=
18
+ #- login_page=false
18
19
  #- gamechanger_delay=0
19
20
  #- PUID=1000
20
21
  #- PGID=1000
package/index.js CHANGED
@@ -129,9 +129,10 @@ var argv = minimist(process.argv, {
129
129
  s: 'session',
130
130
  c: 'cache',
131
131
  v: 'version',
132
- e: 'env'
132
+ e: 'env',
133
+ lp: 'login_page'
133
134
  },
134
- boolean: ['ffmpeg_logging', 'debug', 'logout', 'session', 'cache', 'version', 'free', 'env'],
135
+ boolean: ['ffmpeg_logging', 'debug', 'logout', 'session', 'cache', 'version', 'free', 'env', 'login_page'],
135
136
  string: ['account_username', 'account_password', 'fav_teams', 'multiview_path', 'ffmpeg_path', 'ffmpeg_encoder', 'page_username', 'page_password', 'content_protect', 'data_directory', 'http_root']
136
137
  })
137
138
 
@@ -539,6 +540,10 @@ function getMasterPlaylist(streamURL, req, res, options = {}) {
539
540
  }
540
541
 
541
542
  let resolution = options.resolution || VALID_RESOLUTIONS[0]
543
+ // assume 1080p60 resolution is best, to enable fallback to 720p60 when necessary
544
+ if ( resolution == VALID_RESOLUTIONS[1] ) {
545
+ resolution = 'best'
546
+ }
542
547
  let audio_track = options.audio_track || VALID_AUDIO_TRACKS[0]
543
548
  // if specific audio track is requested, check if master playlist contains it
544
549
  if ( (audio_track != VALID_AUDIO_TRACKS[0]) && (audio_track != VALID_AUDIO_TRACKS[6]) ) {
@@ -1353,31 +1358,92 @@ app.get('/gamechangerplaylist.m3u8', async function(req, res) {
1353
1358
  })
1354
1359
 
1355
1360
  // Protect pages by password, or content by content_protect url parameter
1361
+ function isAuthenticated(req) {
1362
+ if (!req.headers.cookie) return false;
1363
+ const cookies = req.headers.cookie.split(';').map(c => c.trim());
1364
+ const authCookie = cookies.find(c => c.startsWith('auth_token='));
1365
+ if (!authCookie) return false;
1366
+ const token = authCookie.split('=')[1];
1367
+ return token === session.protection.content_protect;
1368
+ }
1369
+
1370
+ const AUTH_TOKEN_TTL = 24 * 60 * 60 * 1000; // 1 day
1371
+ const authenticatedSessions = new Map();
1372
+
1373
+ function isSecure(req) {
1374
+ const forwarded = req.headers['x-forwarded-proto'];
1375
+ if (forwarded) {
1376
+ return forwarded.split(',')[0].trim().toLowerCase() === 'https';
1377
+ }
1378
+ return (req.connection && req.connection.encrypted) || false;
1379
+ }
1380
+
1381
+ function cleanupExpiredSessions() {
1382
+ const now = Date.now();
1383
+ for (const [token, sessionData] of authenticatedSessions.entries()) {
1384
+ if (sessionData.expiry <= now) {
1385
+ authenticatedSessions.delete(token);
1386
+ }
1387
+ }
1388
+ }
1389
+
1390
+ function isAuthenticated(req) {
1391
+ if (!req.headers.cookie) return false;
1392
+ const cookies = req.headers.cookie.split(';').map(c => c.trim());
1393
+ const authCookie = cookies.find(c => c.startsWith('auth_token='));
1394
+ if (!authCookie) return false;
1395
+
1396
+ const token = authCookie.split('=')[1];
1397
+ const sessionData = authenticatedSessions.get(token);
1398
+ if (!sessionData) return false;
1399
+
1400
+ if (sessionData.expiry <= Date.now()) {
1401
+ authenticatedSessions.delete(token);
1402
+ return false;
1403
+ }
1404
+
1405
+ sessionData.expiry = Date.now() + AUTH_TOKEN_TTL;
1406
+ return true;
1407
+ }
1408
+
1356
1409
  async function protect(req, res) {
1410
+ cleanupExpiredSessions();
1411
+
1357
1412
  if (argv.page_username && argv.page_password) {
1358
1413
  if ( !session.protection.content_protect || !req.query.content_protect || (req.query.content_protect != session.protection.content_protect) ) {
1359
1414
  if ( !session.protection.content_protect || !req.query.content_protect || !req.query.content_protect[0] || (req.query.content_protect[0] != session.protection.content_protect) ) {
1360
- const reject = () => {
1361
- res.setHeader('www-authenticate', 'Basic')
1362
- res.error(401, ' Not Authorized')
1363
- return false
1364
- }
1415
+ if (argv.login_page && isAuthenticated(req)) {
1416
+ // Already authenticated via cookie
1417
+ } else {
1418
+ if (argv.login_page) {
1419
+ const redirectUrl = encodeURIComponent(req.url);
1420
+ res.writeHead(302, { 'Location': http_root + '/login?redirect=' + redirectUrl });
1421
+ res.end();
1422
+ return false;
1423
+ } else {
1424
+ const reject = () => {
1425
+ res.setHeader('www-authenticate', 'Basic');
1426
+ res.error(401, ' Not Authorized');
1427
+ return false;
1428
+ }
1365
1429
 
1366
- const authorization = req.headers.authorization
1430
+ const authorization = req.headers.authorization;
1367
1431
 
1368
- if(!authorization) {
1369
- return reject()
1370
- }
1432
+ if (!authorization) {
1433
+ return reject();
1434
+ }
1371
1435
 
1372
- const [username, password] = Buffer.from(authorization.replace('Basic ', ''), 'base64').toString().split(':')
1436
+ const [username, password] = Buffer.from(authorization.replace('Basic ', ''), 'base64').toString().split(':');
1373
1437
 
1374
- if(! (username === argv.page_username && password === argv.page_password)) {
1375
- return reject()
1438
+ if (! (username === argv.page_username && password === argv.page_password)) {
1439
+ return reject();
1440
+ }
1441
+ }
1376
1442
  }
1377
1443
  }
1378
1444
  }
1379
1445
  }
1380
- return true
1446
+ return true;
1381
1447
  }
1382
1448
 
1383
1449
  function getLastName(fullName) {
@@ -1390,6 +1456,89 @@ function getLastName(fullName) {
1390
1456
  return fullName.substring(indexOfSpace + 1);
1391
1457
  }
1392
1458
 
1459
+ // Login page
1460
+ app.get('/login', async function(req, res) {
1461
+ try {
1462
+ if (!argv.login_page) {
1463
+ res.error(404, 'Not Found');
1464
+ return;
1465
+ }
1466
+ const redirect = req.query.redirect || '/';
1467
+ const error = req.query.error ? '<p style="color:red;">Invalid username or password</p>' : '';
1468
+ res.writeHead(200, {'Content-Type': 'text/html'});
1469
+ res.end(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Login - ${appname}</title><style>body{color:lightgray;background-color:black;font-family:Arial,Helvetica,sans-serif;}input{background-color:black;color:lightgray;border:1px solid lightgray;}button{background-color:black;color:lightgray;border:1px solid lightgray;}</style></head><body><h1>Login to ${appname}</h1>${error}<form method="POST" action="${http_root}/login"><input type="hidden" name="redirect" value="${redirect}"><p>Username: <input type="text" name="username" required></p><p>Password: <input type="password" name="password" required></p><p><button type="submit">Login</button></p></form></body></html>`);
1470
+ } catch (e) {
1471
+ session.log('login get request error : ' + e.message);
1472
+ res.end('login get request error, check log');
1473
+ }
1474
+ });
1475
+
1476
+ function getCookieOptions(req) {
1477
+ const options = ['HttpOnly', 'Path=/', 'SameSite=Strict'];
1478
+ if (isSecure(req)) options.push('Secure');
1479
+ return options.join('; ');
1480
+ }
1481
+
1482
+ // Login POST handler
1483
+ app.post('/login', async function(req, res) {
1484
+ try {
1485
+ if (!argv.login_page) {
1486
+ res.error(404, 'Not Found');
1487
+ return;
1488
+ }
1489
+ let body = '';
1490
+ req.on('data', chunk => { body += chunk.toString(); });
1491
+ req.on('end', () => {
1492
+ const params = new URLSearchParams(body);
1493
+ const username = params.get('username');
1494
+ const password = params.get('password');
1495
+ const redirect = params.get('redirect') || '/';
1496
+ if (username === argv.page_username && password === argv.page_password) {
1497
+ const token = crypto.randomBytes(16).toString('hex');
1498
+ authenticatedSessions.set(token, { expiry: Date.now() + AUTH_TOKEN_TTL });
1499
+ res.writeHead(302, {
1500
+ 'Location': redirect,
1501
+ 'Set-Cookie': `auth_token=${token}; ${getCookieOptions(req)}`
1502
+ });
1503
+ res.end();
1504
+ } else {
1505
+ res.writeHead(302, { 'Location': `${http_root}/login?redirect=${encodeURIComponent(redirect)}&error=1` });
1506
+ res.end();
1507
+ }
1508
+ });
1509
+ } catch (e) {
1510
+ session.log('login post request error : ' + e.message);
1511
+ res.end('login post request error, check log');
1512
+ }
1513
+ });
1514
+
1515
+ // Logout
1516
+ app.get('/logout', async function(req, res) {
1517
+ try {
1518
+ if (!argv.login_page) {
1519
+ res.error(404, 'Not Found');
1520
+ return;
1521
+ }
1522
+ let token = '';
1523
+ if (req.headers.cookie) {
1524
+ const cookies = req.headers.cookie.split(';').map(c => c.trim());
1525
+ const authCookie = cookies.find(c => c.startsWith('auth_token='));
1526
+ if (authCookie) token = authCookie.split('=')[1];
1527
+ }
1528
+ if (token && authenticatedSessions.has(token)) {
1529
+ authenticatedSessions.delete(token);
1530
+ }
1531
+ res.writeHead(302, {
1532
+ 'Location': '/',
1533
+ 'Set-Cookie': `auth_token=; ${getCookieOptions(req)}; Max-Age=0`
1534
+ });
1535
+ res.end();
1536
+ } catch (e) {
1537
+ session.log('logout request error : ' + e.message);
1538
+ res.end('logout request error, check log');
1539
+ }
1540
+ });
1541
+
1393
1542
  // Server homepage, base URL
1394
1543
  app.get('/', async function(req, res) {
1395
1544
  try {
@@ -1570,6 +1719,10 @@ app.get('/', async function(req, res) {
1570
1719
 
1571
1720
  body += '</script></head><body><h1>' + appname + '</h1>' + "\n"
1572
1721
 
1722
+ if (argv.login_page) {
1723
+ body += '<p><a href="' + http_root + '/logout">Logout</a></p>' + "\n"
1724
+ }
1725
+
1573
1726
  body += '<p><span class="tooltip tinytext">Touch or hover over an option name for more details</span></p>' + "\n"
1574
1727
 
1575
1728
  todayUTCHours -= 4
@@ -2382,7 +2535,10 @@ app.get('/', async function(req, res) {
2382
2535
  }
2383
2536
 
2384
2537
  if ( mediaType == VALID_MEDIA_TYPES[0] ) {
2385
- body += '<p><span class="tooltip">Video<span class="tooltiptext">For video streams only: you can manually specifiy a video track (resolution) to use. Adaptive will let your client choose. 1080p60 or 720p60 is the best quality. 540p is default for multiview (see below).<br/><br/>None will allow to remove the video tracks, if you just want to listen to the audio while using the "start at inning" or "skip breaks" options enabled.</span></span>: '
2538
+ body += '<p><span class="tooltip">Video<span class="tooltiptext">For video streams only: you can manually specifiy a video track (resolution) to use. Adaptive will let your client choose. Best will select either 1080p60 (MLB) or 720p60 (MiLB). 504p is default for multiview (see below).<br/><br/>None will allow to remove the video tracks, if you just want to listen to the audio while using the "start at inning" or "skip breaks" options enabled.</span></span>: '
2539
+ body += '<button '
2540
+ if ( resolution == 'best' ) body += 'class="default" '
2541
+ body += 'onclick="resolution=\'best\';reload()">best</button> '
2386
2542
  for (var i = 0; i < VALID_RESOLUTIONS.length; i++) {
2387
2543
  body += '<button '
2388
2544
  if ( resolution == VALID_RESOLUTIONS[i] ) body += 'class="default" '
@@ -2593,6 +2749,8 @@ app.get('/', async function(req, res) {
2593
2749
  }
2594
2750
 
2595
2751
  body += '<p><span class="tooltip">Comskip link examples<span class="tooltiptext">You can generate a <a href="https://github.com/erikkaashoek/Comskip">Comskip</a>-style file to automatically skip sections (breaks, idle time, or non-action pitches) of games you record using DVR software when watched in compatible players. For example, if you record a game from your local OTA channel using Tvheadend, you can then fetch one of these Comskip files, put it in the same directory with the same name as your recorded video file, and Kodi will automatically skip those sections while you watch the video.<br><br>Specifying the team and broadcast_start_timestamp in the URL is required! For the timestamp, use the time your DVR software began the recording. This should be your local time in YYYY-MM-DDTHH:MM:SS format.<br><br>Specifying a skip_adjust value in the URL is recommended, to adjust for broadcast delays. This will vary across different channels and different video sources.<br><br>For the txt file format, specifying the video frame rate (fps) in the URL is also required. This will commonly be either 30, 59.94, or 60, depending on your video source.<br><br>Optionally, setting pad to "on" will generate random extra skips at the end, to help avoid timeline spoilers.</span></span>: <a href="' + http_root + '/comskip.edl?team=CHC&date=2025-10-01&pad=on&skip=pitches&skip_adjust=11&broadcast_start_timestamp=2025-10-01T14:00:00' + content_protect_a + '">comskip.edl</a> or <a href="' + http_root + '/comskip.txt?team=CHC&date=2025-10-01&pad=on&skip=pitches&skip_adjust=11&broadcast_start_timestamp=2025-10-01T14:00:00&fps=59.94' + content_protect_a + '">comskip.txt</a></p>' + "\n"
2752
+
2753
+ body += '<p><span class="tooltip">MPEG-TS examples<span class="tooltiptext">Experimental feature: a MPEGTS output format where you can adjust the audio sync with a URL parameter. Useful if the radio track is a consistent number of seconds ahead or behind the video track. Use positive sync values if radio is early, or negative values if radio is late.</span></span>: <a href="' + http_root + '/stream.ts?team=' + example_team + content_protect_a + '">Stream</a> or <a href="' + http_root + '/stream.ts?team=' + example_team + '&audio_track=radio&sync=2.3' + content_protect_a + '">Stream w/ radio sync</a></p>' + "\n"
2596
2754
 
2597
2755
  body += '</p></td></tr></table><br/>' + "\n"
2598
2756
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlbserver",
3
- "version": "2026.4.1-2",
3
+ "version": "2026.4.23",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
package/session.js CHANGED
@@ -3556,40 +3556,43 @@ class sessionClass {
3556
3556
 
3557
3557
  let currentDate = new Date()
3558
3558
  if ( !this.cache || !this.cache.bigInningScheduleCacheExpiry || (currentDate > new Date(this.cache.bigInningScheduleCacheExpiry)) ) {
3559
- if ( !this.cache.bigInningSchedule ) this.cache.bigInningSchedule = {}
3559
+ this.cache.bigInningSchedule = {}
3560
3560
  let reqObj = {
3561
- url: 'https://www.fubo.tv/welcome/channel/mlb-big-inning',
3561
+ url: 'https://watch.product.api.espn.com/api/product/v3/watchespn/web/catalog/ae4eb028-0af3-42e7-8965-9304c5817969?lang=en&features=continueWatching%2Csfb-all%2Cpbov7%2Chigh-volume-row%2Csc4u%2Cguide-menu-header%2Ccutl%2Cheader-quickserve%2Cautoplay%2Cwatch-web-redesign%2CimageRatio58x13%2CpromoTiles%2CopenAuthz%2Cvideo-header%2Cexplore-row%2Cbutton-service%2Cinline-header%2Cflagship&deviceBrand=web&streamMenu=true&headerBgImageWidth=1280&countryCode=US&entitlements=no&tz=UTC-0400&userab=espn_watch_for_you_web-392*watch-fy-a-1642',
3562
3562
  headers: {
3563
- 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3563
+ 'accept': '*/*',
3564
3564
  'accept-encoding': 'gzip, deflate, br, zstd',
3565
- 'referer': 'https://www.fubo.tv',
3565
+ 'origin': 'https://www.espn.com',
3566
+ 'referer': 'https://www.espn.com/',
3566
3567
  'user-agent': USER_AGENT
3567
3568
  },
3569
+ json: true,
3568
3570
  gzip: true
3569
3571
  }
3570
3572
  var response = await this.httpGet(reqObj, false)
3571
3573
  if ( response ) {
3572
- // disabled because it's big
3573
- //this.debuglog(response)
3574
+ this.debuglog(JSON.stringify(response))
3574
3575
 
3575
- let nextdatastring = response.match(/<script id=\"__NEXT_DATA__\" type=\"application\/json\">(.*?)<\/script>/)
3576
- let nextdata = JSON.parse(nextdatastring[1])
3577
- let initialState = JSON.parse(nextdata.props.pageProps.initialState.replace(/\\"/g, '"'))
3578
-
3579
- initialState.channel.channelPrograms.live.data.forEach((program) => {
3580
- program.airings.forEach((airing) => {
3581
- let est_date = new Date(airing.accessRightsV2.live.startTime).toLocaleString("en-US", {timeZone: 'America/New_York'})
3582
- let date_array = est_date.split(',')[0].split('/')
3583
- let this_datestring = date_array[2] + '-' + date_array[0].padStart(2, '0') + '-' + date_array[1].padStart(2, '0')
3584
- if ( !this.cache.bigInningSchedule[this_datestring] || !this.cache.bigInningSchedule[this_datestring].length ) {
3585
- this.cache.bigInningSchedule[this_datestring] = []
3576
+ if ( response.page && response.page.buckets && response.page.buckets[0] && response.page.buckets[0].contents ) {
3577
+ for (var i=0; i < response.page.buckets[0].contents.length; i++) {
3578
+ let content = response.page.buckets[0].contents[i]
3579
+ let big_inning_date = content.utc.substring(0, 10)
3580
+ let big_inning_start = content.utc
3581
+ for (var j=0; j < content.streams.length; j++) {
3582
+ let stream = content.streams[j]
3583
+ let big_inning_end = new Date(content.utc)
3584
+ big_inning_end.setSeconds(stream.durationInSeconds)
3585
+ if ( !this.cache.bigInningSchedule[big_inning_date] || !this.cache.bigInningSchedule[big_inning_date].length ) {
3586
+ this.cache.bigInningSchedule[big_inning_date] = []
3587
+ }
3588
+ this.cache.bigInningSchedule[big_inning_date].push({
3589
+ start: big_inning_start,
3590
+ end: big_inning_end
3591
+ })
3592
+ break
3586
3593
  }
3587
- this.cache.bigInningSchedule[this_datestring].push({
3588
- start: airing.accessRightsV2.live.startTime,
3589
- end: airing.accessRightsV2.live.endTime
3590
- })
3591
- });
3592
- });
3594
+ }
3595
+ }
3593
3596
  this.debuglog(JSON.stringify(this.cache.bigInningSchedule))
3594
3597
 
3595
3598
  // Default cache period is 1 day from now