440 lines
17 KiB
Python
440 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from collections.abc import Generator
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from http.cookiejar import MozillaCookieJar
|
|
from typing import Any, List, Optional, Union
|
|
from uuid import uuid4
|
|
|
|
import click
|
|
from click import Context
|
|
from pyplayready.cdm import Cdm as PlayReadyCdm
|
|
from unshackle.core.credential import Credential
|
|
from unshackle.core.manifests.dash import DASH
|
|
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 Chapter, Chapters, Tracks
|
|
|
|
|
|
class SEVEN(Service):
|
|
"""
|
|
Service code for 7Plus streaming service (https://7plus.com.au/).
|
|
|
|
\b
|
|
Version: 1.0.1
|
|
Author: stabbedbybrick
|
|
Authorization: Cookies
|
|
Geofence: AU (API and downloads)
|
|
Robustness:
|
|
Widevine:
|
|
L3: 720p
|
|
PlayReady:
|
|
SL2000: 720p
|
|
|
|
\b
|
|
Tips:
|
|
- Use complete title URL as input:
|
|
SERIES: https://7plus.com.au/ncis-los-angeles
|
|
EPISODE: https://7plus.com.au/ncis-los-angeles?episode-id=NCIL01-001
|
|
- There's no way to distinguish between series and movies, so use `--movie` to download as movie
|
|
|
|
\b
|
|
Examples:
|
|
- SERIES: unshackle dl -w s01e01 7plus https://7plus.com.au/ncis-los-angeles
|
|
- EPISODE: unshackle dl 7plus https://7plus.com.au/ncis-los-angeles?episode-id=NCIL01-001
|
|
- MOVIE: unshackle dl 7plus --movie https://7plus.com.au/puss-in-boots-the-last-wish
|
|
|
|
"""
|
|
|
|
GEOFENCE = ("au",)
|
|
ALIASES = ("7plus", "sevenplus",)
|
|
|
|
@staticmethod
|
|
@click.command(name="SEVEN", short_help="https://7plus.com.au/", help=__doc__)
|
|
@click.option("-m", "--movie", is_flag=True, default=False, help="Download as Movie")
|
|
@click.argument("title", type=str)
|
|
@click.pass_context
|
|
def cli(ctx: Context, **kwargs: Any) -> SEVEN:
|
|
return SEVEN(ctx, **kwargs)
|
|
|
|
def __init__(self, ctx: Context, movie: bool, title: str):
|
|
self.title = title
|
|
self.movie = movie
|
|
super().__init__(ctx)
|
|
|
|
self.cdm = ctx.obj.cdm
|
|
self.drm_system = "playready" if isinstance(self.cdm, PlayReadyCdm) else "widevine"
|
|
self.key_system = "com.microsoft.playready" if isinstance(self.cdm, PlayReadyCdm) else "com.widevine.alpha"
|
|
|
|
self.profile = ctx.parent.params.get("profile")
|
|
if not self.profile:
|
|
self.profile = "default"
|
|
|
|
self.session.headers.update(self.config["headers"])
|
|
|
|
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
super().authenticate(cookies, credential)
|
|
if cookies is None:
|
|
raise EnvironmentError("Service requires Cookies for Authentication.")
|
|
self.session.cookies.update(cookies)
|
|
|
|
api_key = next((cookie.name.replace("gig_bootstrap_", "") for cookie in cookies if "login_ver" in cookie.value), None)
|
|
login_token = next((cookie.value for cookie in cookies if "glt_" in cookie.name), None)
|
|
if not api_key or not login_token:
|
|
raise ValueError("Invalid cookies. Try refreshing.")
|
|
|
|
market = self.session.get(
|
|
"https://market-cdn.swm.digital/v1/market/ip/",
|
|
params={"apikey": "web"}
|
|
).json()
|
|
|
|
self.market_id = market.get("_id", 4)
|
|
|
|
cache = self.cache.get(f"tokens_{self.profile}")
|
|
|
|
if cache and not cache.expired:
|
|
# cached
|
|
self.log.info(" + Using cached tokens...")
|
|
tokens = cache.data
|
|
elif cache and cache.expired:
|
|
# expired, refresh
|
|
self.log.info("+ Refreshing tokens...")
|
|
payload = {
|
|
"platformId": self.config["PLATFORM_ID"],
|
|
"regSource": "7plus",
|
|
"refreshToken": cache.data.get("refresh_token"),
|
|
}
|
|
r = self.session.post("https://auth2.swm.digital/connect/token", data=payload)
|
|
if r.status_code != 200:
|
|
raise ConnectionError(f"Failed to refresh tokens: {r.text}")
|
|
tokens = r.json()
|
|
cache.set(tokens, expiration=int(tokens["expires_in"]) - 60)
|
|
|
|
else:
|
|
# new
|
|
self.log.info(" + Authenticating...")
|
|
device_id = str(uuid4())
|
|
payload = {
|
|
"platformId": self.config["PLATFORM_ID"],
|
|
"regSource": "7plus",
|
|
"deviceId": device_id,
|
|
"locationVerificationRequired": "false",
|
|
}
|
|
r = self.session.post("https://auth2.swm.digital/account/device/authorize", data=payload)
|
|
if r.status_code != 200:
|
|
raise ConnectionError(f"Failed to authenticate: {r.text}")
|
|
auth = r.json()
|
|
|
|
uri = auth.get("verification_uri_complete")
|
|
user_code = auth.get("user_code")
|
|
device_code = auth.get("device_code")
|
|
if not uri or not user_code or not device_code:
|
|
raise ValueError(f"Failed to authenticate device: {auth}")
|
|
|
|
data = {
|
|
"APIKey": api_key,
|
|
"sdk": "js_next",
|
|
"login_token": login_token,
|
|
"authMode": "cookie",
|
|
"pageURL": "https://7plus.com.au/connect",
|
|
"sdkBuild": "18051",
|
|
"format": "json",
|
|
}
|
|
|
|
response = self.session.post("https://login.7plus.com.au/accounts.getJWT", cookies=cookies, data=data)
|
|
if response.status_code != 200:
|
|
raise ConnectionError(f"Failed to fetch JWT: {response.text}")
|
|
|
|
id_token = response.json().get("id_token")
|
|
if not id_token:
|
|
raise ValueError(f"Failed to fetch JWT: {response.text}")
|
|
|
|
headers = {
|
|
"accept": "application/json, text/plain, */*",
|
|
"accept-language": "en-US,en;q=0.9",
|
|
"authorization": f"Bearer {id_token}",
|
|
"content-type": "application/json;charset=UTF-8",
|
|
"origin": "https://7plus.com.au",
|
|
"referer": "https://7plus.com.au/connect",
|
|
}
|
|
|
|
payload = {
|
|
"platformId": "web",
|
|
"regSource": "7plus",
|
|
"code": user_code,
|
|
"attemptLocationPairing": False,
|
|
}
|
|
r = self.session.post("https://7plus.com.au/auth/otp", headers=headers, json=payload)
|
|
if r.status_code != 200:
|
|
raise ConnectionError(f"Failed to verify OTP: {r.status_code}")
|
|
|
|
payload = {
|
|
"platformId": self.config["PLATFORM_ID"],
|
|
"regSource": "7plus",
|
|
"deviceCode": device_code,
|
|
}
|
|
r = self.session.post("https://auth2.swm.digital/connect/token", data=payload)
|
|
if r.status_code != 200:
|
|
raise ConnectionError(f"Failed to fetch device token: {r.text}")
|
|
tokens = r.json()
|
|
|
|
tokens["device_id"] = device_id
|
|
cache.set(tokens, expiration=int(tokens["expires_in"]) - 60)
|
|
|
|
self.device_id = tokens.get("device_id") or str(uuid4())
|
|
self.session.headers.update({"authorization": f"Bearer {tokens['access_token']}"})
|
|
|
|
def search(self) -> Generator[SearchResult, None, None]:
|
|
params = {
|
|
"searchTerm": self.title,
|
|
"market-id": self.market_id,
|
|
"api-version": "4.4",
|
|
"platform-id": self.config["PLATFORM_ID"],
|
|
"platform-version": self.config["PLATFORM_VERSION"],
|
|
}
|
|
|
|
r = self.session.get("https://searchapi.swm.digital/3.0/api/Search", params=params)
|
|
r.raise_for_status()
|
|
|
|
results = r.json()
|
|
if isinstance(results, list):
|
|
for result in results:
|
|
title = result.get("image", {}).get("altTag")
|
|
slug = result.get("contentLink", {}).get("url")
|
|
|
|
yield SearchResult(
|
|
id_=f"https://7plus.com.au{slug}",
|
|
title=title,
|
|
url=f"https://7plus.com.au{slug}",
|
|
)
|
|
|
|
def get_titles(self) -> Movies | Series:
|
|
if match := re.match(r"https:\/\/7plus\.com\.au\/([^?\/]+)(?:\?.*episode-id=([^&]+))?", self.title):
|
|
slug, episode_id = match.groups()
|
|
else:
|
|
raise ValueError(f"Invalid title: {self.title}")
|
|
|
|
params = {
|
|
"platform-id": self.config["PLATFORM_ID"],
|
|
"market-id": self.market_id,
|
|
"platform-version": self.config["PLATFORM_VERSION"],
|
|
"api-version": self.config["API_VERSION"],
|
|
}
|
|
|
|
r = self.session.get(f"https://component-cdn.swm.digital/content/{slug}", params=params)
|
|
if r.status_code != 200:
|
|
raise ConnectionError(f"Failed to fetch content: {r.text}")
|
|
|
|
content = r.json()
|
|
|
|
if episode_id:
|
|
episodes = self._series(content, slug)
|
|
episode = next((e for e in episodes if e.id == episode_id), None)
|
|
return Series([episode])
|
|
|
|
elif self.movie:
|
|
movie = self._movie(content)
|
|
return Movies([movie])
|
|
|
|
else:
|
|
episodes = self._series(content, slug)
|
|
return Series(episodes)
|
|
|
|
def get_tracks(self, title: Movie | Episode) -> Tracks:
|
|
params = {
|
|
"appId": "7plus",
|
|
"deviceType": self.config["PLATFORM_ID"],
|
|
"platformType": "tv",
|
|
"deviceId": self.device_id,
|
|
"pc": 3181,
|
|
"advertid": "null",
|
|
"accountId": "5303576322001",
|
|
"referenceId": f"ref:{title.id}",
|
|
"deliveryId": "csai",
|
|
"marketId": self.market_id,
|
|
"ozid": "dc6095c7-e895-41d3-6609-79f673fc7f63",
|
|
"sdkverification": "true",
|
|
"cp.encryptionType": "cenc",
|
|
"cp.drmSystems": self.drm_system,
|
|
"cp.containerFormat": "cmaf",
|
|
"cp.supportedCodecs": "avc",
|
|
"cp.drmAuth": "true",
|
|
}
|
|
resp = self.session.get("https://videoservice.swm.digital/playback", params=params)
|
|
if resp.status_code != 200:
|
|
raise ConnectionError(f"Failed to fetch playback data: {resp.text}")
|
|
data = resp.json()
|
|
|
|
drm = data.get("media", {}).get("stream_type_drm", False)
|
|
if drm:
|
|
source_manifest = next((
|
|
x["src"] for x in data["media"]["sources"]
|
|
if x.get("key_systems").get("com.widevine.alpha")),
|
|
None,
|
|
)
|
|
title.data["license_url"] = next((
|
|
x["key_systems"][self.key_system]["license_url"]
|
|
for x in data["media"]["sources"]
|
|
if x.get("key_systems").get(self.key_system)),
|
|
None,
|
|
)
|
|
else:
|
|
source_manifest = next((
|
|
x["src"] for x in data["media"]["sources"]
|
|
if x.get("type") == "application/dash+xml"),
|
|
None,
|
|
)
|
|
if not source_manifest:
|
|
raise ValueError("Failed to get manifest")
|
|
|
|
title.data["cue_points"] = data.get("media", {}).get("cue_points")
|
|
|
|
tracks = DASH.from_url(source_manifest, self.session).to_tracks(title.language)
|
|
|
|
for track in tracks.audio:
|
|
role = track.data["dash"]["representation"].find("Role")
|
|
if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
|
|
track.descriptive = True
|
|
|
|
return tracks
|
|
|
|
def get_chapters(self, title: Movie | Episode) -> Chapters:
|
|
if not (cue_points := title.data.get("cue_points")):
|
|
return Chapters()
|
|
|
|
cue_points = sorted(cue_points, key=lambda x: x["time"])
|
|
|
|
chapters = []
|
|
for cue_point in cue_points:
|
|
if cue_point.get("time", 0) > 0:
|
|
name = "End Credits" if cue_point.get("name", "").lower() == "credits" else None
|
|
chapters.append(Chapter(name=name, timestamp=cue_point["time"] * 1000))
|
|
|
|
return Chapters(chapters)
|
|
|
|
def get_widevine_service_certificate(self, **_: Any) -> str:
|
|
return None
|
|
|
|
def get_widevine_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> Optional[Union[bytes, str]]:
|
|
if license_url := title.data.get("license_url"):
|
|
r = self.session.post(url=license_url, data=challenge)
|
|
if r.status_code != 200:
|
|
raise ConnectionError(r.text)
|
|
return r.content
|
|
|
|
return None
|
|
|
|
def get_playready_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> Optional[Union[bytes, str]]:
|
|
if license_url := title.data.get("license_url"):
|
|
r = self.session.post(url=license_url, data=challenge)
|
|
if r.status_code != 200:
|
|
raise ConnectionError(r.text)
|
|
return r.content
|
|
|
|
return None
|
|
|
|
# Service specific functions
|
|
|
|
def _movie(self, content: dict) -> Movie:
|
|
title = content.get("title")
|
|
metadata = content.get("items", [{}])[0].get("videoMetadata", {})
|
|
if not metadata:
|
|
raise ValueError("Failed to find metadata for this movie")
|
|
|
|
return Movie(
|
|
id_=metadata.get("videoBref"),
|
|
service=self.__class__,
|
|
name=title,
|
|
year=metadata.get("productionYear"),
|
|
language="en",
|
|
data=content,
|
|
)
|
|
|
|
def _get_season_data(self, season_id: str, slug: str) -> List[Episode]:
|
|
params = {
|
|
"component-id": season_id,
|
|
"platform-id": self.config.get("PLATFORM_ID"),
|
|
"market-id": self.market_id,
|
|
"platform-version": self.config.get("PLATFORM_VERSION"),
|
|
"api-version": self.config.get("API_VERSION"),
|
|
"signedUp": "True",
|
|
}
|
|
|
|
try:
|
|
r = self.session.get(f"https://component.swm.digital/component/{slug}", params=params)
|
|
r.raise_for_status()
|
|
comp = r.json()
|
|
except ConnectionError as e:
|
|
self.log.error(f"Error fetching season {season_id}: {e}")
|
|
return []
|
|
except Exception as e:
|
|
self.log.error(f"An unexpected error occurred for season {season_id}: {e}")
|
|
return []
|
|
|
|
episodes = []
|
|
for episode in comp.get("items", []):
|
|
info_panel = episode.get("infoPanelData", {})
|
|
player_data = episode.get("playerData", {})
|
|
card_data = episode.get("cardData", {})
|
|
catalogue_number = episode.get("catalogueNumber", "")
|
|
|
|
title = info_panel.get("title")
|
|
episode_name = card_data.get("image", {}).get("altTag")
|
|
card_name = card_data.get("title", "").lstrip("0123456789. ").split(" - ")[-1].strip()
|
|
|
|
season, number, name = 0, 0, card_name
|
|
if match := re.search(r"(?:Season|Year)\s*(\d+)\s*E(?:pisode)?\s*(\d+)", episode_name, re.IGNORECASE):
|
|
season = int(match.group(1))
|
|
number = int(match.group(2))
|
|
|
|
if not season and not number:
|
|
if match := re.compile(r"\w+(\d+)-(\d+)").search(catalogue_number):
|
|
season = int(match.group(1))
|
|
number = int(match.group(2))
|
|
|
|
episodes.append(
|
|
Episode(
|
|
id_=player_data.get("episodePlayerId"),
|
|
service=self.__class__,
|
|
title=title,
|
|
year=card_data.get("productionYear"),
|
|
season=season,
|
|
number=number,
|
|
name=name,
|
|
language="en",
|
|
data=episode,
|
|
)
|
|
)
|
|
return episodes
|
|
|
|
def _series(self, content: dict, slug: str) -> List[Episode]:
|
|
items = next((x for x in content.get("items", []) if x.get("type") == "shelfContainer"), {})
|
|
episodes_shelf = next((x for x in items.get("items", []) if x.get("title") == "Episodes"), {})
|
|
seasons_container = next((x for x in episodes_shelf.get("items", []) if x.get("title") in ("Season", "Year", "Bulletin")), {})
|
|
|
|
season_ids = [
|
|
item.get("items", [{}])[0].get("id")
|
|
for item in seasons_container.get("items", [])
|
|
if item.get("items") and item.get("items")[0].get("id")
|
|
]
|
|
|
|
if not season_ids:
|
|
return []
|
|
|
|
all_episodes = []
|
|
with ThreadPoolExecutor(max_workers=len(season_ids)) as executor:
|
|
future_to_season = {
|
|
executor.submit(self._get_season_data, season_id, slug): season_id for season_id in season_ids
|
|
}
|
|
for future in future_to_season:
|
|
try:
|
|
episodes_of_season = future.result()
|
|
all_episodes.extend(episodes_of_season)
|
|
except Exception as exc:
|
|
season_id = future_to_season[future]
|
|
self.log.error(f"{season_id} generated an exception: {exc}")
|
|
|
|
return all_episodes
|