diff --git a/README.md b/README.md index f54b6b7..ff6199a 100644 --- a/README.md +++ b/README.md @@ -1,400 +1,98 @@ # SiriusXM -This script creates a server that serves HLS streams for SiriusXM channels. To use it, pass your SiriusXM username and password and a port to run the server on. For example, you start the server by running: -`python sxm.py myuser mypassword 8888` +This script creates a server that serves HLSstreams for SiriusXM channels. +With an optional configuration file, the recording mode can record shows from +specific channels and even populate ID3 tags on the output file for you. -Then in a player that supports HLS (QuickTime, VLC, ffmpeg, etc) you can access a channel at http://127.0.0.1:8888/channel.m3u8 where "channel" is the channel name, ID, or Sirius channel number. +#### Requirements +Python libraries: +* eyeD3 +* requests +* tenacity -Here's a list of some of the channel IDs: +If you wish to record streams, you'll need to have [ffmpeg](https://www.ffmpeg.org/) +installed with [LAME](https://sourceforge.net/projects/lame/) support compiled in. -| Name | ID | -|-----------------------------------|---------------------| -| The Covers Channel | 9416 | -| Sports 958 | 9427 | -| Utah Jazz | 9294 | -| Sports 975 | 9212 | -| VOLUME | 9442 | -| HLN | cnnheadlinenews | -| Laugh USA | laughbreak | -| Washington Wizards | 9295 | -| Carlin's Corner | 9181 | -| 70s on 7 | totally70s | -| SXM NHL Network Radio | 8185 | -| Tom Petty Radio | 9407 | -| Underground Garage | undergroundgarage | -| SiriusXM Spotlight | 9138 | -| Radio Margaritaville | radiomargaritaville | -| Cincinnati Reds | 9237 | -| Portland Trail Blazers | 9290 | -| SiriusXM FC | 9341 | -| Miami Marlins | 9245 | -| SiriusXM Insight | 8183 | -| SiriusXM FLY | 9339 | -| Red White & Booze | 9178 | -| Kids Place Live | 8216 | -| New York Islanders | 9313 | -| New York Rangers | 9314 | -| SiriusXM NASCAR Radio | siriusnascarradio | -| 1st Wave | firstwave | -| Los Angeles Rams | 9203 | -| Houston Rockets | 9276 | -| Washington Capitals | 9324 | -| Joel Osteen Radio | 9392 | -| Attitude Franco | energie2 | -| Classic Rewind | classicrewind | -| SiriusXM PGA TOUR Radio | 8186 | -| Miami Heat | 9281 | -| 80s on 8 | big80s | -| SiriusXM 375 | 9459 | -| Dallas Stars | 9304 | -| Sports 977 | 9214 | -| Denver Broncos | 9155 | -| Hip-Hop Nation | hiphopnation | -| Boston Red Sox | 9234 | -| SXM Limited Edition 5 | 9399 | -| SiriusXM Silk | 9364 | -| Flow Nación | 9185 | -| Miami Dolphins | 9162 | -| Sports 983 | 9327 | -| Viva | 8225 | -| Sports 985 | 9329 | -| Barstool Radio on SiriusXM | 9467 | -| San Francisco 49ers | 9202 | -| Sports 992 | 9336 | -| Arizona Diamondbacks | 9231 | -| ESPN Xtra | 8254 | -| Utopia | 9365 | -| RockBar | 9175 | -| Road Dog Trucking | roaddogtrucking | -| Colorado Rockies | 9239 | -| Colorado Avalanche | 9303 | -| Real Jazz | purejazz | -| Free Bird: LynyrdSkynyrd | 9139 | -| Sports 994 | 9338 | -| Bluegrass Junction | bluegrass | -| Sports 986 | 9330 | -| CBC Radio One | cbcradioone | -| POTUS Politics | indietalk | -| The Groove | 8228 | -| American Latino Radio | 9133 | -| Milwaukee Bucks | 9282 | -| Comedy Central Radio | 9356 | -| Z100/NY | 8242 | -| Philadelphia Flyers | 9316 | -| Chicago Bears | 9151 | -| FOX Business | 9369 | -| Washington Redskins | 9206 | -| Oklahoma City Thunder | 9286 | -| SXM Limited Edition 3 | 9353 | -| SXM Rock Hall Radio | 9174 | -| Dallas Cowboys | 9154 | -| Boston Celtics | 9268 | -| Los Angeles Clippers | 9278 | -| Sports 980 | 9261 | -| Classic Vinyl | classicvinyl | -| Howard 101 | howardstern101 | -| TODAY Show Radio | 9390 | -| Sway's Universe | 9397 | -| ESPN Deportes | espndeportes | -| Houston Texans | 9158 | -| MLB Network Radio | 8333 | -| Sports 974 | 9211 | -| La Politica Talk | 9134 | -| BB King's Bluesville | siriusblues | -| 60s on 6 | 60svibrations | -| Sports 991 | 9335 | -| C-SPAN Radio | 8237 | -| Spa | spa73 | -| St. Louis Blues | 9320 | -| Kansas City Royals | 9242 | -| CBC Radio 3 | cbcradio3 | -| SiriusXM 372 | 9456 | -| The Garth Channel | 9421 | -| Howard 100 | howardstern100 | -| FOX Sports on SiriusXM | 9445 | -| Sports 979 | 9216 | -| CBS Sports Radio | 9473 | -| RURAL Radio | 9367 | -| Sports 984 | 9328 | -| E Street Radio | estreetradio | -| Pop2K | 8208 | -| Indiana Pacers | 9277 | -| Korea Today | 9132 | -| PRX Public Radio | 8239 | -| Philadelphia Phillies | 9251 | -| Sports 963 | 9223 | -| Dallas Mavericks | 9272 | -| Lithium | 90salternative | -| New Orleans Saints | 9165 | -| SiriusXM SEC Radio | 9458 | -| The Joint | reggaerhythms | -| Atlanta Braves | 9232 | -| BPM | thebeat | -| Sports 981 | 9262 | -| Florida Panthers | 9307 | -| Sports 969 | 9229 | -| Willie's Roadhouse | theroadhouse | -| SiriusXMU | leftofcenter | -| Family Talk | 8307 | -| 80s/90s Pop | 9373 | -| FOX News Headlines 24/7 | 9410 | -| Ozzy's Boneyard | buzzsaw | -| Mad Dog Sports Radio | 8213 | -| Diplo's Revolution Radio | 9472 | -| SiriusXM ACC Radio | 9455 | -| Minnesota Timberwolves | 9283 | -| ONEderland | 9419 | -| SXM Limited Edition 9 | 9403 | -| Orlando Magic | 9287 | -| Sports 960 | 9220 | -| Indianapolis Colts | 9159 | -| San Antonio Spurs | 9291 | -| Charlotte Hornets | 9269 | -| SiriusXM Stars | siriusstars | -| Phoenix Suns | 9289 | -| Canada Laughs | 8259 | -| Venus | 9389 | -| Sports 989 | 9333 | -| Minnesota Vikings | 9163 | -| Krishna Das Yoga Radio | 9179 | -| Vancouver Canucks | 9323 | -| En Vivo | 9135 | -| Buffalo Sabres | 9298 | -| Pittsburgh Pirates | 9252 | -| Sports 978 | 9215 | -| The Highway | newcountry | -| Kirk Franklin's Praise | praise | -| Tampa Bay Buccaneers | 9204 | -| SiriusXM Rush | 8230 | -| Hair Nation | hairnation | -| SiriusXM NFL Radio | siriusnflradio | -| The Verge | 8244 | -| Milwaukee Brewers | 9246 | -| Vegas Stats & Info | 9448 | -| Petty's Buried Treasure | 9352 | -| The Loft | 8207 | -| Sports 959 | 9428 | -| The Emo Project | 9447 | -| Yacht Rock Radio | 9420 | -| SiriusXM Pops | siriuspops | -| The Bridge | thebridge | -| SiriusXM Preview | 0 | -| SiriusXM Hits 1 | siriushits1 | -| 90s on 9 | 8206 | -| Cincinnati Bengals | 9152 | -| Raw Dog Comedy Hits | rawdog | -| FOX News Talk | 9370 | -| Cleveland Browns | 9153 | -| Heart & Soul | heartandsoul | -| Faction Punk | faction | -| Toronto Raptors | 9293 | -| SiriusXM Scoreboard | 8248 | -| Ici Première | premiereplus | -| Cleveland Indians | 9238 | -| Chicago White Sox | 9236 | -| Los Angeles Chargers | 9171 | -| New York Knicks | 9285 | -| Carolina Hurricanes | 9299 | -| Montreal Canadiens | 9310 | -| St. Louis Cardinals | 9256 | -| Águila | 9186 | -| Sports 988 | 9332 | -| The Beatles Channel | 9446 | -| New York Yankees | 9249 | -| EW Radio | 9351 | -| Sports 971 | 9208 | -| Canadian IPR | 9358 | -| SiriusXM Comes Alive! | 9176 | -| 40s Junction | 8205 | -| Arizona Cardinals | 9146 | -| Sports 961 | 9221 | -| Elvis Radio | elvisradio | -| enLighten | 8229 | -| Atlanta Hawks | 9266 | -| Chicago Cubs | 9235 | -| Seattle Mariners | 9255 | -| Road Trip Radio | 9415 | -| Symphony Hall | symphonyhall | -| SXM Limited Edition 11 | 9405 | -| Latidos | 9187 | -| SiriusXM Comedy Greats | 9408 | -| Sports 982 | 9326 | -| Sports 957 | 9426 | -| Detroit Lions | 9156 | -| SiriusXM Chill | chill | -| SiriusXM Pac-12 Radio | 9457 | -| Chicago Blackhawks | 9302 | -| Cinemagic | 8211 | -| SiriusXM Progress | siriusleft | -| Atlanta Falcons | 9147 | -| Liquid Metal | hardattack | -| Radio Disney | radiodisney | -| The Blend | starlite | -| Verizon IndyCar Series | 9207 | -| Toronto Blue Jays | 9259 | -| Octane | octane | -| Jam On | jamon | -| The Billy Graham Channel | 9411 | -| Calgary Flames | 9301 | -| Triumph | 9449 | -| Sports 966 | 9226 | -| Houston Astros | 9241 | -| ESPNU Radio | siriussportsaction | -| Chicago Bulls | 9270 | -| Pearl Jam Radio | 8370 | -| Caricia | 9188 | -| Brooklyn Nets | 9267 | -| Sports 990 | 9334 | -| Denver Nuggets | 9273 | -| El Paisa | 9414 | -| New York Jets | 9167 | -| Iceberg | icebergradio | -| 70s/80s Pop | 9372 | -| The Message | spirit | -| Minnesota Wild | 9309 | -| Nashville Predators | 9312 | -| Memphis Grizzlies | 9280 | -| PopRocks | 9450 | -| SXM Limited Edition 8 | 9402 | -| Arizona Coyotes | 9394 | -| La Kueva | 9191 | -| SiriusXM NBA Radio | 9385 | -| Sports 967 | 9227 | -| BBC World Service | bbcworld | -| Sports 976 | 9213 | -| Rumbón | 9190 | -| Ici Musique Chansons | 8245 | -| NPR Now | nprnow | -| KIDZ BOP Radio | 9366 | -| Sports 973 | 9210 | -| SXM Limited Edition 4 | 9398 | -| Velvet | 9361 | -| Classic Rock Party | 9375 | -| Los Angeles Lakers | 9279 | -| Met Opera Radio | metropolitanopera | -| SXM Limited Edition 6 | 9400 | -| Green Bay Packers | 9157 | -| Sacramento Kings | 9292 | -| Pittsburgh Steelers | 9170 | -| Sports 954 | 9423 | -| Carolina Shag Radio | 9404 | -| KIIS-Los Angeles | 8241 | -| Deep Tracks | thevault | -| Business Radio | 9359 | -| Philadelphia Eagles | 9169 | -| Buffalo Bills | 9149 | -| The Spectrum | thespectrum | -| Grateful Dead | gratefuldead | -| Pitbull's Globalization | 9406 | -| CNN | cnn | -| Oldies Party | 9378 | -| Golden State Warriors | 9275 | -| CNBC | cnbc | -| Sports 965 | 9225 | -| The Catholic Channel | thecatholicchannel | -| New England Patriots | 9164 | -| New Orleans Pelicans | 9284 | -| ESPN Radio | espnradio | -| Bloomberg Radio | bloombergradio | -| The Heat | hotjamz | -| Columbus Blue Jackets | 9300 | -| Sports 968 | 9228 | -| Oakland Raiders | 9168 | -| Sports 972 | 9209 | -| Detroit Tigers | 9240 | -| Pittsburgh Penguins | 9318 | -| HBCU | 9130 | -| Los Angeles Kings | 9308 | -| Ottawa Senators | 9315 | -| MSNBC | 8367 | -| Outlaw Country | outlawcountry | -| SXM Limited Edition 7 | 9401 | -| Prime Country | primecountry | -| Jason Ellis | 9363 | -| Alt Nation | altnation | -| No Shoes Radio | 9418 | -| Radio Andy | 9409 | -| Baltimore Ravens | 9148 | -| San Jose Sharks | 9319 | -| San Francisco Giants | 9254 | -| Siriusly Sinatra | siriuslysinatra | -| New York Giants | 9166 | -| Doctor Radio | doctorradio | -| Sports 987 | 9331 | -| San Diego Padres | 9253 | -| Texas Rangers | 9258 | -| SiriusXM Turbo | 9413 | -| Shade 45 | shade45 | -| North Americana | 9468 | -| Kevin Hart's Laugh Out Loud Radio | 9469 | -| Los Angeles Angels | 9243 | -| Sports 964 | 9224 | -| BYUradio | 9131 | -| Ici FrancoCountry | rockvelours | -| Washington Nationals | 9260 | -| SportsCenter | 9180 | -| Baltimore Orioles | 9233 | -| EWTN Radio | ewtnglobal | -| Vivid Radio | 8369 | -| The Village | 8227 | -| Carolina Panthers | 9150 | -| Escape | 8215 | -| Toronto Maple Leafs | 9322 | -| Studio 54 Radio | 9145 | -| New Jersey Devils | 9311 | -| Sports 962 | 9222 | -| Kansas City Chiefs | 9161 | -| FOX News Channel | foxnewschannel | -| RadioClassics | radioclassics | -| Tennessee Titans | 9205 | -| Detroit Red Wings | 9305 | -| Telemundo | 9466 | -| The Coffee House | coffeehouse | -| Vegas Golden Knights | 9453 | -| Neil Diamond Radio | 8372 | -| Minnesota Twins | 9247 | -| The Pulse | thepulse | -| HUR Voices | 9129 | -| Tampa Bay Rays | 9257 | -| SiriusXM Love | siriuslove | -| Rock The Bells Radio | 9471 | -| Jacksonville Jaguars | 9160 | -| Sports 953 | 9422 | -| Philadelphia 76ers | 9288 | -| Oakland Athletics | 9250 | -| Canada Talks | 9172 | -| Watercolors | jazzcafe | -| Edmonton Oilers | 9306 | -| Elevations | 9362 | -| SiriusXM Patriot | siriuspatriot | -| On Broadway | broadwaysbest | -| Detroit Pistons | 9274 | -| CNN en Español | cnnespanol | -| Tampa Bay Lightning | 9321 | -| Indie 1.0 | 9451 | -| NBC Sports Radio | 9452 | -| Celebrate! | 9412 | -| Y2Kountry | 9340 | -| Los Angeles Dodgers | 9244 | -| Sports 993 | 9337 | -| CNN International | 9454 | -| Seattle Seahawks | 9201 | -| Cleveland Cavaliers | 9271 | -| Luna | 9189 | -| Caliente | rumbon | -| Sports 956 | 9425 | -| Ramsey Media Channel | 9443 | -| Faction Talk | 8184 | -| Winnipeg Jets | 9325 | -| 50s on 5 | siriusgold | -| Soul Town | soultown | -| Anaheim Ducks | 9296 | -| New York Mets | 9248 | -| SiriusXM Urban View | 8238 | -| Comedy Roundup | bluecollarcomedy | -| Sports 955 | 9424 | -| Influence Franco | 8246 | -| SXM Fantasy Sports Radio | 8368 | -| CBC Country | bandeapart | -| Boston Bruins | 9297 | -| Holiday Traditions | 9342 | +#### Installation +Install the Python dependencies + +`pip install -r requirements.txt` + + +#### Configuration + +You can store your XM credentials as environment variables if you don't want +to use the arg parser. Use `SIRIUSXM_USER` and `SIRIUSXM_PASS`. + +```bash +export SIRIUSXM_USER="username" +export SIRIUSXM_PASS="password" +``` + +If you wish to record shows, read this section +##### Example configuration + +The following is an example `config.json` +```json +{ + "bitrate": "160k", + "shows": [ + "Soul Assassins" + ], + "tags": { + "DJ_Muggs_&_Ern_D": { + "artist": "DJ Muggs & Ern Dogg", + "album": "Soul Assassins Radio", + "genre": "Hip-Hop", + "track_count": 1 + } + } +} +``` +The `bitrate` (required) can be whatever you wish (i.e. 128k, 192k, 256k). Keep in mind +that a higher bitrate equals a higher file size. + +Your `show` (optional) names are matched using a case insensitive regular expression, so you only need to +match the title of your show partially. + +The `tags` (optional) section uses the short title +from the XM API as the key for tagging data. You can get this from the API +yourself or just add it after you've added a show (the short title is in the +filename). Each entry in the tags list will be matched on the short +title and your MP3 file will be tagged with whatever you enter for +artist, album and genre. + +If you're just addin ga new entry to the tags list, set the track_count +to 0. The tagging code will increment this value for each new occurrence +of the show. + + +## Usage +#### Simple HLS server +`python sxm.py -u myuser -p mypassword` + +Then in a player that supports HLS (QuickTime, VLC, ffmpeg, etc) you can +access a channel at http://127.0.0.1:8888/channel.m3u8 where "channel" is +the channel name, ID, or Sirius channel number. + +#### Start the server in ripping mode +`python sxm.py -u myuser -p mypassword -c channel -r` + +Use the configuration json (`config.json`) to specify bitrate, programs +to record and tagging details. Shows are dumped locally using the short title +of the show which was recorded (i.e. `20180704180000-My_Program.mp3`) + +Tagging occurs once the ffmpeg stream has been closed. + + +#### List all XM channels +`python sxm.py -u myuser -p mypassword -l` + +Example output: + +```bash +ID | Num | Name +big80s | 8 | 80s on 8 +90salternative | 34 | Lithium +altnation | 36 | Alt Nation +``` diff --git a/config.json b/config.json new file mode 100644 index 0000000..460a97b --- /dev/null +++ b/config.json @@ -0,0 +1,70 @@ +{ + "bitrate": "160k", + "shows": [ + "Tony Touch", + "Whoo Kid", + "L.A. Leakers", + "Aphilliates", + "Soul Assassins", + "Statik Selektah", + "Animal Status", + "Scram", + "DJ Premier" + ], + "tags": { + "Animal_Status": { + "artist": "DJ Wonder", + "album": "Animal Status", + "genre": "Hip-Hop", + "track_count": 23 + }, + "DJ_Muggs_&_Ern_D": { + "artist": "DJ Muggs & Ern Dogg", + "album": "Soul Assassins Radio", + "genre": "Hip-Hop", + "track_count": 25 + }, + "DJ_Premier": { + "artist": "DJ Premier", + "album": "Live from HeadQCourterz", + "genre": "Hip-Hop", + "track_count": 24 + }, + "L.A._Leakers": { + "artist": "L.A. Leakers", + "album": "#LEAKShow", + "genre": "Hip-Hop", + "track_count": 23 + }, + "Scram_Jones": { + "artist": "Scram Jones", + "album": "#BeastMusicSXM", + "genre": "Hip-Hop", + "track_count": 22 + }, + "Showoff_Radio": { + "artist": "DJ Statik Selektah", + "album": "Showoff Radio", + "genre": "Hip-Hop", + "track_count": 19 + }, + "The_Aphilliates": { + "artist": "The Aphilliates", + "album": "Streetz Is Watchin'", + "genre": "Hip-Hop", + "track_count": 47 + }, + "Toca_Tuesdays": { + "artist": "Tony Touch", + "album": "Toca Tuesdays", + "genre": "Hip-Hop", + "track_count": 30 + }, + "Whoo_Kid": { + "artist": "DJ Whoo Kid", + "album": "Whoolywood Shuffle", + "genre": "Hip-Hop", + "track_count": 43 + } + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2986b51 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +eyeD3==0.8.7 +requests==2.19.1 +tenacity==5.0.2 diff --git a/sxm.py b/sxm.py index 5513159..246ad2d 100644 --- a/sxm.py +++ b/sxm.py @@ -1,357 +1,929 @@ -import requests -import base64 -import urllib.parse -import json -import time, datetime -import sys -from http.server import BaseHTTPRequestHandler, HTTPServer - -class SiriusXM: - USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/604.5.6 (KHTML, like Gecko) Version/11.0.3 Safari/604.5.6' - REST_FORMAT = 'https://player.siriusxm.com/rest/v2/experience/modules/{}' - LIVE_PRIMARY_HLS = 'https://siriusxm-priprodlive.akamaized.net' - - def __init__(self, username, password): - self.session = requests.Session() - self.session.headers.update({'User-Agent': self.USER_AGENT}) - self.username = username - self.password = password - self.playlists = {} - self.channels = None - - @staticmethod - def log(x): - print('{} : {}'.format(datetime.datetime.now().strftime('%d.%b %Y %H:%M:%S'), x)) - - def is_logged_in(self): - return 'SXMAUTH' in self.session.cookies - - def is_session_authenticated(self): - return 'AWSELB' in self.session.cookies and 'JSESSIONID' in self.session.cookies - - def get(self, method, params, authenticate=True): - if authenticate and not self.is_session_authenticated() and not self.authenticate(): - self.log('Unable to authenticate') - return None - - res = self.session.get(self.REST_FORMAT.format(method), params=params) - if res.status_code != 200: - self.log('Received status code {} for method \'{}\''.format(res.status_code, method)) - return None - - try: - return res.json() - except ValueError: - self.log('Error decoding json for method \'{}\''.format(method)) - return None - - def post(self, method, postdata, authenticate=True): - if authenticate and not self.is_session_authenticated() and not self.authenticate(): - self.log('Unable to authenticate') - return None - - res = self.session.post(self.REST_FORMAT.format(method), data=json.dumps(postdata)) - if res.status_code != 200: - self.log('Received status code {} for method \'{}\''.format(res.status_code, method)) - return None - - try: - return res.json() - except ValueError: - self.log('Error decoding json for method \'{}\''.format(method)) - return None - - def login(self): - postdata = { - 'moduleList': { - 'modules': [{ - 'moduleRequest': { - 'resultTemplate': 'web', - 'deviceInfo': { - 'osVersion': 'Mac', - 'platform': 'Web', - 'sxmAppVersion': '3.1802.10011.0', - 'browser': 'Safari', - 'browserVersion': '11.0.3', - 'appRegion': 'US', - 'deviceModel': 'K2WebClient', - 'clientDeviceId': 'null', - 'player': 'html5', - 'clientDeviceType': 'web', - }, - 'standardAuth': { - 'username': self.username, - 'password': self.password, - }, - }, - }], - }, - } - data = self.post('modify/authentication', postdata, authenticate=False) - if not data: - return False - - try: - return data['ModuleListResponse']['status'] == 1 and self.is_logged_in() - except KeyError: - self.log('Error decoding json response for login') - return False - - def authenticate(self): - if not self.is_logged_in() and not self.login(): - self.log('Unable to authenticate because login failed') - return False - - postdata = { - 'moduleList': { - 'modules': [{ - 'moduleRequest': { - 'resultTemplate': 'web', - 'deviceInfo': { - 'osVersion': 'Mac', - 'platform': 'Web', - 'clientDeviceType': 'web', - 'sxmAppVersion': '3.1802.10011.0', - 'browser': 'Safari', - 'browserVersion': '11.0.3', - 'appRegion': 'US', - 'deviceModel': 'K2WebClient', - 'player': 'html5', - 'clientDeviceId': 'null' - } - } - }] - } - } - data = self.post('resume?OAtrial=false', postdata, authenticate=False) - if not data: - return False - - try: - return data['ModuleListResponse']['status'] == 1 and self.is_session_authenticated() - except KeyError: - self.log('Error parsing json response for authentication') - return False - - def get_sxmak_token(self): - try: - return self.session.cookies['SXMAKTOKEN'].split('=', 1)[1].split(',', 1)[0] - except (KeyError, IndexError): - return None - - def get_gup_id(self): - try: - return json.loads(urllib.parse.unquote(self.session.cookies['SXMDATA']))['gupId'] - except (KeyError, ValueError): - return None - - def get_playlist_url(self, guid, channel_id, use_cache=True, max_attempts=5): - if use_cache and channel_id in self.playlists: - return self.playlists[channel_id] - - params = { - 'assetGUID': guid, - 'ccRequestType': 'AUDIO_VIDEO', - 'channelId': channel_id, - 'hls_output_mode': 'custom', - 'marker_mode': 'all_separate_cue_points', - 'result-template': 'web', - 'time': int(round(time.time() * 1000.0)), - 'timestamp': datetime.datetime.utcnow().isoformat('T') + 'Z' - } - data = self.get('tune/now-playing-live', params) - if not data: - return None - - # get status - try: - status = data['ModuleListResponse']['status'] - message = data['ModuleListResponse']['messages'][0]['message'] - message_code = data['ModuleListResponse']['messages'][0]['code'] - except (KeyError, IndexError): - self.log('Error parsing json response for playlist') - return None - - # login if session expired - if message_code == 201 or message_code == 208: - if max_attempts > 0: - self.log('Session expired, logging in and authenticating') - if self.authenticate(): - self.log('Successfully authenticated') - return self.get_playlist_url(guid, channel_id, use_cache, max_attempts - 1) - else: - self.log('Failed to authenticate') - return None - else: - self.log('Reached max attempts for playlist') - return None - elif status == 0: - self.log('Received error {} {}'.format(message_code, message)) - return None - - # get m3u8 url - try: - playlists = data['ModuleListResponse']['moduleList']['modules'][0]['moduleResponse']['liveChannelData']['hlsAudioInfos'] - except (KeyError, IndexError): - self.log('Error parsing json response for playlist') - return None - for playlist_info in playlists: - if playlist_info['size'] == 'LARGE': - playlist_url = playlist_info['url'].replace('%Live_Primary_HLS%', self.LIVE_PRIMARY_HLS) - self.playlists[channel_id] = self.get_playlist_variant_url(playlist_url) - return self.playlists[channel_id] - - return None - - def get_playlist_variant_url(self, url): - params = { - 'token': self.get_sxmak_token(), - 'consumer': 'k2', - 'gupId': self.get_gup_id(), - } - res = self.session.get(url, params=params) - - if res.status_code != 200: - self.log('Received status code {} on playlist variant retrieval'.format(res.status_code)) - return None - - variant = next(filter(lambda x: x.endswith('.m3u8'), map(lambda x: x.rstrip(), res.text.split('\n'))), None) - return '{}/{}'.format(url.rsplit('/', 1)[0], variant) if variant else None - - def get_playlist(self, name, use_cache=True): - guid, channel_id = self.get_channel(name) - if not guid or not channel_id: - self.log('No channel for {}'.format(name)) - return None - - url = self.get_playlist_url(guid, channel_id, use_cache) - params = { - 'token': self.get_sxmak_token(), - 'consumer': 'k2', - 'gupId': self.get_gup_id(), - } - res = self.session.get(url, params=params) - - if res.status_code == 403: - self.log('Received status code 403 on playlist, renewing session') - return self.get_playlist(name, False) - - if res.status_code != 200: - self.log('Received status code {} on playlist variant'.format(res.status_code)) - return None - - # add base path to segments - lines = list(map(lambda x: x.rstrip(), res.text.split('\n'))) - for x in range(len(lines)): - line = lines[x].rstrip() - if line.endswith('.aac'): - base_url = url.rsplit('/', 1)[0] - base_path = base_url[8:].split('/', 1)[1] - lines[x] = '{}/{}'.format(base_path, line) - return '\n'.join(lines) - - def get_segment(self, path, max_attempts=5): - url = '{}/{}'.format(self.LIVE_PRIMARY_HLS, path) - params = { - 'token': self.get_sxmak_token(), - 'consumer': 'k2', - 'gupId': self.get_gup_id(), - } - res = self.session.get(url, params=params) - - if res.status_code == 403: - if max_attempts > 0: - self.log('Received status code 403 on segment, renewing session') - self.get_playlist(path.split('/', 2)[1], False) - return self.get_segment(path, max_attempts - 1) - else: - self.log('Received status code 403 on segment, max attempts exceeded') - return None - - if res.status_code != 200: - self.log('Received status code {} on segment'.format(res.status_code)) - return None - - return res.content - - def get_channel(self, name): - # download channel list if necessary - if not self.channels: - postdata = { - 'moduleList': { - 'modules': [{ - 'moduleArea': 'Discovery', - 'moduleType': 'ChannelListing', - 'moduleRequest': { - 'consumeRequests': [], - 'resultTemplate': 'responsive', - 'alerts': [], - 'profileInfos': [] - } - }] - } - } - data = self.post('get', postdata) - if not data: - self.log('Unable to get channel list') - return (None, None) - - try: - self.channels = data['ModuleListResponse']['moduleList']['modules'][0]['moduleResponse']['contentData']['channelListing']['channels'] - except (KeyError, IndexError): - self.log('Error parsing json response for channels') - return (None, None) - - name = name.lower() - for x in self.channels: - if x.get('name', '').lower() == name or x.get('channelId', '').lower() == name or x.get('siriusChannelNumber') == name: - return (x['channelGuid'], x['channelId']) - return (None, None) - -def make_sirius_handler(username, password): - class SiriusHandler(BaseHTTPRequestHandler): - HLS_AES_KEY = base64.b64decode('0Nsco7MAgxowGvkUT8aYag==') - sxm = SiriusXM(username, password) - - def do_GET(self): - if self.path.endswith('.m3u8'): - data = self.sxm.get_playlist(self.path.rsplit('/', 1)[1][:-5]) - if data: - self.send_response(200) - self.send_header('Content-Type', 'application/x-mpegURL') - self.end_headers() - self.wfile.write(bytes(data, 'utf-8')) - else: - self.send_response(500) - self.end_headers() - elif self.path.endswith('.aac'): - data = self.sxm.get_segment(self.path[1:]) - if data: - self.send_response(200) - self.send_header('Content-Type', 'audio/x-aac') - self.end_headers() - self.wfile.write(data) - else: - self.send_response(500) - self.end_headers() - elif self.path.endswith('/key/1'): - self.send_response(200) - self.send_header('Content-Type', 'text/plain') - self.end_headers() - self.wfile.write(self.HLS_AES_KEY) - else: - self.send_response(500) - self.end_headers() - return SiriusHandler - -if __name__ == '__main__': - if len(sys.argv) < 4: - print('usage: python sxm.py [username] [password] [port]') - sys.exit(1) - - httpd = HTTPServer(('', int(sys.argv[3])), make_sirius_handler(sys.argv[1], sys.argv[2])) - try: - httpd.serve_forever() - except KeyboardInterrupt: - pass - httpd.server_close() +import argparse +import eyed3 +import os +import re +import requests +import base64 +import urllib.parse +import json +import time +import sys +import subprocess +import datetime +import traceback +from collections import defaultdict +from tenacity import retry +from tenacity import stop_after_attempt +from tenacity import wait_fixed + +from datetime import timedelta +from http.server import BaseHTTPRequestHandler, HTTPServer +from concurrent.futures import ThreadPoolExecutor + + +class AuthenticationError(Exception): + pass + + +class SegmentRetrievalException(Exception): + pass + + +def retry_login(value): + if value is False: + print("Retrying login..") + sys.stdout.flush() + + return value is False + + +def retry_authenticate(value): + if value is False: + print("Retrying authenticate..") + sys.stdout.flush() + + return value is False + + +class SiriusXM: + USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/604.5.6 (KHTML, like Gecko) Version/11.0.3 Safari/604.5.6" + REST_FORMAT = "https://player.siriusxm.com/rest/v2/experience/modules/{}" + LIVE_PRIMARY_HLS = "https://siriusxm-priprodlive.akamaized.net" + + def __init__(self, username, password, output_directory=os.path.abspath(".")): + self.username = username + self.password = password + self.reset_session() + self.playlists = {} + self.channels = None + self.output_directory = output_directory + + @staticmethod + def log(x): + print( + "{} : {}".format( + datetime.datetime.now().strftime("%d.%b %Y %H:%M:%S"), x + ) + ) + + def is_logged_in(self): + return "SXMAUTHNEW" in self.session.cookies + + def is_session_authenticated(self): + return "AWSELB" in self.session.cookies and "JSESSIONID" in self.session.cookies + + @retry(wait=wait_fixed(1), stop=stop_after_attempt(10)) + def get(self, method, params): + if self.is_session_authenticated() and not self.authenticate(): + self.log("Unable to authenticate") + return None + + try: + res = self.session.get(self.REST_FORMAT.format(method), params=params) + except requests.exceptions.ConnectionError as e: + self.log("An Exception occurred when trying to perform the GET request!") + self.log("\tParams: {}".format(params)) + self.log("\tMethod: {}".format(method)) + self.log("Response: {}".format(e.response)) + self.log("Request: {}".format(e.request)) + raise (e) + + if res.status_code != 200: + self.log( + "Received status code {} for method '{}'".format( + res.status_code, method + ) + ) + return None + + try: + return res.json() + except ValueError: + self.log("Error decoding json for method '{}'".format(method)) + return None + + def post(self, method, postdata, authenticate=True): + if ( + authenticate + and not self.is_session_authenticated() + and not self.authenticate() + ): + self.log("Unable to authenticate") + return None + + res = self.session.post( + self.REST_FORMAT.format(method), data=json.dumps(postdata) + ) + if res.status_code != 200: + self.log( + "Received status code {} for method '{}'".format( + res.status_code, method + ) + ) + return None + + try: + return res.json() + except ValueError: + self.log("Error decoding json for method '{}'".format(method)) + return None + + def login(self): + postdata = { + "moduleList": { + "modules": [ + { + "moduleRequest": { + "resultTemplate": "web", + "deviceInfo": { + "osVersion": "Mac", + "platform": "Web", + "sxmAppVersion": "3.1802.10011.0", + "browser": "Safari", + "browserVersion": "11.0.3", + "appRegion": "US", + "deviceModel": "K2WebClient", + "clientDeviceId": "null", + "player": "html5", + "clientDeviceType": "web", + }, + "standardAuth": { + "username": self.username, + "password": self.password, + }, + } + } + ] + } + } + + data = self.post("modify/authentication", postdata, authenticate=False) + + try: + return data["ModuleListResponse"]["status"] == 1 and self.is_logged_in() + except KeyError: + self.log("Error decoding json response for login") + return False + + def reset_session(self): + self.session = requests.Session() + self.session.headers.update({"User-Agent": self.USER_AGENT}) + + @retry(wait=wait_fixed(3), stop=stop_after_attempt(10)) + def authenticate(self): + if not self.is_logged_in() and not self.login(): + self.log("Authentication failed.. retrying") + self.reset_session() + raise AuthenticationError("Reset session") + + # raise AuthenticationError("Unable to authenticate because login failed") + + postdata = { + "moduleList": { + "modules": [ + { + "moduleRequest": { + "resultTemplate": "web", + "deviceInfo": { + "osVersion": "Mac", + "platform": "Web", + "clientDeviceType": "web", + "sxmAppVersion": "3.1802.10011.0", + "browser": "Safari", + "browserVersion": "11.0.3", + "appRegion": "US", + "deviceModel": "K2WebClient", + "player": "html5", + "clientDeviceId": "null", + }, + } + } + ] + } + } + + # TODO: This raised an exception on DNS lookup + data = self.post("resume?OAtrial=false", postdata, authenticate=False) + if not data: + return False + + try: + return ( + data["ModuleListResponse"]["status"] == 1 + and self.is_session_authenticated() + ) + except KeyError: + self.log("Error parsing json response for authentication") + return False + + def get_sxmak_token(self): + try: + # return "d=1588403836_6524c27821b08a50a19157a06934f59e,v=1," + return self.session.cookies["SXMAKTOKEN"].split("=", 1)[1].split(",", 1)[0] + except (KeyError, IndexError): + return None + + def get_gup_id(self): + try: + return json.loads(urllib.parse.unquote(self.session.cookies["SXMDATA"]))[ + "gupId" + ] + except (KeyError, ValueError): + return None + + def get_episodes(self, channel_name): + channel_guid, channel_id = self.get_channel(channel_name) + + now_playing = self.get_now_playing(channel_guid, channel_id) + episodes = [] + + if now_playing is None: + pass + + for marker_list in now_playing["ModuleListResponse"]["moduleList"]["modules"][ + 0 + ]["moduleResponse"]["liveChannelData"]["markerLists"]: + + # The location of the episode layer is not always the same! + if marker_list["layer"] in ["episode", "future-episode"]: + + for marker in marker_list["markers"]: + start = datetime.datetime.strptime( + marker["timestamp"]["absolute"], "%Y-%m-%dT%H:%M:%S.%f%z" + ) + end = start + timedelta(seconds=marker["duration"]) + + start = start.replace(tzinfo=None) + end = end.replace(tzinfo=None) + + if datetime.datetime.utcnow() > end: + continue + + episodes.append( + { + "mediumTitle": marker["episode"].get( + "mediumTitle", "UnknownMediumTitle" + ), + "longTitle": marker["episode"].get( + "longTitle", "UnknownLongTitle" + ), + "shortDescription": marker["episode"].get( + "shortDescription", "UnknownShortDescription" + ), + "longDescription": marker["episode"].get( + "longDescription", "UnknownLongDescription" + ), + "start": start, + "end": end, + } + ) + + return episodes + + def get_now_playing(self, guid, channel_id): + params = { + "assetGUID": guid, + "ccRequestType": "AUDIO_VIDEO", + "channelId": channel_id, + "hls_output_mode": "custom", + "marker_mode": "all_separate_cue_points", + "result-template": "web", + "time": int(round(time.time() * 1000.0)), + "timestamp": datetime.datetime.utcnow().isoformat("T") + "Z", + } + + return self.get("tune/now-playing-live", params) + + def get_playlist_url(self, guid, channel_id, use_cache=True, max_attempts=5): + if use_cache and channel_id in self.playlists: + return self.playlists[channel_id] + + data = self.get_now_playing(guid, channel_id) + + # get status + try: + status = data["ModuleListResponse"]["status"] + message = data["ModuleListResponse"]["messages"][0]["message"] + message_code = data["ModuleListResponse"]["messages"][0]["code"] + except (KeyError, IndexError): + self.log("Error parsing json response for playlist") + return None + + # login if session expired + if message_code == 201 or message_code == 208: + if max_attempts > 0: + self.log("Session expired, logging in and authenticating") + if self.authenticate(): + self.log("Successfully authenticated") + return self.get_playlist_url( + guid, channel_id, use_cache, max_attempts - 1 + ) + else: + self.log("Failed to authenticate") + return None + else: + self.log("Reached max attempts for playlist") + return None + elif message_code != 100: + self.log("Received error {} {}".format(message_code, message)) + return None + + # get m3u8 url + try: + playlists = data["ModuleListResponse"]["moduleList"]["modules"][0][ + "moduleResponse" + ]["liveChannelData"]["hlsAudioInfos"] + except (KeyError, IndexError): + self.log("Error parsing json response for playlist") + return None + for playlist_info in playlists: + if playlist_info["size"] == "LARGE": + playlist_url = playlist_info["url"].replace( + "%Live_Primary_HLS%", self.LIVE_PRIMARY_HLS + ) + self.playlists[channel_id] = self.get_playlist_variant_url(playlist_url) + return self.playlists[channel_id] + + return None + + def get_playlist_variant_url(self, url): + params = { + "token": self.get_sxmak_token(), + "consumer": "k2", + "gupId": self.get_gup_id(), + } + res = self.session.get(url, params=params) + + if res.status_code != 200: + self.log( + "Received status code {} on playlist variant retrieval".format( + res.status_code + ) + ) + return None + + variant = next( + filter( + lambda x: x.endswith(".m3u8"), + map(lambda x: x.rstrip(), res.text.split("\n")), + ), + None, + ) + return "{}/{}".format(url.rsplit("/", 1)[0], variant) if variant else None + + @retry(stop=stop_after_attempt(25), wait=wait_fixed(1)) + def get_playlist(self, name, use_cache=True): + guid, channel_id = self.get_channel(name) + + if not all([guid, channel_id]): + self.log("No channel for {}".format(name)) + return None + + res = None + url = self.get_playlist_url(guid, channel_id, use_cache) + + try: + params = { + "token": self.get_sxmak_token(), + "consumer": "k2", + "gupId": self.get_gup_id(), + } + res = self.session.get(url, params=params) + + if res.status_code == 403: + self.log("Received status code 403 on playlist, renewing session") + return self.get_playlist(name, False) + + if res.status_code != 200: + self.log( + "Received status code {} on playlist variant".format( + res.status_code + ) + ) + return None + + except requests.exceptions.ConnectionError as e: + self.log("Error getting playlist: {}".format(e)) + + playlist_entries = [] + for line in res.text.split("\n"): + line = line.strip() + if line.endswith(".aac"): + playlist_entries.append( + re.sub("[^\/]\w+\.m3u8", line, re.findall("AAC_Data.*", url)[0]) + ) + else: + playlist_entries.append(line) + + return "\n".join(playlist_entries) + + @retry(wait=wait_fixed(1), stop=stop_after_attempt(5)) + def get_segment(self, path): + url = "{}/{}".format(self.LIVE_PRIMARY_HLS, path) + params = { + "token": self.get_sxmak_token(), + "consumer": "k2", + "gupId": self.get_gup_id(), + } + res = self.session.get(url, params=params) + + if res.status_code == 403: + self.get_playlist(path.split("/", 2)[1], False) + raise SegmentRetrievalException( + "Received status code 403 on segment, renewed session" + ) + + if res.status_code != 200: + self.log("Received status code {} on segment".format(res.status_code)) + return None + + return res.content + + def get_channels(self): + # download channel list if necessary + if not self.channels: + postdata = { + "moduleList": { + "modules": [ + { + "moduleArea": "Discovery", + "moduleType": "ChannelListing", + "moduleRequest": { + "consumeRequests": [], + "resultTemplate": "responsive", + "alerts": [], + "profileInfos": [], + }, + } + ] + } + } + + try: + if not self.is_session_authenticated(): + self.authenticate() + except Exception as e: + self.log(e) + + channel_list_uri = "get/discover-channel-list?type=2&batch-mode=true&format=json&request-option=discover-channel-list-withpdt&result-template=web" + data = self.get(channel_list_uri, postdata) + if not data: + self.log("Unable to get channel list") + return None, None + + try: + self.channels = data["ModuleListResponse"]["moduleList"]["modules"][0][ + "moduleResponse" + ]["moduleDetails"]["liveChannelResponse"]["liveChannelResponses"] + except (KeyError, IndexError): + self.log("Error parsing json response for channels") + return [] + return self.channels + + def get_channel(self, name): + name = name.lower() + for x in self.get_channels(): + try: + if ( + x.get("name", "").lower() == name + or x.get("channelId", "").lower() == name + or x.get("siriusChannelNumber") == name + ): + return ( + x["markerLists"][0]["markers"][0]["containerGUID"], + x["channelId"], + ) + except Exception as e: + self.log(e) + + return None, None + + +def make_sirius_handler(args): + class SiriusHandler(BaseHTTPRequestHandler): + HLS_AES_KEY = base64.b64decode("0Nsco7MAgxowGvkUT8aYag==") + sxm = SiriusXM(args.user, args.passwd, args.output_directory) + + def do_GET(self): + if self.path.endswith(".m3u8"): + data = self.sxm.get_playlist(self.path.rsplit("/", 1)[1][:-5]) + if data: + try: + self.send_response(200) + self.send_header("Content-Type", "application/x-mpegURL") + self.end_headers() + self.wfile.write(bytes(data, "utf-8")) + except Exception as e: + self.sxm.log("Error sending playlist to client!") + traceback.print_exc() + else: + self.send_response(500) + self.end_headers() + elif self.path.endswith(".aac"): + data = self.sxm.get_segment(self.path[1:]) + if data: + try: + self.send_response(200) + self.send_header("Content-Type", "audio/x-aac") + self.end_headers() + self.wfile.write(data) + except BrokenPipeError as e: + self.sxm.log("Client stream closed!") + + else: + self.send_response(500) + self.end_headers() + elif self.path.endswith("/key/1"): + try: + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(self.HLS_AES_KEY) + except Exception as e: + self.sxm.log("Error sending HLS_AES_KEY to client") + traceback.print_exc() + else: + self.send_response(500) + self.end_headers() + + return SiriusHandler + + +def start_httpd(handler): + args = parse_args() + + httpd = HTTPServer(("", int(args.port)), handler) + + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + finally: + httpd.server_close() + + +class SiriusXMRipper(object): + DEFAULT_BITRATE = "160k" + + def __init__(self, handler, args): + self.handler = handler + self.episode = None + self.last_episode = None + self.pid = None + self.proc = None + self.completed_files = [] + + try: + if args.file: + self.config = json.load(open(args.file, "r")) + else: + self.config = json.load(open("config.json")) + except Exception as e: + self.handler.sxm.log(f"\033[0;31mWARNING: No config file specified and no default config.json found in relative script path -- entering default mode; bitrate: {self.DEFAULT_BITRATE}\033[0m") + self.config = {} + + self.bitrate = self.config.get("bitrate", self.DEFAULT_BITRATE) + self.recorded_shows = self.config.get("shows", []) + self.tags = self.config.get("tags", {}) + + self.track_parts = defaultdict(int) + self.current_filename = None + self.output_directory = args.output_directory + + self.handler.sxm.log("\033[0;4;32mRecording the following shows\033[0m") + for show in self.recorded_shows: + self.handler.sxm.log("\t{}".format(show)) + + self.handler.sxm.log( + f"\033[0;4;32mDumping music to: {args.output_directory}\033[0m" + ) + + self.handler.sxm.log("\033[0;4;32mAutomatic tagging data\033[0m") + for show, metadata in self.tags.items(): + self.handler.sxm.log( + "\tArtist: {} | Album: {} | Genre: {}".format( + metadata["artist"], metadata["album"], metadata["genre"] + ) + ) + + self.channel = args.channel + self.start = time.time() + + def should_record_episode(self, episode): + shows = re.compile("|".join(self.recorded_shows), re.IGNORECASE) + + for k, v in episode.items(): + try: + if shows.findall(v): + return True + except TypeError: + continue + + return False + + def get_current_episode(self, episodes): + for episode in episodes: + now = datetime.datetime.utcnow() + + if episode["start"] < now < episode["end"]: + return episode + + return None + + def display_episodes(self, episodes): + for episode in sorted(episodes, key=lambda e: e["start"]): + if episode["start"] < datetime.datetime.utcnow() < episode["end"]: + self.handler.sxm.log( + "\033[0;32mNow Playing:\033[0m {} - {} " + "(\033[0;32m{}\033[0m remaining)".format( + episode["longTitle"], + episode["longDescription"], + episode["end"] - datetime.datetime.utcnow(), + ) + ) + elif episode["start"] > datetime.datetime.utcnow(): + self.handler.sxm.log( + "\033[0;36mComing Up:\033[0m {} - {} " + "(\033[0;36m{}\033[0m long)".format( + episode["longTitle"], + episode["longDescription"], + episode["end"] - episode["start"], + ) + ) + + def get_episode_list(self): + episodes = None + episode = None + + while not episodes: + try: + episodes = self.handler.sxm.get_episodes(self.channel) + except KeyError as e: + self.handler.sxm.log("Episodes list seems borked.. will retry..") + + if episodes is not None: + episode = self.get_current_episode(episodes) + + if episode is not None: + break + else: + time.sleep(15) + continue + + else: + time.sleep(15) + self.handler.sxm.log("Waiting for episode list..") + + return episodes + + def poll_episodes(self): + + episodes = None + episode = None + + while True: + + if episodes is None: + episodes = self.get_episode_list() + + if episode is None or datetime.datetime.utcnow() > episode["end"]: + self.display_episodes(episodes) + + current_episode = self.get_current_episode(episodes) + + if ( + not current_episode + or current_episode["longTitle"] == "UnknownLongTitle" + ): + episodes = None + time.sleep(60) + continue + + episode = episodes.pop(episodes.index(current_episode)) + + # A new episode has started; terminate recording + if self.proc is not None: + self.proc.terminate() + + while not self.proc.poll(): + self.handler.sxm.log("Waiting for ffmpeg to terminate..") + time.sleep(1) + + self.proc = None + self.tag_file(f"{self.output_directory}/{self.current_filename}") + + if self.should_record_episode(episode): + if ( + self.proc is None + or self.proc is not None + and self.proc.poll() is not None + ): + self.rip_episode(episode) + + time.sleep(1) + + def rip_episode(self, episode): + try: + filename = time.strftime( + "%Y-%m-%d_%H_%M_%S_{}.mp3".format( + "_".join(episode["mediumTitle"].split()) + ) + ) + self.current_filename = filename + + cmd = "ffmpeg -i http://127.0.0.1:8888/{}.m3u8 -acodec libmp3lame -ac 2 -ab {} {}/{}".format( + self.channel, self.bitrate, self.output_directory, filename + ) + + self.handler.sxm.log("Executing: {}".format(cmd)) + self.proc = subprocess.Popen( + cmd.split(), stdout=subprocess.PIPE, shell=False + ) + self.handler.sxm.log("Launched process: {}".format(self.proc.pid)) + except Exception as e: + self.handler.sxm.log( + "Exception occurred in Ripper.rip_stream: {}".format(e) + ) + self.handler.sxm.log("Tagging file before recovering stream..") + self.tag_file(f"{self.output_directory}/{self.current_filename}") + + def tag_file(self, filename): + if self.config == {}: + return + + playlist = None + with open(self.config, encoding="utf-8") as f: + text = f.read() + playlist = json.loads(text) + + x = "|".join(playlist["tags"].keys()) + playlist_regex = re.compile(x, re.IGNORECASE) + date_regex = re.compile("(\d{4})-(\d{2})-(\d{2})") + track_parts = defaultdict(int) + + if not filename.endswith(".mp3"): + return + + playlist_match = playlist_regex.findall(filename) + date = next(date_regex.finditer(filename), "") + + if not all([date, playlist_match]): + return + + playlist_match = playlist_match[0] + + # Increment the track count + playlist["tags"][playlist_match]["track_count"] += 1 + + title = "{} {}".format( + "".join(date.groups()), playlist["tags"].get(playlist_match).get("artist") + ) + + track_parts[title] += 1 + + tag_title = title + if track_parts.get(title) > 1: + tag_title += " (Part {})".format(track_parts.get(title)) + + self.handler.sxm.log( + "File: {} | Playlist: {} | Date: {}".format( + filename, playlist_match, "".join(date.groups()) + ) + ) + + mp3 = eyed3.load(filename) + self.handler.sxm.log(playlist["tags"].get(playlist_match).get("album")) + mp3.tag.album = playlist["tags"].get(playlist_match).get("album") + mp3.tag.album_artist = playlist["tags"].get(playlist_match).get("artist") + mp3.tag.artist = playlist["tags"].get(playlist_match).get("artist") + mp3.tag.genre = playlist["tags"].get(playlist_match).get("genre") + mp3.tag.recording_date = "-".join(date.groups()) + mp3.tag.release_date = "-".join(date.groups()) + mp3.tag.title = tag_title + mp3.tag.track_num = playlist["tags"].get(playlist_match).get("track_count") + mp3.tag.save() + + with open(self.config, "w") as config: + config.write(json.dumps(playlist, indent=4)) + + self.handler.sxm.log("Track parts") + self.handler.sxm.log(json.dumps(track_parts, indent=4)) + + +def parse_args(): + args = argparse.ArgumentParser(description="It does boss shit") + args.add_argument( + "-u", + "--user", + help="The user to use for authentication", + default=os.environ.get("SIRIUSXM_USER"), + ) + args.add_argument( + "-p", + "--passwd", + help="The pass to use for authentication", + default=os.environ.get("SIRIUSXM_PASS"), + ) + args.add_argument("--port", help="The port to listen on", default=8888, type=int) + args.add_argument( + "-c", + "--channel", + help="The channel(s) to listen on. Supports multiple uses of this arg", + ) + args.add_argument( + "-r", "--rip", help="Record the stream(s)", default=False, action="store_true" + ) + args.add_argument( + "-l", + "--list", + help="Get the list of all radio channels available", + action="store_true", + default=False, + ) + args.add_argument( + "-o", + "--output-directory", + help="Specify a target directory for dumping (defaults to cwd)", + default=os.path.abspath("."), + ) + + args.add_argument( + "-f", + "--file", + help="Optional, config file to use", + default=None + ) + + return args.parse_args() + + +def get_channel_list(sxm): + channels = list( + sorted( + sxm.get_channels(), + key=lambda x: ( + not x.get("isFavorite", False), + int(x.get("siriusChannelNumber", 9999)), + ), + ) + ) + + l1 = max(len(x.get("channelId", "")) for x in channels) + l2 = max(len(str(x.get("siriusChannelNumber", 0))) for x in channels) + l3 = max(len(x.get("name", "")) for x in channels) + print("{} | {} | {}".format("ID".ljust(l1), "Num".ljust(l2), "Name".ljust(l3))) + for channel in channels: + cid = channel.get("channelId", "").ljust(l1)[:l1] + cnum = str(channel.get("siriusChannelNumber", "??")).ljust(l2)[:l2] + cname = channel.get("name", "??").ljust(l3)[:l3] + print("{} | {} | {}".format(cid, cnum, cname)) + + +def main(): + args = parse_args() + + if not os.path.isdir(args.output_directory): + raise Exception( + f"The target output directory {args.output_directory} is not a valid directory" + ) + + if args.user is None or args.passwd is None: + raise Exception( + "Missing username or password. You can also set these as environment variables " + "SIRIUSXM_USER, SIRIUSXM_PASS" + ) + + sirius_handler = make_sirius_handler(args) + + if args.list: + get_channel_list(sirius_handler.sxm) + sys.exit(0) + + ripper = SiriusXMRipper(sirius_handler, args) + + executor = ThreadPoolExecutor(max_workers=2) + httpd_thread = executor.submit(start_httpd, sirius_handler) + ripper_thread = executor.submit(ripper.poll_episodes) + + while True: + if httpd_thread.done(): + sirius_handler.sxm.log( + "HTTPD Thread exited/terminated -- result:{}".format( + httpd_thread.result() + ) + ) + httpd_thread = executor.submit(start_httpd, sirius_handler) + + if ripper_thread.done(): + sirius_handler.sxm.log( + "Ripper Thread exited/terminated -- result:{}".format( + ripper_thread.result() + ) + ) + ripper_thread = executor.submit(ripper.poll_episodes) + + time.sleep(60) + + +if __name__ == "__main__": + main()