mirror of
https://github.com/vinefeeder/TwinVine.git
synced 2026-07-15 18:10:04 +02:00
new service
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import click
|
||||
from requests import Request
|
||||
from unshackle.core.manifests import DASH
|
||||
from unshackle.core.search_result import SearchResult
|
||||
from unshackle.core.service import Service
|
||||
from unshackle.core.titles import Episode, Series, Title_T, Titles_T
|
||||
from unshackle.core.tracks import Chapters, Tracks
|
||||
|
||||
|
||||
class VM(Service):
|
||||
"""
|
||||
Service code for Virgin Media Television (https://play.virginmediatelevision.ie).
|
||||
|
||||
\b
|
||||
Version: 1.0.0
|
||||
Author: billybanana
|
||||
Authorization: None
|
||||
Geofence: IE
|
||||
Robustness:
|
||||
Widevine:
|
||||
L3: 1080p, AAC2.0
|
||||
|
||||
\b
|
||||
Tips:
|
||||
- Use complete series, VOD, or replay URLs:
|
||||
https://play.virginmediatelevision.ie/shows/<uuid>/<slug>
|
||||
https://play.virginmediatelevision.ie/watch/vod/52528176/blood-ep1
|
||||
- Raw numeric video IDs are also accepted. eg: 630bd3d6-80e8-11ef-b3a0-020f80c0527e
|
||||
"""
|
||||
|
||||
GEOFENCE = ("ie",)
|
||||
ALIASES = ("virginmedia", "virgin-media", "virgin",)
|
||||
|
||||
@staticmethod
|
||||
@click.command(name="VM", short_help="https://play.virginmediatelevision.ie", help=__doc__)
|
||||
@click.argument("title", type=str)
|
||||
@click.pass_context
|
||||
def cli(ctx, **kwargs: Any) -> VM:
|
||||
return VM(ctx, **kwargs)
|
||||
|
||||
def __init__(self, ctx, title: str):
|
||||
self.title = title
|
||||
self.license_url: str | None = None
|
||||
super().__init__(ctx)
|
||||
|
||||
self.session.headers.update(self.config["headers"])
|
||||
self.session.headers["Userid"] = str(self.config["api"].get("userid") or "123456")
|
||||
|
||||
def search(self) -> Generator[SearchResult, None, None]:
|
||||
api = self.config["api"]
|
||||
data = self._request(
|
||||
"GET",
|
||||
self.config["endpoints"]["search"],
|
||||
params={
|
||||
"key": api["key"],
|
||||
"cc": api.get("country", "IE"),
|
||||
"lang": "en",
|
||||
"platform": api.get("platform", "chrome"),
|
||||
"q": self.title.strip(),
|
||||
},
|
||||
)
|
||||
|
||||
seen = set()
|
||||
for section in (data.get("response") or {}).get("sections") or []:
|
||||
section_title = section.get("title") or section.get("id") or "Result"
|
||||
for item in section.get("tiles") or []:
|
||||
result = self._search_result(item, section_title)
|
||||
if not result:
|
||||
continue
|
||||
seen_key = str(result.id)
|
||||
if seen_key in seen:
|
||||
continue
|
||||
seen.add(seen_key)
|
||||
yield result
|
||||
|
||||
def get_titles(self) -> Titles_T:
|
||||
parsed = self._parse_title(self.title)
|
||||
|
||||
if parsed["kind"] == "series":
|
||||
episodes = self._series(parsed["series_id"], self.title)
|
||||
if not episodes:
|
||||
raise ValueError(f"Could not find episodes for series: {self.title}")
|
||||
return Series(episodes)
|
||||
|
||||
episode = self._episode(parsed["video_id"], self.title)
|
||||
return Series([episode])
|
||||
|
||||
def get_tracks(self, title: Title_T) -> Tracks:
|
||||
playback = self._playback(title.id, title.data.get("url") or self.title)
|
||||
response = playback.get("response") or {}
|
||||
widevine = (response.get("drm") or {}).get("widevine") or {}
|
||||
|
||||
manifest_url = widevine.get("stream")
|
||||
self.license_url = widevine.get("licenseAcquisitionUrl")
|
||||
if not manifest_url:
|
||||
raise ValueError(f"Could not find a Widevine DASH manifest for {title.id}")
|
||||
|
||||
title.data["playback"] = playback
|
||||
title.data["license_url"] = self.license_url
|
||||
|
||||
tracks = DASH.from_url(manifest_url, self.session).to_tracks(language=title.language)
|
||||
self._mark_descriptive_audio(tracks)
|
||||
return tracks
|
||||
|
||||
def get_widevine_service_certificate(self, **_: Any) -> str | None:
|
||||
return None
|
||||
|
||||
def get_chapters(self, title: Title_T) -> Chapters:
|
||||
return Chapters()
|
||||
|
||||
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: Any) -> bytes:
|
||||
license_url = title.data.get("license_url") or self.license_url
|
||||
if not license_url:
|
||||
raise ValueError("Could not find Virgin Media Widevine license URL")
|
||||
|
||||
r = self.session.post(
|
||||
url=license_url,
|
||||
data=challenge,
|
||||
headers={
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Origin": "https://www.virginmediatelevision.ie",
|
||||
"Referer": "https://www.virginmediatelevision.ie/",
|
||||
"User-Agent": self.config["headers"]["User-Agent"],
|
||||
},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
raise ConnectionError(r.text)
|
||||
return r.content
|
||||
|
||||
# Service specific
|
||||
|
||||
def _series(self, series_id: str, series_url: str) -> list[Episode]:
|
||||
metadata = self._series_metadata(series_id)
|
||||
show_title = (
|
||||
((metadata.get("response") or {}).get("series") or {}).get("title")
|
||||
or self._slug_title(series_url)
|
||||
)
|
||||
if not series_url.startswith("http"):
|
||||
series_url = f"https://play.virginmediatelevision.ie/shows/{series_id}/{self._slugify(show_title)}"
|
||||
|
||||
episode_refs = self._episode_refs(metadata, series_url)
|
||||
if not episode_refs:
|
||||
episode_refs = self._episode_refs_from_html(series_url)
|
||||
|
||||
episodes = []
|
||||
seen = set()
|
||||
for ref in episode_refs:
|
||||
video_id = str(ref.get("id") or "")
|
||||
if not video_id or video_id in seen:
|
||||
continue
|
||||
seen.add(video_id)
|
||||
|
||||
try:
|
||||
episodes.append(self._episode(video_id, ref.get("url") or series_url, ref=ref, show_title=show_title))
|
||||
except Exception as exc:
|
||||
self.log.warning("Skipping VM episode %s: %s", video_id, exc)
|
||||
|
||||
return sorted(
|
||||
episodes,
|
||||
key=lambda item: (int(item.season or 0), int(item.number or 0), item.id),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
def _episode(
|
||||
self,
|
||||
video_id: str,
|
||||
url: str,
|
||||
*,
|
||||
ref: dict[str, Any] | None = None,
|
||||
show_title: str | None = None,
|
||||
) -> Episode:
|
||||
playback = self._playback(video_id, url)
|
||||
metadata = self._stream_metadata(playback)
|
||||
ref = ref or {}
|
||||
|
||||
title = show_title or self._show_title(metadata, url)
|
||||
season = self._to_int(metadata.get("series_season") or ref.get("season") or ref.get("season_number"), 0)
|
||||
number = self._to_int(metadata.get("series_episode") or ref.get("episode") or ref.get("episode_number"), 0)
|
||||
name = metadata.get("title") or ref.get("title") or (f"Episode {number}" if number else "Episode")
|
||||
|
||||
return Episode(
|
||||
id_=str(video_id),
|
||||
service=self.__class__,
|
||||
title=title,
|
||||
season=season,
|
||||
number=number,
|
||||
name=name,
|
||||
year=self._year(metadata.get("created_at")),
|
||||
language="en",
|
||||
data={
|
||||
"url": self._video_url(video_id, url),
|
||||
"metadata": metadata,
|
||||
"playback": playback,
|
||||
"ref": ref,
|
||||
},
|
||||
)
|
||||
|
||||
def _playback(self, video_id: str, url: str) -> dict:
|
||||
token_data = self._request(
|
||||
"GET",
|
||||
self.config["endpoints"]["token"].format(id=video_id),
|
||||
)
|
||||
token = token_data.get("token")
|
||||
expiry = token_data.get("expiry")
|
||||
if not token or not expiry:
|
||||
raise ValueError(f"Could not get Virgin Media token for {video_id}")
|
||||
|
||||
api = self.config["api"]
|
||||
return self._request(
|
||||
"POST",
|
||||
self.config["endpoints"]["stream"].format(company_id=api["company_id"], id=video_id),
|
||||
headers={
|
||||
"Token": token,
|
||||
"Token-Expiry": str(expiry),
|
||||
"Userid": str(api.get("userid") or "123456"),
|
||||
"Uvid": str(video_id),
|
||||
},
|
||||
params={
|
||||
"key": api["key"],
|
||||
"cc": api.get("country", "IE"),
|
||||
"platform": api.get("platform", "chrome"),
|
||||
"url": url,
|
||||
"gdpr": api.get("gdpr", "1"),
|
||||
"gdpr_consent": api.get("gdpr_consent", "undefined"),
|
||||
},
|
||||
)
|
||||
|
||||
def _series_metadata(self, series_id: str) -> dict:
|
||||
api = self.config["api"]
|
||||
params = {
|
||||
"key": api["key"],
|
||||
"cc": api.get("country", "IE"),
|
||||
"lang": "en",
|
||||
"platform": api.get("platform", "chrome"),
|
||||
}
|
||||
|
||||
last_error = None
|
||||
for endpoint in self.config["endpoints"]["series_metadata"]:
|
||||
try:
|
||||
return self._request("GET", endpoint.format(id=series_id), params=params)
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
|
||||
raise ValueError(f"Could not fetch Virgin Media series metadata: {last_error}")
|
||||
|
||||
def _episode_refs(self, metadata: dict, series_url: str) -> list[dict[str, Any]]:
|
||||
series = ((metadata.get("response") or {}).get("series") or {})
|
||||
refs = []
|
||||
for season in series.get("seasons") or []:
|
||||
season_number = self._to_int(
|
||||
season.get("number") or season.get("season_number") or season.get("title"),
|
||||
0,
|
||||
)
|
||||
for episode in season.get("episodes") or season.get("tiles") or []:
|
||||
video_id = (
|
||||
episode.get("uvid")
|
||||
or episode.get("id")
|
||||
or episode.get("video_id")
|
||||
or episode.get("content_id")
|
||||
or episode.get("asset_id")
|
||||
)
|
||||
if not video_id:
|
||||
continue
|
||||
|
||||
refs.append({
|
||||
"id": str(video_id),
|
||||
"season_number": season_number,
|
||||
"episode_number": self._to_int(
|
||||
episode.get("episode") or episode.get("series_episode") or episode.get("episode_number"),
|
||||
0,
|
||||
),
|
||||
"title": episode.get("title") or episode.get("name"),
|
||||
"url": episode.get("url") or series_url,
|
||||
})
|
||||
|
||||
return refs
|
||||
|
||||
def _episode_refs_from_html(self, series_url: str) -> list[dict[str, Any]]:
|
||||
html = self._request(
|
||||
"GET",
|
||||
series_url,
|
||||
headers={"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
|
||||
raw=True,
|
||||
)
|
||||
|
||||
ids = set()
|
||||
for pattern in (
|
||||
r"/replay/(\d{6,})",
|
||||
r"/vod/(\d{6,})",
|
||||
r'"videoId"\s*:\s*(\d{6,})',
|
||||
):
|
||||
ids.update(re.findall(pattern, html))
|
||||
|
||||
refs = []
|
||||
for video_id in ids:
|
||||
refs.append({"id": video_id, "season_number": 0, "episode_number": 0, "url": series_url})
|
||||
return refs
|
||||
|
||||
def _search_result(self, item: dict[str, Any], section_title: str) -> SearchResult | None:
|
||||
item_type = (item.get("type") or item.get("label") or section_title or "").lower()
|
||||
title = self._clean_title(item.get("title") or item.get("name") or "Virgin Media")
|
||||
description = item.get("synopsis") or item.get("description")
|
||||
|
||||
if "series" in item_type or item.get("series_id"):
|
||||
series_id = item.get("series_id") or item.get("id")
|
||||
if not series_id:
|
||||
return None
|
||||
|
||||
seasons = item.get("seasons")
|
||||
episodes = item.get("episodes")
|
||||
label = "SERIES"
|
||||
if seasons or episodes:
|
||||
season_word = "season" if str(seasons) == "1" else "seasons"
|
||||
episode_word = "episode" if str(episodes) == "1" else "episodes"
|
||||
label += f" ({seasons or 0} {season_word}, {episodes or 0} {episode_word})"
|
||||
|
||||
return SearchResult(
|
||||
id_=series_id,
|
||||
title=title,
|
||||
description=description,
|
||||
label=label,
|
||||
url=f"https://play.virginmediatelevision.ie/shows/{series_id}/{self._slugify(title)}",
|
||||
)
|
||||
|
||||
video_id = (
|
||||
item.get("uvid")
|
||||
or item.get("video_id")
|
||||
or item.get("content_id")
|
||||
or item.get("asset_id")
|
||||
or item.get("id")
|
||||
)
|
||||
if not video_id or not re.fullmatch(r"\d+", str(video_id)):
|
||||
return None
|
||||
|
||||
label = item.get("label") or section_title or "VIDEO"
|
||||
return SearchResult(
|
||||
id_=str(video_id),
|
||||
title=title,
|
||||
description=description,
|
||||
label=str(label).upper(),
|
||||
url=f"https://play.virginmediatelevision.ie/replay/{video_id}/{self._slugify(title)}",
|
||||
)
|
||||
|
||||
def _request(self, method: str, endpoint: str, *, raw: bool = False, **kwargs: Any) -> Any:
|
||||
url = endpoint if endpoint.startswith("http") else urljoin(self.config["endpoints"]["base_url"], endpoint)
|
||||
headers = kwargs.pop("headers", None)
|
||||
|
||||
request = Request(
|
||||
method,
|
||||
url,
|
||||
headers={**self.config["headers"], **(headers or {})},
|
||||
**kwargs,
|
||||
)
|
||||
prep = self.session.prepare_request(request)
|
||||
response = self.session.send(prep, timeout=30)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ConnectionError(
|
||||
f"Status: {response.status_code} - {response.url}\n"
|
||||
"Content may be geo-restricted to IE"
|
||||
)
|
||||
|
||||
if raw:
|
||||
return response.text
|
||||
|
||||
try:
|
||||
return json.loads(response.content)
|
||||
except json.JSONDecodeError:
|
||||
return response.text
|
||||
|
||||
@staticmethod
|
||||
def _parse_title(title: str) -> dict[str, str]:
|
||||
value = title.strip()
|
||||
path = urlparse(value).path if value.startswith("http") else value
|
||||
|
||||
series = re.search(r"/shows/(?P<series_id>[0-9a-f-]{36})", path, re.IGNORECASE)
|
||||
if series:
|
||||
return {"kind": "series", "series_id": series.group("series_id")}
|
||||
|
||||
if re.fullmatch(r"[0-9a-f-]{36}", value, re.IGNORECASE):
|
||||
return {"kind": "series", "series_id": value}
|
||||
|
||||
video = re.search(r"/(?:watch/)?(?:vod|replay)/(?P<video_id>\d+)", path, re.IGNORECASE)
|
||||
if video:
|
||||
return {"kind": "episode", "video_id": video.group("video_id")}
|
||||
|
||||
if re.fullmatch(r"\d+", value):
|
||||
return {"kind": "episode", "video_id": value}
|
||||
|
||||
raise ValueError(f"Could not parse Virgin Media title: {title}")
|
||||
|
||||
@staticmethod
|
||||
def _stream_metadata(playback: dict) -> dict:
|
||||
metadata = ((playback.get("response") or {}).get("metadata") or {}).get("metadata") or {}
|
||||
return metadata if isinstance(metadata, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def _to_int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
match = re.search(r"(\d+)", str(value))
|
||||
return int(match.group(1)) if match else int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _year(value: str | None) -> int | None:
|
||||
if value and (match := re.match(r"(\d{4})", value)):
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _slug_title(url: str) -> str:
|
||||
slug = urlparse(url).path.rstrip("/").rsplit("/", 1)[-1]
|
||||
return re.sub(r"[-_]+", " ", slug).title() if slug else "Virgin Media"
|
||||
|
||||
@staticmethod
|
||||
def _show_title(metadata: dict, url: str) -> str:
|
||||
title = metadata.get("title") or VM._slug_title(url)
|
||||
return re.sub(r"\s+Ep\.?\s*\d+.*$", "", title, flags=re.IGNORECASE).strip() or title
|
||||
|
||||
@staticmethod
|
||||
def _clean_title(value: Any) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
|
||||
@staticmethod
|
||||
def _slugify(value: Any) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", VM._clean_title(value).lower()).strip("-")
|
||||
return slug or "title"
|
||||
|
||||
@staticmethod
|
||||
def _video_url(video_id: str, fallback: str) -> str:
|
||||
if fallback and f"/{video_id}" in fallback:
|
||||
return fallback
|
||||
return f"https://play.virginmediatelevision.ie/replay/{video_id}"
|
||||
|
||||
@staticmethod
|
||||
def _mark_descriptive_audio(tracks: Tracks) -> None:
|
||||
for track in tracks.audio:
|
||||
adaptation = track.data.get("dash", {}).get("adaptation_set")
|
||||
role = adaptation.find("Role") if adaptation is not None else None
|
||||
if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
|
||||
track.descriptive = True
|
||||
@@ -0,0 +1,25 @@
|
||||
headers:
|
||||
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
|
||||
Accept: "application/json, text/plain, */*"
|
||||
Accept-Language: "en-IE,en;q=0.9"
|
||||
Origin: "https://play.virginmediatelevision.ie"
|
||||
Referer: "https://play.virginmediatelevision.ie/"
|
||||
X-Requested-With: "XMLHttpRequest"
|
||||
|
||||
api:
|
||||
key: "821254297041614280861178657602"
|
||||
company_id: "company_942f683c-9041-42de-9911-a9e4cd98a4e9"
|
||||
country: "IE"
|
||||
platform: "chrome"
|
||||
gdpr: "1"
|
||||
gdpr_consent: "undefined"
|
||||
userid: "123456"
|
||||
|
||||
endpoints:
|
||||
base_url: "https://play.virginmediatelevision.ie"
|
||||
search: "https://v6-metadata-cf.simplestreamcdn.com/api/search"
|
||||
token: "https://play.virginmediatelevision.ie/api/player/token/{id}"
|
||||
stream: "https://api-virginmedia.simplestreamcdn.com/streams/v2/{company_id}/vod/{id}"
|
||||
series_metadata:
|
||||
- "https://v6-metadata-cf.simplestreamcdn.com/api/series/{id}"
|
||||
- "https://v6-metadata.simplestreamcdn.com/api/series/{id}"
|
||||
Reference in New Issue
Block a user