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

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