From c9a9b902309d340c940c1282ee5c3d16715d3528 Mon Sep 17 00:00:00 2001 From: Alfred Moreno Date: Fri, 12 Oct 2018 10:43:39 -0700 Subject: [PATCH 01/16] Major refactor --- sxm.py | 335 ++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 271 insertions(+), 64 deletions(-) diff --git a/sxm.py b/sxm.py index 5513159..54d61f6 100644 --- a/sxm.py +++ b/sxm.py @@ -1,16 +1,59 @@ +import re import requests import base64 import urllib.parse import json -import time, datetime +import time import sys +import subprocess +import datetime +import traceback +from tenacity import retry +from tenacity import stop_after_attempt +from tenacity import wait_fixed +from tenacity import retry_if_result + +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' + LAYERS = { + 'cut': 0, + 'segment': 1, + 'episode': 2, + 'future-episode': 3, + 'companioncontent': 4 + } + def __init__(self, username, password): self.session = requests.Session() self.session.headers.update({'User-Agent': self.USER_AGENT}) @@ -29,12 +72,22 @@ def is_logged_in(self): 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(): + @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 - res = self.session.get(self.REST_FORMAT.format(method), params=params) + 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 @@ -45,13 +98,21 @@ def get(self, method, params, authenticate=True): self.log('Error decoding json for method \'{}\''.format(method)) return None + @retry(wait=wait_fixed(1), stop=stop_after_attempt(10)) 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: + res = None + + try: + res = self.session.post(self.REST_FORMAT.format(method), data=json.dumps(postdata)) + except requests.exceptions.ConnectionError as e: + self.log("Connection error on POST") + raise(e) + + if res is not None and res.status_code != 200: self.log('Received status code {} for method \'{}\''.format(res.status_code, method)) return None @@ -87,20 +148,21 @@ def login(self): }], }, } + 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') + import pdb; pdb.set_trace() return False def authenticate(self): if not self.is_logged_in() and not self.login(): - self.log('Unable to authenticate because login failed') + self.log("Unable to authenticate because login failed") return False + # raise AuthenticationError("Unable to authenticate because login failed") postdata = { 'moduleList': { @@ -145,10 +207,46 @@ def get_gup_id(self): 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] + 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 in now_playing['ModuleListResponse']['moduleList']['modules'][0]['moduleResponse']['liveChannelData']['markerLists'][self.LAYERS['episode']]['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) + + 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_current_episode(self): + for episode in self.get_episodes('shade45'): + now = datetime.datetime.utcnow() + + if not all(['start' in episode, 'end' in episode]): + self.log("Missing start/end keys in episode: {}".format(episode)) + continue + + if episode['start'] < now < episode['end']: + return episode + + def get_now_playing(self, guid, channel_id): params = { 'assetGUID': guid, 'ccRequestType': 'AUDIO_VIDEO', @@ -159,9 +257,14 @@ def get_playlist_url(self, guid, channel_id, use_cache=True, max_attempts=5): '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 + + 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: @@ -218,39 +321,41 @@ def get_playlist_variant_url(self, url): 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 guid or not channel_id: + + 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) - 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) + 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 != 200: - self.log('Received status code {} on playlist variant'.format(res.status_code)) - return None + 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() + playlist_entries.append(re.sub("[^\/]\w+\.m3u8", line, re.findall("AAC_Data.*", url)[0])) + + return '\n'.join(playlist_entries) - # 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): + @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(), @@ -260,13 +365,8 @@ def get_segment(self, path, max_attempts=5): 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 + 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)) @@ -302,6 +402,7 @@ def get_channel(self, name): self.log('Error parsing json response for channels') return (None, None) + # TODO: Refactor 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: @@ -317,41 +418,147 @@ 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')) + 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: - self.send_response(200) - self.send_header('Content-Type', 'audio/x-aac') - self.end_headers() - self.wfile.write(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("Error sending stream data to the client; connection terminated?") + traceback.print_exc() + 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) + 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 -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])) +def start_httpd(handler): + httpd = HTTPServer(('', int(sys.argv[3])), handler) try: httpd.serve_forever() except KeyboardInterrupt: pass - httpd.server_close() + finally: + httpd.server_close() + + +class SiriusXMRipper(object): + def __init__(self, handler): + self.handler = handler + self.episode = None + self.last_episode = None + self.pid = None + self.proc = None + self.completed_files = [] + self.recorded_shows = json.load(open('config.json', 'r'))['shows'] + self.start = time.time() + + def should_record_current_episode(self): + shows = re.compile('|'.join(self.recorded_shows), re.IGNORECASE) + + if self.episode is None: + self.handler.sxm.log("Current episode is None; cannot check if current episode should be recorded") + return False + + for k, v in self.episode.items(): + try: + if shows.findall(v): + return True + except TypeError: + continue + + return False + + def wait_for_episode_title(self): + episode = self.handler.sxm.get_current_episode() + + while episode is None or episode.get('longTitle') == 'UnknownLongTitle': + self.handler.sxm.log("Current episode registered incorrectly; fetching again..") + time.sleep(30) + + self.episode = episode + + def poll_episodes(self): + while True: + try: + self.wait_for_episode_title() + + if self.last_episode != self.episode: + self.last_episode = self.episode + + self.handler.sxm.log( + "\033[0;32mCurrent Episode:\033[0m {} - {}" + "(\033[0;32m{}\033[0m remaining)".format( + self.episode['longTitle'], self.episode['longDescription'], + self.episode['end'] - datetime.datetime.utcnow())) + + if self.proc is not None: + self.proc.terminate() + self.proc = None + + except Exception as e: + self.handler.sxm.log("Exception occurred in Ripper.poll_episodes: {}".format(e)) + traceback.print_exc() + + if self.should_record_current_episode(): + if self.proc is None or self.proc is not None and self.proc.poll() is not None: + self.rip_stream() + + time.sleep(60) + + def rip_stream(self): + try: + filename = time.strftime("%Y-%m-%d_%H_%M_%S_{}.mp3".format('_'.join(self.episode['mediumTitle'].split()))) + cmd = "/usr/local/bin/ffmpeg -i http://127.0.0.1:8888/shade45.m3u8 -acodec libmp3lame -ac 2 -ab 160k {}".format(filename) + self.handler.sxm.log("Executing: {}".format(cmd)) + self.proc = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE, shell=False) + except Exception as e: + self.handler.sxm.log("Exception occurred in Ripper.rip_stream: {}".format(e)) + + +if __name__ == '__main__': + sirius_handler = make_sirius_handler(sys.argv[1], sys.argv[2]) + ripper = SiriusXMRipper(sirius_handler) + + executor = ThreadPoolExecutor(max_workers=2) + + if len(sys.argv) < 4: + print('usage: python sxm.py [username] [password] [port]') + sys.exit(1) + + httpd_thread = executor.submit(start_httpd, sirius_handler) + episode_thread = executor.submit(ripper.poll_episodes) + + while True: + for index, thread in enumerate([httpd_thread, episode_thread]): + if thread.done(): + sirius_handler.sxm.log("Thread{} exited/terminated -- result:{}".format(index, thread.result())) + + time.sleep(60) From a91d24266fcce9b58d39c0936afe8e5401cdb91b Mon Sep 17 00:00:00 2001 From: Alfred Moreno Date: Fri, 12 Oct 2018 15:58:04 -0700 Subject: [PATCH 02/16] Fix authentication and layer issues Search each layer in the marker lists for the episode layer because it's not always guaranteed to be the same. Fixes the issue where the current episode could not be found. The authentication method now resets the session on login failures. Previously the session would get borked and authentication would no longer work on the session. --- sxm.py | 67 ++++++++++++++++++++++++++-------------------------------- 1 file changed, 30 insertions(+), 37 deletions(-) diff --git a/sxm.py b/sxm.py index 54d61f6..87e2143 100644 --- a/sxm.py +++ b/sxm.py @@ -17,7 +17,6 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from concurrent.futures import ThreadPoolExecutor - class AuthenticationError(Exception): pass @@ -46,19 +45,10 @@ class SiriusXM: REST_FORMAT = 'https://player.siriusxm.com/rest/v2/experience/modules/{}' LIVE_PRIMARY_HLS = 'https://siriusxm-priprodlive.akamaized.net' - LAYERS = { - 'cut': 0, - 'segment': 1, - 'episode': 2, - 'future-episode': 3, - 'companioncontent': 4 - } - 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.reset_session() self.playlists = {} self.channels = None @@ -98,21 +88,13 @@ def get(self, method, params): self.log('Error decoding json for method \'{}\''.format(method)) return None - @retry(wait=wait_fixed(1), stop=stop_after_attempt(10)) 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 = None - - try: - res = self.session.post(self.REST_FORMAT.format(method), data=json.dumps(postdata)) - except requests.exceptions.ConnectionError as e: - self.log("Connection error on POST") - raise(e) - - if res is not None and res.status_code != 200: + 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 @@ -155,13 +137,19 @@ def login(self): return data['ModuleListResponse']['status'] == 1 and self.is_logged_in() except KeyError: self.log('Error decoding json response for login') - import pdb; pdb.set_trace() 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("Unable to authenticate because login failed") - return False + self.log("Authentication failed.. retrying") + self.reset_session() + raise AuthenticationError("Reset session") + # raise AuthenticationError("Unable to authenticate because login failed") postdata = { @@ -216,22 +204,26 @@ def get_episodes(self, channel_name): 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'] == 'episode': - for marker in now_playing['ModuleListResponse']['moduleList']['modules'][0]['moduleResponse']['liveChannelData']['markerLists'][self.LAYERS['episode']]['markers']: - start = datetime.datetime.strptime(marker['timestamp']['absolute'], '%Y-%m-%dT%H:%M:%S.%f%z') - end = start + timedelta(seconds=marker['duration']) + 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) + start = start.replace(tzinfo=None) + end = end.replace(tzinfo=None) - 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 - }) + 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 @@ -501,6 +493,7 @@ def wait_for_episode_title(self): while episode is None or episode.get('longTitle') == 'UnknownLongTitle': self.handler.sxm.log("Current episode registered incorrectly; fetching again..") + self.handler.sxm.reset_session() time.sleep(30) self.episode = episode From 9c266af7b88184471a081edb6f8640d0858fe999 Mon Sep 17 00:00:00 2001 From: Alfred Moreno Date: Sat, 13 Oct 2018 15:01:40 -0700 Subject: [PATCH 03/16] Refactor episode polling Past, current and future episodes are stored when retrieving the now-playing data. As the current episode transitions, it is popped from the store list of episodes until it's empty. --- sxm.py | 98 ++++++++++++++++++++++++++++++---------------------------- 1 file changed, 50 insertions(+), 48 deletions(-) diff --git a/sxm.py b/sxm.py index 87e2143..66d78d3 100644 --- a/sxm.py +++ b/sxm.py @@ -207,7 +207,7 @@ def get_episodes(self, channel_name): 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'] == 'episode': + 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') @@ -216,6 +216,9 @@ def get_episodes(self, channel_name): 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'), @@ -227,17 +230,6 @@ def get_episodes(self, channel_name): return episodes - def get_current_episode(self): - for episode in self.get_episodes('shade45'): - now = datetime.datetime.utcnow() - - if not all(['start' in episode, 'end' in episode]): - self.log("Missing start/end keys in episode: {}".format(episode)) - continue - - if episode['start'] < now < episode['end']: - return episode - def get_now_playing(self, guid, channel_id): params = { 'assetGUID': guid, @@ -342,7 +334,10 @@ def get_playlist(self, name, use_cache=True): playlist_entries = [] for line in res.text.split('\n'): line = line.strip() - playlist_entries.append(re.sub("[^\/]\w+\.m3u8", line, re.findall("AAC_Data.*", url)[0])) + 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) @@ -430,8 +425,7 @@ def do_GET(self): self.end_headers() self.wfile.write(data) except BrokenPipeError as e: - self.sxm.log("Error sending stream data to the client; connection terminated?") - traceback.print_exc() + self.sxm.log("Client stream closed!") else: self.send_response(500) @@ -472,14 +466,10 @@ def __init__(self, handler): self.recorded_shows = json.load(open('config.json', 'r'))['shows'] self.start = time.time() - def should_record_current_episode(self): + def should_record_episode(self, episode): shows = re.compile('|'.join(self.recorded_shows), re.IGNORECASE) - if self.episode is None: - self.handler.sxm.log("Current episode is None; cannot check if current episode should be recorded") - return False - - for k, v in self.episode.items(): + for k, v in episode.items(): try: if shows.findall(v): return True @@ -488,50 +478,62 @@ def should_record_current_episode(self): return False - def wait_for_episode_title(self): - episode = self.handler.sxm.get_current_episode() + def get_current_episode(self, episodes): + for episode in episodes: + now = datetime.datetime.utcnow() + + if episode['start'] < now < episode['end']: + return episode - while episode is None or episode.get('longTitle') == 'UnknownLongTitle': - self.handler.sxm.log("Current episode registered incorrectly; fetching again..") - self.handler.sxm.reset_session() - time.sleep(30) + return None - self.episode = episode + def display_episodes(self, episodes): + for episode in episodes: + if episode['start'] < datetime.datetime.utcnow() < episode['end']: + self.handler.sxm.log( + "\033[0;32mCurrent Episode:\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;36mNext Episode:\033[0m {} - {} " + "(\033[0;36m{}\033[0m long)".format( + episode['longTitle'], episode['longDescription'], + episode['end'] - datetime.datetime.utcnow())) def poll_episodes(self): - while True: - try: - self.wait_for_episode_title() - if self.last_episode != self.episode: - self.last_episode = self.episode + episodes = None + episode = None + + while True: + if not episodes: + episodes = self.handler.sxm.get_episodes('shade45') - self.handler.sxm.log( - "\033[0;32mCurrent Episode:\033[0m {} - {}" - "(\033[0;32m{}\033[0m remaining)".format( - self.episode['longTitle'], self.episode['longDescription'], - self.episode['end'] - datetime.datetime.utcnow())) + if episode is None or episode is not None and datetime.datetime.utcnow() > episode['end']: + self.display_episodes(episodes) - if self.proc is not None: - self.proc.terminate() - self.proc = None + episode = episodes.pop(episodes.index(self.get_current_episode(episodes))) - except Exception as e: - self.handler.sxm.log("Exception occurred in Ripper.poll_episodes: {}".format(e)) - traceback.print_exc() + # A new episode has started; terminate recording + if self.proc is not None: + self.proc.terminate() + self.proc = None - if self.should_record_current_episode(): + 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_stream() + self.rip_episode(episode) time.sleep(60) - def rip_stream(self): + def rip_episode(self, episode): try: - filename = time.strftime("%Y-%m-%d_%H_%M_%S_{}.mp3".format('_'.join(self.episode['mediumTitle'].split()))) + filename = time.strftime("%Y-%m-%d_%H_%M_%S_{}.mp3".format('_'.join(episode['mediumTitle'].split()))) cmd = "/usr/local/bin/ffmpeg -i http://127.0.0.1:8888/shade45.m3u8 -acodec libmp3lame -ac 2 -ab 160k {}".format(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)) From 2da65bcabe863f0e7d4192059a1215e2ae6a063c Mon Sep 17 00:00:00 2001 From: Alfred Moreno Date: Mon, 15 Oct 2018 23:11:02 -0700 Subject: [PATCH 04/16] Replace basic argv with argparse and remove static channel references --- sxm.py | 110 +++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 84 insertions(+), 26 deletions(-) diff --git a/sxm.py b/sxm.py index 66d78d3..e804304 100644 --- a/sxm.py +++ b/sxm.py @@ -1,3 +1,5 @@ +import argparse +import os import re import requests import base64 @@ -11,7 +13,6 @@ from tenacity import retry from tenacity import stop_after_attempt from tenacity import wait_fixed -from tenacity import retry_if_result from datetime import timedelta from http.server import BaseHTTPRequestHandler, HTTPServer @@ -396,10 +397,10 @@ def get_channel(self, name): return (x['channelGuid'], x['channelId']) return (None, None) -def make_sirius_handler(username, password): +def make_sirius_handler(args): class SiriusHandler(BaseHTTPRequestHandler): HLS_AES_KEY = base64.b64decode('0Nsco7MAgxowGvkUT8aYag==') - sxm = SiriusXM(username, password) + sxm = SiriusXM(args.user, args.passwd) def do_GET(self): if self.path.endswith('.m3u8'): @@ -446,7 +447,10 @@ def do_GET(self): def start_httpd(handler): - httpd = HTTPServer(('', int(sys.argv[3])), handler) + args = parse_args() + + httpd = HTTPServer(('', int(args.port)), handler) + try: httpd.serve_forever() except KeyboardInterrupt: @@ -456,7 +460,7 @@ def start_httpd(handler): class SiriusXMRipper(object): - def __init__(self, handler): + def __init__(self, handler, args): self.handler = handler self.episode = None self.last_episode = None @@ -464,6 +468,12 @@ def __init__(self, handler): self.proc = None self.completed_files = [] self.recorded_shows = json.load(open('config.json', 'r'))['shows'] + + 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.channel = args.channel self.start = time.time() def should_record_episode(self, episode): @@ -488,19 +498,41 @@ def get_current_episode(self, episodes): return None def display_episodes(self, episodes): - for episode in 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;32mCurrent Episode:\033[0m {} - {} " + "\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;36mNext Episode:\033[0m {} - {} " + "\033[0;36mComing Up:\033[0m {} - {} " "(\033[0;36m{}\033[0m long)".format( episode['longTitle'], episode['longDescription'], - episode['end'] - datetime.datetime.utcnow())) + episode['end'] - episode['start'])) + + def get_episode_list(self): + episodes = None + episode = None + + while not episodes: + episodes = self.handler.sxm.get_episodes(self.channel) + + 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): @@ -508,13 +540,21 @@ def poll_episodes(self): episode = None while True: - if not episodes: - episodes = self.handler.sxm.get_episodes('shade45') - if episode is None or episode is not None and datetime.datetime.utcnow() > episode['end']: + if episodes is None: + episodes = self.get_episode_list() + + if episode is None or datetime.datetime.utcnow() > episode['end']: self.display_episodes(episodes) - episode = episodes.pop(episodes.index(self.get_current_episode(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: @@ -525,12 +565,15 @@ def poll_episodes(self): if self.proc is None or self.proc is not None and self.proc.poll() is not None: self.rip_episode(episode) - time.sleep(60) + time.sleep(1) def rip_episode(self, episode): try: filename = time.strftime("%Y-%m-%d_%H_%M_%S_{}.mp3".format('_'.join(episode['mediumTitle'].split()))) - cmd = "/usr/local/bin/ffmpeg -i http://127.0.0.1:8888/shade45.m3u8 -acodec libmp3lame -ac 2 -ab 160k {}".format(filename) + + cmd = "/usr/local/bin/ffmpeg -i http://127.0.0.1:8888/{}.m3u8 -acodec libmp3lame -ac 2 -ab 160k {}".format( + self.channel, 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)) @@ -538,22 +581,37 @@ def rip_episode(self, episode): self.handler.sxm.log("Exception occurred in Ripper.rip_stream: {}".format(e)) -if __name__ == '__main__': - sirius_handler = make_sirius_handler(sys.argv[1], sys.argv[2]) - ripper = SiriusXMRipper(sirius_handler) +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['SIRIUSXM_USER']) + args.add_argument('-p', '--passwd', help='The pass to use for authentication', default=os.environ['SIRIUSXM_PASS']) + args.add_argument('--port', help='The port to listen on', default=8888) + args.add_argument('-c', '--channel', help='The channel(s) to listen on. Supports multiple uses of this arg', required=True) + args.add_argument('-r', '--rip', help='Record the stream(s)', default=False, action='store_true') + + return args.parse_args() - executor = ThreadPoolExecutor(max_workers=2) - if len(sys.argv) < 4: - print('usage: python sxm.py [username] [password] [port]') - sys.exit(1) +def main(): + args = parse_args() + sirius_handler = make_sirius_handler(args) + ripper = SiriusXMRipper(sirius_handler, args) + executor = ThreadPoolExecutor(max_workers=2) httpd_thread = executor.submit(start_httpd, sirius_handler) - episode_thread = executor.submit(ripper.poll_episodes) + ripper_thread = executor.submit(ripper.poll_episodes) while True: - for index, thread in enumerate([httpd_thread, episode_thread]): - if thread.done(): - sirius_handler.sxm.log("Thread{} exited/terminated -- result:{}".format(index, thread.result())) + if httpd_thread.done(): + sirius_handler.sxm.log("HTTPD Thread{} exited/terminated -- result:{}".format(index, thread.result())) + httpd_thread = executor.submit(start_httpd, sirius_handler) + + if ripper_thread.done(): + sirius_handler.sxm.log("Ripper Thread{} exited/terminated -- result:{}".format(index, thread.result())) + ripper_thread = executor.submit(ripper.poll_episodes) time.sleep(60) + + +if __name__ == '__main__': + main() From ca8336e6e35b082b532a62073a66a0de54f224f5 Mon Sep 17 00:00:00 2001 From: Alfred Moreno Date: Fri, 21 Dec 2018 15:17:30 -0800 Subject: [PATCH 05/16] Add ripping and refactoring --- sxm.py | 602 ++++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 422 insertions(+), 180 deletions(-) diff --git a/sxm.py b/sxm.py index e804304..bcb9543 100644 --- a/sxm.py +++ b/sxm.py @@ -1,4 +1,5 @@ import argparse +import eyed3 import os import re import requests @@ -10,6 +11,7 @@ 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 @@ -18,6 +20,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from concurrent.futures import ThreadPoolExecutor + class AuthenticationError(Exception): pass @@ -41,10 +44,11 @@ def retry_authenticate(value): 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' + 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.username = username @@ -55,18 +59,22 @@ def __init__(self, username, password): @staticmethod def log(x): - print('{} : {}'.format(datetime.datetime.now().strftime('%d.%b %Y %H:%M:%S'), 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 + return "SXMAUTH" in self.session.cookies def is_session_authenticated(self): - return 'AWSELB' in self.session.cookies and 'JSESSIONID' in self.session.cookies + 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') + self.log("Unable to authenticate") return None try: @@ -77,72 +85,88 @@ def get(self, method, params): self.log("\tMethod: {}".format(method)) self.log("Response: {}".format(e.response)) self.log("Request: {}".format(e.request)) - raise(e) + raise (e) if res.status_code != 200: - self.log('Received status code {} for method \'{}\''.format(res.status_code, method)) + 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)) + 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') + 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)) + 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)) + 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)) + 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, - }, - }, - }], - }, + "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) + data = self.post("modify/authentication", postdata, authenticate=False) try: - return data['ModuleListResponse']['status'] == 1 and self.is_logged_in() + return data["ModuleListResponse"]["status"] == 1 and self.is_logged_in() except KeyError: - self.log('Error decoding json response for login') + 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}) + self.session.headers.update({"User-Agent": self.USER_AGENT}) @retry(wait=wait_fixed(3), stop=stop_after_attempt(10)) def authenticate(self): @@ -154,45 +178,52 @@ def authenticate(self): # 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' + "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) + 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() + return ( + data["ModuleListResponse"]["status"] == 1 + and self.is_session_authenticated() + ) except KeyError: - self.log('Error parsing json response for authentication') + 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] + 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'] + return json.loads(urllib.parse.unquote(self.session.cookies["SXMDATA"]))[ + "gupId" + ] except (KeyError, ValueError): return None @@ -205,14 +236,18 @@ def get_episodes(self, channel_name): if now_playing is None: pass - for marker_list in now_playing['ModuleListResponse']['moduleList']['modules'][0]['moduleResponse']['liveChannelData']['markerLists']: + 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']: + 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']) + 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) @@ -220,72 +255,88 @@ def get_episodes(self, channel_name): 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 - }) + 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' + "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) + 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] + 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'] + 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') + 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') + 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) + self.log("Successfully authenticated") + return self.get_playlist_url( + guid, channel_id, use_cache, max_attempts - 1 + ) else: - self.log('Failed to authenticate') + self.log("Failed to authenticate") return None else: - self.log('Reached max attempts for playlist') + self.log("Reached max attempts for playlist") return None - elif status == 0: - self.log('Received error {} {}'.format(message_code, message)) + 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'] + playlists = data["ModuleListResponse"]["moduleList"]["modules"][0][ + "moduleResponse" + ]["liveChannelData"]["hlsAudioInfos"] except (KeyError, IndexError): - self.log('Error parsing json response for playlist') + 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) + 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] @@ -293,136 +344,168 @@ def get_playlist_url(self, guid, channel_id, use_cache=True, max_attempts=5): def get_playlist_variant_url(self, url): params = { - 'token': self.get_sxmak_token(), - 'consumer': 'k2', - 'gupId': self.get_gup_id(), + "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)) + 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 + 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)) + 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()} + 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') + 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)) + 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'): + 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])) + 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) + 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) + url = "{}/{}".format(self.LIVE_PRIMARY_HLS, path) params = { - 'token': self.get_sxmak_token(), - 'consumer': 'k2', - 'gupId': self.get_gup_id(), + "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") + 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)) + self.log("Received status code {} on segment".format(res.status_code)) return None return res.content - def get_channel(self, name): + 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': [] + "moduleList": { + "modules": [ + { + "moduleArea": "Discovery", + "moduleType": "ChannelListing", + "moduleRequest": { + "consumeRequests": [], + "resultTemplate": "responsive", + "alerts": [], + "profileInfos": [], + }, } - }] + ] } } - data = self.post('get', postdata) + data = self.post("get", postdata) if not data: - self.log('Unable to get channel list') - return (None, None) + self.log("Unable to get channel list") + return None, None try: - self.channels = data['ModuleListResponse']['moduleList']['modules'][0]['moduleResponse']['contentData']['channelListing']['channels'] + 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) + self.log("Error parsing json response for channels") + return [] + return self.channels - # TODO: Refactor + def get_channel(self, name): 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) + for x in self.get_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(args): class SiriusHandler(BaseHTTPRequestHandler): - HLS_AES_KEY = base64.b64decode('0Nsco7MAgxowGvkUT8aYag==') + HLS_AES_KEY = base64.b64decode("0Nsco7MAgxowGvkUT8aYag==") sxm = SiriusXM(args.user, args.passwd) def do_GET(self): - if self.path.endswith('.m3u8'): - data = self.sxm.get_playlist(self.path.rsplit('/', 1)[1][:-5]) + 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.send_header("Content-Type", "application/x-mpegURL") self.end_headers() - self.wfile.write(bytes(data, 'utf-8')) + 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'): + 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.send_header("Content-Type", "audio/x-aac") self.end_headers() self.wfile.write(data) except BrokenPipeError as e: @@ -431,10 +514,10 @@ def do_GET(self): else: self.send_response(500) self.end_headers() - elif self.path.endswith('/key/1'): + elif self.path.endswith("/key/1"): try: self.send_response(200) - self.send_header('Content-Type', 'text/plain') + self.send_header("Content-Type", "text/plain") self.end_headers() self.wfile.write(self.HLS_AES_KEY) except Exception as e: @@ -443,13 +526,14 @@ def do_GET(self): else: self.send_response(500) self.end_headers() + return SiriusHandler def start_httpd(handler): args = parse_args() - httpd = HTTPServer(('', int(args.port)), handler) + httpd = HTTPServer(("", int(args.port)), handler) try: httpd.serve_forever() @@ -467,17 +551,32 @@ def __init__(self, handler, args): self.pid = None self.proc = None self.completed_files = [] - self.recorded_shows = json.load(open('config.json', 'r'))['shows'] + + self.config = json.load(open("config.json", "r")) + self.bitrate = self.config["bitrate"] + self.recorded_shows = self.config["shows"] + self.tags = self.config["tags"] + + self.track_parts = defaultdict(int) + self.current_filename = None 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("\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) + shows = re.compile("|".join(self.recorded_shows), re.IGNORECASE) for k, v in episode.items(): try: @@ -492,25 +591,31 @@ def get_current_episode(self, episodes): for episode in episodes: now = datetime.datetime.utcnow() - if episode['start'] < now < episode['end']: + 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']: + 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(): + 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'])) + episode["longTitle"], + episode["longDescription"], + episode["end"] - episode["start"], + ) + ) def get_episode_list(self): episodes = None @@ -544,12 +649,15 @@ def poll_episodes(self): if episodes is None: episodes = self.get_episode_list() - if episode is None or datetime.datetime.utcnow() > episode['end']: + 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': + if ( + not current_episode + or current_episode["longTitle"] == "UnknownLongTitle" + ): episodes = None time.sleep(60) continue @@ -560,41 +668,167 @@ def poll_episodes(self): if self.proc is not None: self.proc.terminate() self.proc = None + self.tag_file(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: + 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()))) + filename = time.strftime( + "%Y-%m-%d_%H_%M_%S_{}.mp3".format( + "_".join(episode["mediumTitle"].split()) + ) + ) + self.current_filename = filename - cmd = "/usr/local/bin/ffmpeg -i http://127.0.0.1:8888/{}.m3u8 -acodec libmp3lame -ac 2 -ab 160k {}".format( - self.channel, filename) + cmd = "ffmpeg -i http://127.0.0.1:8888/{}.m3u8 -acodec libmp3lame -ac 2 -ab {} {}".format( + self.channel, self.bitrate, filename + ) self.handler.sxm.log("Executing: {}".format(cmd)) - self.proc = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE, shell=False) + 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( + "Exception occurred in Ripper.rip_stream: {}".format(e) + ) + self.handler.sxm.log("Tagging file before recovering stream..") + self.tag_file(self.current_filename) + + def tag_file(self, file): + playlist = None + with open("config.json", 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 f.endswith(".mp3"): + return + + playlist_match = playlist_regex.findall(file) + date = next(date_regex.finditer(file), "") + + 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.log( + "File: {} | Playlist: {} | Date: {}".format( + file, playlist_match, "".join(date.groups()) + ) + ) + + mp3 = eyed3.load(file) + self.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("config.json", "w") as config: + config.write(json.dumps(playlist, indent=4)) + + self.log("Track parts") + self.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['SIRIUSXM_USER']) - args.add_argument('-p', '--passwd', help='The pass to use for authentication', default=os.environ['SIRIUSXM_PASS']) - args.add_argument('--port', help='The port to listen on', default=8888) - args.add_argument('-c', '--channel', help='The channel(s) to listen on. Supports multiple uses of this arg', required=True) - args.add_argument('-r', '--rip', help='Record the stream(s)', default=False, action='store_true') + args.add_argument( + "-u", + "--user", + help="The user to use for authentication", + default=os.environ["SIRIUSXM_USER"], + ) + args.add_argument( + "-p", + "--passwd", + help="The pass to use for authentication", + default=os.environ["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, + ) 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() 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) @@ -603,15 +837,23 @@ def main(): while True: if httpd_thread.done(): - sirius_handler.sxm.log("HTTPD Thread{} exited/terminated -- result:{}".format(index, thread.result())) + 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(index, thread.result())) + 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__': +if __name__ == "__main__": main() From aaa8c1ac330968bc98986b93970638869d83cc64 Mon Sep 17 00:00:00 2001 From: Alfred Moreno Date: Sat, 22 Dec 2018 09:25:04 -0800 Subject: [PATCH 06/16] Add delay when waiting for ffmpeg to stop and fix some logging errors --- sxm.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/sxm.py b/sxm.py index bcb9543..17d4e8d 100644 --- a/sxm.py +++ b/sxm.py @@ -667,6 +667,10 @@ def poll_episodes(self): # 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(self.current_filename) @@ -705,7 +709,7 @@ def rip_episode(self, episode): self.handler.sxm.log("Tagging file before recovering stream..") self.tag_file(self.current_filename) - def tag_file(self, file): + def tag_file(self, filename): playlist = None with open("config.json", encoding="utf-8") as f: text = f.read() @@ -716,11 +720,11 @@ def tag_file(self, file): date_regex = re.compile("^(\d{4})-(\d{2})-(\d{2})") track_parts = defaultdict(int) - if not f.endswith(".mp3"): + if not filename.endswith(".mp3"): return - playlist_match = playlist_regex.findall(file) - date = next(date_regex.finditer(file), "") + playlist_match = playlist_regex.findall(filename) + date = next(date_regex.finditer(filename), "") if not all([date, playlist_match]): return @@ -740,14 +744,14 @@ def tag_file(self, file): if track_parts.get(title) > 1: tag_title += " (Part {})".format(track_parts.get(title)) - self.log( + self.handler.sxm.log( "File: {} | Playlist: {} | Date: {}".format( - file, playlist_match, "".join(date.groups()) + filename, playlist_match, "".join(date.groups()) ) ) - mp3 = eyed3.load(file) - self.log(playlist["tags"].get(playlist_match).get("album")) + 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") @@ -761,8 +765,8 @@ def tag_file(self, file): with open("config.json", "w") as config: config.write(json.dumps(playlist, indent=4)) - self.log("Track parts") - self.log(json.dumps(track_parts, indent=4)) + self.handler.sxm.log("Track parts") + self.handler.sxm.log(json.dumps(track_parts, indent=4)) def parse_args(): @@ -771,13 +775,13 @@ def parse_args(): "-u", "--user", help="The user to use for authentication", - default=os.environ["SIRIUSXM_USER"], + default=os.environ.get("SIRIUSXM_USER"), ) args.add_argument( "-p", "--passwd", help="The pass to use for authentication", - default=os.environ["SIRIUSXM_PASS"], + default=os.environ.get("SIRIUSXM_PASS"), ) args.add_argument("--port", help="The port to listen on", default=8888, type=int) args.add_argument( @@ -823,6 +827,10 @@ def get_channel_list(sxm): def main(): args = parse_args() + 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: From ab11bf5820039d2f658ee4de41d5917b1382c8ea Mon Sep 17 00:00:00 2001 From: Alfred Moreno Date: Sat, 22 Dec 2018 09:41:18 -0800 Subject: [PATCH 07/16] Fix CRLF --- sxm.py | 1734 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 867 insertions(+), 867 deletions(-) diff --git a/sxm.py b/sxm.py index 17d4e8d..4581890 100644 --- a/sxm.py +++ b/sxm.py @@ -1,867 +1,867 @@ -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): - self.username = username - self.password = password - self.reset_session() - 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 - - @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", - }, - } - } - ] - } - } - 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_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": [], - }, - } - ] - } - } - 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 [] - return self.channels - - def get_channel(self, name): - name = name.lower() - for x in self.get_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(args): - class SiriusHandler(BaseHTTPRequestHandler): - HLS_AES_KEY = base64.b64decode("0Nsco7MAgxowGvkUT8aYag==") - sxm = SiriusXM(args.user, args.passwd) - - 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): - def __init__(self, handler, args): - self.handler = handler - self.episode = None - self.last_episode = None - self.pid = None - self.proc = None - self.completed_files = [] - - self.config = json.load(open("config.json", "r")) - self.bitrate = self.config["bitrate"] - self.recorded_shows = self.config["shows"] - self.tags = self.config["tags"] - - self.track_parts = defaultdict(int) - self.current_filename = None - - 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("\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: - episodes = self.handler.sxm.get_episodes(self.channel) - - 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(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, 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(self.current_filename) - - def tag_file(self, filename): - playlist = None - with open("config.json", 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("config.json", "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, - ) - - 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 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() +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): + self.username = username + self.password = password + self.reset_session() + 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 + + @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", + }, + } + } + ] + } + } + 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_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": [], + }, + } + ] + } + } + 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 [] + return self.channels + + def get_channel(self, name): + name = name.lower() + for x in self.get_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(args): + class SiriusHandler(BaseHTTPRequestHandler): + HLS_AES_KEY = base64.b64decode("0Nsco7MAgxowGvkUT8aYag==") + sxm = SiriusXM(args.user, args.passwd) + + 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): + def __init__(self, handler, args): + self.handler = handler + self.episode = None + self.last_episode = None + self.pid = None + self.proc = None + self.completed_files = [] + + self.config = json.load(open("config.json", "r")) + self.bitrate = self.config["bitrate"] + self.recorded_shows = self.config["shows"] + self.tags = self.config["tags"] + + self.track_parts = defaultdict(int) + self.current_filename = None + + 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("\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: + episodes = self.handler.sxm.get_episodes(self.channel) + + 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(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, 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(self.current_filename) + + def tag_file(self, filename): + playlist = None + with open("config.json", 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("config.json", "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, + ) + + 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 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() From 6415295d8757c6a31fcd65d365644c45e8735aff Mon Sep 17 00:00:00 2001 From: Alfred Moreno Date: Sat, 22 Dec 2018 10:26:08 -0800 Subject: [PATCH 08/16] Add documentation and update argparse --- README.md | 481 ++++++++++-------------------------------------------- sxm.py | 8 +- 2 files changed, 91 insertions(+), 398 deletions(-) diff --git a/README.md b/README.md index f54b6b7..e218ac9 100644 --- a/README.md +++ b/README.md @@ -1,400 +1,91 @@ # 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" + } + } +} +``` +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). + + +## 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/sxm.py b/sxm.py index 4581890..a9edd43 100644 --- a/sxm.py +++ b/sxm.py @@ -670,7 +670,7 @@ def poll_episodes(self): while not self.proc.poll(): self.handler.sxm.log("Waiting for ffmpeg to terminate..") time.sleep(1) - + self.proc = None self.tag_file(self.current_filename) @@ -770,7 +770,9 @@ def tag_file(self, filename): def parse_args(): - args = argparse.ArgumentParser(description="It does boss shit") + args = argparse.ArgumentParser(description="""Creates a server that serves HLS streams for SiriusXM channels and + optionally records programs from specified channels. + """) args.add_argument( "-u", "--user", @@ -783,7 +785,7 @@ def parse_args(): 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("--port", help="The port to listen on (default: 8888)", default=8888, type=int) args.add_argument( "-c", "--channel", From c19024abb3e53df58f55e2d21589cb7ab664f36a Mon Sep 17 00:00:00 2001 From: Alfred Moreno Date: Thu, 11 Apr 2019 09:36:01 -0700 Subject: [PATCH 09/16] Add exception catch --- sxm.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/sxm.py b/sxm.py index a9edd43..6f5d232 100644 --- a/sxm.py +++ b/sxm.py @@ -236,9 +236,7 @@ def get_episodes(self, channel_name): if now_playing is None: pass - for marker_list in now_playing["ModuleListResponse"]["moduleList"]["modules"][ - 0 - ]["moduleResponse"]["liveChannelData"]["markerLists"]: + 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"]: @@ -622,7 +620,10 @@ def get_episode_list(self): episode = None while not episodes: - episodes = self.handler.sxm.get_episodes(self.channel) + 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) @@ -667,6 +668,7 @@ def poll_episodes(self): # 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) @@ -770,9 +772,7 @@ def tag_file(self, filename): def parse_args(): - args = argparse.ArgumentParser(description="""Creates a server that serves HLS streams for SiriusXM channels and - optionally records programs from specified channels. - """) + args = argparse.ArgumentParser(description="It does boss shit") args.add_argument( "-u", "--user", @@ -785,7 +785,7 @@ def parse_args(): 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)", default=8888, type=int) + args.add_argument("--port", help="The port to listen on", default=8888, type=int) args.add_argument( "-c", "--channel", From 25359ab79c4a3cdd44d2def9bf6dbb4470ab0e62 Mon Sep 17 00:00:00 2001 From: Alfred Moreno Date: Thu, 11 Apr 2019 09:41:12 -0700 Subject: [PATCH 10/16] Add requirements --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 requirements.txt 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 From 3d22068b518c77f551a3bdc7530185498bf9bfee Mon Sep 17 00:00:00 2001 From: Alfred Moreno Date: Thu, 11 Apr 2019 09:42:13 -0700 Subject: [PATCH 11/16] Add my personal config as an example --- config.json | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 config.json 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 From 53e9e44baae42c80636c393bc9eef45b9cc9f928 Mon Sep 17 00:00:00 2001 From: Alfred Moreno Date: Thu, 11 Apr 2019 09:44:56 -0700 Subject: [PATCH 12/16] Update README.md --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e218ac9..ff6199a 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,8 @@ The following is an example `config.json` "DJ_Muggs_&_Ern_D": { "artist": "DJ Muggs & Ern Dogg", "album": "Soul Assassins Radio", - "genre": "Hip-Hop" + "genre": "Hip-Hop", + "track_count": 1 } } } @@ -57,7 +58,13 @@ 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). +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 From 463d5e71c0380b09b081816ad56e0c167e3d8c00 Mon Sep 17 00:00:00 2001 From: Alfred Moreno Date: Fri, 1 May 2020 23:57:57 -0700 Subject: [PATCH 13/16] Update SXMAUTH cookie name The cookie name appears to have changed to 'SXMAUTHNEW'. This change updates the cookie name so that the script understands it has authenticated. --- sxm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sxm.py b/sxm.py index 6f5d232..4e68c11 100644 --- a/sxm.py +++ b/sxm.py @@ -66,7 +66,7 @@ def log(x): ) def is_logged_in(self): - return "SXMAUTH" in self.session.cookies + return "SXMAUTHNEW" in self.session.cookies def is_session_authenticated(self): return "AWSELB" in self.session.cookies and "JSESSIONID" in self.session.cookies From d7fb58fb67a2b9b2c8f31ff6961a0614c864fb5f Mon Sep 17 00:00:00 2001 From: Alfred Moreno Date: Sat, 18 Jul 2020 13:41:12 -0700 Subject: [PATCH 14/16] Fix broken channel list Sirius recently updated their API and changed the JSON structure around a bit. This change fixes the functions which access the channel list response so that the script continues to work --- sxm.py | 76 ++++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 18 deletions(-) diff --git a/sxm.py b/sxm.py index 4e68c11..6cace40 100644 --- a/sxm.py +++ b/sxm.py @@ -50,12 +50,13 @@ class SiriusXM: REST_FORMAT = "https://player.siriusxm.com/rest/v2/experience/modules/{}" LIVE_PRIMARY_HLS = "https://siriusxm-priprodlive.akamaized.net" - def __init__(self, username, password): + 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): @@ -200,6 +201,8 @@ def authenticate(self): ] } } + + # TODO: This raised an exception on DNS lookup data = self.post("resume?OAtrial=false", postdata, authenticate=False) if not data: return False @@ -215,6 +218,7 @@ def authenticate(self): 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 @@ -236,7 +240,9 @@ def get_episodes(self, channel_name): if now_playing is None: pass - for marker_list in now_playing["ModuleListResponse"]["moduleList"]["modules"][0]["moduleResponse"]["liveChannelData"]["markerLists"]: + 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"]: @@ -452,7 +458,15 @@ def get_channels(self): ] } } - data = self.post("get", postdata) + + 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&time=1595089234094" + data = self.get(channel_list_uri, postdata) if not data: self.log("Unable to get channel list") return None, None @@ -460,7 +474,7 @@ def get_channels(self): try: self.channels = data["ModuleListResponse"]["moduleList"]["modules"][0][ "moduleResponse" - ]["contentData"]["channelListing"]["channels"] + ]["moduleDetails"]["liveChannelResponse"]["liveChannelResponses"] except (KeyError, IndexError): self.log("Error parsing json response for channels") return [] @@ -469,19 +483,26 @@ def get_channels(self): def get_channel(self, name): name = name.lower() for x in self.get_channels(): - if ( - x.get("name", "").lower() == name - or x.get("channelId", "").lower() == name - or x.get("siriusChannelNumber") == name - ): - return x["channelGuid"], x["channelId"] + 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) + sxm = SiriusXM(args.user, args.passwd, args.output_directory) def do_GET(self): if self.path.endswith(".m3u8"): @@ -557,11 +578,16 @@ def __init__(self, handler, args): 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( @@ -674,7 +700,7 @@ def poll_episodes(self): time.sleep(1) self.proc = None - self.tag_file(self.current_filename) + self.tag_file(f"{self.output_directory}/{self.current_filename}") if self.should_record_episode(episode): if ( @@ -695,8 +721,8 @@ def rip_episode(self, episode): ) self.current_filename = filename - cmd = "ffmpeg -i http://127.0.0.1:8888/{}.m3u8 -acodec libmp3lame -ac 2 -ab {} {}".format( - self.channel, self.bitrate, 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)) @@ -709,7 +735,7 @@ def rip_episode(self, episode): "Exception occurred in Ripper.rip_stream: {}".format(e) ) self.handler.sxm.log("Tagging file before recovering stream..") - self.tag_file(self.current_filename) + self.tag_file(f"{self.output_directory}/{self.current_filename}") def tag_file(self, filename): playlist = None @@ -719,7 +745,7 @@ def tag_file(self, filename): x = "|".join(playlist["tags"].keys()) playlist_regex = re.compile(x, re.IGNORECASE) - date_regex = re.compile("^(\d{4})-(\d{2})-(\d{2})") + date_regex = re.compile("(\d{4})-(\d{2})-(\d{2})") track_parts = defaultdict(int) if not filename.endswith(".mp3"): @@ -801,6 +827,12 @@ def parse_args(): 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("."), + ) return args.parse_args() @@ -829,9 +861,17 @@ def get_channel_list(sxm): 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") + raise Exception( + "Missing username or password. You can also set these as environment variables " + "SIRIUSXM_USER, SIRIUSXM_PASS" + ) sirius_handler = make_sirius_handler(args) From 1a4245fc37c8e55c37390ecd2c46df2ebb358759 Mon Sep 17 00:00:00 2001 From: Alfred Moreno Date: Sat, 18 Jul 2020 14:04:00 -0700 Subject: [PATCH 15/16] Remove time parameter from channel list uri --- sxm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sxm.py b/sxm.py index 6cace40..b11fa1a 100644 --- a/sxm.py +++ b/sxm.py @@ -465,7 +465,7 @@ def get_channels(self): 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&time=1595089234094" + 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") From 696edf5305f7c5d797e239c1b3e94a90b86e9557 Mon Sep 17 00:00:00 2001 From: Alfred Moreno Date: Sat, 18 Jul 2020 16:38:01 -0700 Subject: [PATCH 16/16] Make config files optional --- sxm.py | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/sxm.py b/sxm.py index b11fa1a..246ad2d 100644 --- a/sxm.py +++ b/sxm.py @@ -563,6 +563,8 @@ def start_httpd(handler): class SiriusXMRipper(object): + DEFAULT_BITRATE = "160k" + def __init__(self, handler, args): self.handler = handler self.episode = None @@ -571,10 +573,18 @@ def __init__(self, handler, args): self.proc = None self.completed_files = [] - self.config = json.load(open("config.json", "r")) - self.bitrate = self.config["bitrate"] - self.recorded_shows = self.config["shows"] - self.tags = self.config["tags"] + 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 @@ -738,8 +748,11 @@ def rip_episode(self, episode): self.tag_file(f"{self.output_directory}/{self.current_filename}") def tag_file(self, filename): + if self.config == {}: + return + playlist = None - with open("config.json", encoding="utf-8") as f: + with open(self.config, encoding="utf-8") as f: text = f.read() playlist = json.loads(text) @@ -790,7 +803,7 @@ def tag_file(self, filename): mp3.tag.track_num = playlist["tags"].get(playlist_match).get("track_count") mp3.tag.save() - with open("config.json", "w") as config: + with open(self.config, "w") as config: config.write(json.dumps(playlist, indent=4)) self.handler.sxm.log("Track parts") @@ -834,6 +847,13 @@ def parse_args(): default=os.path.abspath("."), ) + args.add_argument( + "-f", + "--file", + help="Optional, config file to use", + default=None + ) + return args.parse_args()