mlbserver 2026.4.1-2 → 2026.4.16

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
 
@@ -1353,31 +1354,92 @@ app.get('/gamechangerplaylist.m3u8', async function(req, res) {
1353
1354
  })
1354
1355
 
1355
1356
  // Protect pages by password, or content by content_protect url parameter
1357
+ function isAuthenticated(req) {
1358
+ if (!req.headers.cookie) return false;
1359
+ const cookies = req.headers.cookie.split(';').map(c => c.trim());
1360
+ const authCookie = cookies.find(c => c.startsWith('auth_token='));
1361
+ if (!authCookie) return false;
1362
+ const token = authCookie.split('=')[1];
1363
+ return token === session.protection.content_protect;
1364
+ }
1365
+
1366
+ const AUTH_TOKEN_TTL = 24 * 60 * 60 * 1000; // 1 day
1367
+ const authenticatedSessions = new Map();
1368
+
1369
+ function isSecure(req) {
1370
+ const forwarded = req.headers['x-forwarded-proto'];
1371
+ if (forwarded) {
1372
+ return forwarded.split(',')[0].trim().toLowerCase() === 'https';
1373
+ }
1374
+ return (req.connection && req.connection.encrypted) || false;
1375
+ }
1376
+
1377
+ function cleanupExpiredSessions() {
1378
+ const now = Date.now();
1379
+ for (const [token, sessionData] of authenticatedSessions.entries()) {
1380
+ if (sessionData.expiry <= now) {
1381
+ authenticatedSessions.delete(token);
1382
+ }
1383
+ }
1384
+ }
1385
+
1386
+ function isAuthenticated(req) {
1387
+ if (!req.headers.cookie) return false;
1388
+ const cookies = req.headers.cookie.split(';').map(c => c.trim());
1389
+ const authCookie = cookies.find(c => c.startsWith('auth_token='));
1390
+ if (!authCookie) return false;
1391
+
1392
+ const token = authCookie.split('=')[1];
1393
+ const sessionData = authenticatedSessions.get(token);
1394
+ if (!sessionData) return false;
1395
+
1396
+ if (sessionData.expiry <= Date.now()) {
1397
+ authenticatedSessions.delete(token);
1398
+ return false;
1399
+ }
1400
+
1401
+ sessionData.expiry = Date.now() + AUTH_TOKEN_TTL;
1402
+ return true;
1403
+ }
1404
+
1356
1405
  async function protect(req, res) {
1406
+ cleanupExpiredSessions();
1407
+
1357
1408
  if (argv.page_username && argv.page_password) {
1358
1409
  if ( !session.protection.content_protect || !req.query.content_protect || (req.query.content_protect != session.protection.content_protect) ) {
1359
1410
  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
- }
1411
+ if (argv.login_page && isAuthenticated(req)) {
1412
+ // Already authenticated via cookie
1413
+ } else {
1414
+ if (argv.login_page) {
1415
+ const redirectUrl = encodeURIComponent(req.url);
1416
+ res.writeHead(302, { 'Location': http_root + '/login?redirect=' + redirectUrl });
1417
+ res.end();
1418
+ return false;
1419
+ } else {
1420
+ const reject = () => {
1421
+ res.setHeader('www-authenticate', 'Basic');
1422
+ res.error(401, ' Not Authorized');
1423
+ return false;
1424
+ }
1365
1425
 
1366
- const authorization = req.headers.authorization
1426
+ const authorization = req.headers.authorization;
1367
1427
 
1368
- if(!authorization) {
1369
- return reject()
1370
- }
1428
+ if (!authorization) {
1429
+ return reject();
1430
+ }
1371
1431
 
1372
- const [username, password] = Buffer.from(authorization.replace('Basic ', ''), 'base64').toString().split(':')
1432
+ const [username, password] = Buffer.from(authorization.replace('Basic ', ''), 'base64').toString().split(':');
1373
1433
 
1374
- if(! (username === argv.page_username && password === argv.page_password)) {
1375
- return reject()
1434
+ if (! (username === argv.page_username && password === argv.page_password)) {
1435
+ return reject();
1436
+ }
1437
+ }
1376
1438
  }
1377
1439
  }
1378
1440
  }
1379
1441
  }
1380
- return true
1442
+ return true;
1381
1443
  }
1382
1444
 
1383
1445
  function getLastName(fullName) {
@@ -1390,6 +1452,89 @@ function getLastName(fullName) {
1390
1452
  return fullName.substring(indexOfSpace + 1);
1391
1453
  }
1392
1454
 
1455
+ // Login page
1456
+ app.get('/login', async function(req, res) {
1457
+ try {
1458
+ if (!argv.login_page) {
1459
+ res.error(404, 'Not Found');
1460
+ return;
1461
+ }
1462
+ const redirect = req.query.redirect || '/';
1463
+ const error = req.query.error ? '<p style="color:red;">Invalid username or password</p>' : '';
1464
+ res.writeHead(200, {'Content-Type': 'text/html'});
1465
+ 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>`);
1466
+ } catch (e) {
1467
+ session.log('login get request error : ' + e.message);
1468
+ res.end('login get request error, check log');
1469
+ }
1470
+ });
1471
+
1472
+ function getCookieOptions(req) {
1473
+ const options = ['HttpOnly', 'Path=/', 'SameSite=Strict'];
1474
+ if (isSecure(req)) options.push('Secure');
1475
+ return options.join('; ');
1476
+ }
1477
+
1478
+ // Login POST handler
1479
+ app.post('/login', async function(req, res) {
1480
+ try {
1481
+ if (!argv.login_page) {
1482
+ res.error(404, 'Not Found');
1483
+ return;
1484
+ }
1485
+ let body = '';
1486
+ req.on('data', chunk => { body += chunk.toString(); });
1487
+ req.on('end', () => {
1488
+ const params = new URLSearchParams(body);
1489
+ const username = params.get('username');
1490
+ const password = params.get('password');
1491
+ const redirect = params.get('redirect') || '/';
1492
+ if (username === argv.page_username && password === argv.page_password) {
1493
+ const token = crypto.randomBytes(16).toString('hex');
1494
+ authenticatedSessions.set(token, { expiry: Date.now() + AUTH_TOKEN_TTL });
1495
+ res.writeHead(302, {
1496
+ 'Location': redirect,
1497
+ 'Set-Cookie': `auth_token=${token}; ${getCookieOptions(req)}`
1498
+ });
1499
+ res.end();
1500
+ } else {
1501
+ res.writeHead(302, { 'Location': `${http_root}/login?redirect=${encodeURIComponent(redirect)}&error=1` });
1502
+ res.end();
1503
+ }
1504
+ });
1505
+ } catch (e) {
1506
+ session.log('login post request error : ' + e.message);
1507
+ res.end('login post request error, check log');
1508
+ }
1509
+ });
1510
+
1511
+ // Logout
1512
+ app.get('/logout', async function(req, res) {
1513
+ try {
1514
+ if (!argv.login_page) {
1515
+ res.error(404, 'Not Found');
1516
+ return;
1517
+ }
1518
+ let token = '';
1519
+ if (req.headers.cookie) {
1520
+ const cookies = req.headers.cookie.split(';').map(c => c.trim());
1521
+ const authCookie = cookies.find(c => c.startsWith('auth_token='));
1522
+ if (authCookie) token = authCookie.split('=')[1];
1523
+ }
1524
+ if (token && authenticatedSessions.has(token)) {
1525
+ authenticatedSessions.delete(token);
1526
+ }
1527
+ res.writeHead(302, {
1528
+ 'Location': '/',
1529
+ 'Set-Cookie': `auth_token=; ${getCookieOptions(req)}; Max-Age=0`
1530
+ });
1531
+ res.end();
1532
+ } catch (e) {
1533
+ session.log('logout request error : ' + e.message);
1534
+ res.end('logout request error, check log');
1535
+ }
1536
+ });
1537
+
1393
1538
  // Server homepage, base URL
1394
1539
  app.get('/', async function(req, res) {
1395
1540
  try {
@@ -1570,6 +1715,10 @@ app.get('/', async function(req, res) {
1570
1715
 
1571
1716
  body += '</script></head><body><h1>' + appname + '</h1>' + "\n"
1572
1717
 
1718
+ if (argv.login_page) {
1719
+ body += '<p><a href="' + http_root + '/logout">Logout</a></p>' + "\n"
1720
+ }
1721
+
1573
1722
  body += '<p><span class="tooltip tinytext">Touch or hover over an option name for more details</span></p>' + "\n"
1574
1723
 
1575
1724
  todayUTCHours -= 4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlbserver",
3
- "version": "2026.4.1-2",
3
+ "version": "2026.4.16",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
package/session.js CHANGED
@@ -3558,38 +3558,41 @@ class sessionClass {
3558
3558
  if ( !this.cache || !this.cache.bigInningScheduleCacheExpiry || (currentDate > new Date(this.cache.bigInningScheduleCacheExpiry)) ) {
3559
3559
  if ( !this.cache.bigInningSchedule ) 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