Skip to content

Commit 9e8a958

Browse files
Adds automatic failover to secondary HLS
1 parent f72e189 commit 9e8a958

7 files changed

Lines changed: 1945 additions & 30 deletions

File tree

.devcontainer/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
FROM python:3.9.6-slim-buster
22

33
RUN apt-get update \
4-
&& apt-get install -y git ffmpeg build-essential vim \
4+
&& apt-get install -y git ffmpeg build-essential vim procps curl \
55
# cleaning up unused files
66
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
77
&& rm -rf /var/lib/apt/lists/*

HISTORY.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ History
99
* Adds `primary_hls` and `seconary_hls`
1010
* Adds quality selection
1111
* Overhauls time/datetime management
12+
* Automatic failover to secondary HLS
1213

1314
0.2.4 (2021-08-15)
1415
------------------

dev-requirements.txt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@ bandit==1.7.0
2929
# via sxm (pyproject.toml)
3030
beautifulsoup4==4.9.3
3131
# via furo
32-
black==21.6b0
32+
black==21.7b0
3333
# via sxm (pyproject.toml)
3434
certifi==2021.5.30
3535
# via
3636
# httpx
3737
# requests
3838
chardet==4.0.0
3939
# via aiohttp
40-
charset-normalizer==2.0.2
40+
charset-normalizer==2.0.3
4141
# via requests
4242
click==7.1.2
4343
# via
@@ -268,7 +268,6 @@ termcolor==1.1.0
268268
# via pytest-sugar
269269
toml==0.10.2
270270
# via
271-
# black
272271
# flit
273272
# flit-core
274273
# mypy
@@ -279,6 +278,8 @@ toml==0.10.2
279278
# pytest-cov
280279
# snooty-lextudio
281280
# tox
281+
tomli==1.0.4
282+
# via black
282283
tox==3.24.0
283284
# via sxm (pyproject.toml)
284285
typer==0.3.2

sxm/client.py

Lines changed: 147 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,7 @@
1414
from tenacity import retry, stop_after_attempt, wait_fixed
1515
from ua_parser import user_agent_parser # type: ignore
1616

17-
from sxm.models import (
18-
LIVE_PRIMARY_HLS,
19-
QualitySize,
20-
RegionChoice,
21-
XMChannel,
22-
XMLiveChannel,
23-
)
17+
from sxm.models import QualitySize, RegionChoice, XMChannel, XMLiveChannel
2418

2519
__all__ = [
2620
"HLS_AES_KEY",
@@ -41,16 +35,24 @@
4135
REST_V4_FORMAT = "https://player.siriusxm.com/rest/v4/experience/modules/{}"
4236
SESSION_MAX_LIFE = 14400
4337

44-
ENABLE_NEW_CHANNELS = False
38+
ENABLE_NEW_CHANNELS = True
39+
40+
41+
class SXMError(Exception):
42+
"""Base class for all other SXM Errors"""
4543

4644

47-
class AuthenticationError(Exception):
45+
class ConfigurationError(SXMError):
46+
"""SXM Configuration retrive failed, renew session, and try again later"""
47+
48+
49+
class AuthenticationError(SXMError):
4850
"""SXM Authentication failed, renew session"""
4951

5052
pass
5153

5254

53-
class SegmentRetrievalException(Exception):
55+
class SegmentRetrievalException(SXMError):
5456
"""failed to get HLS segment, renew session"""
5557

5658
pass
@@ -106,8 +108,11 @@ class SXMClientAsync:
106108
_channels: Optional[List[XMChannel]]
107109
_favorite_channels: Optional[List[XMChannel]]
108110
_playlists: Dict[str, str]
111+
_use_primary: bool
109112
_ua: Dict[str, Any]
110113
_session: httpx.AsyncClient
114+
_configuration: Optional[Dict] = None
115+
_urls: Optional[Dict[str, str]] = None
111116

112117
def __init__(
113118
self,
@@ -140,6 +145,7 @@ def __init__(
140145
self._playlists = {}
141146
self._channels = None
142147
self._favorite_channels = None
148+
self._use_primary = True
143149

144150
# vars to manage session cache
145151
self.last_renew = None
@@ -148,6 +154,9 @@ def __init__(
148154
# hook function to call whenever the playlist updates
149155
self.update_handler = update_handler
150156

157+
def __del__(self):
158+
make_sync(self.close_session)()
159+
151160
@property
152161
def is_logged_in(self) -> bool:
153162
return "SXMAUTHNEW" in self._session.cookies
@@ -198,6 +207,63 @@ async def favorite_channels(self) -> List[XMChannel]:
198207
self._favorite_channels = [c for c in await self.channels if c.is_favorite]
199208
return self._favorite_channels
200209

210+
def _extract_configuration(self, data: dict):
211+
_config = {}
212+
config = data["moduleList"]["modules"][0]["moduleResponse"]["configuration"][
213+
"components"
214+
]
215+
for item in config:
216+
_config[item["name"]] = item
217+
return _config
218+
219+
@property
220+
async def configuration(self) -> dict:
221+
if self._configuration is None:
222+
data = await self.get_configuration()
223+
if data is None:
224+
raise ConfigurationError()
225+
self._configuration = self._extract_configuration(data)
226+
227+
return self._configuration
228+
229+
def _extract_urls(self, urls: dict):
230+
_urls = {}
231+
for url in urls["settings"][0]["relativeUrls"]:
232+
if "url" in url:
233+
_urls[url["name"]] = url["url"]
234+
235+
return _urls
236+
237+
@property
238+
async def urls(self) -> Dict[str, str]:
239+
if self._urls is None:
240+
urls = (await self.configuration)["relativeUrls"]
241+
self._urls = self._extract_urls(urls)
242+
return self._urls
243+
244+
@property
245+
def primary(self) -> bool:
246+
return self._use_primary
247+
248+
async def get_primary_hls_root(self) -> str:
249+
urls = await self.urls
250+
251+
return urls["Live_Primary_HLS"]
252+
253+
async def get_secondary_hls_root(self) -> str:
254+
urls = await self.urls
255+
256+
return urls["Live_Secondary_HLS"]
257+
258+
async def get_hls_root(self) -> str:
259+
if self._use_primary:
260+
return await self.get_primary_hls_root()
261+
return await self.get_secondary_hls_root()
262+
263+
def set_primary(self, value: bool):
264+
self._use_primary = value
265+
self._playlists = {}
266+
201267
async def login(self) -> bool:
202268
"""Attempts to log into SXM with stored username/password"""
203269

@@ -251,6 +317,16 @@ async def authenticate(self) -> bool:
251317
self._log.error(traceback.format_exc())
252318
return False
253319

320+
@retry(wait=wait_fixed(3), stop=stop_after_attempt(10))
321+
async def get_configuration(self) -> Optional[Dict[str, Any]]:
322+
params = {
323+
"result-template": "html5",
324+
"app-region": self.region.value,
325+
"cacheBuster": str(int(time.time())),
326+
}
327+
328+
return await self._get("get/configuration", params=params)
329+
254330
@retry(stop=stop_after_attempt(25), wait=wait_fixed(1))
255331
async def get_playlist(
256332
self, channel_id: str, use_cache: bool = True
@@ -303,15 +379,13 @@ async def get_playlist(
303379
return "\n".join(playlist_entries)
304380

305381
@retry(wait=wait_fixed(1), stop=stop_after_attempt(5))
306-
async def get_segment(self, path: str, max_attempts: int = 5) -> Union[bytes, None]:
382+
async def get_segment(self, path: str) -> Union[bytes, None]:
307383
"""Gets raw HLS segment for given path
308384
309385
Parameters
310386
----------
311387
path : :class:`str`
312388
SXM path
313-
max_attempts : :class:`int`
314-
Number of times to try to get segment. Defaults to 5.
315389
316390
Raises
317391
------
@@ -320,7 +394,7 @@ async def get_segment(self, path: str, max_attempts: int = 5) -> Union[bytes, No
320394
needs reset
321395
"""
322396

323-
url = f"{LIVE_PRIMARY_HLS}/{path}"
397+
url = urllib.parse.urljoin(await self.get_hls_root(), path)
324398
res = await self._session.get(url, params=self._token_params())
325399

326400
if res.status_code == 403:
@@ -436,6 +510,8 @@ def reset_session(self) -> None:
436510
self._session_start = time.monotonic()
437511
self._session = httpx.AsyncClient()
438512
self._session.headers.update({"User-Agent": self._ua["string"]})
513+
self._urls = None
514+
self._configuration = None
439515

440516
def _token_params(self) -> Dict[str, Union[str, None]]:
441517
return {
@@ -580,7 +656,10 @@ async def _post(
580656
)
581657

582658
async def _get_playlist_url(
583-
self, channel_id: str, use_cache: bool = True, max_attempts: int = 5
659+
self,
660+
channel_id: str,
661+
use_cache: bool = True,
662+
max_attempts: int = 5,
584663
) -> Union[str, None]:
585664
"""Returns HLS live stream URL for a given `XMChannel`"""
586665

@@ -590,7 +669,6 @@ async def _get_playlist_url(
590669
return None
591670

592671
now = time.monotonic()
593-
594672
if use_cache and channel.id in self._playlists:
595673
if (
596674
self.last_renew is None
@@ -649,11 +727,18 @@ async def _get_playlist_url(
649727
live_channel_raw = data["moduleList"]["modules"][0]
650728
live_channel = XMLiveChannel.from_dict(live_channel_raw)
651729
live_channel.set_stream_quality(self.stream_quality)
730+
live_channel.set_hls_roots(
731+
await self.get_primary_hls_root(), await self.get_secondary_hls_root()
732+
)
652733

653734
self.update_interval = int(data["moduleList"]["modules"][0]["updateFrequency"])
654735

655736
# get m3u8 url
656-
playlist = await self._get_playlist_variant_url(live_channel.primary_hls.url)
737+
url = live_channel.primary_hls.url
738+
if not self._use_primary:
739+
url = live_channel.secondary_hls.url
740+
741+
playlist = await self._get_playlist_variant_url(url)
657742
if playlist is not None:
658743
self._playlists[channel.id] = playlist
659744
self.last_renew = time.monotonic()
@@ -763,6 +848,10 @@ def update_interval(self) -> int:
763848
def username(self) -> str:
764849
return self.async_client.username
765850

851+
@property
852+
def stream_quality(self) -> QualitySize:
853+
return self.async_client.stream_quality
854+
766855
@property
767856
def is_logged_in(self) -> bool:
768857
return self.async_client.is_logged_in
@@ -807,21 +896,57 @@ def favorite_channels(self) -> List[XMChannel]:
807896
]
808897
return self.async_client._favorite_channels
809898

899+
@property
900+
def configuration(self) -> dict:
901+
if self.async_client._configuration is None:
902+
data = self.get_configuration()
903+
if data is None:
904+
raise ConfigurationError()
905+
906+
self.async_client._configuration = self.async_client._extract_configuration(
907+
data
908+
)
909+
return self.async_client._configuration
910+
911+
@property
912+
def urls(self) -> Dict[str, str]:
913+
if self.async_client._urls is None:
914+
urls = self.configuration["relativeUrls"]
915+
self.async_client._urls = self.async_client._extract_urls(urls)
916+
return self.async_client._urls
917+
918+
@property
919+
def primary(self) -> bool:
920+
return self.async_client._use_primary
921+
922+
def get_primary_hls_root(self) -> str:
923+
return make_sync(self.async_client.get_primary_hls_root)()
924+
925+
def get_secondary_hls_root(self) -> str:
926+
return make_sync(self.async_client.get_secondary_hls_root)()
927+
928+
def get_hls_root(self) -> str:
929+
return make_sync(self.async_client.get_hls_root)()
930+
931+
def set_primary(self, value: bool):
932+
self.async_client.set_primary(value)
933+
810934
def login(self) -> bool:
811-
return make_sync(self.async_client.is_logged_in)()
935+
return make_sync(self.async_client.login)()
812936

813937
def authenticate(self) -> bool:
814938
return make_sync(self.async_client.authenticate)()
815939

940+
def get_configuration(self) -> Optional[Dict[str, Any]]:
941+
return make_sync(self.async_client.get_configuration)()
942+
816943
def get_playlist(self, channel_id: str, use_cache: bool = True) -> Union[str, None]:
817944
return make_sync(self.async_client.get_playlist)(
818945
channel_id=channel_id, use_cache=use_cache
819946
)
820947

821-
def get_segment(self, path: str, max_attempts: int = 5) -> Union[bytes, None]:
822-
return make_sync(self.async_client.get_segment)(
823-
path=path, max_attempts=max_attempts
824-
)
948+
def get_segment(self, path: str) -> Union[bytes, None]:
949+
return make_sync(self.async_client.get_segment)(path=path)
825950

826951
def get_channels(self) -> List[dict]:
827952
return make_sync(self.async_client.get_channels)()

sxm/http.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,14 @@ async def sxm_handler(request: web.Request):
3131

3232
response = web.Response(status=404)
3333
if request.path.endswith(".m3u8"):
34+
if not sxm.primary:
35+
sxm.set_primary(True)
3436
playlist = await sxm.get_playlist(request.path.rsplit("/", 1)[1][:-5])
37+
38+
if not playlist:
39+
sxm.set_primary(False)
40+
playlist = await sxm.get_playlist(request.path.rsplit("/", 1)[1][:-5])
41+
3542
if playlist:
3643
response = web.Response(
3744
status=200,
@@ -108,6 +115,14 @@ def run_http_server(
108115
if logger is None:
109116
logger = logging.getLogger(__file__)
110117

118+
if not sxm.authenticate():
119+
logging.fatal("Could not log into SXM")
120+
exit(1)
121+
122+
if not sxm.configuration:
123+
logging.fatal("Could not get SXM configuration")
124+
exit(1)
125+
111126
app = web.Application()
112127
app.router.add_get("/{_:.*}", make_http_handler(sxm.async_client))
113128
try:

0 commit comments

Comments
 (0)