feat(services): Add STV Player service
This commit is contained in:
parent
81080328b9
commit
1e4730070f
230
services/STV/__init__.py
Normal file
230
services/STV/__init__.py
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Generator
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any, Union
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import click
|
||||||
|
from click import Context
|
||||||
|
from devine.core.manifests.dash import DASH
|
||||||
|
from devine.core.search_result import SearchResult
|
||||||
|
from devine.core.service import Service
|
||||||
|
from devine.core.titles import Episode, Movie, Movies, Series
|
||||||
|
from devine.core.tracks import Chapter, Chapters, Tracks
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
|
||||||
|
class STV(Service):
|
||||||
|
"""
|
||||||
|
Service code for STV Player streaming service (https://player.stv.tv/).
|
||||||
|
|
||||||
|
\b
|
||||||
|
Author: stabbedbybrick
|
||||||
|
Authorization: None
|
||||||
|
Robustness:
|
||||||
|
L3: 1080p
|
||||||
|
|
||||||
|
\b
|
||||||
|
Tips:
|
||||||
|
- Use complete title URL as input:
|
||||||
|
SERIES: https://player.stv.tv/summary/rebus
|
||||||
|
EPISODE: https://player.stv.tv/episode/2ro8/rebus
|
||||||
|
- Use the episode URL for movies:
|
||||||
|
MOVIE: https://player.stv.tv/episode/4lw7/wonder-woman-1984
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
GEOFENCE = ("gb",)
|
||||||
|
ALIASES = ("stvplayer",)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@click.command(name="STV", short_help="https://player.stv.tv/", help=__doc__)
|
||||||
|
@click.argument("title", type=str)
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx: Context, **kwargs: Any) -> STV:
|
||||||
|
return STV(ctx, **kwargs)
|
||||||
|
|
||||||
|
def __init__(self, ctx: Context, title: str):
|
||||||
|
self.title = title
|
||||||
|
super().__init__(ctx)
|
||||||
|
|
||||||
|
self.session.headers.update({"user-agent": "okhttp/4.11.0"})
|
||||||
|
self.base = self.config["endpoints"]["base"]
|
||||||
|
|
||||||
|
def search(self) -> Generator[SearchResult, None, None]:
|
||||||
|
data = {
|
||||||
|
"engine_key": "S1jgssBHdk8ZtMWngK_y",
|
||||||
|
"q": self.title,
|
||||||
|
}
|
||||||
|
r = self.session.post(self.config["endpoints"]["search"], data=data)
|
||||||
|
r.raise_for_status()
|
||||||
|
results = r.json()["records"]["page"]
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
label = result.get("category")
|
||||||
|
if label and isinstance(label, list):
|
||||||
|
label = result["category"][0]
|
||||||
|
|
||||||
|
yield SearchResult(
|
||||||
|
id_=result.get("url"),
|
||||||
|
title=result.get("title"),
|
||||||
|
description=result.get("body"),
|
||||||
|
label=label,
|
||||||
|
url=result.get("url"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_titles(self) -> Union[Movies, Series]:
|
||||||
|
kind, slug = self.parse_title(self.title)
|
||||||
|
self.session.headers.update({"stv-drm": "true"})
|
||||||
|
|
||||||
|
if kind == "episode":
|
||||||
|
r = self.session.get(self.base + f"episodes/{slug}")
|
||||||
|
r.raise_for_status()
|
||||||
|
episode = r.json()["results"]
|
||||||
|
|
||||||
|
if episode.get("genre").lower() == "movie":
|
||||||
|
return Movies(
|
||||||
|
[
|
||||||
|
Movie(
|
||||||
|
id_=episode["video"].get("id"),
|
||||||
|
service=self.__class__,
|
||||||
|
year=None,
|
||||||
|
name=episode.get("title"),
|
||||||
|
language="en",
|
||||||
|
data=episode,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
episodes = [
|
||||||
|
Episode(
|
||||||
|
id_=episode["video"].get("id"),
|
||||||
|
service=self.__class__,
|
||||||
|
title=episode["programme"].get("name"),
|
||||||
|
season=int(episode["playerSeries"]["name"].split(" ")[1])
|
||||||
|
if episode.get("playerSeries") and re.match(r"Series \d+", episode["playerSeries"]["name"])
|
||||||
|
else 0,
|
||||||
|
number=int(episode.get("number", 0)),
|
||||||
|
name=episode.get("title"),
|
||||||
|
language="en",
|
||||||
|
data=episode,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
elif kind == "summary":
|
||||||
|
r = self.session.get(self.base + f"programmes/{slug}")
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
|
series = [series.get("guid") for series in data["results"]["series"]]
|
||||||
|
seasons = [self.session.get(self.base + f"episodes?series.guid={i}").json() for i in series]
|
||||||
|
|
||||||
|
episodes = [
|
||||||
|
Episode(
|
||||||
|
id_=episode["video"].get("id"),
|
||||||
|
service=self.__class__,
|
||||||
|
title=data["results"].get("name"),
|
||||||
|
season=int(episode["playerSeries"]["name"].split(" ")[1])
|
||||||
|
if episode.get("playerSeries") and re.match(r"Series \d+", episode["playerSeries"]["name"])
|
||||||
|
else 0,
|
||||||
|
number=int(episode.get("number", 0)),
|
||||||
|
name=episode.get("title"),
|
||||||
|
language="en",
|
||||||
|
data=episode,
|
||||||
|
)
|
||||||
|
for season in seasons
|
||||||
|
for episode in season["results"]
|
||||||
|
]
|
||||||
|
|
||||||
|
self.session.headers.pop("stv-drm")
|
||||||
|
return Series(episodes)
|
||||||
|
|
||||||
|
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
||||||
|
self.drm = title.data["programme"].get("drmEnabled")
|
||||||
|
headers = self.config["headers"]["drm"] if self.drm else self.config["headers"]["clear"]
|
||||||
|
accounts = self.config["accounts"]["drm"] if self.drm else self.config["accounts"]["clear"]
|
||||||
|
|
||||||
|
r = self.session.get(
|
||||||
|
self.config["endpoints"]["playback"].format(accounts=accounts, id=title.id),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
if not r.ok:
|
||||||
|
raise ConnectionError(r.text)
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
|
source_manifest = next(
|
||||||
|
(source["src"] for source in data["sources"] if source.get("type") == "application/dash+xml"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.license = None
|
||||||
|
if self.drm:
|
||||||
|
key_systems = next((
|
||||||
|
source
|
||||||
|
for source in data["sources"]
|
||||||
|
if source.get("type") == "application/dash+xml"
|
||||||
|
and source.get("key_systems").get("com.widevine.alpha")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.license = key_systems["key_systems"]["com.widevine.alpha"]["license_url"] if key_systems else None
|
||||||
|
|
||||||
|
manifest = self.trim_duration(source_manifest)
|
||||||
|
tracks = DASH.from_text(manifest, source_manifest).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: Union[Movie, Episode]) -> Chapters:
|
||||||
|
cue_points = title.data.get("_cuePoints")
|
||||||
|
if not cue_points:
|
||||||
|
return Chapters()
|
||||||
|
|
||||||
|
return Chapters([Chapter(timestamp=int(cue)) for cue in cue_points])
|
||||||
|
|
||||||
|
def get_widevine_service_certificate(self, **_: Any) -> str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_widevine_license(self, challenge: bytes, **_: Any) -> bytes:
|
||||||
|
if not self.license:
|
||||||
|
return None
|
||||||
|
|
||||||
|
r = self.session.post(url=self.license, data=challenge)
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise ConnectionError(r.text)
|
||||||
|
return r.content
|
||||||
|
|
||||||
|
# Service specific functions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_title(title: str) -> tuple[str, str]:
|
||||||
|
parsed_url = urlparse(title).path.split("/")
|
||||||
|
kind, slug = parsed_url[1], parsed_url[2]
|
||||||
|
if kind not in ["episode", "summary"]:
|
||||||
|
raise ValueError("Failed to parse title - is the URL correct?")
|
||||||
|
|
||||||
|
return kind, slug
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def trim_duration(source_manifest: str) -> str:
|
||||||
|
"""
|
||||||
|
The last segment on all tracks return a 404 for some reason, causing a failed download.
|
||||||
|
So we trim the duration by exactly one segment to account for that.
|
||||||
|
|
||||||
|
TODO: Calculate the segment duration instead of assuming length.
|
||||||
|
"""
|
||||||
|
manifest = DASH.from_url(source_manifest).manifest
|
||||||
|
period_duration = manifest.get("mediaPresentationDuration")
|
||||||
|
period_duration = DASH.pt_to_sec(period_duration)
|
||||||
|
|
||||||
|
hours, minutes, seconds = str(timedelta(seconds=period_duration - 6)).split(":")
|
||||||
|
new_duration = f"PT{hours}H{minutes}M{seconds}S"
|
||||||
|
manifest.set("mediaPresentationDuration", new_duration)
|
||||||
|
|
||||||
|
return etree.tostring(manifest, encoding="unicode")
|
20
services/STV/config.yaml
Normal file
20
services/STV/config.yaml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
accounts:
|
||||||
|
drm: "6204867266001"
|
||||||
|
clear: "1486976045"
|
||||||
|
|
||||||
|
headers:
|
||||||
|
drm:
|
||||||
|
BCOV-POLICY: BCpkADawqM32Q7lZg8ME0ydIOV8bD_9Ke2YD5wvY_T2Rq2TBtz6QQfpHtSAJTiDL-MiYAxyJVvScaKt82d1Q6b_wP6MG-O8SGQjRnwczfdsTesTZy-uj23uKv1vjHijtTeQC0DONN53zS38v
|
||||||
|
User-Agent: Dalvik/2.1.0 (Linux; U; Android 12; SM-A226B Build/SP1A.210812.016)
|
||||||
|
Host: edge.api.brightcove.com
|
||||||
|
Connection: keep-alive
|
||||||
|
clear:
|
||||||
|
BCOV-POLICY: BCpkADawqM2Dpx-ht5hP1rQqWFTcOTqTT5x5bSUlY8FaOO1_P8LcKxmL2wrFzTvRb3HzO2YTIzVDuoeLfqvFvp1dWRPnxKT8zt9ErkENYteaU9T6lz7OogjL8W8
|
||||||
|
User-Agent: Dalvik/2.1.0 (Linux; U; Android 12; SM-A226B Build/SP1A.210812.016)
|
||||||
|
Host: edge.api.brightcove.com
|
||||||
|
Connection: keep-alive
|
||||||
|
|
||||||
|
endpoints:
|
||||||
|
base: https://player.api.stv.tv/v1/
|
||||||
|
playback: https://edge.api.brightcove.com/playback/v1/accounts/{accounts}/videos/{id}
|
||||||
|
search: https://api.swiftype.com/api/v1/public/engines/search.json
|
Loading…
Reference in New Issue
Block a user