Unshackle-Services/SBS/__init__.py
2026-01-03 17:53:34 +02:00

225 lines
7.6 KiB
Python

from __future__ import annotations
import hashlib
import json
import re
from collections.abc import Generator
from urllib.parse import urljoin, urlparse
from typing import Any
import click
from click import Context
from requests import Request
from unshackle.core.manifests import HLS
from unshackle.core.search_result import SearchResult
from unshackle.core.service import Service
from unshackle.core.titles import Episode, Movie, Movies, Series
from unshackle.core.tracks import Chapters, Subtitle, Tracks
from unshackle.core.utils.xml import load_xml
class SBS(Service):
"""
\b
Service code for SBS ondemand streaming service (https://www.sbs.com.au/ondemand/).
\b
Version: 1.0.1
Author: stabbedbybrick
Authorization: None
Geofence: AU (API and downloads)
Robustness:
AES: 720p, AAC2.0
\b
Tips:
- Input should be complete URL:
SERIES: https://www.sbs.com.au/ondemand/tv-series/reckless
EPISODE: https://www.sbs.com.au/ondemand/tv-series/reckless/season-1/reckless-s1-ep1/2459384899653
MOVIE: https://www.sbs.com.au/ondemand/movie/silence/1363535939614
SPORT: https://www.sbs.com.au/ondemand/sports-series/australian-championship-2025/football-australian-championship-2025/australian-championship-2025-s2025-ep40/2457638979614
\b
Notes:
- SBS uses transport streams for HLS, meaning the video and audio are a part of the same stream.
As a result only videos are listed as tracks, but the audio will be included as well.
"""
GEOFENCE = ("au",)
@staticmethod
@click.command(name="SBS", short_help="https://www.sbs.com.au/ondemand/", help=__doc__)
@click.argument("title", type=str)
@click.pass_context
def cli(ctx: Context, **kwargs: Any) -> SBS:
return SBS(ctx, **kwargs)
def __init__(self, ctx: Context, title: str):
self.title = title
super().__init__(ctx)
self.session.headers.update(self.config["headers"])
def search(self) -> Generator[SearchResult, None, None]:
params = {
"q": self.title.strip(),
}
results = self._request("GET", "https://content-search.pr.sbsod.com/catalogue", params=params)["items"]
for result in results:
if result.get("entityType") in ("PAGE"):
continue
label = result.get("entityType")
slug = result.get("slug")
title = result.get("title")
description = result.get("description")
yield SearchResult(
id_=f"https://www.sbs.com.au/ondemand/{label}/{slug}",
title=title,
description=description,
label=label,
url=f"https://www.sbs.com.au/ondemand/{label}/{slug}",
)
def get_titles(self) -> Movies | Series:
regex = re.compile(
r"^https://www.sbs.com.au/ondemand/"
r"(?P<entity>tv-series|tv-program|sports-series|movie|watch)"
r"(?:/|/.*/)"
r"(?P<id>[^/]+)/?$"
)
match = regex.search(self.title)
if not match:
raise ValueError(f"Invalid URL input: {self.title}")
entity_type, entity_id = (match.group(i) for i in ("entity", "id"))
if entity_type in ("movie", "tv-program") and entity_id.isdigit():
movie = self._movie(entity_id)
return Movies(movie)
elif entity_id.isdigit():
episode = self._episode(entity_id)
return Series(episode)
elif entity_type in ("tv-series", "sports-series"):
episodes = self._series(urlparse(self.title).path)
return Series(episodes)
def get_tracks(self, title: Movie | Episode) -> Tracks:
smil = self._request("GET", f"/api/v3/video_smil?id={title.id}")
body = load_xml(smil).find("body").find("seq")
section = body.find("par") or body
manifest = next((x.get("src") for x in section.findall("video")), None)
subtitles = [(x.get("src"), x.get("lang"), x.get("type")) for x in section.findall("textstream")]
tracks = HLS.from_url(manifest, self.session).to_tracks(title.language)
if subtitles:
for url, lang, type in subtitles:
if "ttaf+xml" in type:
continue
codec = type.split("/")[-1]
tracks.add(
Subtitle(
id_=hashlib.md5(url.encode()).hexdigest()[0:6],
url=url,
codec=Subtitle.Codec.from_mime(codec),
language=lang,
sdh="_CC" in url,
)
)
return tracks
def get_chapters(self, title: Movie | Episode) -> Chapters:
return Chapters()
# Service specific
def _series(self, path: str) -> Episode:
if "ondemand" in path:
path = path.split("ondemand")[1]
metadata = self._request("GET", f"https://catalogue.pr.sbsod.com{path}")
seasons = metadata.get("seasons")
if not seasons:
raise ValueError(f"Failed to find seasons for title: {path}")
episodes = []
for season in seasons:
for episode in season.get("episodes"):
episodes.append(
Episode(
id_=episode.get("mpxMediaID"),
service=self.__class__,
title=episode.get("seriesTitle"),
season=int(episode.get("seasonNumber", 0)),
number=int(episode.get("episodeNumber", 0)),
name=episode.get("title"),
year=episode.get("releaseYear"),
language=metadata.get("localeID") or "en",
data=episode,
)
)
return episodes
def _movie(self, entity_id: str) -> Movie:
metadata = self._request("GET", f"https://catalogue.pr.sbsod.com/mpx-media/{entity_id}")
return [
Movie(
id_=metadata.get("mpxMediaID"),
service=self.__class__,
name=metadata.get("title") or metadata.get("cdpTitle"),
year=metadata.get("releaseYear"),
language=metadata.get("localeID") or "en",
data=metadata,
)
]
def _episode(self, entity_id: str) -> Episode:
metadata = self._request("GET", f"https://catalogue.pr.sbsod.com/mpx-media/{entity_id}")
return [
Episode(
id_=metadata.get("mpxMediaID"),
service=self.__class__,
title=metadata.get("seriesTitle"),
season=int(metadata.get("seasonNumber", 0)),
number=int(metadata.get("episodeNumber", 0)),
name=metadata.get("title") or metadata.get("cdpTitle"),
year=metadata.get("releaseYear"),
language=metadata.get("localeID") or "en",
data=metadata,
)
]
def _request(self, method: str, endpoint: str, **kwargs: Any) -> Any[dict | str]:
url = urljoin(self.config["endpoints"]["base_url"], endpoint)
prep = self.session.prepare_request(Request(method, url, **kwargs))
response = self.session.send(prep)
if response.status_code != 200:
raise ConnectionError(f"{response.text}")
try:
return json.loads(response.content)
except json.JSONDecodeError:
return response.text
except ValueError as e:
raise ValueError(f"Failed to parse JSON: {response.text}") from e