mlbserver 2025.2.25 → 2025.3.2-9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Dockerfile CHANGED
@@ -21,4 +21,4 @@ RUN npm install
21
21
  COPY . .
22
22
 
23
23
  EXPOSE 9999 10000
24
- CMD [ "node", "index.js", "--env" ]
24
+ CMD [ "node", "index.js", "--env", "--port", "9999", "--multiview_port", "10000", "--data_directory", "/mlbserver/data_directory" ]
package/README.md CHANGED
@@ -14,7 +14,7 @@ npm install -g mlbserver
14
14
 
15
15
  ### docker
16
16
  ```
17
- docker pull tonywagner/mlbserver
17
+ docker pull tonywagner/mlbserver:latest
18
18
  ```
19
19
 
20
20
 
@@ -30,54 +30,10 @@ node index.js
30
30
  ```
31
31
 
32
32
  ### docker-compose
33
- Update the environment variables below and save it as docker-compose.yml:
34
- ```
35
- services:
36
- mlbserver:
37
- image: tonywagner/mlbserver:latest
38
- container_name: mlbserver
39
- environment:
40
- - TZ=America/New York
41
- - data_directory=/mlbserver/data_directory
42
- - account_username=your.account.email@example.com
43
- - account_password=youraccountpassword
44
- #- fav_teams=AZ,BAL
45
- #- debug=false
46
- #- port=9999
47
- #- multiview_port=10000
48
- #- multiview_path=
49
- #- ffmpeg_path=
50
- #- ffmpeg_encoder=
51
- #- page_username=
52
- #- page_password=
53
- #- content_protect=
54
- #- gamechanger_delay=0
55
- #- http_root=/mlbserver
56
- ports:
57
- - 9999:9999
58
- - 10000:10000
59
- volumes:
60
- - /path/to/your/desired/local/mlbserver/persistent/data/directory:/mlbserver/data_directory
61
- ```
62
- Then launch it with ```docker-compose up --detach```
33
+ Update the required environment variables in the sample [docker-compose.yml](https://github.com/tonywagner/mlbserver/blob/master/docker-compose.yml) file, then launch it with ```docker-compose up --detach```
63
34
 
64
35
  ### docker-cli
65
- Update the environment variables in the command below and run it:
66
- ```
67
- docker run -d \
68
- --name mlbserver \
69
- --env TZ="America/New_York" \
70
- --env data_directory=/mlbserver/data_directory \
71
- --env account_username=your.account.email@example.com \
72
- --env account_password=youraccountpassword \
73
- -p 9999:9999 \
74
- -p 10000:10000 \
75
- --volume /path/to/your/desired/local/mlbserver/persistent/data/directory:/mlbserver/data_directory \
76
- tonywagner/mlbserver
77
- ```
78
- Subsequent runs can be launched with ```docker start mlbserver```
79
-
80
- Docker installs may require further configuration to get multiview streaming to work.
36
+ Update the environment variables in the sample [docker-cli](https://github.com/tonywagner/mlbserver/blob/master/docker-cli) command, then run the command. Subsequent runs can be launched with ```docker start mlbserver```
81
37
 
82
38
 
83
39
  ## Usage
@@ -93,7 +49,7 @@ Basic command line or Docker environment options:
93
49
  --logout or -l (logs out and clears session)
94
50
  --session or -s (clears session)
95
51
  --cache or -c (clears cache)
96
- --env or -e (use environment variables instead of command line arguments; necessary for Docker)
52
+ --env or -e (use environment variables instead of command line arguments; automatically applied in the Docker image)
97
53
  ```
98
54
 
99
55
  Advanced command line or Docker environment options:
@@ -102,8 +58,9 @@ Advanced command line or Docker environment options:
102
58
  --account_username (required, email address, default will use stored credentials or prompt user to enter them if not using Docker)
103
59
  --account_password (required, default will use stored credentials or prompt user to enter them if not using Docker)
104
60
  --fav_teams (optional, comma-separated list of favorite team abbreviations from https://github.com/tonywagner/mlbserver/blob/master/session.js#L23 -- will prompt if not set or stored and not using Docker)
61
+ --http_root (specify the alternative http webroot or initial path prefix, default is none)
105
62
  --free (optional, highlights free games)
106
- --multiview_port (port for multiview streaming; defaults to 1 more than primary port, or 10000)
63
+ --multiview_port (local port for multiview streaming; defaults to 1 more than primary port, or 10000; does not need to be mapped or used externally)
107
64
  --multiview_path (where to create the folder for multiview encoded files; defaults to app directory)
108
65
  --ffmpeg_path (path to ffmpeg binary to use for multiview encoding; default downloads a binary using ffmpeg-static)
109
66
  --ffmpeg_encoder (ffmpeg video encoder to use for multiview; default is the software encoder libx264)
@@ -112,9 +69,11 @@ Advanced command line or Docker environment options:
112
69
  --page_password (password to protect pages; default is no protection)
113
70
  --content_protect (specify the content protection key to include as a URL parameter, if page protection is enabled)
114
71
  --gamechanger_delay (specify extra delay for the gamechanger switches in 10 second increments, default is 0)
115
- --http_root (specify the alternative http webroot or initial path prefix, default is none)
72
+ --data_directory (defaults to installed application directory; in the Docker image, this defaults to /mlbserver/data_directory for mapping persistent storage)
116
73
  ```
117
74
 
75
+ Supports [SWAG](https://docs.linuxserver.io/general/swag/#preset-proxy-confs) using the custom [mlbserver.subfolder.conf](https://github.com/tonywagner/mlbserver/blob/master/mlbserver.subfolder.conf) file.
76
+
118
77
  For multiview, the default software encoder is limited by your CPU. You may want to experiment with different ffmpeg hardware encoders. "h264_videotoolbox" is confirmed to work on supported Macs, and "h264_v4l2m2m" is confirmed to work on a Raspberry Pi 4 (and likely other Linux systems) when ffmpeg is compiled with this patch: https://www.raspberrypi.org/forums/viewtopic.php?p=1780625#p1780625
119
78
 
120
79
  More potential hardware encoders are described at https://stackoverflow.com/a/50703794
package/docker-cli ADDED
@@ -0,0 +1,8 @@
1
+ docker run -d \
2
+ --name mlbserver \
3
+ --env TZ="America/New_York" \
4
+ --env account_username=your.account.email@example.com \
5
+ --env account_password=youraccountpassword \
6
+ -p 9999:9999 \
7
+ --volume /path/to/your/desired/local/mlbserver/persistent/data/directory:/mlbserver/data_directory \
8
+ tonywagner/mlbserver
@@ -4,13 +4,11 @@ services:
4
4
  container_name: mlbserver
5
5
  environment:
6
6
  - TZ=America/New York
7
- - data_directory=/mlbserver/data_directory
8
7
  - account_username=your.account.email@example.com
9
8
  - account_password=youraccountpassword
10
9
  #- fav_teams=AZ,BAL
10
+ #- http_root=/mlbserver
11
11
  #- debug=false
12
- #- port=9999
13
- #- multiview_port=10000
14
12
  #- multiview_path=
15
13
  #- ffmpeg_path=
16
14
  #- ffmpeg_encoder=
@@ -18,8 +16,9 @@ services:
18
16
  #- page_password=
19
17
  #- content_protect=
20
18
  #- gamechanger_delay=0
19
+ #- PUID=1000
20
+ #- PGID=1000
21
21
  ports:
22
22
  - 9999:9999
23
- - 10000:10000
24
23
  volumes:
25
24
  - /path/to/your/desired/local/mlbserver/persistent/data/directory:/mlbserver/data_directory
package/index.js CHANGED
@@ -29,10 +29,10 @@ const VALID_CONTROLS = [ 'Show', 'Hide' ]
29
29
  const VALID_INNING_HALF = [ '', 'top', 'bottom' ]
30
30
  const VALID_INNING_NUMBER = [ '', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12' ]
31
31
  const VALID_SCORES = [ 'Hide', 'Show' ]
32
- const VALID_RESOLUTIONS = [ 'adaptive', '1080p60', '720p60', '720p', '540p', '504p', '360p', '288p', '214p', 'none' ]
32
+ const VALID_RESOLUTIONS = [ 'adaptive', '1080p60', '720p60', '720p', '540p', '504p', '360p', '288p', '216p', '180p', 'none' ]
33
33
  const DEFAULT_MULTIVIEW_RESOLUTION = '504p'
34
34
  // Corresponding andwidths to display for above resolutions
35
- const DISPLAY_BANDWIDTHS = [ '', '9600k', '6600k', '4160k', '2950k', '2120k', '1400k', '1000k', '600k', '' ]
35
+ const DISPLAY_BANDWIDTHS = [ '', '9600k', '6600k', '4160k', '2950k', '2120k', '1400k', '1000k', '600k', '300k', '' ]
36
36
  const VALID_AUDIO_TRACKS = [ 'all', 'English', 'Home Radio', 'Casa Radio', 'Away Radio', 'Visita Radio', 'Park', 'none' ]
37
37
  const DISPLAY_AUDIO_TRACKS = [ 'all', 'TV', 'Radio', 'Spa.', 'Away Rad.', 'Away Sp.', 'Park', 'none' ]
38
38
  const DEFAULT_MULTIVIEW_AUDIO_TRACK = 'English'
@@ -56,21 +56,42 @@ const GAMECHANGER_RESOLUTIONS = {
56
56
  'frame_rate': '29.97',
57
57
  'url_bandwidth': '1800',
58
58
  'bandwidth': '2120',
59
- 'codec': '4d001f'
59
+ 'codec': '4d401f'
60
+ },
61
+ '180p': {
62
+ 'resolution': '320x180',
63
+ 'frame_rate': '29.97',
64
+ 'url_bandwidth': '192',
65
+ 'bandwidth': '600',
66
+ 'codec': '4d401f'
67
+ },
68
+ '216p': {
69
+ 'resolution': '384x216',
70
+ 'frame_rate': '29.97',
71
+ 'url_bandwidth': '450',
72
+ 'bandwidth': '600',
73
+ 'codec': '4d401f'
74
+ },
75
+ '288p': {
76
+ 'resolution': '512x288',
77
+ 'frame_rate': '29.97',
78
+ 'url_bandwidth': '800',
79
+ 'bandwidth': '1000',
80
+ 'codec': '4d401f'
60
81
  },
61
82
  '360p': {
62
83
  'resolution': '640x360',
63
84
  'frame_rate': '29.97',
64
85
  'url_bandwidth': '1200',
65
86
  'bandwidth': '1400',
66
- 'codec': '4d001f'
87
+ 'codec': '4d401f'
67
88
  },
68
89
  '540p': {
69
90
  'resolution': '960x540',
70
91
  'frame_rate': '29.97',
71
92
  'url_bandwidth': '2500',
72
93
  'bandwidth': '2950',
73
- 'codec': '4d001f'
94
+ 'codec': '4d401f'
74
95
  },
75
96
  '720p': {
76
97
  'resolution': '1280x720',
@@ -84,7 +105,14 @@ const GAMECHANGER_RESOLUTIONS = {
84
105
  'frame_rate': '59.94',
85
106
  'url_bandwidth': '5600',
86
107
  'bandwidth': '6600',
87
- 'codec': '640028'
108
+ 'codec': '640029'
109
+ },
110
+ '1080p60': {
111
+ 'resolution': '1920x1080',
112
+ 'frame_rate': '59.94',
113
+ 'url_bandwidth': '7500',
114
+ 'bandwidth': '9600',
115
+ 'codec': '64002a'
88
116
  }
89
117
  }
90
118
  const GAMECHANGER_LIST_SIZE = 6
@@ -106,7 +134,12 @@ var argv = minimist(process.argv, {
106
134
  string: ['account_username', 'account_password', 'fav_teams', 'multiview_path', 'ffmpeg_path', 'ffmpeg_encoder', 'page_username', 'page_password', 'content_protect', 'data_directory', 'http_root']
107
135
  })
108
136
 
109
- if (argv.env) argv = process.env
137
+ if (argv.env) {
138
+ process.env.port = argv.port
139
+ process.env.multiview_port = argv.multiview_port
140
+ process.env.data_directory = argv.data_directory
141
+ argv = process.env
142
+ }
110
143
 
111
144
  // Version
112
145
  var version = require('./package').version
@@ -500,7 +533,7 @@ function getMasterPlaylist(streamURL, req, res, options = {}) {
500
533
  return line
501
534
  } else if ( segment_found ) {
502
535
  segment_found = false
503
- return http_root + '/ts?url='+encodeURIComponent(url.resolve(streamURL, line.trim())) + content_protect + referer_parameter + token_parameter
536
+ return http_root + '/segment.ts?url='+encodeURIComponent(url.resolve(streamURL, line.trim())) + content_protect + referer_parameter + token_parameter
504
537
  }
505
538
 
506
539
  // Omit keyframe tracks
@@ -562,7 +595,7 @@ function getMasterPlaylist(streamURL, req, res, options = {}) {
562
595
  //var parsed = line.match(/URI="([^"]+)"?$/)
563
596
  var parsed = line.match(',URI="([^"]+)"')
564
597
  if ( parsed[1] ) {
565
- newurl = http_root + '/playlist?url='+encodeURIComponent(url.resolve(streamURL, parsed[1].trim()))
598
+ newurl = http_root + '/playlist.m3u8?url='+encodeURIComponent(url.resolve(streamURL, parsed[1].trim()))
566
599
  if ( force_vod != VALID_FORCE_VOD[0] ) newurl += '&force_vod=on'
567
600
  if ( inning_half != VALID_INNING_HALF[0] ) newurl += '&inning_half=' + inning_half
568
601
  if ( inning_number != VALID_INNING_NUMBER[0] ) newurl += '&inning_number=' + inning_number
@@ -595,7 +628,7 @@ function getMasterPlaylist(streamURL, req, res, options = {}) {
595
628
  if ( resolution == VALID_RESOLUTIONS[VALID_RESOLUTIONS.length-1] ) {
596
629
  return
597
630
  } else if ( resolution === VALID_RESOLUTIONS[0] ) {
598
- return line
631
+ return line
599
632
  } else {
600
633
  if ( (video_track_found == false) && (line.indexOf(resolution+',FRAME-RATE='+frame_rate) > 0) ) {
601
634
  video_track_matched = true
@@ -616,7 +649,7 @@ function getMasterPlaylist(streamURL, req, res, options = {}) {
616
649
  if ( line.startsWith('#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO,LANGUAGE="eng",URI="') ) {
617
650
  var parsed = line.match(',URI="([^"]+)"')
618
651
  if ( parsed[1] ) {
619
- newurl = http_root + '/playlist?url='+encodeURIComponent(url.resolve(streamURL, parsed[1].trim())) + content_protect + referer_parameter + token_parameter
652
+ newurl = http_root + '/playlist.m3u8?url='+encodeURIComponent(url.resolve(streamURL, parsed[1].trim())) + content_protect + referer_parameter + token_parameter
620
653
  return '#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO,LANGUAGE="eng",URI="' + newurl + '"'
621
654
  }
622
655
  return
@@ -638,7 +671,7 @@ function getMasterPlaylist(streamURL, req, res, options = {}) {
638
671
  if ( gamePk ) newurl += '&gamePk=' + gamePk
639
672
  if ( audio_track != VALID_AUDIO_TRACKS[0] ) newurl += '&audio_track=' + encodeURIComponent(audio_track)
640
673
  newurl += content_protect + referer_parameter + token_parameter
641
- return http_root + '/playlist?url='+newurl
674
+ return http_root + '/playlist.m3u8?url='+newurl
642
675
  }
643
676
  })
644
677
  .filter(function(line) {
@@ -661,21 +694,21 @@ function getMasterPlaylist(streamURL, req, res, options = {}) {
661
694
 
662
695
 
663
696
  // Listen for playlist requests
664
- app.get('/playlist', async function(req, res) {
697
+ app.get('/playlist.m3u8', async function(req, res) {
665
698
  if ( ! (await protect(req, res)) ) return
666
699
 
667
- session.requestlog('playlist', req, true)
700
+ session.requestlog('playlist.m3u8', req, true)
668
701
 
669
702
  delete req.headers.host
670
703
 
671
704
  var u = req.query.url
672
- session.debuglog('playlist url : ' + u)
705
+ session.debuglog('playlist.m3u8 url : ' + u)
673
706
 
674
707
  var referer = false
675
708
  var referer_parameter = ''
676
709
  if ( req.query.referer ) {
677
710
  referer = decodeURIComponent(req.query.referer)
678
- session.debuglog('found playlist referer : ' + referer)
711
+ session.debuglog('found playlist.m3u8 referer : ' + referer)
679
712
  referer_parameter = '&referer=' + encodeURIComponent(req.query.referer)
680
713
  }
681
714
 
@@ -810,9 +843,9 @@ app.get('/playlist', async function(req, res) {
810
843
 
811
844
  if (line[0] === '#') return line
812
845
 
813
- let newline = http_root + '/ts'
846
+ let newline = http_root + '/segment.ts'
814
847
  if ( line.includes('.vtt') ) {
815
- newline = http_root + '/vtt'
848
+ newline = http_root + '/subtitles.vtt'
816
849
  }
817
850
 
818
851
  newline += '?url='+encodeURIComponent(url.resolve(u, line.trim())) + content_protect + referer_parameter + token_parameter
@@ -820,7 +853,7 @@ app.get('/playlist', async function(req, res) {
820
853
 
821
854
  // if an alternate audio track is specified, force removal of embedded audio
822
855
  if ( (audio_track != VALID_AUDIO_TRACKS[0]) && (audio_track != VALID_AUDIO_TRACKS[1]) ) {
823
- newline = 'download.html?audio_track=' + encodeURIComponent(audio_track) + '&src=' + encodeURIComponent('http://127.0.0.1:' + session.data.port + newline) + content_protect
856
+ newline = http_root + '/download.ts?audio_track=' + encodeURIComponent(audio_track) + '&src=' + encodeURIComponent('http://127.0.0.1:' + session.data.port + newline) + content_protect
824
857
  }
825
858
 
826
859
  return newline
@@ -864,20 +897,20 @@ app.get('/playlist', async function(req, res) {
864
897
  })
865
898
 
866
899
  // Listen for ts requests (video segments) and decode them
867
- app.get('/ts', async function(req, res) {
900
+ app.get('/segment.ts', async function(req, res) {
868
901
  if ( ! (await protect(req, res)) ) return
869
902
 
870
- session.requestlog('ts', req, true)
903
+ session.requestlog('segment.ts', req, true)
871
904
 
872
905
  delete req.headers.host
873
906
 
874
907
  var u = req.query.url
875
- session.debuglog('ts url : ' + u)
908
+ session.debuglog('segment.ts url : ' + u)
876
909
 
877
910
  var headers = {encoding:null}
878
911
 
879
912
  if ( req.query.referer ) {
880
- session.debuglog('found segment referer : ' + req.query.referer)
913
+ session.debuglog('found segment.ts referer : ' + req.query.referer)
881
914
  referer = decodeURIComponent(req.query.referer)
882
915
  headers.referer = referer
883
916
  headers.origin = getOriginFromURL(referer)
@@ -926,20 +959,20 @@ app.get('/ts', async function(req, res) {
926
959
 
927
960
 
928
961
  // Listen for WebVTT subtitle requests
929
- app.get('/vtt', async function(req, res) {
962
+ app.get('/subtitles.vtt', async function(req, res) {
930
963
  if ( ! (await protect(req, res)) ) return
931
964
 
932
- session.requestlog('vtt', req, true)
965
+ session.requestlog('subtitles.vtt', req, true)
933
966
 
934
967
  delete req.headers.host
935
968
 
936
969
  var u = req.query.url
937
- session.debuglog('vtt url : ' + u)
970
+ session.debuglog('subtitles.vtt url : ' + u)
938
971
 
939
972
  var referer = false
940
973
  if ( req.query.referer ) {
941
974
  referer = decodeURIComponent(req.query.referer)
942
- session.debuglog('found vtt referer : ' + referer)
975
+ session.debuglog('found subtitles.vtt referer : ' + referer)
943
976
  }
944
977
 
945
978
  var req = function () {
@@ -1168,7 +1201,7 @@ app.get('/gamechangerplaylist', async function(req, res) {
1168
1201
  if ( session.temp_cache.gamechanger[id].segments[i].discontinuity ) {
1169
1202
  session.temp_cache.gamechanger[id].playlist[resolution] += '#EXT-X-DISCONTINUITY' + '\n'
1170
1203
  }
1171
- session.temp_cache.gamechanger[id].playlist[resolution] += session.temp_cache.gamechanger[id].segments[i].extinf + '\n' + '/ts?url=' + encodeURIComponent(session.temp_cache.gamechanger[id].segments[i].ts) + '&streamURLToken='+encodeURIComponent(session.temp_cache.gamechanger[id].segments[i].streamURLToken) + content_protect + '\n'
1204
+ session.temp_cache.gamechanger[id].playlist[resolution] += session.temp_cache.gamechanger[id].segments[i].extinf + '\n' + '/segment.ts?url=' + encodeURIComponent(session.temp_cache.gamechanger[id].segments[i].ts) + '&streamURLToken='+encodeURIComponent(session.temp_cache.gamechanger[id].segments[i].streamURLToken) + content_protect + '\n'
1172
1205
  }
1173
1206
 
1174
1207
  session.debuglog(game_changer_title + 'playlist ' + session.temp_cache.gamechanger[id].playlist[resolution])
@@ -1518,6 +1551,8 @@ app.get('/', async function(req, res) {
1518
1551
  let link = linkType.toLowerCase() + '.html'
1519
1552
  if ( linkType == VALID_LINK_TYPES[1] ) {
1520
1553
  link = linkType.toLowerCase() + '.m3u8'
1554
+ } else if ( linkType == VALID_LINK_TYPES[4] ) {
1555
+ link = linkType.toLowerCase() + '.ts'
1521
1556
  } else {
1522
1557
  force_vod = VALID_FORCE_VOD[0]
1523
1558
  }
@@ -1569,7 +1604,7 @@ app.get('/', async function(req, res) {
1569
1604
  body += '</td></tr>' + "\n"
1570
1605
  }*/
1571
1606
 
1572
- if ( (gameDate >= today) && cache_data.dates && cache_data.dates[0] && cache_data.dates[0].games && (cache_data.dates[0].games.length > 0) ) {
1607
+ if ( cache_data.dates && cache_data.dates[0] && cache_data.dates[0].games && (cache_data.dates[0].games.length > 0) ) {
1573
1608
  blackouts = await session.get_blackout_games(cache_data.dates[0].date, true)
1574
1609
  }
1575
1610
 
@@ -1681,6 +1716,7 @@ app.get('/', async function(req, res) {
1681
1716
  } else {
1682
1717
  teams += hometeam
1683
1718
  }
1719
+ let filename_teams = awayteam + " @ " + hometeam
1684
1720
  let pitchers = ""
1685
1721
  let state = "<br/>"
1686
1722
 
@@ -1759,7 +1795,7 @@ app.get('/', async function(req, res) {
1759
1795
  state += "<br/>" + detailedState
1760
1796
  }
1761
1797
 
1762
- var filename = gameDate + ' ' + teams + ' '
1798
+ var filename = gameDate + ' ' + filename_teams + ' '
1763
1799
 
1764
1800
  if ( cache_data.dates[0].games[j].doubleHeader != 'N' ) {
1765
1801
  state += "<br/>Game " + cache_data.dates[0].games[j].gameNumber
@@ -1839,7 +1875,7 @@ app.get('/', async function(req, res) {
1839
1875
  body += '><td>' + description + teams + pitchers + state + '</td>'
1840
1876
 
1841
1877
  // Check if Winter League / MiLB game first
1842
- if ( (cache_data.dates[0].games[j].teams['home'].team.sport.id != levels['MLB']) && (mediaType == 'MLBTV') ) {
1878
+ if ( (cache_data.dates[0].games[j].teams['away'].team.sport.id != levels['MLB']) && (cache_data.dates[0].games[j].teams['home'].team.sport.id != levels['MLB']) && (mediaType == 'MLBTV') ) {
1843
1879
  body += "<td>"
1844
1880
  if ( cache_data.dates[0].games[j].broadcasts ) {
1845
1881
  let broadcastName = 'N/A'
@@ -1935,9 +1971,12 @@ app.get('/', async function(req, res) {
1935
1971
 
1936
1972
  // display blackout tooltip, if necessary
1937
1973
  if ( blackouts[gamePk] ) {
1938
- body += '<span class="tooltip"><span class="blackout">' + teamabbr + '</span><span class="tooltiptext">' + blackouts[gamePk].blackout_type + ' video blackout until approx. 2.5 hours after the game'
1939
- if ( blackouts[gamePk].blackoutExpiry ) {
1940
- body += ' (~' + blackouts[gamePk].blackoutExpiry.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + ')'
1974
+ body += '<span class="tooltip"><span class="blackout">' + teamabbr + '</span><span class="tooltiptext">' + blackouts[gamePk].blackout_type
1975
+ if ( blackouts[gamePk].blackout_type != 'Not entitled' ) {
1976
+ body += ' video blackout until approx. 2.5 hours after the game'
1977
+ if ( blackouts[gamePk].blackoutExpiry ) {
1978
+ body += ' (~' + blackouts[gamePk].blackoutExpiry.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + ')'
1979
+ }
1941
1980
  }
1942
1981
  body += '</span></span>'
1943
1982
  } else {
@@ -2054,7 +2093,7 @@ app.get('/', async function(req, res) {
2054
2093
  body += "</table>" + "\n"
2055
2094
 
2056
2095
  if ( (Object.keys(blackouts).length > 0) ) {
2057
- body += '<span class="tooltip tinytext"><span class="blackout">strikethrough</span> indicates a live blackout<span class="tooltiptext">Tap or hover over the team abbreviation to see an estimate of when the blackout will be lifted (officially ~90 minutes, but more likely ~150 minutes or ~2.5 hours after the game ends).</span></span>' + "\n"
2096
+ body += '<span class="tooltip tinytext"><span class="blackout">strikethrough</span> indicates a live blackout or non-entitled video<span class="tooltiptext">Tap or hover over the team abbreviation to see an estimate of when the blackout will be lifted (officially ~90 minutes, but more likely ~150 minutes or ~2.5 hours after the game ends).</span></span>' + "\n"
2058
2097
  if ( argv.free ) {
2059
2098
  body += '<br/>'
2060
2099
  }
@@ -2127,7 +2166,7 @@ app.get('/', async function(req, res) {
2127
2166
  body += '<input type="checkbox" id="reencode"/> <span class="tooltip">Re-encode all audio<span class="tooltiptext">Uses more CPU. Generally only necessary if you need the multiview stream to continue after one of the individual streams has ended. (Any streams with sync adjustments above will automatically be re-encoded, regardless of this setting.)</span></span><br/>' + "\n"
2128
2167
  body += '<input type="checkbox" id="park_audio"/> <span class="tooltip">Park audio: filter out announcers<span class="tooltiptext">Implies re-encoding all audio. If this is enabled, an extra audio filter is applied to remove the announcer voices.</span></span><br/>' + "\n"
2129
2168
  body += '<hr><span class="tooltip">Alternate audio URL and sync<span class="tooltiptext">Optional: you can also include a separate audio-only URL as an additional alternate audio track. Archive games will likely require a very large negative sync value, as the radio broadcasts may not be trimmed like the video archives.</span></span>:<br/><textarea id="audio_url" rows=2 cols=60 oninput="this.value=stream_substitution(this.value)"></textarea><input id="audio_url_seek" type="number" value="0" style="vertical-align:top;font-size:.8em;width:4em"/>'
2130
- body += '<hr>Watch: <a href="' + http_root + '/embed.html?msrc=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Embed</a> | <a href="' + http_root + '/stream.m3u8?src=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Stream</a> | <a href="' + http_root + '/chromecast.html?msrc=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Chromecast</a> | <a href="' + http_root + '/advanced.html?msrc=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Advanced</a> | <a href="' + http_root + '/download.html?src=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '&filename=' + gameDate + ' Multiview">Download</a><br/><span class="tinytext">Kodi STRM files: <a href="' + http_root + '/kodi.strm?src=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Matrix/19+</a> (<a href="' + http_root + '/kodi.strm?version=18&src=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Leia/18</a>)</span>'
2169
+ body += '<hr>Watch: <a href="' + http_root + '/embed.html?msrc=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Embed</a> | <a href="' + http_root + '/stream.m3u8?src=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Stream</a> | <a href="' + http_root + '/chromecast.html?msrc=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Chromecast</a> | <a href="' + http_root + '/advanced.html?msrc=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Advanced</a> | <a href="' + http_root + '/download.ts?src=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '&filename=' + gameDate + ' Multiview">Download</a><br/><span class="tinytext">Kodi STRM files: <a href="' + http_root + '/kodi.strm?src=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Matrix/19+</a> (<a href="' + http_root + '/kodi.strm?version=18&src=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Leia/18</a>)</span>'
2131
2170
  body += '</td></tr></table><br/>' + "\n"
2132
2171
  }
2133
2172
 
@@ -2983,18 +3022,18 @@ app.get('/kodi.strm', async function(req, res) {
2983
3022
  })
2984
3023
 
2985
3024
  // Listen for download requests (either for actual downloads or just proxying stream through ffmpeg)
2986
- app.get('/download.html', async function(req, res) {
3025
+ app.get('/download.ts', async function(req, res) {
2987
3026
  if ( ! (await protect(req, res)) ) return
2988
3027
 
2989
3028
  try {
2990
3029
  // we'll know it's an actual download request if it include a filename parameter
2991
3030
  if ( req.query.filename ) {
2992
- session.requestlog('download', req)
3031
+ session.requestlog('download.ts', req)
2993
3032
  } else {
2994
3033
  session.debuglog('force alternate audio', req)
2995
3034
  }
2996
3035
 
2997
- let server = (req.headers['x-forwarded-proto'] ? req.headers['x-forwarded-proto'] : 'http') + '://' + req.headers.host + http_root
3036
+ let server = 'http://127.0.0.1:' + session.data.port + http_root
2998
3037
 
2999
3038
  let video_url = '/stream.m3u8'
3000
3039
  if ( req.query.src ) {
@@ -3006,7 +3045,7 @@ app.get('/download.html', async function(req, res) {
3006
3045
  }
3007
3046
  video_url = server + video_url
3008
3047
  }
3009
- session.debuglog('download src : ' + video_url)
3048
+ session.debuglog('download.ts src : ' + video_url)
3010
3049
 
3011
3050
  // force adaptive streams to just use a single video resolution/track
3012
3051
  if ( req.query.filename ) {
@@ -3022,13 +3061,13 @@ app.get('/download.html', async function(req, res) {
3022
3061
  // Set input stream and minimize ffmpeg startup latency
3023
3062
  ffmpeg_command.input(video_url)
3024
3063
  .addInputOption('-fflags', 'nobuffer')
3025
- .addInputOption('-probesize', '32')
3064
+ .addInputOption('-probesize', '1000000')
3026
3065
  .addInputOption('-analyzeduration', '0')
3027
3066
 
3028
3067
  // video
3029
3068
  if ( !req.query.resolution || (req.query.resolution != VALID_RESOLUTIONS[VALID_RESOLUTIONS.length-1]) ) {
3030
3069
  // copy first video track if available
3031
- ffmpeg_command.addOutputOption('-map', '0:v:0?')
3070
+ ffmpeg_command.addOutputOption('-map', '0:v:0')
3032
3071
  .addOutputOption('-c:v', 'copy')
3033
3072
  } else {
3034
3073
  // suppress video is "none" resolution was specified
@@ -3053,13 +3092,6 @@ app.get('/download.html', async function(req, res) {
3053
3092
  ffmpeg_command.addOutputOption('-map', '[out0]')
3054
3093
  .addOutputOption('-c:a', 'aac')
3055
3094
  .addOutputOption('-ac:a:0', '1')
3056
-
3057
- // if not downloading to a file, also copy source PTS values for continuous playback
3058
- if ( !req.query.filename ) {
3059
- ffmpeg_command.addOutputOption('-copyts')
3060
- .addOutputOption('-muxpreload', '0')
3061
- .addOutputOption('-muxdelay', '0')
3062
- }
3063
3095
  } else if ( (!req.query.filename && (req.query.audio_track != VALID_AUDIO_TRACKS[1])) || (req.query.audio_track == VALID_AUDIO_TRACKS[7]) ) {
3064
3096
  // if we're not downloading a file, and we requested an alternate audio track, then we want to suppress the embedded TV audio
3065
3097
  // or if the user requested no audio tracks in their download, we will suppress all
@@ -3071,22 +3103,29 @@ app.get('/download.html', async function(req, res) {
3071
3103
  }
3072
3104
  }
3073
3105
 
3106
+ // if not downloading to a file, also copy source PTS values for continuous playback
3107
+ if ( !req.query.filename ) {
3108
+ ffmpeg_command.addOutputOption('-copyts')
3109
+ .addOutputOption('-muxpreload', '0')
3110
+ .addOutputOption('-muxdelay', '0')
3111
+ }
3112
+
3074
3113
  // output mpegts to response stream
3075
3114
  ffmpeg_command.addOutputOption('-f', 'mpegts')
3076
3115
  .output(res)
3077
3116
  .on('start', function(commandLine) {
3078
- session.debuglog('download command started')
3117
+ session.debuglog('download.ts command started')
3079
3118
  if ( argv.debug || argv.ffmpeg_logging ) {
3080
- session.debuglog('download command: ' + commandLine)
3119
+ session.debuglog('download.ts command: ' + commandLine)
3081
3120
  }
3082
3121
  })
3083
3122
  .on('error', function(err, stdout, stderr) {
3084
- session.debuglog('download command stopped: ' + err.message)
3123
+ session.debuglog('download.ts command stopped: ' + err.message)
3085
3124
  if ( stdout ) session.log(stdout)
3086
3125
  if ( stderr ) session.log(stderr)
3087
3126
  })
3088
3127
  .on('end', function() {
3089
- session.debuglog('download command ended')
3128
+ session.debuglog('download.ts command ended')
3090
3129
  })
3091
3130
 
3092
3131
  if ( argv.ffmpeg_logging ) {
@@ -3104,7 +3143,7 @@ app.get('/download.html', async function(req, res) {
3104
3143
 
3105
3144
  ffmpeg_command.run()
3106
3145
  } catch (e) {
3107
- session.log('download request error : ' + e.message)
3146
+ session.log('download.ts request error : ' + e.message)
3108
3147
  res.end('')
3109
3148
  }
3110
- })
3149
+ })
@@ -0,0 +1,20 @@
1
+ ## Version 2025/02/27
2
+ # make sure that your mlbserver container is named mlbserver
3
+ # make sure that mlbserver is set to work with the base url /mlbserver/
4
+ #
5
+ # For the subfolder to work you need to edit your mlbserver docker compose / cli config
6
+ # and set the http_root environment variable to /mlbserver, e.g. in docker compose:
7
+ # - http_root=/mlbserver
8
+
9
+ location /mlbserver {
10
+ return 301 $scheme://$host/mlbserver/;
11
+ }
12
+
13
+ location /mlbserver/ {
14
+ include /config/nginx/proxy.conf;
15
+ include /config/nginx/resolver.conf;
16
+ set $upstream_app mlbserver;
17
+ set $upstream_port 9999;
18
+ set $upstream_proto http;
19
+ proxy_pass $upstream_proto://$upstream_app:$upstream_port;
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlbserver",
3
- "version": "2025.02.25",
3
+ "version": "2025.03.29.2",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
package/session.js CHANGED
@@ -38,10 +38,14 @@ const WINTER_LEAGUES = [AFL_ID, LIDOM_ID]
38
38
  const BREAK_TYPES = ['Game Advisory', 'Pitching Substitution', 'Offensive Substitution', 'Defensive Sub', 'Defensive Switch', 'Runner Placed On Base']
39
39
  // These are the events to keep, in addition to the last event of each at-bat, if we're skipping pitches
40
40
  const ACTION_TYPES = ['Wild Pitch', 'Passed Ball', 'Stolen Base', 'Caught Stealing', 'Pickoff', 'Error', 'Out', 'Balk', 'Defensive Indiff', 'Other Advance']
41
+ // These are some idle events to skip
42
+ const IDLE_TYPES = ['Mound Visit', 'Batter Timeout', 'Pitcher Step Off']
41
43
  const EVENT_START_PADDING = -3
42
- const PITCH_END_PADDING = -4
44
+ const PITCH_END_PADDING = 2
43
45
  const ACTION_END_PADDING = 7
44
46
  const MINIMUM_BREAK_DURATION = 5
47
+ // extra padding for MLB events (2025)
48
+ const MLB_PADDING = 39
45
49
 
46
50
  const LI_TABLE = {
47
51
  1: {
@@ -1776,7 +1780,7 @@ class sessionClass {
1776
1780
  if ( ((team.toUpperCase() == home_team) && (LEVELS[level.toUpperCase()] == home_level)) || ((team.toUpperCase() == away_team) && (LEVELS[level.toUpperCase()] == away_level)) || ((team.toUpperCase().indexOf('NATIONAL.') == 0) && (home_level == LEVELS['MLB'])) || ((team.toUpperCase().startsWith('FREE.') && cache_data.dates[0].games[j].broadcasts && cache_data.dates[0].games[j].broadcasts[0] && (cache_data.dates[0].games[j].broadcasts[0].freeGame == true))) ) {
1777
1781
 
1778
1782
  // Check if Winter League / MiLB game first
1779
- if ( (home_level != LEVELS['MLB']) && (mediaType == 'MLBTV') ) {
1783
+ if ( (away_level != LEVELS['MLB']) && (home_level != LEVELS['MLB']) && (mediaType == 'MLBTV') ) {
1780
1784
  this.debuglog('matched non-MLB team for ' + cache_data.dates[0].games[j].teams['home'].team.abbreviation + '@' + cache_data.dates[0].games[j].teams['away'].team.abbreviation)
1781
1785
  if ( cache_data.dates[0].games[j].broadcasts ) {
1782
1786
  let broadcastName = 'N/A'
@@ -2892,8 +2896,14 @@ class sessionClass {
2892
2896
  async getBroadcastStart(streamURL, streamURLToken) {
2893
2897
  try {
2894
2898
  this.debuglog('getBroadcastStart')
2899
+
2900
+ // MLB version
2901
+ let variant = '_5600K'
2902
+ if ( streamURL.includes('milb.com') ) {
2903
+ variant = '_1280x720_59_5472K'
2904
+ }
2895
2905
 
2896
- let variant_url = 'http://localhost:' + this.data.port + '/playlist?url=' + encodeURIComponent(streamURL.substr(0,streamURL.length-5) + '_5600K.m3u8')
2906
+ let variant_url = 'http://localhost:' + this.data.port + '/playlist.m3u8?url=' + encodeURIComponent(streamURL.substr(0,streamURL.length-5) + variant + '.m3u8')
2897
2907
  if ( streamURLToken ) {
2898
2908
  variant_url += '&streamURLToken=' + encodeURIComponent(streamURLToken)
2899
2909
  }
@@ -2929,6 +2939,15 @@ class sessionClass {
2929
2939
  this.debuglog('getSkipMarkers')
2930
2940
 
2931
2941
  if ( skip_adjust != 0 ) this.log('manual adjustment of ' + skip_adjust + ' seconds being applied')
2942
+
2943
+ let event_start_padding = EVENT_START_PADDING
2944
+ let pitch_end_padding = PITCH_END_PADDING
2945
+ let action_end_padding = ACTION_END_PADDING
2946
+ if ( !streamURL.includes('milb.com') ) {
2947
+ event_start_padding += MLB_PADDING
2948
+ pitch_end_padding += MLB_PADDING
2949
+ action_end_padding += MLB_PADDING
2950
+ }
2932
2951
 
2933
2952
  if ( !this.temp_cache[gamePk] ) {
2934
2953
  this.temp_cache[gamePk] = {}
@@ -2993,7 +3012,7 @@ class sessionClass {
2993
3012
  if ((current_inning > start_inning) || ((current_inning == start_inning) && ((current_inning_half == start_inning_half) || (current_inning_half == 'bottom')))) {
2994
3013
  // loop through events within each play
2995
3014
  for (var j=0; j < cache_data.liveData.plays.allPlays[i].playEvents.length; j++) {
2996
- let event_end_padding = ACTION_END_PADDING
3015
+ let event_end_padding = action_end_padding
2997
3016
  // always exclude break types
2998
3017
  if (cache_data.liveData.plays.allPlays[i].playEvents[j].details && cache_data.liveData.plays.allPlays[i].playEvents[j].details.event && BREAK_TYPES.includes(cache_data.liveData.plays.allPlays[i].playEvents[j].details.event)) {
2999
3018
  // if we're in the process of skipping inning breaks, treat the first break type we find as another inning break
@@ -3004,15 +3023,18 @@ class sessionClass {
3004
3023
  continue
3005
3024
  } else {
3006
3025
  if ( (j < (cache_data.liveData.plays.allPlays[i].playEvents.length - 1)) && (!cache_data.liveData.plays.allPlays[i].playEvents[j].details || !cache_data.liveData.plays.allPlays[i].playEvents[j].details.event || !ACTION_TYPES.some(v => cache_data.liveData.plays.allPlays[i].playEvents[j].details.event.includes(v))) ) {
3007
- event_end_padding = PITCH_END_PADDING
3026
+ event_end_padding = pitch_end_padding
3008
3027
  }
3009
3028
  let action_index
3010
- // skip type 1 (breaks) && 2 (idle time) will look at all plays with an endTime
3011
- if ((skip_type <= 2) && cache_data.liveData.plays.allPlays[i].playEvents[j].endTime) {
3029
+ // skip type 1 (breaks) will look at all plays with an endTime
3030
+ if ((skip_type == 1) && cache_data.liveData.plays.allPlays[i].playEvents[j].endTime) {
3031
+ action_index = j
3032
+ // skip type 2 (idle time) will look at all non-idle plays with an endTime
3033
+ } else if ((skip_type == 2) && cache_data.liveData.plays.allPlays[i].playEvents[j].endTime && !IDLE_TYPES.some(v => cache_data.liveData.plays.allPlays[i].playEvents[j].details.description.includes(v))) {
3012
3034
  action_index = j
3013
3035
  } else if (skip_type == 3) {
3014
3036
  // skip type 3 excludes non-action pitches (events that aren't last in the at-bat and don't fall under action types)
3015
- if ( event_end_padding == PITCH_END_PADDING ) {
3037
+ if ( event_end_padding == pitch_end_padding ) {
3016
3038
  continue
3017
3039
  } else {
3018
3040
  // if the action is associated with another play or the event doesn't have an end time, use the previous event instead
@@ -3026,7 +3048,7 @@ class sessionClass {
3026
3048
  if (typeof action_index === 'undefined') {
3027
3049
  continue
3028
3050
  } else {
3029
- let break_end = ((new Date(cache_data.liveData.plays.allPlays[i].playEvents[action_index].startTime) - broadcast_start_timestamp) / 1000) + EVENT_START_PADDING
3051
+ let break_end = ((new Date(cache_data.liveData.plays.allPlays[i].playEvents[action_index].startTime) - broadcast_start_timestamp) / 1000) + event_start_padding
3030
3052
 
3031
3053
  // attempt to fix erroneous timestamps, like NYY-SEA 2022-08-09, bottom 11
3032
3054
  if ( break_end < break_start ) {
@@ -3583,25 +3605,27 @@ class sessionClass {
3583
3605
  let game = cache_data.results[j]
3584
3606
  let game_pk = game.gamePk
3585
3607
  this.debuglog('get_blackout_games checking game ' + game_pk)
3586
- if ( game.blackedOutVideo ) {
3587
- this.debuglog('get_blackout_games found blackout')
3608
+ if ( game.blackedOutVideo || !game.entitledVideo ) {
3609
+ this.debuglog('get_blackout_games found blackout or non-entitled video')
3588
3610
  let blackout_type = ''
3589
- // local/national blackout label disabled, as all were returning local
3590
- /*if ( game.videoStatusCodes.includes('2') ) {
3611
+ if ( game.videoStatusCodes.includes(2) ) {
3591
3612
  this.debuglog('get_blackout_games found national blackout')
3592
3613
  blackout_type = 'National/International'
3593
- } else {
3614
+ } else if ( game.videoStatusCodes.includes(1) ) {
3594
3615
  this.debuglog('get_blackout_games found local blackout')
3595
3616
  blackout_type = 'Local'
3596
- }*/
3617
+ } else {
3618
+ this.debuglog('get_blackout_games found non-entitled video')
3619
+ blackout_type = 'Not entitled'
3620
+ }
3597
3621
  blackouts[game_pk] = { blackout_type: blackout_type }
3598
- } else if ( !game.entitledVideo && (game.videoStatusCodes[0] == '3') ) {
3622
+ } /*else if ( !game.entitledVideo && (game.videoStatusCodes[0] == '3') ) {
3599
3623
  this.debuglog('get_blackout_games found non-entitled MVPD required blackout')
3600
3624
  blackouts[game_pk] = { blackout_type: '' }
3601
- }
3625
+ }*/
3602
3626
 
3603
3627
  // add blackout expiry, if requested
3604
- if ( blackouts[game_pk] && calculate_expiries && await this.check_game_time(game.gameData) ) {
3628
+ if ( blackouts[game_pk] && (blackouts[game_pk].blackout_type != 'Not entitled') && calculate_expiries && await this.check_game_time(game.gameData) ) {
3605
3629
  this.debuglog('get_blackout_games calculating blackout expiry')
3606
3630
  let date_cache_data = await this.getDayData(gameDate)
3607
3631
  if ( date_cache_data.dates && date_cache_data.dates[0] && date_cache_data.dates[0].games && (date_cache_data.dates[0].games.length > 0) ) {