mlbserver 2026.3.27-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/Dockerfile CHANGED
@@ -1,24 +1,13 @@
1
- # --- Build Stage ---
2
- FROM node:18-alpine AS build
1
+ FROM node:16-alpine
3
2
 
4
- # Set environment variable to skip the automatic Chromium download by Puppeteer
5
- ENV PUPPETEER_SKIP_DOWNLOAD=true
6
- ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
7
-
8
- # Install system-level dependencies for Chromium on Alpine
9
- RUN apk update && apk add --no-cache \
10
- tzdata \
11
- udev \
12
- ttf-freefont \
13
- chromium \
14
- nss \
15
- freetype \
16
- harfbuzz \
17
- ca-certificates
3
+ RUN apk update && apk add tzdata
18
4
 
19
5
  # Create app directory
20
6
  WORKDIR /mlbserver
21
7
 
8
+ # Add data directory
9
+ VOLUME /mlbserver/data_directory
10
+
22
11
  # Install app dependencies
23
12
  # A wildcard is used to ensure both package.json AND package-lock.json are copied
24
13
  # where available (npm@5+)
@@ -31,29 +20,5 @@ RUN npm install
31
20
  # Bundle app source
32
21
  COPY . .
33
22
 
34
- # --- Runtime Stage ---
35
- FROM node:20-alpine AS runtime
36
-
37
- # Install only the necessary runtime dependencies again
38
- RUN apk add --no-cache \
39
- tzdata \
40
- udev \
41
- ttf-freefont \
42
- chromium \
43
- nss \
44
- freetype \
45
- harfbuzz \
46
- ca-certificates
47
-
48
- # Set the executable path for Puppeteer
49
- ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
50
-
51
- WORKDIR /mlbserver
52
- # Copy built application from the build stage
53
- COPY --from=build /mlbserver .
54
-
55
- # Add data directory
56
- VOLUME /mlbserver/data_directory
57
-
58
23
  EXPOSE 9999 10000
59
24
  CMD [ "node", "index.js", "--env", "--port", "9999", "--multiview_port", "10000", "--data_directory", "/mlbserver/data_directory" ]
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
 
@@ -262,6 +263,7 @@ app.get('/clearcache', async function(req, res) {
262
263
  session.log('Clearing session...')
263
264
  session.clear_session_data()
264
265
  session = new sessionClass(argv)
266
+ session.setPorts(port, multiview_port)
265
267
 
266
268
  let server = (req.headers['x-forwarded-proto'] ? req.headers['x-forwarded-proto'] : 'http') + '://' + req.headers.host + http_root
267
269
  res.redirect(server)
@@ -1352,31 +1354,92 @@ app.get('/gamechangerplaylist.m3u8', async function(req, res) {
1352
1354
  })
1353
1355
 
1354
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
+
1355
1405
  async function protect(req, res) {
1406
+ cleanupExpiredSessions();
1407
+
1356
1408
  if (argv.page_username && argv.page_password) {
1357
1409
  if ( !session.protection.content_protect || !req.query.content_protect || (req.query.content_protect != session.protection.content_protect) ) {
1358
1410
  if ( !session.protection.content_protect || !req.query.content_protect || !req.query.content_protect[0] || (req.query.content_protect[0] != session.protection.content_protect) ) {
1359
- const reject = () => {
1360
- res.setHeader('www-authenticate', 'Basic')
1361
- res.error(401, ' Not Authorized')
1362
- return false
1363
- }
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
+ }
1364
1425
 
1365
- const authorization = req.headers.authorization
1426
+ const authorization = req.headers.authorization;
1366
1427
 
1367
- if(!authorization) {
1368
- return reject()
1369
- }
1428
+ if (!authorization) {
1429
+ return reject();
1430
+ }
1370
1431
 
1371
- const [username, password] = Buffer.from(authorization.replace('Basic ', ''), 'base64').toString().split(':')
1432
+ const [username, password] = Buffer.from(authorization.replace('Basic ', ''), 'base64').toString().split(':');
1372
1433
 
1373
- if(! (username === argv.page_username && password === argv.page_password)) {
1374
- return reject()
1434
+ if (! (username === argv.page_username && password === argv.page_password)) {
1435
+ return reject();
1436
+ }
1437
+ }
1375
1438
  }
1376
1439
  }
1377
1440
  }
1378
1441
  }
1379
- return true
1442
+ return true;
1380
1443
  }
1381
1444
 
1382
1445
  function getLastName(fullName) {
@@ -1389,6 +1452,89 @@ function getLastName(fullName) {
1389
1452
  return fullName.substring(indexOfSpace + 1);
1390
1453
  }
1391
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
+
1392
1538
  // Server homepage, base URL
1393
1539
  app.get('/', async function(req, res) {
1394
1540
  try {
@@ -1542,8 +1688,7 @@ app.get('/', async function(req, res) {
1542
1688
  var body = '<!DOCTYPE html><html><head><meta charset="UTF-8"><meta http-equiv="Content-type" content="text/html;charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"><title>' + appname + '</title><link rel="icon" href="favicon.svg' + content_protect_a + '"><style type="text/css">input[type=text],input[type=button]{-webkit-appearance:none;-webkit-border-radius:0}body{width:480px;color:lightgray;background-color:black;font-family:Arial,Helvetica,sans-serif;-webkit-text-size-adjust:none}a{color:darkgray}button{color:lightgray;background-color:black}button.default{color:black;background-color:lightgray}table{width:100%;pad}table,th,td{border:1px solid darkgray;border-collapse:collapse}th,td{padding:5px}.tinytext,textarea,input[type="number"]{font-size:.8em}textarea{width:380px}.freegame,.freegame a{color:green}.blackout,.blackout a{text-decoration:line-through}'
1543
1689
 
1544
1690
  // Highlights CSS
1545
- //max-height:calc(100vh-110px);
1546
- body += '.modal{display:none;position:fixed;z-index:1;padding-top:100px;left:0;top:0;width:100%;height:100%;overflow:auto;-webkit-overflow-scrolling:touch;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)}.modal-content{background-color:#fefefe;margin:auto;padding:10px;border:1px solid #888;width:360px;color:black}#highlights a{color:black}.close{color:black;float:right;font-size:28px;font-weight:bold;}#highlights a:hover,#highlights a:focus,.close:hover,.close:focus{color:gray;text-decoration:none;cursor:pointer;}'
1691
+ body += '.modal{display:none;position:fixed;z-index:1;left:0;top:0;width:100%;height:100%;overflow:hidden;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)}.modal-content{position:absolute;top:100px;bottom:20px;left:50%;transform:translateX(-50%);background-color:#fefefe;padding:10px;border:1px solid #888;width:360px;overflow-y:auto;color:black}#highlights{overflow-y:auto;}#highlights a{color:black}.close{color:black;float:right;font-size:28px;font-weight:bold;}#highlights a:hover,#highlights a:focus,.close:hover,.close:focus{color:gray;text-decoration:none;cursor:pointer;}'
1547
1692
 
1548
1693
  // Tooltip CSS
1549
1694
  body += '.tooltip{position:relative;display:inline-block;border-bottom: 1px dotted gray;}.tooltip .tooltiptext{font-size:.8em;visibility:hidden;width:360px;background-color:gray;color:white;text-align:left;padding:5px;border-radius:6px;position:absolute;z-index:1;top:100%;left:75%;margin-left:-30px;}.tooltip:hover .tooltiptext{visibility:visible;}'
@@ -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
@@ -1829,37 +1978,38 @@ app.get('/', async function(req, res) {
1829
1978
  if ( (entitlements.length > 0) && cache_data.dates && cache_data.dates[0] && (cache_data.dates[0].date >= today) && cache_data.dates[0].games && (cache_data.dates[0].games.length > 1) && cache_data.dates[0].games[0] && (cache_data.dates[0].games[0].seriesDescription == 'Regular Season') ) {
1830
1979
  // Scraped Big Inning schedule
1831
1980
  big_inning = await session.getBigInningSchedule(gameDate)
1832
-
1833
- // Generated Big Inning schedule (disabled)
1834
- //big_inning = await session.generateBigInningSchedule(gameDate)
1835
- }
1836
- if ( big_inning && big_inning.start ) {
1837
- body += '<tr><td><span class="tooltip">' + new Date(big_inning.start).toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + ' - ' + new Date(big_inning.end).toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + '<span class="tooltiptext">Big Inning is the live look-in and highlights show. <a href="https://support.mlb.com/s/article/What-Is-MLB-Big-Inning">See here for more information</a>.</span></span></td><td>'
1838
- let compareStart = new Date(big_inning.start)
1839
- compareStart.setMinutes(compareStart.getMinutes()-10)
1840
- let compareEnd = new Date(big_inning.end)
1841
- compareEnd.setHours(compareEnd.getHours()+1)
1842
- if ( (currentDate >= compareStart) && (currentDate < compareEnd) ) {
1843
- let querystring = '?event=biginning'
1844
- let multiviewquerystring = querystring + '&resolution=' + DEFAULT_MULTIVIEW_RESOLUTION
1845
- if ( linkType == VALID_LINK_TYPES[0] ) {
1846
- if ( startFrom != VALID_START_FROM[0] ) querystring += '&startFrom=' + startFrom
1847
- if ( controls != VALID_CONTROLS[0] ) querystring += '&controls=' + controls
1848
- }
1849
- if ( resolution != VALID_RESOLUTIONS[0] ) querystring += '&resolution=' + resolution
1850
- if ( linkType == VALID_LINK_TYPES[1] ) {
1851
- if ( force_vod != VALID_FORCE_VOD[0] ) querystring += '&force_vod=' + force_vod
1852
- } else if ( linkType == VALID_LINK_TYPES[4] ) {
1853
- querystring += '&filename=' + gameDate + ' Big Inning'
1981
+ }
1982
+ if ( big_inning ) {
1983
+ for (var i = 0; i < big_inning.length; i++) {
1984
+ if ( big_inning[i].start ) {
1985
+ body += '<tr><td><span class="tooltip">' + new Date(big_inning[i].start).toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + ' - ' + new Date(big_inning[i].end).toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + '<span class="tooltiptext">Big Inning is the live look-in and highlights show. <a href="https://support.mlb.com/s/article/What-Is-MLB-Big-Inning">See here for more information</a>.</span></span></td><td>'
1986
+ let compareStart = new Date(big_inning[i].start)
1987
+ compareStart.setMinutes(compareStart.getMinutes()-10)
1988
+ let compareEnd = new Date(big_inning[i].end)
1989
+ compareEnd.setHours(compareEnd.getHours()+1)
1990
+ if ( (currentDate >= compareStart) && (currentDate < compareEnd) ) {
1991
+ let querystring = '?event=biginning'
1992
+ let multiviewquerystring = querystring + '&resolution=' + DEFAULT_MULTIVIEW_RESOLUTION
1993
+ if ( linkType == VALID_LINK_TYPES[0] ) {
1994
+ if ( startFrom != VALID_START_FROM[0] ) querystring += '&startFrom=' + startFrom
1995
+ if ( controls != VALID_CONTROLS[0] ) querystring += '&controls=' + controls
1996
+ }
1997
+ if ( resolution != VALID_RESOLUTIONS[0] ) querystring += '&resolution=' + resolution
1998
+ if ( linkType == VALID_LINK_TYPES[1] ) {
1999
+ if ( force_vod != VALID_FORCE_VOD[0] ) querystring += '&force_vod=' + force_vod
2000
+ } else if ( linkType == VALID_LINK_TYPES[4] ) {
2001
+ querystring += '&filename=' + gameDate + ' Big Inning'
2002
+ }
2003
+ querystring += content_protect_b
2004
+ multiviewquerystring += content_protect_b
2005
+ body += '<a href="' + thislink + querystring + '">Big Inning</a>'
2006
+ body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + '/stream.m3u8' + multiviewquerystring + '" onclick="addmultiview(this)">'
2007
+ } else {
2008
+ body += 'Big Inning'
2009
+ }
2010
+ body += '</td></tr>' + "\n"
1854
2011
  }
1855
- querystring += content_protect_b
1856
- multiviewquerystring += content_protect_b
1857
- body += '<a href="' + thislink + querystring + '">Big Inning</a>'
1858
- body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + '/stream.m3u8' + multiviewquerystring + '" onclick="addmultiview(this)">'
1859
- } else {
1860
- body += 'Big Inning'
1861
2012
  }
1862
- body += '</td></tr>' + "\n"
1863
2013
  }
1864
2014
 
1865
2015
  // Game Changer and Stream Finder
@@ -3320,11 +3470,13 @@ function start_multiview_stream(streams, sync, dvr, faster, reencode, park_audio
3320
3470
  //let audio_input = audio_present[i] + ':a:m:language:en?'
3321
3471
  let audio_input = audio_present[i] + ':a:'
3322
3472
  let video_url = streams[audio_present[i]]
3323
- if ( !video_url || video_url.includes('audio_track=English') || !video_url.includes('audio_track=') ) {
3473
+ // disabled code below because we can now assume
3474
+ // the first audio track is the one we want
3475
+ //if ( !video_url || video_url.includes('audio_track=English') || !video_url.includes('audio_track=') ) {
3324
3476
  audio_input += '0'
3325
- } else {
3477
+ /*} else {
3326
3478
  audio_input += '1'
3327
- }
3479
+ }*/
3328
3480
  let filter = ''
3329
3481
  // Optionally apply sync adjustments
3330
3482
  if ( sync[audio_present[i]] ) {
@@ -3531,11 +3683,13 @@ app.get('/download.ts', async function(req, res) {
3531
3683
  if ( ! (await protect(req, res)) ) return
3532
3684
 
3533
3685
  try {
3686
+ let ffmpeg_timeout = 432000
3534
3687
  // we'll know it's an actual download request if it include a filename parameter
3535
3688
  if ( req.query.filename ) {
3536
3689
  session.requestlog('download.ts', req)
3537
3690
  } else {
3538
3691
  session.debuglog('force alternate audio', req)
3692
+ ffmpeg_timeout = 20
3539
3693
  }
3540
3694
 
3541
3695
  let server = 'http://127.0.0.1:' + session.data.port + http_root
@@ -3561,7 +3715,7 @@ app.get('/download.ts', async function(req, res) {
3561
3715
  }
3562
3716
  }
3563
3717
 
3564
- ffmpeg_command = ffmpeg({ timeout: 432000 })
3718
+ ffmpeg_command = ffmpeg({ timeout: ffmpeg_timeout })
3565
3719
 
3566
3720
  // Set input stream and minimize ffmpeg startup latency
3567
3721
  ffmpeg_command.input(video_url)
@@ -3619,15 +3773,21 @@ app.get('/download.ts', async function(req, res) {
3619
3773
  ffmpeg_command.addOutputOption('-f', 'mpegts')
3620
3774
  .output(res)
3621
3775
  .on('start', function(commandLine) {
3622
- session.debuglog('download.ts command started')
3776
+ session.debuglog('download.ts command started for ' + video_url)
3623
3777
  if ( argv.debug || argv.ffmpeg_logging ) {
3624
- session.debuglog('download.ts command: ' + commandLine)
3778
+ session.log('download.ts command: ' + commandLine)
3625
3779
  }
3626
3780
  })
3627
3781
  .on('error', function(err, stdout, stderr) {
3628
- session.debuglog('download.ts command stopped: ' + err.message)
3629
- if ( stdout ) session.log(stdout)
3630
- if ( stderr ) session.log(stderr)
3782
+ if (err.message.includes('timeout')) {
3783
+ session.debuglog('download.ts command timeout: ' + err.message)
3784
+ } else {
3785
+ session.debuglog('download.ts command error: ' + err.message)
3786
+ }
3787
+ if ( stdout ) session.debuglog(stdout)
3788
+ if ( stderr ) session.debuglog(stderr)
3789
+ ffmpeg_command.kill('SIGKILL')
3790
+ session.debuglog('killed ffmpeg process due to error processing ' + video_url)
3631
3791
  })
3632
3792
  .on('end', function() {
3633
3793
  session.debuglog('download.ts command ended')
@@ -3795,3 +3955,142 @@ app.get('/comskip.txt', async function(req, res) {
3795
3955
  res.end('comskip.txt request error, check log')
3796
3956
  }
3797
3957
  })
3958
+
3959
+ // Listen for embedded MPEGTS stream requests
3960
+ // embedded player not fully tested
3961
+ app.get('/mpegts.html', async function(req, res) {
3962
+ if ( ! (await protect(req, res)) ) return
3963
+
3964
+ try {
3965
+ let server = 'http://127.0.0.1:' + session.data.port + http_root
3966
+
3967
+ let video_url = '/stream.ts'
3968
+ if ( req.query.src ) {
3969
+ video_url = req.query.src
3970
+ } else {
3971
+ let urlArray = req.url.split('?')
3972
+ if ( (urlArray.length == 2) ) {
3973
+ video_url += '?' + urlArray[1]
3974
+ }
3975
+ video_url = server + video_url
3976
+ }
3977
+ session.debuglog('mpegts.html src : ' + video_url)
3978
+
3979
+ var body = '<html><script src="https://xqq.im/mpegts.js/dist/mpegts.js"></script><style type"text/css">body{background-color:black}video{width:100% !important;height:auto !important;max-width:1280px}</style><body><video id="videoElement" controls autoplay playsinline></video><script>if (mpegts.getFeatureList().mseLivePlayback) { var videoElement = document.getElementById("videoElement"); var player = mpegts.createPlayer({ type: "mpegts", isLive: true, url: "' + video_url + '" }); player.attachMediaElement(videoElement); player.load(); player.play(); }</script></body></html>'
3980
+
3981
+ res.end(body)
3982
+ } catch (e) {
3983
+ session.log('mpegts.html request error : ' + e.message)
3984
+ res.end('')
3985
+ }
3986
+ })
3987
+
3988
+
3989
+ // Listen for MPEGTS stream requests
3990
+ app.get('/stream.ts', async function(req, res) {
3991
+ if ( ! (await protect(req, res)) ) return
3992
+
3993
+ try {
3994
+ let server = 'http://127.0.0.1:' + session.data.port + http_root
3995
+
3996
+ let video_url = '/stream.m3u8'
3997
+ if ( req.query.src ) {
3998
+ video_url = req.query.src
3999
+ } else {
4000
+ let urlArray = req.url.split('?')
4001
+ if ( (urlArray.length == 2) ) {
4002
+ video_url += '?' + urlArray[1]
4003
+ }
4004
+ video_url = server + video_url
4005
+ }
4006
+ session.debuglog('stream.ts src : ' + video_url)
4007
+
4008
+ // force adaptive streams to just use a single video resolution/track
4009
+ if ( !video_url.includes('resolution=') ) {
4010
+ video_url += '&resolution=best'
4011
+ } else if ( video_url.includes('resolution=adaptive') ) {
4012
+ video_url = video_url.replace('resolution=adaptive', 'resolution=best')
4013
+ }
4014
+
4015
+ // force streams to just use a single audio track, if they aren't already
4016
+ if ( !video_url.includes('audio_track=') ) {
4017
+ video_url += '&audio_track=English'
4018
+ } else if ( video_url.includes('audio_track=all') ) {
4019
+ video_url = video_url.replace('audio_track=all', 'audio_track=English')
4020
+ }
4021
+
4022
+ ffmpeg_command = ffmpeg({ timeout: 432000 })
4023
+
4024
+ // Set input live stream and minimize ffmpeg startup latency
4025
+ ffmpeg_command.input(video_url)
4026
+ .addInputOption('-thread_queue_size', '4096')
4027
+ .addInputOption('-fflags', 'nobuffer')
4028
+ .addInputOption('-probesize', '1000000')
4029
+ .addInputOption('-analyzeduration', '0')
4030
+
4031
+ // We'll limit our processing to real-time
4032
+ ffmpeg_command.native()
4033
+
4034
+ let video_input = 0
4035
+ let audio_input = 0
4036
+
4037
+ // Adjust audio sync, if specified
4038
+ if ( req.query.sync ) {
4039
+ if ( req.query.sync > 0 ) {
4040
+ session.log('stream.ts delaying video by ' + req.query.sync + ' seconds')
4041
+ video_input = 1
4042
+ ffmpeg_command.addInputOption('-itsoffset', req.query.sync)
4043
+ } else {
4044
+ session.log('stream.ts delaying audio by ' + (req.query.sync * -1) + ' seconds')
4045
+ audio_input = 1
4046
+ ffmpeg_command.addInputOption('-itsoffset', (req.query.sync * -1))
4047
+ }
4048
+ ffmpeg_command.input(video_url)
4049
+ .addInputOption('-thread_queue_size', '4096')
4050
+
4051
+ // We'll limit our processing to real-time
4052
+ ffmpeg_command.native()
4053
+ }
4054
+
4055
+ // video
4056
+ ffmpeg_command.addOutputOption('-map', video_input + ':v:0')
4057
+ .addOutputOption('-c:v', 'copy')
4058
+
4059
+ // audio
4060
+ ffmpeg_command.addOutputOption('-map', audio_input + ':a')
4061
+ .addOutputOption('-c:a', 'copy')
4062
+
4063
+ // output mpegts to response stream
4064
+ ffmpeg_command.addOutputOption('-f', 'mpegts')
4065
+ .output(res)
4066
+ .on('start', function(commandLine) {
4067
+ session.debuglog('stream.ts command started')
4068
+ if ( argv.debug || argv.ffmpeg_logging ) {
4069
+ session.log('stream.ts command: ' + commandLine)
4070
+ }
4071
+ })
4072
+ .on('error', function(err, stdout, stderr) {
4073
+ session.debuglog('stream.ts command stopped: ' + err.message)
4074
+ if ( stdout ) session.debuglog(stdout)
4075
+ if ( stderr ) session.debuglog(stderr)
4076
+ })
4077
+ .on('end', function() {
4078
+ session.debuglog('stream.ts command ended')
4079
+ })
4080
+
4081
+ if ( argv.ffmpeg_logging ) {
4082
+ session.log('ffmpeg output logging enabled')
4083
+ ffmpeg_command.on('stderr', function(stderrLine) {
4084
+ session.log(stderrLine);
4085
+ })
4086
+ }
4087
+
4088
+ var headers = {'Content-Type': 'video/mp2t',"access-control-allow-origin":"*"}
4089
+ res.writeHead(200, headers)
4090
+
4091
+ ffmpeg_command.run()
4092
+ } catch (e) {
4093
+ session.log('stream.ts request error : ' + e.message)
4094
+ res.end('')
4095
+ }
4096
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlbserver",
3
- "version": "2026.3.27-2",
3
+ "version": "2026.4.16",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,7 +13,6 @@
13
13
  "http": "0.0.1-security",
14
14
  "http-attach": "^1.0.0",
15
15
  "minimist": "^1.2.8",
16
- "puppeteer": "^24.40.0",
17
16
  "readline-sync": "^1.4.10",
18
17
  "request": "^2.88.2",
19
18
  "request-promise": "^4.2.6",
package/session.js CHANGED
@@ -8,7 +8,6 @@ const path = require('path')
8
8
  const readlineSync = require('readline-sync')
9
9
  const FileCookieStore = require('tough-cookie-filestore')
10
10
  const parseString = require('xml2js').parseString
11
- const puppeteer = require('puppeteer')
12
11
 
13
12
  const MULTIVIEW_DIRECTORY_NAME = 'multiview'
14
13
 
@@ -865,8 +864,6 @@ class sessionClass {
865
864
  constructor(argv = {}) {
866
865
  this.debug = argv.debug
867
866
 
868
- this.executablePath = argv.PUPPETEER_EXECUTABLE_PATH
869
-
870
867
  let dirname = __dirname
871
868
  if ( argv.data_directory ) {
872
869
  dirname = argv.data_directory
@@ -2922,25 +2919,28 @@ class sessionClass {
2922
2919
  let gameDate = cache_data.dates[i].date
2923
2920
  if ( (gameDate >= today) && cache_data.dates[i].games && (cache_data.dates[i].games.length > 1) && cache_data.dates[i].games[0] && (cache_data.dates[i].games[0].seriesDescription == 'Regular Season') && this.cache.bigInningSchedule[gameDate] ) {
2924
2921
  this.debuglog('getTVData Big Inning active for date ' + cache_data.dates[i].date)
2925
- // Scraped Big Inning schedule
2926
- let start = this.convertDateToXMLTV(new Date(this.cache.bigInningSchedule[gameDate].start))
2927
- let stop = this.convertDateToXMLTV(new Date(this.cache.bigInningSchedule[gameDate].end))
2928
-
2929
- // Big Inning calendar ICS
2930
- let prefix = 'Watch'
2931
- let location = server + '/embed.html?event=biginning&mediaType=Video&resolution=' + resolution
2932
- if ( this.protection.content_protect ) location += '&content_protect=' + this.protection.content_protect
2933
- calendar += await this.generate_ics_event(prefix, new Date(this.cache.bigInningSchedule[gameDate].start), new Date(this.cache.bigInningSchedule[gameDate].end), title, description, location)
2934
2922
 
2935
- // Off Air if necessary
2936
- let off_air_event = await this.generate_off_air_event(offAir, channelid, gameDate, channels[channelid].stop, this.cache.bigInningSchedule[gameDate].start, title)
2937
- if ( off_air_event ) {
2938
- programs += off_air_event
2939
- channels[channelid].stop = stop
2940
- }
2923
+ for (var j = 0; j < this.cache.bigInningSchedule[gameDate].length; j++) {
2924
+ // Scraped Big Inning schedule
2925
+ let start = this.convertDateToXMLTV(new Date(this.cache.bigInningSchedule[gameDate][j].start))
2926
+ let stop = this.convertDateToXMLTV(new Date(this.cache.bigInningSchedule[gameDate][j].end))
2941
2927
 
2942
- // Big Inning guide XML
2943
- programs += await this.generate_xml_program(channelid, start, stop, title, description, logo, this.convertDateToAirDate(new Date(this.cache.bigInningSchedule[gameDate].start)))
2928
+ // Big Inning calendar ICS
2929
+ let prefix = 'Watch'
2930
+ let location = server + '/embed.html?event=biginning&mediaType=Video&resolution=' + resolution
2931
+ if ( this.protection.content_protect ) location += '&content_protect=' + this.protection.content_protect
2932
+ calendar += await this.generate_ics_event(prefix, new Date(this.cache.bigInningSchedule[gameDate][j].start), new Date(this.cache.bigInningSchedule[gameDate][j].end), title, description, location)
2933
+
2934
+ // Off Air if necessary
2935
+ let off_air_event = await this.generate_off_air_event(offAir, channelid, gameDate, channels[channelid].stop, this.cache.bigInningSchedule[gameDate][j].start, title)
2936
+ if ( off_air_event ) {
2937
+ programs += off_air_event
2938
+ channels[channelid].stop = stop
2939
+ }
2940
+
2941
+ // Big Inning guide XML
2942
+ programs += await this.generate_xml_program(channelid, start, stop, title, description, logo, this.convertDateToAirDate(new Date(this.cache.bigInningSchedule[gameDate][j].start)))
2943
+ }
2944
2944
  }
2945
2945
  this.debuglog('getTVData completed Big Inning for date ' + cache_data.dates[i].date)
2946
2946
  }
@@ -3542,122 +3542,69 @@ class sessionClass {
3542
3542
  // temporarily disable Big Inning schedule checking until a new source URL is available
3543
3543
  /*this.cache.bigInningSchedule = {}
3544
3544
  return*/
3545
-
3545
+
3546
+ // reset for new format as of 2026-04-01
3547
+ try {
3548
+ if ( !this.cache.bigInningSchedule[Object.keys(this.cache.bigInningSchedule)[0]].length ) {
3549
+ this.log('getBigInningSchedule cache reset')
3550
+ delete this.cache.bigInningScheduleCacheExpiry
3551
+ delete this.cache.bigInningSchedule
3552
+ }
3553
+ } catch (e) {
3554
+ //this.debuglog('getBigInningSchedule cache reset error : ' + e.message)
3555
+ }
3556
+
3546
3557
  let currentDate = new Date()
3547
3558
  if ( !this.cache || !this.cache.bigInningScheduleCacheExpiry || (currentDate > new Date(this.cache.bigInningScheduleCacheExpiry)) ) {
3548
3559
  if ( !this.cache.bigInningSchedule ) this.cache.bigInningSchedule = {}
3549
-
3550
- const browser = await puppeteer.launch({
3551
- headless: 'new',
3552
- executablePath: this.executablePath,
3553
- args: [
3554
- '--no-sandbox',
3555
- '--disable-gpu',
3556
- '--disable-setuid-sandbox',
3557
- '--disable-dev-shm-usage'
3558
- ],
3559
- })
3560
- const page = await browser.newPage()
3561
- await page.setUserAgent(USER_AGENT)
3562
- await page.goto('https://support.mlb.com/s/article/What-Is-MLB-Big-Inning?language=en_US', { waitUntil: 'networkidle0' })
3563
- const response = await page.content()
3564
- await browser.close()
3565
-
3566
- // break HTML into array based on table rows
3567
- var rows = response.split('<tr ')
3568
- // start iterating at 2 (after header row)
3569
- for (var i=2; i<rows.length; i++) {
3570
- // split HTML row into array with columns
3571
- let cols = rows[i].split('<td ')
3572
-
3573
- // define some variables that persist for each row
3574
- let parts
3575
- let year
3576
- let month
3577
- let day
3578
- let this_datestring
3579
- let add_date = 0
3580
- let d
3581
-
3582
- // start iterating at 2 (after DOW column)
3583
- for (var j=2; j<cols.length; j++) {
3584
- // split on brackets to get column text at resulting array index 0
3585
- let col = cols[j].split('>')[1].split('<')
3586
- switch(j){
3587
- // first column is date
3588
- case 2:
3589
- // split date into array
3590
- // old date format (January 1, 1970) (disabled)
3591
- /*parts = col[0].split(' ')
3592
- year = parts[2]
3593
- // get month index, zero-based
3594
- month = new Date(Date.parse(parts[0] +" 1, 2021")).getMonth()
3595
- day = parts[1].substring(0,parts[1].length-3)*/
3596
- // new date format (01/01/70)
3597
- parts = col[0].split('/')
3598
- year = parts[2]
3599
- if ( year.length == 2 ) {
3600
- year = '20' + parts[2]
3601
- }
3602
- // get month index, zero-based
3603
- month = parseInt(parts[0]) - 1
3604
- day = parts[1]
3605
- this_datestring = new Date(year, month, day).toISOString().substring(0,10)
3606
- this.cache.bigInningSchedule[this_datestring] = {}
3607
- // increment month index (not zero-based)
3608
- month += 1
3609
- break
3610
- // remaining columns are times
3611
- default:
3612
- let hour
3613
- let minute = '00'
3614
- let ampm
3615
- // if time has colon, split into array on that to get hour and minute parts
3616
- if ( col[0].indexOf(':') > 0 ) {
3617
- parts = col[0].split(':')
3618
- hour = parseInt(parts[0])
3619
- minute = parts[1].substring(0,2)
3620
- } else {
3621
- hour = parseInt(col[0].substring(0,col[0].length-2))
3622
- }
3623
- ampm = col[0].substring(col[0].length-2,col[0].length)
3624
- // convert hour to 24-hour format
3625
- if ( (ampm == 'PM') || ((hour == 12) && (ampm == 'AM')) ) {
3626
- hour += 12
3627
- }
3628
- // these times are EDT so add 4 for UTC
3629
- hour += 4
3630
- // if hour is beyond 23, note we will have to add 1 day
3631
- if ( hour > 23 ) {
3632
- add_date = 1
3633
- hour -= 24
3634
- }
3635
-
3636
- d = new Date(this_datestring + 'T' + hour.toString().padStart(2, '0') + ':' + minute.toString().padStart(2, '0') + ':00.000+00:00')
3637
- d.setDate(d.getDate()+add_date)
3638
- switch(j){
3639
- // 3rd column is start time
3640
- case 3:
3641
- this.cache.bigInningSchedule[this_datestring].start = d
3642
- break
3643
- // 3rd column is end time
3644
- case 4:
3645
- this.cache.bigInningSchedule[this_datestring].end = d
3646
- break
3560
+ let reqObj = {
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
+ headers: {
3563
+ 'accept': '*/*',
3564
+ 'accept-encoding': 'gzip, deflate, br, zstd',
3565
+ 'origin': 'https://www.espn.com',
3566
+ 'referer': 'https://www.espn.com/',
3567
+ 'user-agent': USER_AGENT
3568
+ },
3569
+ json: true,
3570
+ gzip: true
3571
+ }
3572
+ var response = await this.httpGet(reqObj, false)
3573
+ if ( response ) {
3574
+ this.debuglog(JSON.stringify(response))
3575
+
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] = []
3647
3587
  }
3588
+ this.cache.bigInningSchedule[big_inning_date].push({
3589
+ start: big_inning_start,
3590
+ end: big_inning_end
3591
+ })
3648
3592
  break
3593
+ }
3649
3594
  }
3650
3595
  }
3651
- }
3652
- this.debuglog(JSON.stringify(this.cache.bigInningSchedule))
3596
+ this.debuglog(JSON.stringify(this.cache.bigInningSchedule))
3653
3597
 
3654
- // Default cache period is 1 day from now
3655
- let oneDayFromNow = new Date()
3656
- oneDayFromNow.setDate(oneDayFromNow.getDate()+1)
3657
- let cacheExpiry = oneDayFromNow
3658
- this.cache.bigInningScheduleCacheExpiry = cacheExpiry
3598
+ // Default cache period is 1 day from now
3599
+ let oneDayFromNow = new Date()
3600
+ oneDayFromNow.setDate(oneDayFromNow.getDate()+1)
3601
+ let cacheExpiry = oneDayFromNow
3602
+ this.cache.bigInningScheduleCacheExpiry = cacheExpiry
3659
3603
 
3660
- this.save_cache_data()
3604
+ this.save_cache_data()
3605
+ } else {
3606
+ this.log('error : invalid response from url ' + reqObj.url)
3607
+ }
3661
3608
  } else {
3662
3609
  this.debuglog('using cached big inning schedule')
3663
3610
  }
@@ -3672,35 +3619,6 @@ class sessionClass {
3672
3619
  }
3673
3620
  }
3674
3621
 
3675
- // Generate generic Big Inning schedule for specified date
3676
- // times in UTC (and DST) according to https://www.mlb.com/live-stream-games/help-center/subscription-access-big-inning
3677
- async generateBigInningSchedule(dateString) {
3678
- try {
3679
- this.debuglog('generateBigInningSchedule')
3680
-
3681
- let utc_start_string = '01:00'
3682
- let utc_end_string = '03:30'
3683
- let add_date = 1
3684
- // Different Sunday schedule
3685
- let weekday_index = new Date(dateString + ' 00:00:00').getDay()
3686
- if ( weekday_index == 0 ) {
3687
- utc_start_string = '19:00'
3688
- utc_end_string = '21:30'
3689
- add_date = 0
3690
- }
3691
- let d = new Date(dateString + 'T' + utc_start_string + ':00.000+00:00')
3692
- d.setDate(d.getDate()+add_date)
3693
- let start = d
3694
- d = new Date(dateString + 'T' + utc_end_string + ':00.000+00:00')
3695
- d.setDate(d.getDate()+add_date)
3696
- let end = d
3697
-
3698
- return {start: start, end: end}
3699
- } catch(e) {
3700
- this.log('generateBigInningSchedule error : ' + e.message)
3701
- }
3702
- }
3703
-
3704
3622
  // Get event data
3705
3623
  async getEventData(url) {
3706
3624
  try {