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 +2 -0
- package/docker-compose.yml +1 -0
- package/index.js +174 -16
- package/package.json +1 -1
- package/session.js +26 -23
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
|
```
|
package/docker-compose.yml
CHANGED
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
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
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
|
-
|
|
1430
|
+
const authorization = req.headers.authorization;
|
|
1367
1431
|
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1432
|
+
if (!authorization) {
|
|
1433
|
+
return reject();
|
|
1434
|
+
}
|
|
1371
1435
|
|
|
1372
|
-
|
|
1436
|
+
const [username, password] = Buffer.from(authorization.replace('Basic ', ''), 'base64').toString().split(':');
|
|
1373
1437
|
|
|
1374
|
-
|
|
1375
|
-
|
|
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
|
|
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
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
|
-
|
|
3559
|
+
this.cache.bigInningSchedule = {}
|
|
3560
3560
|
let reqObj = {
|
|
3561
|
-
url: 'https://
|
|
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': '
|
|
3563
|
+
'accept': '*/*',
|
|
3564
3564
|
'accept-encoding': 'gzip, deflate, br, zstd',
|
|
3565
|
-
'
|
|
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
|
-
|
|
3573
|
-
//this.debuglog(response)
|
|
3574
|
+
this.debuglog(JSON.stringify(response))
|
|
3574
3575
|
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
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
|
-
|
|
3588
|
-
|
|
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
|