mirror of
https://github.com/vinefeeder/TwinVine.git
synced 2026-07-15 18:10:04 +02:00
services
This commit is contained in:
@@ -1,419 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from collections.abc import Generator
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from http.cookiejar import MozillaCookieJar
|
|
||||||
from typing import Any, Optional, Union
|
|
||||||
|
|
||||||
import click
|
|
||||||
from click import Context
|
|
||||||
from Crypto.Util.Padding import unpad
|
|
||||||
from Cryptodome.Cipher import AES
|
|
||||||
from pywidevine.cdm import Cdm as WidevineCdm
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.manifests.dash import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series
|
|
||||||
from envied.core.tracks import Chapter, Subtitle, Tracks
|
|
||||||
|
|
||||||
|
|
||||||
class ALL4(Service):
|
|
||||||
"""
|
|
||||||
Service code for Channel 4's All4 streaming service (https://channel4.com).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.1
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: Credentials
|
|
||||||
Robustness:
|
|
||||||
L3: 1080p, AAC2.0
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Use complete title URL or slug as input:
|
|
||||||
https://www.channel4.com/programmes/taskmaster OR taskmaster
|
|
||||||
- Use on demand URL for directly downloading episodes:
|
|
||||||
https://www.channel4.com/programmes/taskmaster/on-demand/75588-002
|
|
||||||
- Both android and web/pc endpoints are checked for quality profiles.
|
|
||||||
If android is missing 1080p, it automatically falls back to web.
|
|
||||||
"""
|
|
||||||
|
|
||||||
GEOFENCE = ("gb", "ie")
|
|
||||||
TITLE_RE = r"^(?:https?://(?:www\.)?channel4\.com/programmes/)?(?P<id>[a-z0-9-]+)(?:/on-demand/(?P<vid>[0-9-]+))?"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="ALL4", short_help="https://channel4.com", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> ALL4:
|
|
||||||
return ALL4(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
self.authorization: str
|
|
||||||
self.asset_id: int
|
|
||||||
self.license_token: str
|
|
||||||
self.manifest: str
|
|
||||||
|
|
||||||
self.session.headers.update(
|
|
||||||
{
|
|
||||||
"X-C4-Platform-Name": self.config["device"]["platform_name"],
|
|
||||||
"X-C4-Device-Type": self.config["device"]["device_type"],
|
|
||||||
"X-C4-Device-Name": self.config["device"]["device_name"],
|
|
||||||
"X-C4-App-Version": self.config["device"]["app_version"],
|
|
||||||
"X-C4-Optimizely-Datafile": self.config["device"]["optimizely_datafile"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
if not credential:
|
|
||||||
raise EnvironmentError("Service requires Credentials for Authentication.")
|
|
||||||
|
|
||||||
cache = self.cache.get(f"tokens_{credential.sha1}")
|
|
||||||
|
|
||||||
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 cached Tokens")
|
|
||||||
r = self.session.post(
|
|
||||||
self.config["endpoints"]["login"],
|
|
||||||
headers={"authorization": f"Basic {self.config['android']['auth']}"},
|
|
||||||
data={
|
|
||||||
"grant_type": "refresh_token",
|
|
||||||
"username": credential.username,
|
|
||||||
"password": credential.password,
|
|
||||||
"refresh_token": cache.data["refreshToken"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
res = r.json()
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise ValueError(f"Failed to refresh tokens: {r.text}")
|
|
||||||
|
|
||||||
if "error" in res:
|
|
||||||
self.log.error(f"Failed to refresh tokens: {res['errorMessage']}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
tokens = res
|
|
||||||
self.log.info(" + Refreshed")
|
|
||||||
else:
|
|
||||||
# new
|
|
||||||
headers = {"authorization": f"Basic {self.config['android']['auth']}"}
|
|
||||||
data = {
|
|
||||||
"grant_type": "password",
|
|
||||||
"username": credential.username,
|
|
||||||
"password": credential.password,
|
|
||||||
}
|
|
||||||
r = self.session.post(self.config["endpoints"]["login"], headers=headers, data=data)
|
|
||||||
try:
|
|
||||||
res = r.json()
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise ValueError(f"Failed to log in: {r.text}")
|
|
||||||
|
|
||||||
if "error" in res:
|
|
||||||
self.log.error(f"Failed to log in: {res['errorMessage']}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
tokens = res
|
|
||||||
self.log.info(" + Acquired tokens...")
|
|
||||||
|
|
||||||
cache.set(tokens, expiration=tokens["expiresIn"])
|
|
||||||
|
|
||||||
self.authorization = f"Bearer {tokens['accessToken']}"
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
params = {
|
|
||||||
"expand": "default",
|
|
||||||
"q": self.title,
|
|
||||||
"limit": "100",
|
|
||||||
"offset": "0",
|
|
||||||
}
|
|
||||||
|
|
||||||
r = self.session.get(self.config["endpoints"]["search"], params=params)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
results = r.json()
|
|
||||||
if isinstance(results["results"], list):
|
|
||||||
for result in results["results"]:
|
|
||||||
yield SearchResult(
|
|
||||||
id_=result["brand"].get("websafeTitle"),
|
|
||||||
title=result["brand"].get("title"),
|
|
||||||
description=result["brand"].get("description"),
|
|
||||||
label=result.get("label"),
|
|
||||||
url=result["brand"].get("href"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_titles(self) -> Union[Movies, Series]:
|
|
||||||
title, on_demand = (re.match(self.TITLE_RE, self.title).group(i) for i in ("id", "vid"))
|
|
||||||
|
|
||||||
r = self.session.get(
|
|
||||||
self.config["endpoints"]["title"].format(title=title),
|
|
||||||
params={"client": "android-mod", "deviceGroup": "mobile", "include": "extended-restart"},
|
|
||||||
headers={"Authorization": self.authorization},
|
|
||||||
)
|
|
||||||
if not r.ok:
|
|
||||||
self.log.error(r.text)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
data = r.json()
|
|
||||||
|
|
||||||
if on_demand is not None:
|
|
||||||
episodes = [
|
|
||||||
Episode(
|
|
||||||
id_=episode["programmeId"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=data["brand"]["title"],
|
|
||||||
season=episode["seriesNumber"],
|
|
||||||
number=episode["episodeNumber"],
|
|
||||||
name=episode["originalTitle"],
|
|
||||||
language="en",
|
|
||||||
data=episode["assetInfo"].get("streaming") or episode["assetInfo"].get("download"),
|
|
||||||
)
|
|
||||||
for episode in data["brand"]["episodes"]
|
|
||||||
if episode.get("assetInfo") and episode["programmeId"] == on_demand
|
|
||||||
]
|
|
||||||
if not episodes:
|
|
||||||
# Parse HTML of episode page to find title
|
|
||||||
data = self.get_html(self.title)
|
|
||||||
episodes = [
|
|
||||||
Episode(
|
|
||||||
id_=data["selectedEpisode"]["programmeId"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=data["brand"]["title"],
|
|
||||||
season=data["selectedEpisode"]["seriesNumber"] or 0,
|
|
||||||
number=data["selectedEpisode"]["episodeNumber"] or 0,
|
|
||||||
name=data["selectedEpisode"]["originalTitle"],
|
|
||||||
language="en",
|
|
||||||
data=data["selectedEpisode"],
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
elif data["brand"]["programmeType"] == "FM":
|
|
||||||
return Movies(
|
|
||||||
[
|
|
||||||
Movie(
|
|
||||||
id_=movie["programmeId"],
|
|
||||||
service=self.__class__,
|
|
||||||
name=data["brand"]["title"],
|
|
||||||
year=int(data["brand"]["summary"].split(" ")[0].strip().strip("()")),
|
|
||||||
language="en",
|
|
||||||
data=movie["assetInfo"].get("streaming") or movie["assetInfo"].get("download"),
|
|
||||||
)
|
|
||||||
for movie in data["brand"]["episodes"]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return Series(
|
|
||||||
[
|
|
||||||
Episode(
|
|
||||||
id_=episode["programmeId"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=data["brand"]["title"],
|
|
||||||
season=episode["seriesNumber"],
|
|
||||||
number=episode["episodeNumber"],
|
|
||||||
name=episode["originalTitle"],
|
|
||||||
language="en",
|
|
||||||
data=episode["assetInfo"].get("streaming") or episode["assetInfo"].get("download"),
|
|
||||||
)
|
|
||||||
for episode in data["brand"]["episodes"]
|
|
||||||
if episode.get("assetInfo")
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
|
||||||
android_assets: tuple = self.android_playlist(title.id)
|
|
||||||
web_assets: tuple = self.web_playlist(title.id)
|
|
||||||
self.manifest, self.license_token, subtitle, data = self.sort_assets(title, android_assets, web_assets)
|
|
||||||
self.asset_id = int(title.data["assetId"])
|
|
||||||
|
|
||||||
tracks = DASH.from_url(self.manifest, self.session).to_tracks(title.language)
|
|
||||||
tracks.videos[0].data = data
|
|
||||||
|
|
||||||
# manifest subtitles are sometimes empty even if they exist
|
|
||||||
# so we clear them and add the subtitles manually
|
|
||||||
tracks.subtitles.clear()
|
|
||||||
if subtitle is not None:
|
|
||||||
tracks.add(
|
|
||||||
Subtitle(
|
|
||||||
id_=hashlib.md5(subtitle.encode()).hexdigest()[0:6],
|
|
||||||
url=subtitle,
|
|
||||||
codec=Subtitle.Codec.from_mime(subtitle[-3:]),
|
|
||||||
language=title.language,
|
|
||||||
is_original_lang=True,
|
|
||||||
forced=False,
|
|
||||||
sdh=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.log.warning("- Subtitles are either missing or empty")
|
|
||||||
|
|
||||||
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]) -> list[Chapter]:
|
|
||||||
track = title.tracks.videos[0]
|
|
||||||
|
|
||||||
chapters = [
|
|
||||||
Chapter(
|
|
||||||
name=f"Chapter {i + 1:02}",
|
|
||||||
timestamp=datetime.fromtimestamp((ms / 1000), tz=timezone.utc).strftime("%H:%M:%S.%f")[:-3],
|
|
||||||
)
|
|
||||||
for i, ms in enumerate(x["breakOffset"] for x in track.data["adverts"]["breaks"])
|
|
||||||
]
|
|
||||||
|
|
||||||
if track.data.get("endCredits", {}).get("squeezeIn"):
|
|
||||||
chapters.append(
|
|
||||||
Chapter(
|
|
||||||
name="Credits",
|
|
||||||
timestamp=datetime.fromtimestamp(
|
|
||||||
(track.data["endCredits"]["squeezeIn"] / 1000), tz=timezone.utc
|
|
||||||
).strftime("%H:%M:%S.%f")[:-3],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return chapters
|
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
|
||||||
return WidevineCdm.common_privacy_cert
|
|
||||||
|
|
||||||
def get_widevine_license(self, challenge: bytes, **_: Any) -> str:
|
|
||||||
payload = {
|
|
||||||
"message": base64.b64encode(challenge).decode("utf8"),
|
|
||||||
"token": self.license_token,
|
|
||||||
"request_id": self.asset_id,
|
|
||||||
"video": {"type": "ondemand", "url": self.manifest},
|
|
||||||
}
|
|
||||||
|
|
||||||
r = self.session.post(self.config["endpoints"]["license"], json=payload)
|
|
||||||
if not r.ok:
|
|
||||||
raise ConnectionError(f"License request failed: {r.json()['status']['type']}")
|
|
||||||
|
|
||||||
return r.json()["license"]
|
|
||||||
|
|
||||||
# Service specific functions
|
|
||||||
|
|
||||||
def sort_assets(self, title: Union[Movie, Episode], android_assets: tuple, web_assets: tuple) -> tuple:
|
|
||||||
android_heights = None
|
|
||||||
web_heights = None
|
|
||||||
|
|
||||||
if android_assets is not None:
|
|
||||||
try:
|
|
||||||
a_manifest, a_token, a_subtitle, data = android_assets
|
|
||||||
android_tracks = DASH.from_url(a_manifest, self.session).to_tracks(title.language)
|
|
||||||
android_heights = sorted([int(track.height) for track in android_tracks.videos], reverse=True)
|
|
||||||
except Exception:
|
|
||||||
android_heights = None
|
|
||||||
|
|
||||||
if web_assets is not None:
|
|
||||||
try:
|
|
||||||
b_manifest, b_token, b_subtitle, data = web_assets
|
|
||||||
session = self.session
|
|
||||||
session.headers.update(self.config["headers"])
|
|
||||||
web_tracks = DASH.from_url(b_manifest, session).to_tracks(title.language)
|
|
||||||
web_heights = sorted([int(track.height) for track in web_tracks.videos], reverse=True)
|
|
||||||
except Exception:
|
|
||||||
web_heights = None
|
|
||||||
|
|
||||||
if not android_heights and not web_heights:
|
|
||||||
self.log.error("Failed to request manifest data. If you're behind a VPN/proxy, you might be blocked")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if not android_heights or android_heights[0] < 1080:
|
|
||||||
lic_token = self.decrypt_token(b_token, client="WEB")
|
|
||||||
return b_manifest, lic_token, b_subtitle, data
|
|
||||||
else:
|
|
||||||
lic_token = self.decrypt_token(a_token, client="ANDROID")
|
|
||||||
return a_manifest, lic_token, a_subtitle, data
|
|
||||||
|
|
||||||
def android_playlist(self, video_id: str) -> tuple:
|
|
||||||
url = self.config["android"]["vod"].format(video_id=video_id)
|
|
||||||
headers = {"authorization": self.authorization}
|
|
||||||
|
|
||||||
r = self.session.get(url=url, headers=headers)
|
|
||||||
if not r.ok:
|
|
||||||
self.log.warning("Request for Android endpoint returned %s", r)
|
|
||||||
return None
|
|
||||||
|
|
||||||
data = json.loads(r.content)
|
|
||||||
manifest = data["videoProfiles"][0]["streams"][0]["uri"]
|
|
||||||
token = data["videoProfiles"][0]["streams"][0]["token"]
|
|
||||||
subtitle = next(
|
|
||||||
(x["url"] for x in data["subtitlesAssets"] if x["url"].endswith(".vtt")),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
return manifest, token, subtitle, data
|
|
||||||
|
|
||||||
def web_playlist(self, video_id: str) -> tuple:
|
|
||||||
url = self.config["web"]["vod"].format(programmeId=video_id)
|
|
||||||
r = self.session.get(url, headers=self.config["headers"])
|
|
||||||
if not r.ok:
|
|
||||||
self.log.warning("Request for WEB endpoint returned %s", r)
|
|
||||||
return None
|
|
||||||
|
|
||||||
data = json.loads(r.content)
|
|
||||||
|
|
||||||
for item in data["videoProfiles"]:
|
|
||||||
if item["name"] == "dashwv-dyn-stream-1":
|
|
||||||
token = item["streams"][0]["token"]
|
|
||||||
manifest = item["streams"][0]["uri"]
|
|
||||||
|
|
||||||
subtitle = next(
|
|
||||||
(x["url"] for x in data["subtitlesAssets"] if x["url"].endswith(".vtt")),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
return manifest, token, subtitle, data
|
|
||||||
|
|
||||||
def decrypt_token(self, token: str, client: str) -> tuple:
|
|
||||||
if client == "ANDROID":
|
|
||||||
key = self.config["android"]["key"]
|
|
||||||
iv = self.config["android"]["iv"]
|
|
||||||
|
|
||||||
if client == "WEB":
|
|
||||||
key = self.config["web"]["key"]
|
|
||||||
iv = self.config["web"]["iv"]
|
|
||||||
|
|
||||||
if isinstance(token, str):
|
|
||||||
token = base64.b64decode(token)
|
|
||||||
cipher = AES.new(
|
|
||||||
key=base64.b64decode(key),
|
|
||||||
iv=base64.b64decode(iv),
|
|
||||||
mode=AES.MODE_CBC,
|
|
||||||
)
|
|
||||||
data = unpad(cipher.decrypt(token), AES.block_size)
|
|
||||||
dec_token = data.decode().split("|")[1]
|
|
||||||
return dec_token.strip()
|
|
||||||
|
|
||||||
def get_html(self, url: str) -> dict:
|
|
||||||
r = self.session.get(url=url, headers=self.config["headers"])
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
init_data = re.search(
|
|
||||||
"<script>window.__PARAMS__ = (.*)</script>",
|
|
||||||
"".join(r.content.decode().replace("\u200c", "").replace("\r\n", "").replace("undefined", "null")),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
data = json.loads(init_data.group(1))
|
|
||||||
return data["initialData"]
|
|
||||||
except Exception:
|
|
||||||
self.log.error(f"Failed to get episode for {url}")
|
|
||||||
sys.exit(1)
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
headers:
|
|
||||||
Accept-Language: en-US,en;q=0.8
|
|
||||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.75 Safari/537.36
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
login: https://api.channel4.com/online/v2/auth/token
|
|
||||||
title: https://api.channel4.com/online/v1/views/content-hubs/{title}.json
|
|
||||||
license: https://c4.eme.lp.aws.redbeemedia.com/wvlicenceproxy-service/widevine/acquire
|
|
||||||
search: https://all4nav.channel4.com/v1/api/search
|
|
||||||
|
|
||||||
android:
|
|
||||||
key: QVlESUQ4U0RGQlA0TThESA=="
|
|
||||||
iv: MURDRDAzODNES0RGU0w4Mg=="
|
|
||||||
auth: MzZVVUN0OThWTVF2QkFnUTI3QXU4ekdIbDMxTjlMUTE6Sllzd3lIdkdlNjJWbGlrVw==
|
|
||||||
vod: https://api.channel4.com/online/v1/vod/stream/{video_id}?client=android-mod
|
|
||||||
|
|
||||||
web:
|
|
||||||
key: bjljTGllWWtxd3pOQ3F2aQ==
|
|
||||||
iv: b2R6Y1UzV2RVaVhMdWNWZA==
|
|
||||||
vod: https://www.channel4.com/vod/stream/{programmeId}
|
|
||||||
|
|
||||||
device:
|
|
||||||
platform_name: android
|
|
||||||
device_type: mobile
|
|
||||||
device_name: "Sony C6903 (C6903)"
|
|
||||||
app_version: "android_app:9.4.2"
|
|
||||||
optimizely_datafile: "2908"
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from http.cookiejar import MozillaCookieJar
|
|
||||||
from typing import Any, Optional, Union
|
|
||||||
from functools import partial
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
import re
|
|
||||||
|
|
||||||
import click
|
|
||||||
import webvtt
|
|
||||||
import requests
|
|
||||||
from click import Context
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Movie, Movies, Episode, Series
|
|
||||||
from envied.core.tracks import Track, Chapter, Tracks, Video, Subtitle
|
|
||||||
from envied.core.manifests.hls import HLS
|
|
||||||
from envied.core.manifests.dash import DASH
|
|
||||||
|
|
||||||
|
|
||||||
class ARD(Service):
|
|
||||||
"""
|
|
||||||
Service code for ARD Mediathek (https://www.ardmediathek.de)
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.0
|
|
||||||
Author: lambda
|
|
||||||
Authorization: None
|
|
||||||
Robustness:
|
|
||||||
Unencrypted: 2160p, AAC2.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
GEOFENCE = ("de",)
|
|
||||||
TITLE_RE = r"^(https://www\.ardmediathek\.de/(?P<item_type>serie|video)/.+/)(?P<item_id>[a-zA-Z0-9]{10,})(/[0-9]{1,3})?$"
|
|
||||||
EPISODE_NAME_RE = r"^(Folge [0-9]+:)?(?P<name>[^\(]+) \(S(?P<season>[0-9]+)/E(?P<episode>[0-9]+)\)$"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="ARD", short_help="https://www.ardmediathek.de", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> ARD:
|
|
||||||
return ARD(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_titles(self) -> Union[Movies, Series]:
|
|
||||||
match = re.match(self.TITLE_RE, self.title)
|
|
||||||
if not match:
|
|
||||||
return
|
|
||||||
|
|
||||||
item_id = match.group("item_id")
|
|
||||||
if match.group("item_type") == "video":
|
|
||||||
return self.load_player(item_id)
|
|
||||||
|
|
||||||
r = self.session.get(self.config["endpoints"]["grouping"].format(item_id=item_id))
|
|
||||||
item = r.json()
|
|
||||||
|
|
||||||
for widget in item["widgets"]:
|
|
||||||
if widget["type"] == "gridlist" and widget.get("compilationType") == "itemsOfShow":
|
|
||||||
episodes = Series()
|
|
||||||
for teaser in widget["teasers"]:
|
|
||||||
if teaser["coreAssetType"] != "EPISODE":
|
|
||||||
continue
|
|
||||||
|
|
||||||
if 'Hörfassung' in teaser['longTitle']:
|
|
||||||
continue
|
|
||||||
|
|
||||||
episodes += self.load_player(teaser["id"])
|
|
||||||
return episodes
|
|
||||||
|
|
||||||
def get_tracks(self, title: Union[Episode, Movie]) -> Tracks:
|
|
||||||
if title.data["blockedByFsk"]:
|
|
||||||
self.log.error(
|
|
||||||
"This content is age-restricted and not currently available. "
|
|
||||||
"Try again after 10pm German time")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
media_collection = title.data["mediaCollection"]["embedded"]
|
|
||||||
tracks = Tracks()
|
|
||||||
for stream_collection in media_collection["streams"]:
|
|
||||||
if stream_collection["kind"] != "main":
|
|
||||||
continue
|
|
||||||
|
|
||||||
for stream in stream_collection["media"]:
|
|
||||||
if stream["mimeType"] == "application/vnd.apple.mpegurl":
|
|
||||||
tracks += Tracks(HLS.from_url(stream["url"]).to_tracks(stream["audios"][0]["languageCode"]))
|
|
||||||
break
|
|
||||||
|
|
||||||
# Fetch tracks from HBBTV endpoint to check for potential H.265/2160p DASH
|
|
||||||
r = self.session.get(self.config["endpoints"]["hbbtv"].format(item_id=title.id))
|
|
||||||
hbbtv = r.json()
|
|
||||||
for stream in hbbtv["video"]["streams"]:
|
|
||||||
for media in stream["media"]:
|
|
||||||
if media["mimeType"] == "application/dash+xml" and media["audios"][0]["kind"] == "standard":
|
|
||||||
tracks += Tracks(DASH.from_url(media["url"]).to_tracks(media["audios"][0]["languageCode"]))
|
|
||||||
break
|
|
||||||
|
|
||||||
# for stream in title.data["video"]["streams"]:
|
|
||||||
# for media in stream["media"]:
|
|
||||||
# if media["mimeType"] != "video/mp4" or media["audios"][0]["kind"] != "standard":
|
|
||||||
# continue
|
|
||||||
|
|
||||||
# tracks += Video(
|
|
||||||
# codec=Video.Codec.AVC, # Should check media["videoCodec"]
|
|
||||||
# range_=Video.Range.SDR, # Should check media["isHighDynamicRange"]
|
|
||||||
# width=media["maxHResolutionPx"],
|
|
||||||
# height=media["maxVResolutionPx"],
|
|
||||||
# url=media["url"],
|
|
||||||
# language=media["audios"][0]["languageCode"],
|
|
||||||
# fps=50,
|
|
||||||
# )
|
|
||||||
|
|
||||||
for sub in media_collection["subtitles"]:
|
|
||||||
for source in sub["sources"]:
|
|
||||||
if source["kind"] == "ebutt":
|
|
||||||
tracks.add(Subtitle(
|
|
||||||
codec=Subtitle.Codec.TimedTextMarkupLang,
|
|
||||||
language=sub["languageCode"],
|
|
||||||
url=source["url"]
|
|
||||||
))
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Union[Episode, Movie]) -> list[Chapter]:
|
|
||||||
return []
|
|
||||||
|
|
||||||
def load_player(self, item_id):
|
|
||||||
r = self.session.get(self.config["endpoints"]["item"].format(item_id=item_id))
|
|
||||||
item = r.json()
|
|
||||||
|
|
||||||
for widget in item["widgets"]:
|
|
||||||
if widget["type"] != "player_ondemand":
|
|
||||||
continue
|
|
||||||
|
|
||||||
common_data = {
|
|
||||||
"id_": item_id,
|
|
||||||
"data": widget,
|
|
||||||
"service": self.__class__,
|
|
||||||
"language": "de",
|
|
||||||
"year": widget["broadcastedOn"][0:4],
|
|
||||||
}
|
|
||||||
|
|
||||||
if widget["show"]["coreAssetType"] == "SINGLE" or not widget["show"].get("availableSeasons"):
|
|
||||||
return Movies([Movie(
|
|
||||||
name=widget["title"],
|
|
||||||
**common_data
|
|
||||||
)])
|
|
||||||
else:
|
|
||||||
match = re.match(self.EPISODE_NAME_RE, widget["title"])
|
|
||||||
if not match:
|
|
||||||
name = widget["title"]
|
|
||||||
season = 0
|
|
||||||
episode = 0
|
|
||||||
else:
|
|
||||||
name = match.group("name")
|
|
||||||
season = match.group("season") or 0
|
|
||||||
episode = match.group("episode") or 0
|
|
||||||
|
|
||||||
return Series([Episode(
|
|
||||||
name=name,
|
|
||||||
title=widget["show"]["title"],
|
|
||||||
#season=widget["show"]["availableSeasons"][0],
|
|
||||||
season=season,
|
|
||||||
number=episode,
|
|
||||||
**common_data
|
|
||||||
)])
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
headers:
|
|
||||||
Accept-Language: de-DE,de;q=0.8
|
|
||||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
item: https://api.ardmediathek.de/page-gateway/pages/ard/item/{item_id}?embedded=true&mcV6=true
|
|
||||||
grouping: https://api.ardmediathek.de/page-gateway/pages/ard/grouping/{item_id}?seasoned=true&embedded=true
|
|
||||||
hbbtv: https://tv.ardmediathek.de/dyn/get?id=video:{item_id}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from collections.abc import Generator
|
|
||||||
from typing import Any, Optional, Union
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
import click
|
|
||||||
from click import Context
|
|
||||||
from requests import Request
|
|
||||||
from envied.core.constants import AnyTrack
|
|
||||||
from envied.core.manifests.dash import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series
|
|
||||||
from envied.core.tracks import Chapter, Chapters, Subtitle, Tracks
|
|
||||||
|
|
||||||
|
|
||||||
class AUBC(Service):
|
|
||||||
"""
|
|
||||||
\b
|
|
||||||
Service code for ABC iView streaming service (https://iview.abc.net.au/).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.2
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: None
|
|
||||||
Robustness:
|
|
||||||
L3: 1080p, AAC2.0
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Input should be complete URL:
|
|
||||||
SHOW: https://iview.abc.net.au/show/return-to-paradise
|
|
||||||
EPISODE: https://iview.abc.net.au/video/DR2314H001S00
|
|
||||||
MOVIE: https://iview.abc.net.au/show/way-back / https://iview.abc.net.au/show/way-back/video/ZW3981A001S00
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
GEOFENCE = ("au",)
|
|
||||||
ALIASES = ("iview", "abciview", "iv",)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="AUBC", short_help="https://iview.abc.net.au/", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> AUBC:
|
|
||||||
return AUBC(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]:
|
|
||||||
url = (
|
|
||||||
"https://y63q32nvdl-1.algolianet.com/1/indexes/*/queries?x-algolia-agent=Algolia"
|
|
||||||
"%20for%20JavaScript%20(4.9.1)%3B%20Browser%20(lite)%3B%20react%20(17.0.2)%3B%20"
|
|
||||||
"react-instantsearch%20(6.30.2)%3B%20JS%20Helper%20(3.10.0)&x-"
|
|
||||||
"algolia-api-key=bcdf11ba901b780dc3c0a3ca677fbefc&x-algolia-application-id=Y63Q32NVDL"
|
|
||||||
)
|
|
||||||
payload = {
|
|
||||||
"requests": [
|
|
||||||
{
|
|
||||||
"indexName": "ABC_production_iview_web",
|
|
||||||
"params": f"query={self.title}&tagFilters=&userToken=anonymous-74be3cf1-1dc7-4fa1-9cff-19592162db1c",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
results = self._request("POST", url, payload=payload)["results"]
|
|
||||||
hits = [x for x in results[0]["hits"] if x["docType"] == "Program"]
|
|
||||||
|
|
||||||
for result in hits:
|
|
||||||
yield SearchResult(
|
|
||||||
id_="https://iview.abc.net.au/show/{}".format(result.get("slug")),
|
|
||||||
title=result.get("title"),
|
|
||||||
description=result.get("synopsis"),
|
|
||||||
label=result.get("subType"),
|
|
||||||
url="https://iview.abc.net.au/show/{}".format(result.get("slug")),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_titles(self) -> Union[Movies, Series]:
|
|
||||||
title_re = r"^(?:https?://(?:www.)?iview.abc.net.au/(?P<type>show|video)/)?(?P<id>[a-zA-Z0-9_-]+)"
|
|
||||||
try:
|
|
||||||
kind, title_id = (re.match(title_re, self.title).group(i) for i in ("type", "id"))
|
|
||||||
except Exception:
|
|
||||||
raise ValueError("- Could not parse ID from title")
|
|
||||||
|
|
||||||
if kind == "show":
|
|
||||||
data = self._request("GET", "/v3/show/{}".format(title_id))
|
|
||||||
label = data.get("type")
|
|
||||||
|
|
||||||
if label.lower() in ("series", "program"):
|
|
||||||
episodes = self._series(title_id)
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
elif label.lower() in ("feature", "movie"):
|
|
||||||
movie = self._movie(data)
|
|
||||||
return Movies(movie)
|
|
||||||
|
|
||||||
elif kind == "video":
|
|
||||||
episode = self._episode(title_id)
|
|
||||||
return Series([episode])
|
|
||||||
|
|
||||||
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
|
||||||
video = self._request("GET", "/v3/video/{}".format(title.id))
|
|
||||||
if not video.get("playable"):
|
|
||||||
raise ConnectionError(video.get("unavailableMessage"))
|
|
||||||
|
|
||||||
playlist = video.get("_embedded", {}).get("playlist", {})
|
|
||||||
if not playlist:
|
|
||||||
raise ConnectionError("Could not find a playlist for this title")
|
|
||||||
|
|
||||||
streams = next(x["streams"]["mpegdash"] for x in playlist if x["type"] == "program")
|
|
||||||
captions = next((x.get("captions") for x in playlist if x["type"] == "program"), None)
|
|
||||||
title.data["protected"] = streams.get("protected", False)
|
|
||||||
|
|
||||||
if "720" in streams:
|
|
||||||
streams["1080"] = streams["720"].replace("720", "1080")
|
|
||||||
|
|
||||||
manifest = next(
|
|
||||||
(url for key in ["1080", "720", "sd", "sd-low"] if key in streams
|
|
||||||
for url in [streams[key]]
|
|
||||||
if self.session.head(url).status_code == 200),
|
|
||||||
None
|
|
||||||
)
|
|
||||||
if not manifest:
|
|
||||||
raise ValueError("Could not find a manifest for this title")
|
|
||||||
|
|
||||||
tracks = DASH.from_url(manifest, self.session).to_tracks(title.language)
|
|
||||||
|
|
||||||
for track in tracks.audio:
|
|
||||||
role = track.data["dash"]["adaptation_set"].find("Role")
|
|
||||||
if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
|
|
||||||
track.descriptive = True
|
|
||||||
|
|
||||||
if captions:
|
|
||||||
subtitles = captions.get("src-vtt")
|
|
||||||
tracks.add(
|
|
||||||
Subtitle(
|
|
||||||
id_=hashlib.md5(subtitles.encode()).hexdigest()[0:6],
|
|
||||||
url=subtitles,
|
|
||||||
codec=Subtitle.Codec.from_mime(subtitles[-3:]),
|
|
||||||
language=title.language,
|
|
||||||
forced=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
|
|
||||||
if not title.data.get("cuePoints"):
|
|
||||||
return Chapters()
|
|
||||||
|
|
||||||
credits = next((x.get("start") for x in title.data["cuePoints"] if x["type"] == "end-credits"), None)
|
|
||||||
if credits:
|
|
||||||
return Chapters([Chapter(name="Credits", timestamp=credits * 1000)])
|
|
||||||
|
|
||||||
return Chapters()
|
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title: Union[Movies, Series], track: AnyTrack) -> Optional[Union[bytes, str]]:
|
|
||||||
if not title.data.get("protected"):
|
|
||||||
return None
|
|
||||||
|
|
||||||
customdata = self._license(title.id)
|
|
||||||
headers = {"customdata": customdata}
|
|
||||||
|
|
||||||
r = self.session.post(self.config["endpoints"]["license"], headers=headers, data=challenge)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
# Service specific
|
|
||||||
|
|
||||||
def _series(self, title: str) -> Episode:
|
|
||||||
data = self._request("GET", "/v3/series/{}".format(title))
|
|
||||||
|
|
||||||
episodes = [
|
|
||||||
self.create_episode(episode)
|
|
||||||
for season in data
|
|
||||||
for episode in reversed(season["_embedded"]["videoEpisodes"]["items"])
|
|
||||||
if season.get("episodeCount")
|
|
||||||
]
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
def _movie(self, data: dict) -> Movie:
|
|
||||||
return [
|
|
||||||
Movie(
|
|
||||||
id_=data["_embedded"]["highlightVideo"]["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
name=data.get("title"),
|
|
||||||
year=data.get("productionYear"),
|
|
||||||
data=data,
|
|
||||||
language=data.get("analytics", {}).get("dataLayer", {}).get("d_language", "en"),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def _episode(self, video_id: str) -> Episode:
|
|
||||||
data = self._request("GET", "/v3/video/{}".format(video_id))
|
|
||||||
return self.create_episode(data)
|
|
||||||
|
|
||||||
def _license(self, video_id: str):
|
|
||||||
token = self._request("POST", "/v3/token/jwt", data={"clientId": self.config["client"]})["token"]
|
|
||||||
response = self._request("GET", "/v3/token/drm/{}".format(video_id), headers={"bearer": token})
|
|
||||||
|
|
||||||
return response["license"]
|
|
||||||
|
|
||||||
def create_episode(self, episode: dict) -> Episode:
|
|
||||||
title = episode["showTitle"]
|
|
||||||
series_id = episode.get("analytics", {}).get("dataLayer", {}).get("d_series_id", "")
|
|
||||||
episode_name = episode.get("analytics", {}).get("dataLayer", {}).get("d_episode_name", "")
|
|
||||||
number = re.search(r"Episode (\d+)", episode.get("displaySubtitle", ""))
|
|
||||||
name = re.search(r"S\d+\sEpisode\s\d+\s(.*)", episode_name)
|
|
||||||
|
|
||||||
language = episode.get("analytics", {}).get("dataLayer", {}).get("d_language", "en")
|
|
||||||
|
|
||||||
return Episode(
|
|
||||||
id_=episode["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=title,
|
|
||||||
season=int(series_id.split("-")[-1]) if series_id else 0,
|
|
||||||
number=int(number.group(1)) if number else 0,
|
|
||||||
name=name.group(1) if name else None,
|
|
||||||
data=episode,
|
|
||||||
language=language,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _request(self, method: str, api: str, **kwargs: Any) -> Any[dict | str]:
|
|
||||||
url = urljoin(self.config["endpoints"]["base_url"], api)
|
|
||||||
|
|
||||||
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 as e:
|
|
||||||
raise ValueError(f"Failed to parse JSON: {response.text}") from e
|
|
||||||
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
headers:
|
|
||||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0
|
|
||||||
accept-language: en-US,en;q=0.8
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
base_url: https://api.iview.abc.net.au
|
|
||||||
license: https://wv-keyos.licensekeyserver.com/
|
|
||||||
|
|
||||||
client: "1d4b5cba-42d2-403e-80e7-34565cdf772d"
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from collections.abc import Generator
|
|
||||||
from http.cookiejar import CookieJar
|
|
||||||
from typing import Any, Optional, Union
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
import click
|
|
||||||
from click import Context
|
|
||||||
from requests import Request
|
|
||||||
from envied.core.constants import AnyTrack
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.manifests import DASH, HLS
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series
|
|
||||||
from envied.core.tracks import Chapter, Chapters, Tracks
|
|
||||||
|
|
||||||
|
|
||||||
class CBC(Service):
|
|
||||||
"""
|
|
||||||
\b
|
|
||||||
Service code for CBC Gem streaming service (https://gem.cbc.ca/).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.1
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: Credentials
|
|
||||||
Robustness:
|
|
||||||
AES-128: 1080p, DDP5.1
|
|
||||||
Widevine L3: 720p, DDP5.1
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Input can be complete title URL or just the slug:
|
|
||||||
SHOW: https://gem.cbc.ca/murdoch-mysteries OR murdoch-mysteries
|
|
||||||
MOVIE: https://gem.cbc.ca/the-babadook OR the-babadook
|
|
||||||
|
|
||||||
\b
|
|
||||||
Notes:
|
|
||||||
- DRM encrypted titles max out at 720p.
|
|
||||||
- CCExtrator v0.94 will likely fail to extract subtitles. It's recommended to downgrade to v0.93.
|
|
||||||
- Some audio tracks contain invalid data, causing warning messages from mkvmerge during muxing
|
|
||||||
These can be ignored.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
GEOFENCE = ("ca",)
|
|
||||||
ALIASES = ("gem", "cbcgem",)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="CBC", short_help="https://gem.cbc.ca/", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> CBC:
|
|
||||||
return CBC(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
|
||||||
self.title: str = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
self.base_url: str = self.config["endpoints"]["base_url"]
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
params = {
|
|
||||||
"device": "web",
|
|
||||||
"pageNumber": "1",
|
|
||||||
"pageSize": "20",
|
|
||||||
"term": self.title,
|
|
||||||
}
|
|
||||||
response: dict = self._request("GET", "/ott/catalog/v1/gem/search", params=params)
|
|
||||||
|
|
||||||
for result in response.get("result", []):
|
|
||||||
yield SearchResult(
|
|
||||||
id_="https://gem.cbc.ca/{}".format(result.get("url")),
|
|
||||||
title=result.get("title"),
|
|
||||||
description=result.get("synopsis"),
|
|
||||||
label=result.get("type"),
|
|
||||||
url="https://gem.cbc.ca/{}".format(result.get("url")),
|
|
||||||
)
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
if not credential:
|
|
||||||
raise EnvironmentError("Service requires Credentials for Authentication.")
|
|
||||||
|
|
||||||
tokens: Optional[Any] = self.cache.get(f"tokens_{credential.sha1}")
|
|
||||||
|
|
||||||
"""
|
|
||||||
All grant types for future reference:
|
|
||||||
PASSWORD("password"),
|
|
||||||
ACCESS_TOKEN("access_token"),
|
|
||||||
REFRESH_TOKEN("refresh_token"),
|
|
||||||
CLIENT_CREDENTIALS("client_credentials"),
|
|
||||||
AUTHORIZATION_CODE("authorization_code"),
|
|
||||||
CODE("code");
|
|
||||||
"""
|
|
||||||
|
|
||||||
if tokens and not tokens.expired:
|
|
||||||
# cached
|
|
||||||
self.log.info(" + Using cached tokens")
|
|
||||||
auth_token: str = tokens.data["access_token"]
|
|
||||||
|
|
||||||
elif tokens and tokens.expired:
|
|
||||||
# expired, refresh
|
|
||||||
self.log.info("Refreshing cached tokens...")
|
|
||||||
auth_url, scopes = self.settings()
|
|
||||||
params = {
|
|
||||||
"client_id": self.config["client"]["id"],
|
|
||||||
"grant_type": "refresh_token",
|
|
||||||
"refresh_token": tokens.data["refresh_token"],
|
|
||||||
"scope": scopes,
|
|
||||||
}
|
|
||||||
|
|
||||||
access: dict = self._request("POST", auth_url, params=params)
|
|
||||||
|
|
||||||
# Shorten expiration by one hour to account for clock skew
|
|
||||||
tokens.set(access, expiration=int(access["expires_in"]) - 3600)
|
|
||||||
auth_token: str = access["access_token"]
|
|
||||||
|
|
||||||
else:
|
|
||||||
# new
|
|
||||||
self.log.info("Requesting new tokens...")
|
|
||||||
auth_url, scopes = self.settings()
|
|
||||||
params = {
|
|
||||||
"client_id": self.config["client"]["id"],
|
|
||||||
"grant_type": "password",
|
|
||||||
"username": credential.username,
|
|
||||||
"password": credential.password,
|
|
||||||
"scope": scopes,
|
|
||||||
}
|
|
||||||
|
|
||||||
access: dict = self._request("POST", auth_url, params=params)
|
|
||||||
|
|
||||||
# Shorten expiration by one hour to account for clock skew
|
|
||||||
tokens.set(access, expiration=int(access["expires_in"]) - 3600)
|
|
||||||
auth_token: str = access["access_token"]
|
|
||||||
|
|
||||||
claims_token: str = self.claims_token(auth_token)
|
|
||||||
self.session.headers.update({"x-claims-token": claims_token})
|
|
||||||
|
|
||||||
def get_titles(self) -> Union[Movies, Series]:
|
|
||||||
title_re: str = r"^(?:https?://(?:www.)?gem.cbc.ca/)?(?P<id>[a-zA-Z0-9_-]+)"
|
|
||||||
try:
|
|
||||||
title_id: str = re.match(title_re, self.title).group("id")
|
|
||||||
except Exception:
|
|
||||||
raise ValueError("- Could not parse ID from title")
|
|
||||||
|
|
||||||
params = {"device": "web"}
|
|
||||||
data: dict = self._request("GET", "/ott/catalog/v2/gem/show/{}".format(title_id), params=params)
|
|
||||||
label: str = data.get("contentType", "").lower()
|
|
||||||
|
|
||||||
if label in ("film", "movie", "standalone"):
|
|
||||||
movies: list[Movie] = self._movie(data)
|
|
||||||
return Movies(movies)
|
|
||||||
|
|
||||||
else:
|
|
||||||
episodes: list[Episode] = self._show(data)
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
|
||||||
index: dict = self._request(
|
|
||||||
"GET", "/media/meta/v1/index.ashx", params={"appCode": "gem", "idMedia": title.id, "output": "jsonObject"}
|
|
||||||
)
|
|
||||||
|
|
||||||
title.data["extra"] = {
|
|
||||||
"chapters": index["Metas"].get("Chapitres"),
|
|
||||||
"credits": index["Metas"].get("CreditStartTime"),
|
|
||||||
}
|
|
||||||
|
|
||||||
self.drm: bool = index["Metas"].get("isDrmActive") == "true"
|
|
||||||
if self.drm:
|
|
||||||
tech: str = next(tech["name"] for tech in index["availableTechs"] if "widevine" in tech["drm"])
|
|
||||||
else:
|
|
||||||
tech: str = next(tech["name"] for tech in index["availableTechs"] if not tech["drm"])
|
|
||||||
|
|
||||||
response: dict = self._request(
|
|
||||||
"GET", self.config["endpoints"]["validation"].format("android", title.id, "smart-tv", tech)
|
|
||||||
)
|
|
||||||
|
|
||||||
manifest = response.get("url")
|
|
||||||
self.license = next((x["value"] for x in response["params"] if "widevineLicenseUrl" in x["name"]), None)
|
|
||||||
self.token = next((x["value"] for x in response["params"] if "widevineAuthToken" in x["name"]), None)
|
|
||||||
|
|
||||||
stream_type: Union[HLS, DASH] = HLS if tech == "hls" else DASH
|
|
||||||
tracks: Tracks = stream_type.from_url(manifest, self.session).to_tracks(language=title.language)
|
|
||||||
|
|
||||||
if stream_type == DASH:
|
|
||||||
for track in tracks.audio:
|
|
||||||
label = track.data["dash"]["adaptation_set"].find("Label")
|
|
||||||
if label is not None and "descriptive" in label.text.lower():
|
|
||||||
track.descriptive = True
|
|
||||||
|
|
||||||
for track in tracks:
|
|
||||||
track.language = title.language
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
|
|
||||||
extra: dict = title.data["extra"]
|
|
||||||
|
|
||||||
chapters = []
|
|
||||||
if extra.get("chapters"):
|
|
||||||
chapters = [Chapter(timestamp=x) for x in set(extra["chapters"].split(","))]
|
|
||||||
|
|
||||||
if extra.get("credits"):
|
|
||||||
chapters.append(Chapter(name="Credits", timestamp=float(extra["credits"])))
|
|
||||||
|
|
||||||
return Chapters(chapters)
|
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_widevine_license(
|
|
||||||
self, *, challenge: bytes, title: Union[Movies, Series], track: AnyTrack
|
|
||||||
) -> Optional[Union[bytes, str]]:
|
|
||||||
if not self.license or not self.token:
|
|
||||||
return None
|
|
||||||
|
|
||||||
headers = {"x-dt-auth-token": self.token}
|
|
||||||
r = self.session.post(self.license, headers=headers, data=challenge)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
# Service specific
|
|
||||||
|
|
||||||
def _show(self, data: dict) -> list[Episode]:
|
|
||||||
lineups: list = next((x["lineups"] for x in data["content"] if x.get("title", "").lower() == "episodes"), None)
|
|
||||||
if not lineups:
|
|
||||||
self.log.warning("No episodes found for: {}".format(data.get("title")))
|
|
||||||
return
|
|
||||||
|
|
||||||
titles = []
|
|
||||||
for season in lineups:
|
|
||||||
for episode in season["items"]:
|
|
||||||
if episode.get("mediaType", "").lower() == "episode":
|
|
||||||
parts = episode.get("title", "").split(".", 1)
|
|
||||||
episode_name = parts[1].strip() if len(parts) > 1 else parts[0].strip()
|
|
||||||
titles.append(
|
|
||||||
Episode(
|
|
||||||
id_=episode["idMedia"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=data.get("title"),
|
|
||||||
season=int(season.get("seasonNumber", 0)),
|
|
||||||
number=int(episode.get("episodeNumber", 0)),
|
|
||||||
name=episode_name,
|
|
||||||
year=episode.get("metadata", {}).get("productionYear"),
|
|
||||||
language=data["structuredMetadata"].get("inLanguage", "en-CA"),
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return titles
|
|
||||||
|
|
||||||
def _movie(self, data: dict) -> list[Movie]:
|
|
||||||
unwanted: tuple = ("episodes", "trailers", "extras")
|
|
||||||
lineups: list = next((x["lineups"] for x in data["content"] if x.get("title", "").lower() not in unwanted), None)
|
|
||||||
if not lineups:
|
|
||||||
self.log.warning("No movies found for: {}".format(data.get("title")))
|
|
||||||
return
|
|
||||||
|
|
||||||
titles = []
|
|
||||||
for season in lineups:
|
|
||||||
for movie in season["items"]:
|
|
||||||
if movie.get("mediaType", "").lower() == "episode":
|
|
||||||
parts = movie.get("title", "").split(".", 1)
|
|
||||||
movie_name = parts[1].strip() if len(parts) > 1 else parts[0].strip()
|
|
||||||
titles.append(
|
|
||||||
Movie(
|
|
||||||
id_=movie.get("idMedia"),
|
|
||||||
service=self.__class__,
|
|
||||||
name=movie_name,
|
|
||||||
year=movie.get("metadata", {}).get("productionYear"),
|
|
||||||
language=data["structuredMetadata"].get("inLanguage", "en-CA"),
|
|
||||||
data=movie,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return titles
|
|
||||||
|
|
||||||
def settings(self) -> tuple:
|
|
||||||
settings = self._request("GET", "/ott/catalog/v1/gem/settings", params={"device": "web"})
|
|
||||||
auth_url: str = settings["identityManagement"]["ropc"]["url"]
|
|
||||||
scopes: str = settings["identityManagement"]["ropc"]["scopes"]
|
|
||||||
return auth_url, scopes
|
|
||||||
|
|
||||||
def claims_token(self, token: str) -> str:
|
|
||||||
headers = {
|
|
||||||
"Authorization": "Bearer " + token,
|
|
||||||
}
|
|
||||||
params = {"device": "web"}
|
|
||||||
response: dict = self._request(
|
|
||||||
"GET", "/ott/subscription/v2/gem/Subscriber/profile", headers=headers, params=params
|
|
||||||
)
|
|
||||||
return response["claimsToken"]
|
|
||||||
|
|
||||||
def _request(self, method: str, api: str, **kwargs: Any) -> Any[dict | str]:
|
|
||||||
url: str = urljoin(self.base_url, api)
|
|
||||||
|
|
||||||
prep: Request = self.session.prepare_request(Request(method, url, **kwargs))
|
|
||||||
response = self.session.send(prep)
|
|
||||||
if response.status_code not in (200, 426):
|
|
||||||
raise ConnectionError(f"{response.status_code} - {response.text}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(response.content)
|
|
||||||
error_keys = ["errorMessage", "ErrorMessage", "ErrorCode", "errorCode", "error"]
|
|
||||||
error_message = next((data.get(key) for key in error_keys if key in data), None)
|
|
||||||
if error_message:
|
|
||||||
self.log.error(f"\n - Error: {error_message}\n")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise ConnectionError("Request for {} failed: {}".format(response.url, response.text))
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
endpoints:
|
|
||||||
base_url: "https://services.radio-canada.ca"
|
|
||||||
validation: "/media/validation/v2?appCode=gem&&deviceType={}&idMedia={}&manifestType={}&output=json&tech={}"
|
|
||||||
api_key: "3f4beddd-2061-49b0-ae80-6f1f2ed65b37"
|
|
||||||
|
|
||||||
client:
|
|
||||||
id: "fc05b0ee-3865-4400-a3cc-3da82c330c23"
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from collections.abc import Generator
|
|
||||||
from typing import Any, Optional, Union
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
import click
|
|
||||||
from requests import Request
|
|
||||||
from envied.core.constants import AnyTrack
|
|
||||||
from envied.core.manifests import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Series, Title_T, Titles_T
|
|
||||||
from envied.core.tracks import Chapter, Chapters, Tracks
|
|
||||||
from envied.core.utils.sslciphers import SSLCiphers
|
|
||||||
from envied.core.utils.xml import load_xml
|
|
||||||
|
|
||||||
|
|
||||||
class CBS(Service):
|
|
||||||
"""
|
|
||||||
\b
|
|
||||||
Service code for CBS.com streaming service (https://cbs.com).
|
|
||||||
Credit to @srpen6 for the tip on anonymous session
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.1
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: None
|
|
||||||
Robustness:
|
|
||||||
Widevine:
|
|
||||||
L3: 2160p, DDP5.1
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Input should be complete URLs:
|
|
||||||
SERIES: https://www.cbs.com/shows/tracker/
|
|
||||||
EPISODE: https://www.cbs.com/shows/video/E0wG_ovVMkLlHOzv7KDpUV9bjeKFFG2v/
|
|
||||||
|
|
||||||
\b
|
|
||||||
Common VPN/proxy errors:
|
|
||||||
- SSLError(SSLEOFError(8, '[SSL: UNEXPECTED_EOF_WHILE_READING]'))
|
|
||||||
- ConnectionError: 406 Not Acceptable, 403 Forbidden
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
GEOFENCE = ("us",)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="CBS", short_help="https://cbs.com", help=__doc__)
|
|
||||||
@click.argument("title", type=str, required=False)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, **kwargs) -> CBS:
|
|
||||||
return CBS(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx, title):
|
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
params = {
|
|
||||||
"term": self.title,
|
|
||||||
"termCount": 50,
|
|
||||||
"showCanVids": "true",
|
|
||||||
}
|
|
||||||
results = self._request("GET", "/apps-api/v3.1/androidphone/contentsearch/search.json", params=params)["terms"]
|
|
||||||
|
|
||||||
for result in results:
|
|
||||||
yield SearchResult(
|
|
||||||
id_=result.get("path"),
|
|
||||||
title=result.get("title"),
|
|
||||||
description=None,
|
|
||||||
label=result.get("term_type"),
|
|
||||||
url=result.get("path"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_titles(self) -> Titles_T:
|
|
||||||
title_re = r"https://www\.cbs\.com/shows/(?P<video>video/)?(?P<id>[a-zA-Z0-9_-]+)/?$"
|
|
||||||
try:
|
|
||||||
video, title_id = (re.match(title_re, self.title).group(i) for i in ("video", "id"))
|
|
||||||
except Exception:
|
|
||||||
raise ValueError("- Could not parse ID from title")
|
|
||||||
|
|
||||||
if video:
|
|
||||||
episodes = self._episode(title_id)
|
|
||||||
else:
|
|
||||||
episodes = self._show(title_id)
|
|
||||||
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
|
||||||
self.token, self.license = self.ls_session(title.id)
|
|
||||||
manifest = self.get_manifest(title)
|
|
||||||
return DASH.from_url(url=manifest).to_tracks(language=title.language)
|
|
||||||
|
|
||||||
def get_chapters(self, title: Episode) -> Chapters:
|
|
||||||
if not title.data.get("playbackEvents", {}).get("endCreditChapterTimeMs"):
|
|
||||||
return Chapters()
|
|
||||||
|
|
||||||
end_credits = title.data["playbackEvents"]["endCreditChapterTimeMs"]
|
|
||||||
return Chapters([Chapter(name="Credits", timestamp=end_credits)])
|
|
||||||
|
|
||||||
def certificate(self, **_):
|
|
||||||
return None # will use common privacy cert
|
|
||||||
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
|
||||||
headers = {"Authorization": f"Bearer {self.token}"}
|
|
||||||
r = self.session.post(self.license, headers=headers, data=challenge)
|
|
||||||
if not r.ok:
|
|
||||||
self.log.error(r.text)
|
|
||||||
sys.exit(1)
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
# Service specific functions
|
|
||||||
|
|
||||||
def _show(self, title: str) -> Episode:
|
|
||||||
data = self._request("GET", "/apps-api/v3.0/androidphone/shows/slug/{}.json".format(title))
|
|
||||||
|
|
||||||
links = next((x.get("links") for x in data["showMenu"] if x.get("device_app_id") == "all_platforms"), None)
|
|
||||||
config = next((x.get("videoConfigUniqueName") for x in links if x.get("title").strip() == "Episodes"), None)
|
|
||||||
show = next((x for x in data["show"]["results"] if x.get("type").strip() == "show"), None)
|
|
||||||
seasons = [x.get("seasonNum") for x in data["available_video_seasons"].get("itemList", [])]
|
|
||||||
locale = show.get("locale", "en-US")
|
|
||||||
|
|
||||||
show_data = self._request(
|
|
||||||
"GET", "/apps-api/v2.0/androidphone/shows/{}/videos/config/{}.json".format(show.get("show_id"), config),
|
|
||||||
params={"platformType": "apps", "rows": "1", "begin": "0"},
|
|
||||||
)
|
|
||||||
|
|
||||||
section = next(
|
|
||||||
(x["sectionId"] for x in show_data["videoSectionMetadata"] if x["title"] == "Full Episodes"), None
|
|
||||||
)
|
|
||||||
|
|
||||||
episodes = []
|
|
||||||
for season in seasons:
|
|
||||||
res = self._request(
|
|
||||||
"GET", "/apps-api/v2.0/androidphone/videos/section/{}.json".format(section),
|
|
||||||
params={"begin": "0", "rows": "999", "params": f"seasonNum={season}", "seasonNum": season},
|
|
||||||
)
|
|
||||||
episodes.extend(res["sectionItems"].get("itemList", []))
|
|
||||||
|
|
||||||
return [
|
|
||||||
Episode(
|
|
||||||
id_=episode["contentId"],
|
|
||||||
title=episode["seriesTitle"],
|
|
||||||
season=episode["seasonNum"] if episode["fullEpisode"] else 0,
|
|
||||||
number=episode["episodeNum"] if episode["fullEpisode"] else episode["positionNum"],
|
|
||||||
name=episode["label"],
|
|
||||||
language=locale,
|
|
||||||
service=self.__class__,
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
for episode in episodes
|
|
||||||
if episode["fullEpisode"]
|
|
||||||
]
|
|
||||||
|
|
||||||
def _episode(self, title: str) -> Episode:
|
|
||||||
data = self._request("GET", "/apps-api/v2.0/androidphone/video/cid/{}.json".format(title))
|
|
||||||
|
|
||||||
return [
|
|
||||||
Episode(
|
|
||||||
id_=episode["contentId"],
|
|
||||||
title=episode["seriesTitle"],
|
|
||||||
season=episode["seasonNum"] if episode["fullEpisode"] else 0,
|
|
||||||
number=episode["episodeNum"] if episode["fullEpisode"] else episode["positionNum"],
|
|
||||||
name=episode["label"],
|
|
||||||
language="en-US",
|
|
||||||
service=self.__class__,
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
for episode in data["itemList"]
|
|
||||||
]
|
|
||||||
|
|
||||||
def ls_session(self, content_id: str) -> str:
|
|
||||||
res = self._request(
|
|
||||||
"GET", "/apps-api/v3.0/androidphone/irdeto-control/anonymous-session-token.json",
|
|
||||||
params={"contentId": content_id},
|
|
||||||
)
|
|
||||||
|
|
||||||
return res.get("ls_session"), res.get("url")
|
|
||||||
|
|
||||||
def get_manifest(self, title: Episode) -> str:
|
|
||||||
try:
|
|
||||||
res = self._request(
|
|
||||||
"GET", "http://link.theplatform.com/s/{}/media/guid/2198311517/{}".format(
|
|
||||||
title.data.get("cmsAccountId"), title.id
|
|
||||||
),
|
|
||||||
params={
|
|
||||||
"format": "SMIL",
|
|
||||||
"assetTypes": "|".join(self.config["assets"]),
|
|
||||||
"formats": "MPEG-DASH,MPEG4,M3U",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
body = load_xml(res).find("body").find("seq").findall("switch")
|
|
||||||
bitrate = max(body, key=lambda x: int(x.find("video").get("system-bitrate")))
|
|
||||||
videos = [x.get("src") for x in bitrate.findall("video")]
|
|
||||||
if not videos:
|
|
||||||
raise ValueError("Could not find any streams - is the title still available?")
|
|
||||||
|
|
||||||
manifest = next(
|
|
||||||
(x for x in videos if "hdr_dash" in x.lower()),
|
|
||||||
next((x for x in videos if "cenc_dash" in x.lower()), videos[0]),
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log.warning("ThePlatform request failed: {}, falling back to standard manifest".format(e))
|
|
||||||
if not title.data.get("streamingUrl"):
|
|
||||||
raise ValueError("Could not find any streams - is the title still available?")
|
|
||||||
|
|
||||||
manifest = title.data.get("streamingUrl")
|
|
||||||
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
def _request(self, method: str, api: str, params: dict = None, headers: dict = None) -> Any[dict | str]:
|
|
||||||
url = urljoin(self.config["endpoints"]["base_url"], api)
|
|
||||||
self.session.headers.update(self.config["headers"])
|
|
||||||
self.session.params = {"at": self.config["endpoints"]["token"]}
|
|
||||||
for prefix in ("https://", "http://"):
|
|
||||||
self.session.mount(prefix, SSLCiphers(security_level=2))
|
|
||||||
|
|
||||||
if params:
|
|
||||||
self.session.params.update(params)
|
|
||||||
if headers:
|
|
||||||
self.session.headers.update(headers)
|
|
||||||
|
|
||||||
prep = self.session.prepare_request(Request(method, url))
|
|
||||||
|
|
||||||
response = self.session.send(prep)
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise ConnectionError(f"{response.text}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(response.content)
|
|
||||||
if not data.get("success"):
|
|
||||||
raise ValueError(data.get("message"))
|
|
||||||
return data
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return response.text
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
headers:
|
|
||||||
user-agent: Mozilla/5.0 (Linux; Android 13; SM-A536E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
base_url: https://cbsdigital.cbs.com
|
|
||||||
token: ABBsaBMagMmYLUc9iXB0lXEKsUQ0/MwRn6z3Tg0KKQaH7Q6QGqJcABwlBP4XiMR1b0Q=
|
|
||||||
|
|
||||||
assets: [HLS_AES, DASH_LIVE, DASH_CENC, DASH_CENC_HDR10, DASH_LIVE, DASH_TA, DASH_CENC_PS4]
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,774 +0,0 @@
|
|||||||
import re
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from threading import Lock
|
|
||||||
from typing import Generator, Optional, Union
|
|
||||||
|
|
||||||
import click
|
|
||||||
import jwt
|
|
||||||
from langcodes import Language
|
|
||||||
|
|
||||||
from envied.core.manifests import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.session import session
|
|
||||||
from envied.core.titles import Episode, Series
|
|
||||||
from envied.core.tracks import Attachment, Chapters, Tracks
|
|
||||||
from envied.core.tracks.chapter import Chapter
|
|
||||||
from envied.core.tracks.subtitle import Subtitle
|
|
||||||
|
|
||||||
|
|
||||||
class CR(Service):
|
|
||||||
"""
|
|
||||||
Service code for Crunchyroll streaming service (https://www.crunchyroll.com).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 2.0.0
|
|
||||||
Author: sp4rk.y
|
|
||||||
Date: 2025-11-01
|
|
||||||
Authorization: Credentials
|
|
||||||
Robustness:
|
|
||||||
Widevine:
|
|
||||||
L3: 1080p, AAC2.0
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Input should be complete URL or series ID
|
|
||||||
https://www.crunchyroll.com/series/GRMG8ZQZR/series-name OR GRMG8ZQZR
|
|
||||||
- Supports multiple audio and subtitle languages
|
|
||||||
- Device ID is cached for consistent authentication across runs
|
|
||||||
|
|
||||||
\b
|
|
||||||
Notes:
|
|
||||||
- Uses password-based authentication with token caching
|
|
||||||
- Manages concurrent stream limits automatically
|
|
||||||
"""
|
|
||||||
|
|
||||||
TITLE_RE = r"^(?:https?://(?:www\.)?crunchyroll\.com/(?:series|watch)/)?(?P<id>[A-Z0-9]+)"
|
|
||||||
LICENSE_LOCK = Lock()
|
|
||||||
MAX_CONCURRENT_STREAMS = 3
|
|
||||||
ACTIVE_STREAMS: list[tuple[str, str]] = []
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_session():
|
|
||||||
return session("okhttp4")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="CR", short_help="https://crunchyroll.com")
|
|
||||||
@click.argument("title", type=str, required=True)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, **kwargs) -> "CR":
|
|
||||||
return CR(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx, title: str):
|
|
||||||
self.title = title
|
|
||||||
self.account_id: Optional[str] = None
|
|
||||||
self.access_token: Optional[str] = None
|
|
||||||
self.token_expiration: Optional[int] = None
|
|
||||||
self.anonymous_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
device_cache_key = "cr_device_id"
|
|
||||||
cached_device = self.cache.get(device_cache_key)
|
|
||||||
|
|
||||||
if cached_device and not cached_device.expired:
|
|
||||||
self.device_id = cached_device.data["device_id"]
|
|
||||||
else:
|
|
||||||
self.device_id = str(uuid.uuid4())
|
|
||||||
cached_device.set(
|
|
||||||
data={"device_id": self.device_id},
|
|
||||||
expiration=60 * 60 * 24 * 365 * 10,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.device_name = self.config.get("device", {}).get("name", "SHIELD Android TV")
|
|
||||||
self.device_type = self.config.get("device", {}).get("type", "ANDROIDTV")
|
|
||||||
|
|
||||||
self.session.headers.update(self.config.get("headers", {}))
|
|
||||||
self.session.headers["etp-anonymous-id"] = self.anonymous_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def auth_header(self) -> dict:
|
|
||||||
"""Return authorization header dict."""
|
|
||||||
return {"authorization": f"Bearer {self.access_token}"}
|
|
||||||
|
|
||||||
def ensure_authenticated(self) -> None:
|
|
||||||
"""Check if token is expired and re-authenticate if needed."""
|
|
||||||
if not self.token_expiration:
|
|
||||||
cache_key = f"cr_auth_token_{self.credential.sha1 if self.credential else 'default'}"
|
|
||||||
cached = self.cache.get(cache_key)
|
|
||||||
|
|
||||||
if cached and not cached.expired:
|
|
||||||
self.access_token = cached.data["access_token"]
|
|
||||||
self.account_id = cached.data.get("account_id")
|
|
||||||
self.token_expiration = cached.data.get("token_expiration")
|
|
||||||
self.session.headers.update(self.auth_header)
|
|
||||||
self.log.debug("Loaded authentication from cache")
|
|
||||||
else:
|
|
||||||
self.log.debug("No valid cached token, authenticating")
|
|
||||||
self.authenticate(credential=self.credential)
|
|
||||||
return
|
|
||||||
|
|
||||||
current_time = int(time.time())
|
|
||||||
if current_time >= (self.token_expiration - 60):
|
|
||||||
self.log.debug("Authentication token expired or expiring soon, re-authenticating")
|
|
||||||
self.authenticate(credential=self.credential)
|
|
||||||
|
|
||||||
def authenticate(self, cookies=None, credential=None) -> None:
|
|
||||||
"""Authenticate using username and password credentials."""
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
|
|
||||||
cache_key = f"cr_auth_token_{credential.sha1 if credential else 'default'}"
|
|
||||||
cached = self.cache.get(cache_key)
|
|
||||||
|
|
||||||
if cached and not cached.expired:
|
|
||||||
self.access_token = cached.data["access_token"]
|
|
||||||
self.account_id = cached.data.get("account_id")
|
|
||||||
self.token_expiration = cached.data.get("token_expiration")
|
|
||||||
else:
|
|
||||||
if not credential:
|
|
||||||
raise ValueError("Username and password credential required for authentication")
|
|
||||||
|
|
||||||
response = self.session.post(
|
|
||||||
url=self.config["endpoints"]["token"],
|
|
||||||
headers={
|
|
||||||
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
||||||
"request-type": "SignIn",
|
|
||||||
},
|
|
||||||
data={
|
|
||||||
"grant_type": "password",
|
|
||||||
"username": credential.username,
|
|
||||||
"password": credential.password,
|
|
||||||
"scope": "offline_access",
|
|
||||||
"client_id": self.config["client"]["id"],
|
|
||||||
"client_secret": self.config["client"]["secret"],
|
|
||||||
"device_type": self.device_type,
|
|
||||||
"device_id": self.device_id,
|
|
||||||
"device_name": self.device_name,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
self.log.error(f"Login failed: {response.status_code}")
|
|
||||||
try:
|
|
||||||
error_data = response.json()
|
|
||||||
error_msg = error_data.get("error", "Unknown error")
|
|
||||||
error_code = error_data.get("code", "")
|
|
||||||
self.log.error(f"Error: {error_msg} ({error_code})")
|
|
||||||
except Exception:
|
|
||||||
self.log.error(f"Response: {response.text}")
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
token_data = response.json()
|
|
||||||
self.access_token = token_data["access_token"]
|
|
||||||
self.account_id = self.get_account_id()
|
|
||||||
|
|
||||||
try:
|
|
||||||
decoded_token = jwt.decode(self.access_token, options={"verify_signature": False})
|
|
||||||
self.token_expiration = decoded_token.get("exp")
|
|
||||||
except Exception:
|
|
||||||
self.token_expiration = int(time.time()) + token_data.get("expires_in", 3600)
|
|
||||||
|
|
||||||
cached.set(
|
|
||||||
data={
|
|
||||||
"access_token": self.access_token,
|
|
||||||
"account_id": self.account_id,
|
|
||||||
"token_expiration": self.token_expiration,
|
|
||||||
},
|
|
||||||
expiration=self.token_expiration
|
|
||||||
if isinstance(self.token_expiration, int) and self.token_expiration > int(time.time())
|
|
||||||
else 3600,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.session.headers.update(self.auth_header)
|
|
||||||
|
|
||||||
if self.ACTIVE_STREAMS:
|
|
||||||
self.ACTIVE_STREAMS.clear()
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.clear_all_sessions()
|
|
||||||
except Exception as e:
|
|
||||||
self.log.warning(f"Failed to clear previous sessions: {e}")
|
|
||||||
|
|
||||||
def get_titles(self) -> Union[Series]:
|
|
||||||
"""Fetch series and episode information."""
|
|
||||||
series_id = self.parse_series_id(self.title)
|
|
||||||
|
|
||||||
series_response = self.session.get(
|
|
||||||
url=self.config["endpoints"]["series"].format(series_id=series_id),
|
|
||||||
params={"locale": self.config["params"]["locale"]},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
if "error" in series_response:
|
|
||||||
raise ValueError(f"Series not found: {series_id}")
|
|
||||||
|
|
||||||
series_data = (
|
|
||||||
series_response.get("data", [{}])[0] if isinstance(series_response.get("data"), list) else series_response
|
|
||||||
)
|
|
||||||
series_title = series_data.get("title", "Unknown Series")
|
|
||||||
|
|
||||||
seasons_response = self.session.get(
|
|
||||||
url=self.config["endpoints"]["seasons"].format(series_id=series_id),
|
|
||||||
params={"locale": self.config["params"]["locale"]},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
seasons_data = seasons_response.get("data", [])
|
|
||||||
|
|
||||||
if not seasons_data:
|
|
||||||
raise ValueError(f"No seasons found for series: {series_id}")
|
|
||||||
|
|
||||||
all_episode_data = []
|
|
||||||
special_episodes = []
|
|
||||||
|
|
||||||
for season in seasons_data:
|
|
||||||
season_id = season["id"]
|
|
||||||
season_number = season.get("season_number", 0)
|
|
||||||
|
|
||||||
episodes_response = self.session.get(
|
|
||||||
url=self.config["endpoints"]["season_episodes"].format(season_id=season_id),
|
|
||||||
params={"locale": self.config["params"]["locale"]},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
episodes_data = episodes_response.get("data", [])
|
|
||||||
|
|
||||||
for episode_data in episodes_data:
|
|
||||||
episode_number = episode_data.get("episode_number")
|
|
||||||
|
|
||||||
if episode_number is None or isinstance(episode_number, float):
|
|
||||||
special_episodes.append(episode_data)
|
|
||||||
|
|
||||||
all_episode_data.append((episode_data, season_number))
|
|
||||||
|
|
||||||
if not all_episode_data:
|
|
||||||
raise ValueError(f"No episodes found for series: {series_id}")
|
|
||||||
|
|
||||||
series_year = None
|
|
||||||
if all_episode_data:
|
|
||||||
first_episode_data = all_episode_data[0][0]
|
|
||||||
first_air_date = first_episode_data.get("episode_air_date")
|
|
||||||
if first_air_date:
|
|
||||||
series_year = int(first_air_date[:4])
|
|
||||||
|
|
||||||
special_episodes.sort(key=lambda x: x.get("episode_air_date", ""))
|
|
||||||
special_episode_numbers = {ep["id"]: idx + 1 for idx, ep in enumerate(special_episodes)}
|
|
||||||
episodes = []
|
|
||||||
season_episode_counts = {}
|
|
||||||
|
|
||||||
for episode_data, season_number in all_episode_data:
|
|
||||||
episode_number = episode_data.get("episode_number")
|
|
||||||
|
|
||||||
if episode_number is None or isinstance(episode_number, float):
|
|
||||||
final_season = 0
|
|
||||||
final_number = special_episode_numbers[episode_data["id"]]
|
|
||||||
else:
|
|
||||||
final_season = season_number
|
|
||||||
if final_season not in season_episode_counts:
|
|
||||||
season_episode_counts[final_season] = 0
|
|
||||||
|
|
||||||
season_episode_counts[final_season] += 1
|
|
||||||
final_number = season_episode_counts[final_season]
|
|
||||||
|
|
||||||
original_language = None
|
|
||||||
versions = episode_data.get("versions", [])
|
|
||||||
for version in versions:
|
|
||||||
if "main" in version.get("roles", []):
|
|
||||||
original_language = version.get("audio_locale")
|
|
||||||
break
|
|
||||||
|
|
||||||
episode = Episode(
|
|
||||||
id_=episode_data["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=series_title,
|
|
||||||
season=final_season,
|
|
||||||
number=final_number,
|
|
||||||
name=episode_data.get("title"),
|
|
||||||
year=series_year,
|
|
||||||
language=original_language,
|
|
||||||
description=episode_data.get("description"),
|
|
||||||
data=episode_data,
|
|
||||||
)
|
|
||||||
episodes.append(episode)
|
|
||||||
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
def set_track_metadata(self, tracks: Tracks, episode_id: str, is_original: bool) -> None:
|
|
||||||
"""Set metadata for video and audio tracks."""
|
|
||||||
for video in tracks.videos:
|
|
||||||
video.needs_repack = True
|
|
||||||
video.data["episode_id"] = episode_id
|
|
||||||
video.is_original_lang = is_original
|
|
||||||
for audio in tracks.audio:
|
|
||||||
audio.data["episode_id"] = episode_id
|
|
||||||
audio.is_original_lang = is_original
|
|
||||||
|
|
||||||
def get_tracks(self, title: Episode) -> Tracks:
|
|
||||||
"""Fetch video, audio, and subtitle tracks for an episode."""
|
|
||||||
self.ensure_authenticated()
|
|
||||||
|
|
||||||
episode_id = title.id
|
|
||||||
|
|
||||||
if self.ACTIVE_STREAMS:
|
|
||||||
self.ACTIVE_STREAMS.clear()
|
|
||||||
|
|
||||||
self.clear_all_sessions()
|
|
||||||
|
|
||||||
initial_response = self.get_playback_data(episode_id, track_stream=False)
|
|
||||||
versions = initial_response.get("versions", [])
|
|
||||||
|
|
||||||
if not versions:
|
|
||||||
self.log.warning("No versions found in playback response, using single version")
|
|
||||||
versions = [{"audio_locale": initial_response.get("audioLocale", "ja-JP")}]
|
|
||||||
|
|
||||||
tracks = None
|
|
||||||
|
|
||||||
for idx, version in enumerate(versions):
|
|
||||||
audio_locale = version.get("audio_locale")
|
|
||||||
version_guid = version.get("guid")
|
|
||||||
is_original = version.get("original", False)
|
|
||||||
|
|
||||||
if not audio_locale:
|
|
||||||
continue
|
|
||||||
|
|
||||||
request_episode_id = version_guid if version_guid else episode_id
|
|
||||||
|
|
||||||
if idx == 0 and not version_guid:
|
|
||||||
version_response = initial_response
|
|
||||||
version_token = version_response.get("token")
|
|
||||||
else:
|
|
||||||
if idx == 1 and not versions[0].get("guid"):
|
|
||||||
initial_token = initial_response.get("token")
|
|
||||||
if initial_token:
|
|
||||||
self.close_stream(episode_id, initial_token)
|
|
||||||
|
|
||||||
try:
|
|
||||||
version_response = self.get_playback_data(request_episode_id, track_stream=False)
|
|
||||||
except ValueError as e:
|
|
||||||
self.log.warning(f"Could not get playback info for audio {audio_locale}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
version_token = version_response.get("token")
|
|
||||||
|
|
||||||
hard_subs = version_response.get("hardSubs", {})
|
|
||||||
dash_url = None
|
|
||||||
|
|
||||||
if "none" in hard_subs:
|
|
||||||
dash_url = hard_subs["none"].get("url")
|
|
||||||
elif hard_subs:
|
|
||||||
first_key = list(hard_subs.keys())[0]
|
|
||||||
dash_url = hard_subs[first_key].get("url")
|
|
||||||
|
|
||||||
if not dash_url:
|
|
||||||
self.log.warning(f"No DASH manifest found for audio {audio_locale}, skipping")
|
|
||||||
if version_token:
|
|
||||||
self.close_stream(request_episode_id, version_token)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
version_tracks = DASH.from_url(
|
|
||||||
url=dash_url,
|
|
||||||
session=self.session,
|
|
||||||
).to_tracks(language=audio_locale)
|
|
||||||
|
|
||||||
if tracks is None:
|
|
||||||
tracks = version_tracks
|
|
||||||
self.set_track_metadata(tracks, request_episode_id, is_original)
|
|
||||||
else:
|
|
||||||
self.set_track_metadata(version_tracks, request_episode_id, is_original)
|
|
||||||
for video in version_tracks.videos:
|
|
||||||
tracks.add(video)
|
|
||||||
for audio in version_tracks.audio:
|
|
||||||
tracks.add(audio)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log.warning(f"Failed to parse DASH manifest for audio {audio_locale}: {e}")
|
|
||||||
if version_token:
|
|
||||||
self.close_stream(request_episode_id, version_token)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if is_original:
|
|
||||||
captions = version_response.get("captions", {})
|
|
||||||
subtitles_data = version_response.get("subtitles", {})
|
|
||||||
all_subs = {**captions, **subtitles_data}
|
|
||||||
|
|
||||||
for lang_code, sub_data in all_subs.items():
|
|
||||||
if lang_code == "none":
|
|
||||||
continue
|
|
||||||
|
|
||||||
if isinstance(sub_data, dict) and "url" in sub_data:
|
|
||||||
try:
|
|
||||||
lang = Language.get(lang_code)
|
|
||||||
except (ValueError, LookupError):
|
|
||||||
lang = Language.get("en")
|
|
||||||
|
|
||||||
subtitle_format = sub_data.get("format", "vtt").lower()
|
|
||||||
if subtitle_format == "ass" or subtitle_format == "ssa":
|
|
||||||
codec = Subtitle.Codec.SubStationAlphav4
|
|
||||||
else:
|
|
||||||
codec = Subtitle.Codec.WebVTT
|
|
||||||
|
|
||||||
tracks.add(
|
|
||||||
Subtitle(
|
|
||||||
id_=f"subtitle-{audio_locale}-{lang_code}",
|
|
||||||
url=sub_data["url"],
|
|
||||||
codec=codec,
|
|
||||||
language=lang,
|
|
||||||
forced=False,
|
|
||||||
sdh=False,
|
|
||||||
),
|
|
||||||
warn_only=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if version_token:
|
|
||||||
self.close_stream(request_episode_id, version_token)
|
|
||||||
|
|
||||||
if versions and versions[0].get("guid"):
|
|
||||||
initial_token = initial_response.get("token")
|
|
||||||
if initial_token:
|
|
||||||
self.close_stream(episode_id, initial_token)
|
|
||||||
|
|
||||||
if tracks is None:
|
|
||||||
raise ValueError(f"Failed to fetch any tracks for episode: {episode_id}")
|
|
||||||
|
|
||||||
for track in tracks.audio + tracks.subtitles:
|
|
||||||
if track.language:
|
|
||||||
try:
|
|
||||||
lang_obj = Language.get(str(track.language))
|
|
||||||
base_lang = Language.get(lang_obj.language)
|
|
||||||
lang_display = base_lang.language_name()
|
|
||||||
track.name = lang_display
|
|
||||||
except (ValueError, LookupError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
images = title.data.get("images", {})
|
|
||||||
thumbnails = images.get("thumbnail", [])
|
|
||||||
if thumbnails:
|
|
||||||
thumb_variants = thumbnails[0] if isinstance(thumbnails[0], list) else [thumbnails[0]]
|
|
||||||
if thumb_variants:
|
|
||||||
thumb_index = min(7, len(thumb_variants) - 1)
|
|
||||||
thumb = thumb_variants[thumb_index]
|
|
||||||
if isinstance(thumb, dict) and "source" in thumb:
|
|
||||||
thumbnail_name = f"{title.name or title.title} - S{title.season:02d}E{title.number:02d}"
|
|
||||||
tracks.add(Attachment.from_url(url=thumb["source"], name=thumbnail_name))
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_widevine_license(self, challenge: bytes, title: Episode, track) -> bytes:
|
|
||||||
"""
|
|
||||||
Get Widevine license for decryption.
|
|
||||||
|
|
||||||
Creates a fresh playback session for each track, gets the license, then immediately
|
|
||||||
closes the stream. This prevents hitting the 3 concurrent stream limit.
|
|
||||||
CDN authorization is embedded in the manifest URLs, not tied to active sessions.
|
|
||||||
"""
|
|
||||||
self.ensure_authenticated()
|
|
||||||
|
|
||||||
track_episode_id = track.data.get("episode_id", title.id)
|
|
||||||
|
|
||||||
with self.LICENSE_LOCK:
|
|
||||||
playback_token = None
|
|
||||||
try:
|
|
||||||
playback_data = self.get_playback_data(track_episode_id, track_stream=True)
|
|
||||||
playback_token = playback_data.get("token")
|
|
||||||
|
|
||||||
if not playback_token:
|
|
||||||
raise ValueError(f"No playback token in response for {track_episode_id}")
|
|
||||||
|
|
||||||
track.data["playback_token"] = playback_token
|
|
||||||
|
|
||||||
license_response = self.session.post(
|
|
||||||
url=self.config["endpoints"]["license_widevine"],
|
|
||||||
params={"specConform": "true"},
|
|
||||||
data=challenge,
|
|
||||||
headers={
|
|
||||||
**self.auth_header,
|
|
||||||
"content-type": "application/octet-stream",
|
|
||||||
"accept": "application/octet-stream",
|
|
||||||
"x-cr-content-id": track_episode_id,
|
|
||||||
"x-cr-video-token": playback_token,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if license_response.status_code != 200:
|
|
||||||
self.log.error(f"License request failed with status {license_response.status_code}")
|
|
||||||
self.log.error(f"Response: {license_response.text[:500]}")
|
|
||||||
self.close_stream(track_episode_id, playback_token)
|
|
||||||
raise ValueError(f"License request failed: {license_response.status_code}")
|
|
||||||
|
|
||||||
self.close_stream(track_episode_id, playback_token)
|
|
||||||
return license_response.content
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
if playback_token:
|
|
||||||
try:
|
|
||||||
self.close_stream(track_episode_id, playback_token)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise
|
|
||||||
|
|
||||||
def cleanup_active_streams(self) -> None:
|
|
||||||
"""
|
|
||||||
Close all remaining active streams.
|
|
||||||
Called to ensure no streams are left open.
|
|
||||||
"""
|
|
||||||
if self.ACTIVE_STREAMS:
|
|
||||||
try:
|
|
||||||
self.authenticate()
|
|
||||||
except Exception as e:
|
|
||||||
self.log.warning(f"Failed to re-authenticate during cleanup: {e}")
|
|
||||||
|
|
||||||
for episode_id, token in list(self.ACTIVE_STREAMS):
|
|
||||||
try:
|
|
||||||
self.close_stream(episode_id, token)
|
|
||||||
except Exception as e:
|
|
||||||
self.log.warning(f"Failed to close stream {episode_id}: {e}")
|
|
||||||
if (episode_id, token) in self.ACTIVE_STREAMS:
|
|
||||||
self.ACTIVE_STREAMS.remove((episode_id, token))
|
|
||||||
|
|
||||||
def __del__(self) -> None:
|
|
||||||
"""Cleanup any remaining streams when service is destroyed."""
|
|
||||||
try:
|
|
||||||
self.cleanup_active_streams()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_chapters(self, title: Episode) -> Chapters:
|
|
||||||
"""Get chapters/skip events for an episode."""
|
|
||||||
chapters = Chapters()
|
|
||||||
|
|
||||||
chapter_response = self.session.get(
|
|
||||||
url=self.config["endpoints"]["skip_events"].format(episode_id=title.id),
|
|
||||||
)
|
|
||||||
|
|
||||||
if chapter_response.status_code == 200:
|
|
||||||
try:
|
|
||||||
chapter_data = chapter_response.json()
|
|
||||||
except Exception as e:
|
|
||||||
self.log.warning(f"Failed to parse chapter data: {e}")
|
|
||||||
return chapters
|
|
||||||
|
|
||||||
for chapter_type in ["intro", "recap", "credits", "preview"]:
|
|
||||||
if chapter_info := chapter_data.get(chapter_type):
|
|
||||||
try:
|
|
||||||
chapters.add(
|
|
||||||
Chapter(
|
|
||||||
timestamp=int(chapter_info["start"] * 1000),
|
|
||||||
name=chapter_info["type"].capitalize(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.log.debug(f"Failed to add {chapter_type} chapter: {e}")
|
|
||||||
|
|
||||||
return chapters
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
"""Search for content on Crunchyroll."""
|
|
||||||
try:
|
|
||||||
response = self.session.get(
|
|
||||||
url=self.config["endpoints"]["search"],
|
|
||||||
params={
|
|
||||||
"q": self.title,
|
|
||||||
"type": "series",
|
|
||||||
"start": 0,
|
|
||||||
"n": 20,
|
|
||||||
"locale": self.config["params"]["locale"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
self.log.error(f"Search request failed with status {response.status_code}")
|
|
||||||
return
|
|
||||||
|
|
||||||
search_data = response.json()
|
|
||||||
for result_group in search_data.get("data", []):
|
|
||||||
for series in result_group.get("items", []):
|
|
||||||
series_id = series.get("id")
|
|
||||||
|
|
||||||
if not series_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
title = series.get("title", "Unknown")
|
|
||||||
description = series.get("description", "")
|
|
||||||
year = series.get("series_launch_year")
|
|
||||||
if len(description) > 300:
|
|
||||||
description = description[:300] + "..."
|
|
||||||
|
|
||||||
url = f"https://www.crunchyroll.com/series/{series_id}"
|
|
||||||
label = f"SERIES ({year})" if year else "SERIES"
|
|
||||||
|
|
||||||
yield SearchResult(
|
|
||||||
id_=series_id,
|
|
||||||
title=title,
|
|
||||||
label=label,
|
|
||||||
description=description,
|
|
||||||
url=url,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log.error(f"Search failed: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
def get_account_id(self) -> str:
|
|
||||||
"""Fetch and return the account ID."""
|
|
||||||
response = self.session.get(url=self.config["endpoints"]["account_me"], headers=self.auth_header)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
self.log.error(f"Failed to get account info: {response.status_code}")
|
|
||||||
self.log.error(f"Response: {response.text}")
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
return data["account_id"]
|
|
||||||
|
|
||||||
def close_stream(self, episode_id: str, token: str) -> None:
|
|
||||||
"""Close an active playback stream to free up concurrent stream slots."""
|
|
||||||
should_remove = False
|
|
||||||
try:
|
|
||||||
response = self.session.delete(
|
|
||||||
url=self.config["endpoints"]["playback_delete"].format(episode_id=episode_id, token=token),
|
|
||||||
headers=self.auth_header,
|
|
||||||
)
|
|
||||||
if response.status_code in (200, 204, 403):
|
|
||||||
should_remove = True
|
|
||||||
else:
|
|
||||||
self.log.error(
|
|
||||||
f"Failed to close stream for {episode_id} (status {response.status_code}): {response.text[:200]}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.log.error(f"Error closing stream for {episode_id}: {e}")
|
|
||||||
finally:
|
|
||||||
if should_remove and (episode_id, token) in self.ACTIVE_STREAMS:
|
|
||||||
self.ACTIVE_STREAMS.remove((episode_id, token))
|
|
||||||
|
|
||||||
def get_active_sessions(self) -> list:
|
|
||||||
"""Get all active streaming sessions for the account."""
|
|
||||||
try:
|
|
||||||
response = self.session.get(
|
|
||||||
url=self.config["endpoints"]["playback_sessions"],
|
|
||||||
headers=self.auth_header,
|
|
||||||
)
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
return data.get("items", [])
|
|
||||||
else:
|
|
||||||
self.log.warning(f"Failed to get active sessions (status {response.status_code})")
|
|
||||||
return []
|
|
||||||
except Exception as e:
|
|
||||||
self.log.warning(f"Error getting active sessions: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def clear_all_sessions(self) -> int:
|
|
||||||
"""
|
|
||||||
Clear all active streaming sessions created during this or previous runs.
|
|
||||||
|
|
||||||
Tries multiple approaches to ensure all streams are closed:
|
|
||||||
1. Clear tracked streams with known tokens
|
|
||||||
2. Query active sessions API and close all found streams
|
|
||||||
3. Try alternate token formats if needed
|
|
||||||
"""
|
|
||||||
cleared = 0
|
|
||||||
|
|
||||||
if self.ACTIVE_STREAMS:
|
|
||||||
streams_to_close = self.ACTIVE_STREAMS[:]
|
|
||||||
for episode_id, playback_token in streams_to_close:
|
|
||||||
try:
|
|
||||||
self.close_stream(episode_id, playback_token)
|
|
||||||
cleared += 1
|
|
||||||
except Exception:
|
|
||||||
if (episode_id, playback_token) in self.ACTIVE_STREAMS:
|
|
||||||
self.ACTIVE_STREAMS.remove((episode_id, playback_token))
|
|
||||||
|
|
||||||
sessions = self.get_active_sessions()
|
|
||||||
if sessions:
|
|
||||||
for session_data in sessions:
|
|
||||||
content_id = session_data.get("contentId")
|
|
||||||
session_token = session_data.get("token")
|
|
||||||
|
|
||||||
if content_id and session_token:
|
|
||||||
tokens_to_try = (
|
|
||||||
["11-" + session_token[3:], session_token]
|
|
||||||
if session_token.startswith("08-")
|
|
||||||
else [session_token]
|
|
||||||
)
|
|
||||||
|
|
||||||
session_closed = False
|
|
||||||
for token in tokens_to_try:
|
|
||||||
try:
|
|
||||||
response = self.session.delete(
|
|
||||||
url=self.config["endpoints"]["playback_delete"].format(
|
|
||||||
episode_id=content_id, token=token
|
|
||||||
),
|
|
||||||
headers=self.auth_header,
|
|
||||||
)
|
|
||||||
if response.status_code in (200, 204):
|
|
||||||
cleared += 1
|
|
||||||
session_closed = True
|
|
||||||
break
|
|
||||||
elif response.status_code == 403:
|
|
||||||
session_closed = True
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not session_closed:
|
|
||||||
self.log.warning(f"Unable to close session {content_id} with any token format")
|
|
||||||
|
|
||||||
return cleared
|
|
||||||
|
|
||||||
def get_playback_data(self, episode_id: str, track_stream: bool = True) -> dict:
|
|
||||||
"""
|
|
||||||
Get playback data for an episode with automatic retry on stream limits.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
episode_id: The episode ID to get playback data for
|
|
||||||
track_stream: Whether to track this stream in active_streams (False for temporary streams)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: The playback response data
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If playback request fails after retry
|
|
||||||
"""
|
|
||||||
self.ensure_authenticated()
|
|
||||||
|
|
||||||
max_retries = 2
|
|
||||||
for attempt in range(max_retries + 1):
|
|
||||||
response = self.session.get(
|
|
||||||
url=self.config["endpoints"]["playback"].format(episode_id=episode_id),
|
|
||||||
params={"queue": "false"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
if "error" in response:
|
|
||||||
error_code = response.get("code", "")
|
|
||||||
error_msg = response.get("message", response.get("error", "Unknown error"))
|
|
||||||
|
|
||||||
if error_code == "TOO_MANY_ACTIVE_STREAMS" and attempt < max_retries:
|
|
||||||
self.log.warning(f"Hit stream limit: {error_msg}")
|
|
||||||
cleared = self.clear_all_sessions()
|
|
||||||
|
|
||||||
if cleared == 0 and attempt == 0:
|
|
||||||
wait_time = 30
|
|
||||||
self.log.warning(
|
|
||||||
f"Found orphaned sessions from previous run. Waiting {wait_time}s for them to expire..."
|
|
||||||
)
|
|
||||||
time.sleep(wait_time)
|
|
||||||
|
|
||||||
continue
|
|
||||||
|
|
||||||
self.log.error(f"Playback API error: {error_msg}")
|
|
||||||
self.log.debug(f"Full response: {response}")
|
|
||||||
raise ValueError(f"Could not get playback info for episode: {episode_id} - {error_msg}")
|
|
||||||
|
|
||||||
playback_token = response.get("token")
|
|
||||||
if playback_token and track_stream:
|
|
||||||
self.ACTIVE_STREAMS.append((episode_id, playback_token))
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
raise ValueError(f"Failed to get playback data for episode: {episode_id}")
|
|
||||||
|
|
||||||
def parse_series_id(self, title_input: str) -> str:
|
|
||||||
"""Parse series ID from URL or direct ID input."""
|
|
||||||
match = re.match(self.TITLE_RE, title_input, re.IGNORECASE)
|
|
||||||
if not match:
|
|
||||||
raise ValueError(f"Could not parse series ID from: {title_input}")
|
|
||||||
return match.group("id")
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Crunchyroll API Configuration
|
|
||||||
client:
|
|
||||||
id: "lkesi7snsy9oojmi2r9h"
|
|
||||||
secret: "-aGDXFFNTluZMLYXERngNYnEjvgH5odv"
|
|
||||||
|
|
||||||
# API Endpoints
|
|
||||||
endpoints:
|
|
||||||
# Authentication
|
|
||||||
token: "https://www.crunchyroll.com/auth/v1/token"
|
|
||||||
|
|
||||||
# Account
|
|
||||||
account_me: "https://www.crunchyroll.com/accounts/v1/me"
|
|
||||||
multiprofile: "https://www.crunchyroll.com/accounts/v1/{account_id}/multiprofile"
|
|
||||||
|
|
||||||
# Content Metadata
|
|
||||||
series: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}"
|
|
||||||
seasons: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}/seasons"
|
|
||||||
season_episodes: "https://www.crunchyroll.com/content/v2/cms/seasons/{season_id}/episodes"
|
|
||||||
skip_events: "https://static.crunchyroll.com/skip-events/production/{episode_id}.json"
|
|
||||||
|
|
||||||
# Playback
|
|
||||||
playback: "https://www.crunchyroll.com/playback/v2/{episode_id}/tv/android_tv/play"
|
|
||||||
playback_delete: "https://www.crunchyroll.com/playback/v1/token/{episode_id}/{token}"
|
|
||||||
playback_sessions: "https://www.crunchyroll.com/playback/v1/sessions/streaming"
|
|
||||||
license_widevine: "https://cr-license-proxy.prd.crunchyrollsvc.com/v1/license/widevine"
|
|
||||||
|
|
||||||
# Discovery
|
|
||||||
search: "https://www.crunchyroll.com/content/v2/discover/search"
|
|
||||||
|
|
||||||
# Headers for Android TV client
|
|
||||||
headers:
|
|
||||||
user-agent: "Crunchyroll/ANDROIDTV/3.49.1_22281 (Android 11; en-US; SHIELD Android TV)"
|
|
||||||
accept: "application/json"
|
|
||||||
accept-charset: "UTF-8"
|
|
||||||
accept-encoding: "gzip"
|
|
||||||
connection: "Keep-Alive"
|
|
||||||
content-type: "application/x-www-form-urlencoded; charset=UTF-8"
|
|
||||||
|
|
||||||
# Query parameters
|
|
||||||
params:
|
|
||||||
locale: "en-US"
|
|
||||||
|
|
||||||
# Device parameters for authentication
|
|
||||||
device:
|
|
||||||
type: "ANDROIDTV"
|
|
||||||
name: "SHIELD Android TV"
|
|
||||||
model: "SHIELD Android TV"
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from collections.abc import Generator
|
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
||||||
from http.cookiejar import CookieJar
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
import click
|
|
||||||
from pywidevine.cdm import Cdm as WidevineCdm
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.manifests import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
|
||||||
from envied.core.tracks import Audio, Chapter, Subtitle, Tracks, Video
|
|
||||||
|
|
||||||
|
|
||||||
class CTV(Service):
|
|
||||||
"""
|
|
||||||
Service code for CTV.ca (https://www.ctv.ca)
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.1
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: Credentials for subscription, none for freely available titles
|
|
||||||
Robustness:
|
|
||||||
Widevine:
|
|
||||||
L3: 1080p, DD5.1
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Input can be either complete title/episode URL or just the path:
|
|
||||||
/shows/young-sheldon
|
|
||||||
/shows/young-sheldon/baptists-catholics-and-an-attempted-drowning-s7e6
|
|
||||||
/movies/war-for-the-planet-of-the-apes
|
|
||||||
"""
|
|
||||||
|
|
||||||
TITLE_RE = r"^(?:https?://(?:www\.)?ctv\.ca(?:/[a-z]{2})?)?/(?P<type>movies|shows)/(?P<id>[a-z0-9-]+)(?:/(?P<episode>[a-z0-9-]+))?$"
|
|
||||||
GEOFENCE = ("ca",)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="CTV", short_help="https://www.ctv.ca", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, **kwargs):
|
|
||||||
return CTV(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx, title):
|
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
self.authorization: str = None
|
|
||||||
|
|
||||||
self.api = self.config["endpoints"]["api"]
|
|
||||||
self.license_url = self.config["endpoints"]["license"]
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
if credential:
|
|
||||||
cache = self.cache.get(f"tokens_{credential.sha1}")
|
|
||||||
|
|
||||||
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 cached Tokens")
|
|
||||||
r = self.session.post(
|
|
||||||
self.config["endpoints"]["login"],
|
|
||||||
headers={"authorization": f"Basic {self.config['endpoints']['auth']}"},
|
|
||||||
data={
|
|
||||||
"grant_type": "refresh_token",
|
|
||||||
"username": credential.username,
|
|
||||||
"password": credential.password,
|
|
||||||
"refresh_token": cache.data["refresh_token"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
res = r.json()
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise ValueError(f"Failed to refresh tokens: {r.text}")
|
|
||||||
|
|
||||||
tokens = res
|
|
||||||
self.log.info(" + Refreshed")
|
|
||||||
else:
|
|
||||||
# new
|
|
||||||
r = self.session.post(
|
|
||||||
self.config["endpoints"]["login"],
|
|
||||||
headers={"authorization": f"Basic {self.config['endpoints']['auth']}"},
|
|
||||||
data={
|
|
||||||
"grant_type": "password",
|
|
||||||
"username": credential.username,
|
|
||||||
"password": credential.password,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
res = r.json()
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise ValueError(f"Failed to log in: {r.text}")
|
|
||||||
|
|
||||||
tokens = res
|
|
||||||
self.log.info(" + Acquired tokens...")
|
|
||||||
|
|
||||||
cache.set(tokens, expiration=tokens["expires_in"])
|
|
||||||
|
|
||||||
self.authorization = f"Bearer {tokens['access_token']}"
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
payload = {
|
|
||||||
"operationName": "searchMedia",
|
|
||||||
"variables": {"title": f"{self.title}"},
|
|
||||||
"query": """
|
|
||||||
query searchMedia($title: String!) {searchMedia(titleMatches: $title) {
|
|
||||||
... on Medias {page {items {title\npath}}}}}, """,
|
|
||||||
}
|
|
||||||
|
|
||||||
r = self.session.post(self.config["endpoints"]["search"], json=payload)
|
|
||||||
if r.status_code != 200:
|
|
||||||
self.log.error(r.text)
|
|
||||||
return
|
|
||||||
|
|
||||||
for result in r.json()["data"]["searchMedia"]["page"]["items"]:
|
|
||||||
yield SearchResult(
|
|
||||||
id_=result.get("path"),
|
|
||||||
title=result.get("title"),
|
|
||||||
description=result.get("description"),
|
|
||||||
label=result["path"].split("/")[1],
|
|
||||||
url="https://www.ctv.ca" + result.get("path"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_titles(self) -> Titles_T:
|
|
||||||
title, kind, episode = (re.match(self.TITLE_RE, self.title).group(i) for i in ("id", "type", "episode"))
|
|
||||||
title_path = self.get_title_id(kind, title, episode)
|
|
||||||
|
|
||||||
if episode is not None:
|
|
||||||
data = self.get_episode_data(title_path)
|
|
||||||
return Series(
|
|
||||||
[
|
|
||||||
Episode(
|
|
||||||
id_=data["axisId"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=data["axisMedia"]["title"],
|
|
||||||
season=int(data["seasonNumber"]),
|
|
||||||
number=int(data["episodeNumber"]),
|
|
||||||
name=data["title"],
|
|
||||||
year=data.get("firstAirYear"),
|
|
||||||
language=data["axisPlaybackLanguages"][0].get("language", "en"),
|
|
||||||
data=data["axisPlaybackLanguages"][0]["destinationCode"],
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if kind == "shows":
|
|
||||||
data = self.get_series_data(title_path)
|
|
||||||
titles = self.fetch_episodes(data["contentData"]["seasons"])
|
|
||||||
return Series(
|
|
||||||
[
|
|
||||||
Episode(
|
|
||||||
id_=episode["axisId"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=data["contentData"]["title"],
|
|
||||||
season=int(episode["seasonNumber"]),
|
|
||||||
number=int(episode["episodeNumber"]),
|
|
||||||
name=episode["title"],
|
|
||||||
year=data["contentData"]["firstAirYear"],
|
|
||||||
language=episode["axisPlaybackLanguages"][0].get("language", "en"),
|
|
||||||
data=episode["axisPlaybackLanguages"][0]["destinationCode"],
|
|
||||||
)
|
|
||||||
for episode in titles
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if kind == "movies":
|
|
||||||
data = self.get_movie_data(title_path)
|
|
||||||
return Movies(
|
|
||||||
[
|
|
||||||
Movie(
|
|
||||||
id_=data["contentData"]["firstPlayableContent"]["axisId"],
|
|
||||||
service=self.__class__,
|
|
||||||
name=data["contentData"]["title"],
|
|
||||||
year=data["contentData"]["firstAirYear"],
|
|
||||||
language=data["contentData"]["firstPlayableContent"]["axisPlaybackLanguages"][0].get(
|
|
||||||
"language", "en"
|
|
||||||
),
|
|
||||||
data=data["contentData"]["firstPlayableContent"]["axisPlaybackLanguages"][0]["destinationCode"],
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
|
||||||
content = "https://capi.9c9media.com/destinations/{}/platforms/desktop/contents/{}/contentPackages".format(
|
|
||||||
title.data, title.id
|
|
||||||
)
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"$include": "[Desc,Constraints,EndCreditOffset,Breaks,Stacks.ManifestHost.mpd]",
|
|
||||||
}
|
|
||||||
r = self.session.get(content, params=params)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
pkg_id = r.json()["Items"][0]["Id"]
|
|
||||||
manifest = f"{content}/{pkg_id}/manifest.mpd"
|
|
||||||
subtitle = f"{content}/{pkg_id}/manifest.vtt"
|
|
||||||
|
|
||||||
if self.authorization:
|
|
||||||
self.session.headers.update({"authorization": self.authorization})
|
|
||||||
|
|
||||||
tracks = Tracks()
|
|
||||||
for num in ["14", "3", "25", "fe&mca=true&mta=true"]:
|
|
||||||
version = DASH.from_url(url=f"{manifest}?filter={num}", session=self.session).to_tracks(language=title.language)
|
|
||||||
tracks.videos.extend(version.videos)
|
|
||||||
tracks.audio.extend(version.audio)
|
|
||||||
|
|
||||||
tracks.add(
|
|
||||||
Subtitle(
|
|
||||||
id_=hashlib.md5(subtitle.encode()).hexdigest()[0:6],
|
|
||||||
url=subtitle,
|
|
||||||
codec=Subtitle.Codec.from_mime(subtitle[-3:]),
|
|
||||||
language=title.language,
|
|
||||||
is_original_lang=True,
|
|
||||||
forced=False,
|
|
||||||
sdh=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Title_T) -> list[Chapter]:
|
|
||||||
return [] # Chapters not available
|
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
|
||||||
return WidevineCdm.common_privacy_cert
|
|
||||||
|
|
||||||
def get_widevine_license(self, challenge: bytes, **_: Any) -> bytes:
|
|
||||||
r = self.session.post(url=self.license_url, data=challenge)
|
|
||||||
if r.status_code != 200:
|
|
||||||
self.log.error(r.text)
|
|
||||||
sys.exit(1)
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
# service specific functions
|
|
||||||
|
|
||||||
def get_title_id(self, kind: str, title: tuple, episode: str) -> str:
|
|
||||||
if episode is not None:
|
|
||||||
title += f"/{episode}"
|
|
||||||
payload = {
|
|
||||||
"operationName": "resolvePath",
|
|
||||||
"variables": {"path": f"{kind}/{title}"},
|
|
||||||
"query": """
|
|
||||||
query resolvePath($path: String!) {
|
|
||||||
resolvedPath(path: $path) {
|
|
||||||
lastSegment {
|
|
||||||
content {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""",
|
|
||||||
}
|
|
||||||
r = self.session.post(self.api, json=payload).json()
|
|
||||||
return r["data"]["resolvedPath"]["lastSegment"]["content"]["id"]
|
|
||||||
|
|
||||||
def get_series_data(self, title_id: str) -> json:
|
|
||||||
payload = {
|
|
||||||
"operationName": "axisMedia",
|
|
||||||
"variables": {"axisMediaId": f"{title_id}"},
|
|
||||||
"query": """
|
|
||||||
query axisMedia($axisMediaId: ID!) {
|
|
||||||
contentData: axisMedia(id: $axisMediaId) {
|
|
||||||
title
|
|
||||||
description
|
|
||||||
originalSpokenLanguage
|
|
||||||
mediaType
|
|
||||||
firstAirYear
|
|
||||||
seasons {
|
|
||||||
title
|
|
||||||
id
|
|
||||||
seasonNumber
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""",
|
|
||||||
}
|
|
||||||
|
|
||||||
return self.session.post(self.api, json=payload).json()["data"]
|
|
||||||
|
|
||||||
def get_movie_data(self, title_id: str) -> json:
|
|
||||||
payload = {
|
|
||||||
"operationName": "axisMedia",
|
|
||||||
"variables": {"axisMediaId": f"{title_id}"},
|
|
||||||
"query": """
|
|
||||||
query axisMedia($axisMediaId: ID!) {
|
|
||||||
contentData: axisMedia(id: $axisMediaId) {
|
|
||||||
title
|
|
||||||
description
|
|
||||||
firstAirYear
|
|
||||||
firstPlayableContent {
|
|
||||||
axisId
|
|
||||||
axisPlaybackLanguages {
|
|
||||||
destinationCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""",
|
|
||||||
}
|
|
||||||
|
|
||||||
return self.session.post(self.api, json=payload).json()["data"]
|
|
||||||
|
|
||||||
def get_episode_data(self, title_path: str) -> json:
|
|
||||||
payload = {
|
|
||||||
"operationName": "axisContent",
|
|
||||||
"variables": {"id": f"{title_path}"},
|
|
||||||
"query": """
|
|
||||||
query axisContent($id: ID!) {
|
|
||||||
axisContent(id: $id) {
|
|
||||||
axisId
|
|
||||||
title
|
|
||||||
description
|
|
||||||
contentType
|
|
||||||
seasonNumber
|
|
||||||
episodeNumber
|
|
||||||
axisMedia {
|
|
||||||
title
|
|
||||||
}
|
|
||||||
axisPlaybackLanguages {
|
|
||||||
language
|
|
||||||
destinationCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""",
|
|
||||||
}
|
|
||||||
return self.session.post(self.api, json=payload).json()["data"]["axisContent"]
|
|
||||||
|
|
||||||
def fetch_episode(self, episode: str) -> json:
|
|
||||||
payload = {
|
|
||||||
"operationName": "season",
|
|
||||||
"variables": {"seasonId": f"{episode}"},
|
|
||||||
"query": """
|
|
||||||
query season($seasonId: ID!) {
|
|
||||||
axisSeason(id: $seasonId) {
|
|
||||||
episodes {
|
|
||||||
axisId
|
|
||||||
title
|
|
||||||
description
|
|
||||||
contentType
|
|
||||||
seasonNumber
|
|
||||||
episodeNumber
|
|
||||||
axisPlaybackLanguages {
|
|
||||||
language
|
|
||||||
destinationCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""",
|
|
||||||
}
|
|
||||||
response = self.session.post(self.api, json=payload)
|
|
||||||
return response.json()["data"]["axisSeason"]["episodes"]
|
|
||||||
|
|
||||||
def fetch_episodes(self, data: dict) -> list:
|
|
||||||
"""TODO: Switch to async once https proxies are fully supported"""
|
|
||||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
||||||
tasks = [executor.submit(self.fetch_episode, x["id"]) for x in data]
|
|
||||||
titles = [future.result() for future in as_completed(tasks)]
|
|
||||||
return [episode for episodes in titles for episode in episodes]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
endpoints:
|
|
||||||
login: https://account.bellmedia.ca/api/login/v2.1
|
|
||||||
auth: Y3R2LXdlYjpkZWZhdWx0
|
|
||||||
api: https://api.ctv.ca/space-graphql/graphql
|
|
||||||
license: https://license.9c9media.ca/widevine
|
|
||||||
search: https://www.ctv.ca/space-graphql/apq/graphql
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from collections.abc import Generator
|
|
||||||
from datetime import timedelta
|
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import quote, urljoin
|
|
||||||
|
|
||||||
import click
|
|
||||||
from click import Context
|
|
||||||
from lxml import etree
|
|
||||||
from requests import Request
|
|
||||||
from envied.core.manifests import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series
|
|
||||||
from envied.core.tracks import Chapter, Chapters, Tracks
|
|
||||||
|
|
||||||
|
|
||||||
class CWTV(Service):
|
|
||||||
"""
|
|
||||||
\b
|
|
||||||
Service code for CWTV streaming service (https://www.cwtv.com/).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.1
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: None
|
|
||||||
Geofence: US (API and downloads)
|
|
||||||
Robustness:
|
|
||||||
L3: 1080p, AAC2.0
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Input should be complete URL:
|
|
||||||
SHOW: https://www.cwtv.com/shows/sullivans-crossing
|
|
||||||
EPISODE: https://www.cwtv.com/series/sullivans-crossing/new-beginnings/?play=7778f443-c7cc-4843-8e3c-d97d53b813d2
|
|
||||||
MOVIE: https://www.cwtv.com/movies/burnt/
|
|
||||||
"""
|
|
||||||
|
|
||||||
GEOFENCE = ("us",)
|
|
||||||
ALIASES = ("cw",)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="CWTV", short_help="https://www.cwtv.com/", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> CWTV:
|
|
||||||
return CWTV(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]:
|
|
||||||
results = self._request(
|
|
||||||
"GET", "https://www.cwtv.com/search/",
|
|
||||||
params={
|
|
||||||
"q": quote(self.title),
|
|
||||||
"format": "json2",
|
|
||||||
"service": "t",
|
|
||||||
"cwuid": "8195356001251527455",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
for result in results["items"]:
|
|
||||||
if result.get("type") not in ("shows", "series", "movies"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
video_type = "shows" if result.get("type") in ("series", "shows") else "movies"
|
|
||||||
|
|
||||||
yield SearchResult(
|
|
||||||
id_=f"https://www.cwtv.com/{video_type}/{result.get('show_slug')}",
|
|
||||||
title=result.get("title"),
|
|
||||||
description=result.get("description_long"),
|
|
||||||
label=result.get("type").capitalize(),
|
|
||||||
url=f"https://www.cwtv.com/{video_type}/{result.get('show_slug')}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_titles(self) -> Movies | Series:
|
|
||||||
url_pattern = re.compile(
|
|
||||||
r"^https:\/\/www\.cwtv\.com\/"
|
|
||||||
r"(?P<type>series|shows|movies)\/"
|
|
||||||
r"(?P<id>[\w-]+(?:\/[\w-]+)?)"
|
|
||||||
r"(?:\/?\?play=(?P<play_id>[\w-]+))?"
|
|
||||||
)
|
|
||||||
|
|
||||||
match = url_pattern.match(self.title)
|
|
||||||
if not match:
|
|
||||||
raise ValueError(f"Could not parse ID from title: {self.title}")
|
|
||||||
|
|
||||||
kind, guid, play_id = (match.group(i) for i in ("type", "id", "play_id"))
|
|
||||||
|
|
||||||
if kind in ("series", "shows") and not play_id:
|
|
||||||
episodes = self._series(guid)
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
elif kind == "movies" and not play_id:
|
|
||||||
movie = self._movie(guid)
|
|
||||||
return Movies(movie)
|
|
||||||
|
|
||||||
elif kind in ("series", "shows") and play_id:
|
|
||||||
episode = self._episode(play_id)
|
|
||||||
return Series(episode)
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Could not parse conent type from title: {self.title}")
|
|
||||||
|
|
||||||
def get_tracks(self, title: Movie | Episode) -> Tracks:
|
|
||||||
data = self._request(
|
|
||||||
"GET", self.config["endpoints"]["playback"].format(title.id),
|
|
||||||
headers={"accept": f'application/json;pk={self.config["policy_key"]}'},
|
|
||||||
)
|
|
||||||
has_drm = data.get("custom_fields", {}).get("is_drm") == "1"
|
|
||||||
|
|
||||||
title.data["chapters"] = data.get("cue_points")
|
|
||||||
|
|
||||||
source_manifest = next(
|
|
||||||
(source.get("src") for source in data["sources"] if source.get("type") == "application/dash+xml"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not source_manifest:
|
|
||||||
raise ValueError("Could not find DASH manifest")
|
|
||||||
|
|
||||||
license_url = next((
|
|
||||||
source.get("key_systems", {}).get("com.widevine.alpha", {}).get("license_url")
|
|
||||||
for source in data["sources"] if source.get("src") == source_manifest),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if has_drm and not license_url:
|
|
||||||
raise ValueError("Could not find license URL")
|
|
||||||
|
|
||||||
title.data["license_url"] = license_url
|
|
||||||
|
|
||||||
manifest = self.trim_duration(source_manifest)
|
|
||||||
tracks = DASH.from_text(manifest, source_manifest).to_tracks(language="en")
|
|
||||||
|
|
||||||
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 title.data.get("chapters"):
|
|
||||||
return Chapters()
|
|
||||||
|
|
||||||
chapters = []
|
|
||||||
for cue in title.data["chapters"]:
|
|
||||||
if cue["time"] > 0:
|
|
||||||
chapters.append(Chapter(timestamp=cue["time"]))
|
|
||||||
|
|
||||||
return Chapters(chapters)
|
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title: Movie | Episode, track: Any) -> bytes | str | None:
|
|
||||||
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
|
|
||||||
|
|
||||||
def _series(self, guid: str) -> list[Episode]:
|
|
||||||
series = self._request("GET", f"/feed/app-2/videos/show_{guid}/type_episodes/apiversion_24/device_androidtv")
|
|
||||||
if not series.get("items"):
|
|
||||||
raise ValueError(f"Could not find any episodes with ID {guid}")
|
|
||||||
|
|
||||||
episodes = [
|
|
||||||
Episode(
|
|
||||||
id_=episode.get("bc_video_id"),
|
|
||||||
service=self.__class__,
|
|
||||||
name=episode.get("title"),
|
|
||||||
season=int(episode.get("season") or 0),
|
|
||||||
number=int(episode.get("episode_in_season") or 0),
|
|
||||||
title=episode.get("series_name") or episode.get("show_title"),
|
|
||||||
year=episode.get("release_year"),
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
for episode in series.get("items")
|
|
||||||
if episode.get("fullep", 0) == 1
|
|
||||||
]
|
|
||||||
|
|
||||||
return episodes
|
|
||||||
|
|
||||||
def _movie(self, guid: str) -> Movie:
|
|
||||||
data = self._request("GET", f"/feed/app-2/videos/show_{guid}/type_episodes/apiversion_24/device_androidtv")
|
|
||||||
if not data.get("items"):
|
|
||||||
raise ValueError(f"Could not find any data for ID {guid}")
|
|
||||||
|
|
||||||
movies = [
|
|
||||||
Movie(
|
|
||||||
id_=movie.get("bc_video_id"),
|
|
||||||
service=self.__class__,
|
|
||||||
name=movie.get("series_name") or movie.get("show_title"),
|
|
||||||
year=movie.get("release_year"),
|
|
||||||
data=movie,
|
|
||||||
)
|
|
||||||
for movie in data.get("items")
|
|
||||||
if movie.get("fullep", 0) == 1
|
|
||||||
]
|
|
||||||
|
|
||||||
return movies
|
|
||||||
|
|
||||||
def _episode(self, guid: str) -> Episode:
|
|
||||||
data = self._request("GET", f"/feed/app-2/video-meta/guid_{guid}/apiversion_24/device_androidtv")
|
|
||||||
if not data.get("video"):
|
|
||||||
raise ValueError(f"Could not find any data for ID {guid}")
|
|
||||||
|
|
||||||
episodes = [
|
|
||||||
Episode(
|
|
||||||
id_=data.get("video", {}).get("bc_video_id"),
|
|
||||||
service=self.__class__,
|
|
||||||
name=data.get("video", {}).get("title"),
|
|
||||||
season=int(data.get("video", {}).get("season") or 0),
|
|
||||||
number=int(data.get("video", {}).get("episode_in_season") or 0),
|
|
||||||
title=data.get("video", {}).get("series_name") or data.get("video", {}).get("show_title"),
|
|
||||||
year=data.get("video", {}).get("release_year"),
|
|
||||||
data=data.get("video"),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
return episodes
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
@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")
|
|
||||||
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
headers:
|
|
||||||
User-Agent: Mozilla/5.0 (Linux; Android 11; Smart TV Build/AR2101; wv)
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
base_url: https://images.cwtv.com
|
|
||||||
playback: https://edge.api.brightcove.com/playback/v1/accounts/6415823816001/videos/{}
|
|
||||||
|
|
||||||
policy_key: BCpkADawqM0t2qFXB_K2XdHv2JmeRgQjpP6De9_Fl7d4akhL5aeqYwErorzsAxa7dyOF2FdxuG5wWVOREHEwb0DI-M8CGBBDpqwvDBEPfDKQg7kYGnccdNDErkvEh2O28CrGR3sEG6MZBlZ03I0xH7EflYKooIhfwvNWWw
|
|
||||||
|
|
||||||
@@ -1,583 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import uuid
|
|
||||||
from collections import defaultdict
|
|
||||||
from collections.abc import Generator
|
|
||||||
from copy import deepcopy
|
|
||||||
from http.cookiejar import CookieJar
|
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
from zlib import crc32
|
|
||||||
|
|
||||||
import click
|
|
||||||
from click import Context
|
|
||||||
from langcodes import Language
|
|
||||||
from lxml import etree
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.manifests import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.session import session as CurlSession
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series
|
|
||||||
from envied.core.tracks import Audio, Chapter, Chapters, Subtitle, Track, Tracks
|
|
||||||
from envied.core.utilities import is_close_match
|
|
||||||
|
|
||||||
|
|
||||||
class DSCP(Service):
|
|
||||||
"""
|
|
||||||
\b
|
|
||||||
Service code for Discovery Plus streaming service (https://www.discoveryplus.com).
|
|
||||||
Credit to @sp4rk.y for the subtitle fix.
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.0
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: Cookies
|
|
||||||
Robustness:
|
|
||||||
Widevine:
|
|
||||||
L1: 2160p, 1080p
|
|
||||||
L3: 720p
|
|
||||||
PlayReady:
|
|
||||||
SL3000: 2160p
|
|
||||||
SL2000: 1080p, 720p
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Input examples:
|
|
||||||
SHOW: https://play.discoveryplus.com/show/eb26e00e-9582-4790-a61c-48d785926f58
|
|
||||||
STANDALONE: https://play.discoveryplus.com/standalone/5012ae3f-d9bd-46ec-ad42-b8116b811441
|
|
||||||
SPORT: https://play.discoveryplus.com/sport/9cc449de-2a64-524d-bcb6-cabd4ac70340
|
|
||||||
EPISODE: https://play.discoveryplus.com/video/watch/8685efdd-a3c4-4892-b1d1-5f9f071cacf1/de67ea8e-a90f-4609-81af-4f09906f60b2
|
|
||||||
|
|
||||||
\b
|
|
||||||
Notes:
|
|
||||||
- Language tags can be mislabelled or missing on some titles. List tracks with --list to verify.
|
|
||||||
- All qualities, codecs, and ranges are included when available. Use -v H.265, -r HDR10, -q 1080p, etc. to select.
|
|
||||||
|
|
||||||
\b
|
|
||||||
Bonus tip: With some minor adjustments to the code and config, you can convert this to an HMAX service.
|
|
||||||
- Replace all instances of "DSCP" with "HMAX"
|
|
||||||
- Replace all instances of "dplus" with "beam"
|
|
||||||
- Replace all instances of "discoveryplus" with "hbomax"
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
ALIASES = ("discoveryplus",)
|
|
||||||
TITLE_RE = (
|
|
||||||
r"^(?:https?://play.discoveryplus\.com?)?/(?P<type>show|mini-series|video|movie|topical|standalone|sport)/(?P<id>[a-z0-9-/]+)"
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="DSCP", short_help="https://www.discoveryplus.com/", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> DSCP:
|
|
||||||
return DSCP(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
|
||||||
super().__init__(ctx)
|
|
||||||
self.title = title
|
|
||||||
|
|
||||||
self.profile = ctx.parent.params.get("profile")
|
|
||||||
if not self.profile:
|
|
||||||
self.profile = "default"
|
|
||||||
|
|
||||||
self.cdm = ctx.obj.cdm
|
|
||||||
if self.cdm is not None:
|
|
||||||
self.drm_system = "playready"
|
|
||||||
self.security_level = "SL3000"
|
|
||||||
|
|
||||||
if self.cdm.security_level <= 3:
|
|
||||||
self.drm_system = "widevine"
|
|
||||||
self.security_level = "L1"
|
|
||||||
|
|
||||||
def get_session(self) -> CurlSession:
|
|
||||||
return CurlSession("okhttp4", status_forcelist=[429, 502, 503, 504])
|
|
||||||
|
|
||||||
def authenticate(self, cookies: CookieJar | None = None, credential: Credential | None = None) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
if not cookies:
|
|
||||||
raise EnvironmentError("Service requires Cookies for Authentication.")
|
|
||||||
|
|
||||||
cache = self.cache.get(f"tokens_{self.profile}")
|
|
||||||
if cache:
|
|
||||||
self.log.info(" + Using cached Tokens...")
|
|
||||||
tokens = cache.data
|
|
||||||
else:
|
|
||||||
self.log.info(" + Setting up new profile...")
|
|
||||||
|
|
||||||
st_token = self.session.cookies.get_dict().get("st")
|
|
||||||
if not st_token:
|
|
||||||
raise ValueError("- Unable to find token in cookies, try refreshing.")
|
|
||||||
|
|
||||||
profile = {"token": st_token, "device_id": str(uuid.uuid1())}
|
|
||||||
cache.set(profile)
|
|
||||||
tokens = cache.data
|
|
||||||
|
|
||||||
self.device_id = tokens["device_id"]
|
|
||||||
client_id = self.config["client_id"]
|
|
||||||
|
|
||||||
self.session.headers.update({
|
|
||||||
"user-agent": "androidtv dplus/20.8.1.2 (android/9; en-US; SHIELD Android TV-NVIDIA; Build/1)",
|
|
||||||
"x-disco-client": "ANDROIDTV:9:dplus:20.8.1.2",
|
|
||||||
"x-disco-params": "realm=bolt,bid=dplus,features=ar",
|
|
||||||
"x-device-info": f"dplus/20.8.1.2 (NVIDIA/SHIELD Android TV; android/9-mdarcy; {self.device_id}/{client_id})",
|
|
||||||
})
|
|
||||||
|
|
||||||
self.base_url = self.config["endpoints"]["base_url"].format("any", "any")
|
|
||||||
access = self._request("GET", "/token", params={"realm": "bolt", "deviceId": self.device_id})
|
|
||||||
|
|
||||||
self.access_token = access["data"]["attributes"]["token"]
|
|
||||||
|
|
||||||
config = self._request("POST", "/session-context/headwaiter/v1/bootstrap")
|
|
||||||
self.base_url = self.config["endpoints"]["base_url"].format(config["routing"]["tenant"], config["routing"]["homeMarket"])
|
|
||||||
|
|
||||||
user = self._request("GET", "/users/me")
|
|
||||||
if user["data"]["attributes"]["anonymous"]:
|
|
||||||
raise ValueError("Anonymous user - try refreshing cookies.")
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
params = {
|
|
||||||
"include": "default",
|
|
||||||
"decorators": "viewingHistory,isFavorite,playbackAllowed,contentAction,badges",
|
|
||||||
"contentFilter[query]": self.title,
|
|
||||||
"page[items.number]": "1",
|
|
||||||
"page[items.size]": "8",
|
|
||||||
}
|
|
||||||
data = self._request("GET", "/cms/routes/search/result", params=params)
|
|
||||||
|
|
||||||
results = [x.get("attributes") for x in data["included"] if x.get("type") == "show"]
|
|
||||||
|
|
||||||
for result in results:
|
|
||||||
yield SearchResult(
|
|
||||||
id_=f"https://play.discoveryplus.com/show/{result.get('alternateId')}",
|
|
||||||
title=result.get("name"),
|
|
||||||
description=result.get("description"),
|
|
||||||
label="show",
|
|
||||||
url=f"https://play.discoveryplus.com/show/{result.get('alternateId')}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_titles(self) -> Movies | Series:
|
|
||||||
try:
|
|
||||||
entity, content_id = (re.match(self.TITLE_RE, self.title).group(i) for i in ("type", "id"))
|
|
||||||
except Exception:
|
|
||||||
raise ValueError("Could not parse ID from title - is the URL correct?")
|
|
||||||
|
|
||||||
if entity in ("show", "mini-series", "topical"):
|
|
||||||
episodes = self._show(content_id)
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
elif entity in ("movie", "standalone"):
|
|
||||||
movie = self._movie(content_id, entity)
|
|
||||||
return Movies(movie)
|
|
||||||
|
|
||||||
elif entity == "sport":
|
|
||||||
sport = self._sport(content_id)
|
|
||||||
return Movies(sport)
|
|
||||||
|
|
||||||
elif entity == "video":
|
|
||||||
episodes = self._episode(content_id)
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown content: {entity}")
|
|
||||||
|
|
||||||
|
|
||||||
def get_tracks(self, title: Movie | Episode) -> Tracks:
|
|
||||||
payload = {
|
|
||||||
"appBundle": "com.wbd.stream",
|
|
||||||
"applicationSessionId": self.device_id,
|
|
||||||
"capabilities": {
|
|
||||||
"codecs": {
|
|
||||||
"audio": {
|
|
||||||
"decoders": [
|
|
||||||
{"codec": "aac", "profiles": ["lc", "he", "hev2", "xhe"]},
|
|
||||||
{"codec": "eac3", "profiles": ["atmos"]},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"video": {
|
|
||||||
"decoders": [
|
|
||||||
{
|
|
||||||
"codec": "h264",
|
|
||||||
"levelConstraints": {
|
|
||||||
"framerate": {"max": 60, "min": 0},
|
|
||||||
"height": {"max": 2160, "min": 48},
|
|
||||||
"width": {"max": 3840, "min": 48},
|
|
||||||
},
|
|
||||||
"maxLevel": "5.2",
|
|
||||||
"profiles": ["baseline", "main", "high"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"codec": "h265",
|
|
||||||
"levelConstraints": {
|
|
||||||
"framerate": {"max": 60, "min": 0},
|
|
||||||
"height": {"max": 2160, "min": 144},
|
|
||||||
"width": {"max": 3840, "min": 144},
|
|
||||||
},
|
|
||||||
"maxLevel": "5.1",
|
|
||||||
"profiles": ["main10", "main"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"hdrFormats": ["hdr10", "hdr10plus", "dolbyvision", "dolbyvision5", "dolbyvision8", "hlg"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"contentProtection": {
|
|
||||||
"contentDecryptionModules": [
|
|
||||||
{"drmKeySystem": self.drm_system, "maxSecurityLevel": self.security_level}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"manifests": {"formats": {"dash": {}}},
|
|
||||||
},
|
|
||||||
"consumptionType": "streaming",
|
|
||||||
"deviceInfo": {
|
|
||||||
"player": {
|
|
||||||
"mediaEngine": {"name": "", "version": ""},
|
|
||||||
"playerView": {"height": 2160, "width": 3840},
|
|
||||||
"sdk": {"name": "", "version": ""},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"editId": title.id,
|
|
||||||
"firstPlay": False,
|
|
||||||
"gdpr": False,
|
|
||||||
"playbackSessionId": str(uuid.uuid4()),
|
|
||||||
"userPreferences": {
|
|
||||||
#'uiLanguage': 'en'
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
playback = self._request(
|
|
||||||
"POST", "/playback-orchestrator/any/playback-orchestrator/v1/playbackInfo",
|
|
||||||
headers={"Authorization": f"Bearer {self.access_token}"},
|
|
||||||
json=payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
original_language = next((
|
|
||||||
x.get("language")
|
|
||||||
for x in playback["videos"][0]["audioTracks"]
|
|
||||||
if "Original" in x.get("displayName", "")
|
|
||||||
), "")
|
|
||||||
|
|
||||||
manifest = (
|
|
||||||
playback.get("fallback", {}).get("manifest", {}).get("url", "").replace("_fallback", "")
|
|
||||||
or playback.get("manifest", {}).get("url")
|
|
||||||
)
|
|
||||||
|
|
||||||
license_url = (
|
|
||||||
playback.get("fallback", {}).get("drm", {}).get("schemes", {}).get(self.drm_system, {}).get("licenseUrl")
|
|
||||||
or playback.get("drm", {}).get("schemes", {}).get(self.drm_system, {}).get("licenseUrl")
|
|
||||||
)
|
|
||||||
|
|
||||||
title.data["license_url"] = license_url
|
|
||||||
title.data["chapters"] = next((x.get("annotations") for x in playback["videos"] if x["type"] == "main"), None)
|
|
||||||
|
|
||||||
dash = DASH.from_url(url=manifest, session=self.session)
|
|
||||||
tracks = dash.to_tracks(language="en", period_filter=self._period_filter)
|
|
||||||
|
|
||||||
for track in tracks:
|
|
||||||
track.is_original_lang = str(track.language) == original_language
|
|
||||||
track.name = "Original" if track.is_original_lang else track.name
|
|
||||||
|
|
||||||
if isinstance(track, Audio):
|
|
||||||
role = track.data["dash"]["representation"].find("Role")
|
|
||||||
if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
|
|
||||||
track.descriptive = True
|
|
||||||
|
|
||||||
if isinstance(track, Subtitle):
|
|
||||||
tracks.subtitles.remove(track)
|
|
||||||
|
|
||||||
subtitles = self._process_subtitles(dash, original_language)
|
|
||||||
tracks.add(subtitles)
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Movie | Episode) -> Chapters:
|
|
||||||
if not title.data.get("chapters"):
|
|
||||||
return Chapters()
|
|
||||||
|
|
||||||
chapters = []
|
|
||||||
for chapter in title.data["chapters"]:
|
|
||||||
if "recap" in chapter.get("secondaryType", "").lower():
|
|
||||||
chapters.append(Chapter(name="Recap", timestamp=chapter["start"]))
|
|
||||||
if chapter.get("end"):
|
|
||||||
chapters.append(Chapter(timestamp=chapter.get("end")))
|
|
||||||
if "intro" in chapter.get("secondaryType", "").lower():
|
|
||||||
chapters.append(Chapter(name="Intro", timestamp=chapter["start"]))
|
|
||||||
if chapter.get("end"):
|
|
||||||
chapters.append(Chapter(timestamp=chapter.get("end")))
|
|
||||||
elif "credits" in chapter.get("type", "").lower():
|
|
||||||
chapters.append(Chapter(name="Credits", timestamp=chapter["start"]))
|
|
||||||
|
|
||||||
if not any(c.timestamp == 0 for c in chapters):
|
|
||||||
chapters.append(Chapter(timestamp=0))
|
|
||||||
|
|
||||||
return sorted(chapters, key=lambda x: x.timestamp)
|
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, challenge: bytes, title: Episode | Movie, **_: Any) -> str:
|
|
||||||
if not (license_url := title.data.get("license_url")):
|
|
||||||
return None
|
|
||||||
|
|
||||||
return self.session.post(url=license_url, data=challenge).content
|
|
||||||
|
|
||||||
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> bytes | str | None:
|
|
||||||
if not (license_url := title.data.get("license_url")):
|
|
||||||
return None
|
|
||||||
|
|
||||||
r = self.session.post(url=license_url, data=challenge)
|
|
||||||
if r.status_code != 200:
|
|
||||||
raise ConnectionError(r.status_code, r.text)
|
|
||||||
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
def get_playready_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> bytes | str | None:
|
|
||||||
if not (license_url := title.data.get("license_url")):
|
|
||||||
return None
|
|
||||||
|
|
||||||
r = self.session.post(url=license_url, data=challenge)
|
|
||||||
if r.status_code != 200:
|
|
||||||
raise ConnectionError(r.status_code, r.text)
|
|
||||||
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
# Service specific functions
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _process_subtitles(dash: DASH, language: str) -> list[Subtitle]:
|
|
||||||
subtitle_groups = defaultdict(list)
|
|
||||||
manifest = dash.manifest
|
|
||||||
|
|
||||||
for period in manifest.findall("Period"):
|
|
||||||
for adapt_set in period.findall("AdaptationSet"):
|
|
||||||
if adapt_set.get("contentType") != "text" or not adapt_set.get("lang"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
role = adapt_set.find("Role")
|
|
||||||
label = adapt_set.find("Label")
|
|
||||||
key = (
|
|
||||||
adapt_set.get("lang"),
|
|
||||||
role.get("value") if role is not None else "subtitle",
|
|
||||||
label.text if label is not None else "",
|
|
||||||
)
|
|
||||||
subtitle_groups[key].append((period, adapt_set))
|
|
||||||
|
|
||||||
final_tracks = []
|
|
||||||
for (lang, role_value, label_text), adapt_set_group in subtitle_groups.items():
|
|
||||||
first_period, first_adapt = adapt_set_group[0]
|
|
||||||
if first_adapt.find("Representation") is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
s_elements_with_context = []
|
|
||||||
for _, adapt_set in adapt_set_group:
|
|
||||||
rep = adapt_set.find("Representation")
|
|
||||||
if rep is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
template = rep.find("SegmentTemplate") or adapt_set.find("SegmentTemplate")
|
|
||||||
timeline = template.find("SegmentTimeline") if template is not None else None
|
|
||||||
|
|
||||||
if timeline is not None:
|
|
||||||
start_num = int(template.get("startNumber", 1))
|
|
||||||
s_elements_with_context.extend((start_num, s_elem) for s_elem in timeline.findall("S"))
|
|
||||||
|
|
||||||
s_elements_with_context.sort(key=lambda x: x[0])
|
|
||||||
|
|
||||||
combined_adapt = deepcopy(first_adapt)
|
|
||||||
combined_rep = combined_adapt.find("Representation")
|
|
||||||
|
|
||||||
seg_template = combined_rep.find("SegmentTemplate")
|
|
||||||
if seg_template is None:
|
|
||||||
template_at_adapt = combined_adapt.find("SegmentTemplate")
|
|
||||||
if template_at_adapt is not None:
|
|
||||||
seg_template = deepcopy(template_at_adapt)
|
|
||||||
combined_rep.append(seg_template)
|
|
||||||
combined_adapt.remove(template_at_adapt)
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if seg_template.find("SegmentTimeline") is not None:
|
|
||||||
seg_template.remove(seg_template.find("SegmentTimeline"))
|
|
||||||
|
|
||||||
new_timeline = etree.Element("SegmentTimeline")
|
|
||||||
new_timeline.extend(deepcopy(s) for _, s in s_elements_with_context)
|
|
||||||
seg_template.append(new_timeline)
|
|
||||||
|
|
||||||
seg_template.set("startNumber", "1")
|
|
||||||
if "endNumber" in seg_template.attrib:
|
|
||||||
del seg_template.attrib["endNumber"]
|
|
||||||
|
|
||||||
track_id = hex(crc32(f"sub-{lang}-{role_value}-{label_text}".encode()) & 0xFFFFFFFF)[2:]
|
|
||||||
lang_obj = Language.get(lang)
|
|
||||||
track_name = "Original" if (language and is_close_match(lang_obj, [language])) else lang_obj.display_name()
|
|
||||||
|
|
||||||
final_tracks.append(
|
|
||||||
Subtitle(
|
|
||||||
id_=track_id,
|
|
||||||
url=dash.url,
|
|
||||||
codec=Subtitle.Codec.WebVTT,
|
|
||||||
language=lang_obj,
|
|
||||||
is_original_lang=bool(language and is_close_match(lang_obj, [language])),
|
|
||||||
descriptor=Track.Descriptor.DASH,
|
|
||||||
sdh="sdh" in label_text.lower() or role_value == "caption",
|
|
||||||
forced="forced" in label_text.lower() or "forced" in role_value.lower(),
|
|
||||||
name=track_name,
|
|
||||||
data={
|
|
||||||
"dash": {
|
|
||||||
"manifest": manifest,
|
|
||||||
"period": first_period,
|
|
||||||
"adaptation_set": combined_adapt,
|
|
||||||
"representation": combined_rep,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return final_tracks
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _period_filter(period: Any) -> bool:
|
|
||||||
"""Shouldn't be needed for fallback manifest"""
|
|
||||||
if not (duration := period.get("duration")):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return DASH.pt_to_sec(duration) < 120
|
|
||||||
|
|
||||||
def _show(self, title: str) -> Episode:
|
|
||||||
params = {
|
|
||||||
"include": "default",
|
|
||||||
"decorators": "viewingHistory,badges,isFavorite,contentAction",
|
|
||||||
}
|
|
||||||
data = self._request("GET", "/cms/routes/show/{}".format(title), params=params)
|
|
||||||
|
|
||||||
info = next(x for x in data["included"] if x.get("attributes", {}).get("alternateId", "") == title)
|
|
||||||
content = next((x for x in data["included"] if "show-page-rail-episodes-tabbed-content" in x["attributes"].get("alias", "")), None)
|
|
||||||
if not content:
|
|
||||||
raise ValueError("Show not found")
|
|
||||||
|
|
||||||
content_id = content.get("id")
|
|
||||||
show_id = content["attributes"]["component"].get("mandatoryParams", "")
|
|
||||||
season_params = [x.get("parameter") for x in content["attributes"]["component"]["filters"][0]["options"]]
|
|
||||||
page = next(x for x in data["included"] if x.get("type", "") == "page")
|
|
||||||
|
|
||||||
seasons = [
|
|
||||||
self._request(
|
|
||||||
"GET", "/cms/collections/{}?{}&{}".format(content_id, season, show_id),
|
|
||||||
params={"include": "default", "decorators": "viewingHistory,badges,isFavorite,contentAction"},
|
|
||||||
)
|
|
||||||
for season in season_params
|
|
||||||
]
|
|
||||||
|
|
||||||
videos = [[x for x in season["included"] if x["type"] == "video"] for season in seasons]
|
|
||||||
|
|
||||||
return [
|
|
||||||
Episode(
|
|
||||||
id_=ep["relationships"]["edit"]["data"]["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=page["attributes"].get("title") or info["attributes"].get("originalName"),
|
|
||||||
year=ep["attributes"]["airDate"][:4] if ep["attributes"].get("airDate") else None,
|
|
||||||
season=ep["attributes"].get("seasonNumber"),
|
|
||||||
number=ep["attributes"].get("episodeNumber"),
|
|
||||||
name=ep["attributes"]["name"],
|
|
||||||
data=ep,
|
|
||||||
)
|
|
||||||
for episodes in videos
|
|
||||||
for ep in episodes
|
|
||||||
if ep.get("attributes", {}).get("videoType", "") == "EPISODE"
|
|
||||||
]
|
|
||||||
|
|
||||||
def _episode(self, title: str) -> Episode:
|
|
||||||
video_id = title.split("/")[1]
|
|
||||||
|
|
||||||
params = {"decorators": "isFavorite", "include": "show"}
|
|
||||||
content = self._request("GET", "/content/videos/{}".format(video_id), params=params)
|
|
||||||
|
|
||||||
episode = content.get("data", {}).get("attributes")
|
|
||||||
video_type = episode.get("videoType")
|
|
||||||
relationships = content.get("data", {}).get("relationships")
|
|
||||||
show = next((x for x in content["included"] if x.get("type", "") == "show"), {})
|
|
||||||
|
|
||||||
show_title = show.get("attributes", {}).get("name") or show.get("attributes", {}).get("originalName")
|
|
||||||
episode_name = episode.get("originalName") or episode.get("secondaryTitle")
|
|
||||||
if video_type.lower() in ("clip", "standalone_event"):
|
|
||||||
show_title = episode.get("originalName")
|
|
||||||
episode_name = episode.get("secondaryTitle", "")
|
|
||||||
|
|
||||||
return [
|
|
||||||
Episode(
|
|
||||||
id_=relationships.get("edit", {}).get("data", {}).get("id"),
|
|
||||||
service=self.__class__,
|
|
||||||
title=show_title,
|
|
||||||
year=int(episode.get("airDate")[:4]) if episode.get("airDate") else None,
|
|
||||||
season=episode.get("seasonNumber") or 0,
|
|
||||||
number=episode.get("episodeNumber") or 0,
|
|
||||||
name=episode_name,
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def _sport(self, title: str) -> Movie:
|
|
||||||
params = {
|
|
||||||
"include": "default",
|
|
||||||
"decorators": "viewingHistory,badges,isFavorite,contentAction",
|
|
||||||
}
|
|
||||||
data = self._request("GET", "/cms/routes/sport/{}".format(title), params=params)
|
|
||||||
|
|
||||||
content = next((x for x in data["included"] if x.get("attributes", {}).get("alternateId", "") == title), None)
|
|
||||||
if not content:
|
|
||||||
raise ValueError(f"Content not found for title: {title}")
|
|
||||||
|
|
||||||
movie = content.get("attributes")
|
|
||||||
relationships = content.get("relationships")
|
|
||||||
|
|
||||||
name = movie.get("name") or movie.get("originalName")
|
|
||||||
year = int(movie.get("firstAvailableDate")[:4]) if movie.get("firstAvailableDate") else None
|
|
||||||
|
|
||||||
return [
|
|
||||||
Movie(
|
|
||||||
id_=relationships.get("edit", {}).get("data", {}).get("id"),
|
|
||||||
service=self.__class__,
|
|
||||||
name=name + " - " + movie.get("secondaryTitle", ""),
|
|
||||||
year=year,
|
|
||||||
data=movie,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def _movie(self, title: str, entity: str) -> Movie:
|
|
||||||
params = {
|
|
||||||
"include": "default",
|
|
||||||
"decorators": "isFavorite,playbackAllowed,contentAction,badges",
|
|
||||||
}
|
|
||||||
data = self._request("GET", "/cms/routes/movie/{}".format(title), params=params)
|
|
||||||
|
|
||||||
movie = next((
|
|
||||||
x for x in data["included"]if x.get("attributes", {}).get("videoType", "").lower() == entity), None
|
|
||||||
)
|
|
||||||
if not movie:
|
|
||||||
raise ValueError("Movie not found")
|
|
||||||
|
|
||||||
return [
|
|
||||||
Movie(
|
|
||||||
id_=movie["relationships"]["edit"]["data"]["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
name=movie["attributes"].get("name") or movie["attributes"].get("originalName"),
|
|
||||||
year=int(movie["attributes"]["airDate"][:4]) if movie["attributes"].get("airDate") else None,
|
|
||||||
data=movie,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def _request(self, method: str, endpoint: str, **kwargs: Any) -> Any[dict | str]:
|
|
||||||
url = urljoin(self.base_url, endpoint)
|
|
||||||
|
|
||||||
response = self.session.request(method, url, **kwargs)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(response.content)
|
|
||||||
|
|
||||||
if data.get("errors"):
|
|
||||||
raise ConnectionError(data["errors"])
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise ConnectionError(f"Request failed for {url}: {e}")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
endpoints:
|
|
||||||
base_url: "https://default.{}-{}.prd.api.discoveryplus.com"
|
|
||||||
|
|
||||||
client_id: "b6746ddc-7bc7-471f-a16c-f6aaf0c34d26" # androidtv
|
|
||||||
@@ -1,657 +0,0 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import uuid
|
|
||||||
from collections.abc import Generator
|
|
||||||
from http.cookiejar import CookieJar
|
|
||||||
from typing import Any, Optional, Union
|
|
||||||
|
|
||||||
import click
|
|
||||||
from click import Context
|
|
||||||
from requests import Request
|
|
||||||
|
|
||||||
#from envied.core.downloaders import n_m3u8dl_re
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.manifests import HLS
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series
|
|
||||||
from envied.core.tracks import Chapter, Chapters, Tracks, Video
|
|
||||||
from envied.core.utils.collections import as_list
|
|
||||||
|
|
||||||
from . import queries
|
|
||||||
|
|
||||||
|
|
||||||
class DSNP(Service):
|
|
||||||
"""
|
|
||||||
\b
|
|
||||||
Service code for DisneyPlus streaming service (https://www.disneyplus.com).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Authorization: Credentials
|
|
||||||
Robustness:
|
|
||||||
Widevine:
|
|
||||||
L1: 2160p, 1080p
|
|
||||||
L3: 720p
|
|
||||||
PlayReady:
|
|
||||||
SL3: 2160p, 1080p
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Input should be only the entity ID for both series and movies:
|
|
||||||
MOVIE: entity-99e15d53-926e-4074-b9f4-6524d10c8bed
|
|
||||||
SERIES: entity-30429ad6-dd12-41bf-924e-19131fa66bb5
|
|
||||||
- Use the --lang LANG_RANGE option to request non-english tracks
|
|
||||||
- CDM level dictates playback quality (L3 == 720p, L1 == 1080p, 2160p)
|
|
||||||
|
|
||||||
\b
|
|
||||||
Notes:
|
|
||||||
- On first run, the program will look for the first account profile that doesn't
|
|
||||||
have kids mode or pin protection enabled. If none are found, the program will exit.
|
|
||||||
- The profile will be cached and re-used until cache is cleared.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="DSNP", short_help="https://www.disneyplus.com", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> DSNP:
|
|
||||||
return DSNP(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
self.cdm = ctx.obj.cdm
|
|
||||||
self.playback_data = {}
|
|
||||||
|
|
||||||
vcodec = ctx.parent.params.get("vcodec")
|
|
||||||
range = ctx.parent.params.get("range_")
|
|
||||||
|
|
||||||
self.range = range[0].name if range else "SDR"
|
|
||||||
self.vcodec = "H265" if vcodec and vcodec == Video.Codec.HEVC else "H264"
|
|
||||||
if self.range != "SDR" and self.vcodec != "H265":
|
|
||||||
self.vcodec = "H265"
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
if not credential:
|
|
||||||
raise EnvironmentError("Service requires Credentials for Authentication.")
|
|
||||||
|
|
||||||
self.session.headers.update(self.config["HEADERS"])
|
|
||||||
self.session.headers.update({"x-bamsdk-transaction-id": str(uuid.uuid4())})
|
|
||||||
self.prd_config = self.session.get(self.config["CONFIG_URL"]).json()
|
|
||||||
|
|
||||||
self._cache = self.cache.get(f"tokens_{credential.sha1}")
|
|
||||||
if self._cache:
|
|
||||||
self.log.info(" + Refreshing Tokens")
|
|
||||||
profile = self.refresh_token(self._cache.data["token"]["refreshToken"])
|
|
||||||
self._cache.set(profile, expiration=profile["token"]["expiresIn"] - 30)
|
|
||||||
token = self._cache.data["token"]["accessToken"]
|
|
||||||
self.session.headers.update({"Authorization": "Bearer {}".format(token)})
|
|
||||||
self.active_session = self.account()["activeSession"]
|
|
||||||
else:
|
|
||||||
self.log.info(" + Setting up new profile...")
|
|
||||||
token = self.register_device()
|
|
||||||
status = self.check_email(credential.username, token)
|
|
||||||
if status.lower() == "register":
|
|
||||||
raise ValueError("Account is not registered. Please register first.")
|
|
||||||
elif status.lower() == "otp":
|
|
||||||
self.log.error(" - Account requires passcode for login.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
else:
|
|
||||||
tokens = self.login(credential.username, credential.password, token)
|
|
||||||
self.session.headers.update({"Authorization": "Bearer {}".format(tokens["accessToken"])})
|
|
||||||
account = self.account()
|
|
||||||
profile_id = next(
|
|
||||||
(
|
|
||||||
x.get("id")
|
|
||||||
for x in account["account"]["profiles"]
|
|
||||||
if not x["attributes"]["kidsModeEnabled"]
|
|
||||||
and not x["attributes"]["parentalControls"]["isPinProtected"]
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not profile_id:
|
|
||||||
self.log.error(
|
|
||||||
" - Missing profile - you need at least one profile with kids mode and pin protection disabled"
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
set_profile = self.switch_profile(profile_id)
|
|
||||||
profile = self.refresh_token(set_profile["token"]["refreshToken"])
|
|
||||||
self._cache.set(profile, expiration=profile["token"]["expiresIn"] - 30)
|
|
||||||
token = self._cache.data["token"]["accessToken"]
|
|
||||||
self.session.headers.update({"Authorization": "Bearer {}".format(token)})
|
|
||||||
self.active_session = self.account()["activeSession"]
|
|
||||||
|
|
||||||
self.log.info(" + Acquired tokens...")
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
params = {
|
|
||||||
"query": self.title,
|
|
||||||
}
|
|
||||||
endpoint = self.href(
|
|
||||||
self.prd_config["services"]["explore"]["client"]["endpoints"]["search"]["href"],
|
|
||||||
version=self.config["EXPLORE_VERSION"],
|
|
||||||
)
|
|
||||||
data = self._request("GET", endpoint, params=params)["data"]["page"]
|
|
||||||
if not data.get("containers"):
|
|
||||||
return
|
|
||||||
|
|
||||||
results = data["containers"][0]["items"]
|
|
||||||
|
|
||||||
for result in results:
|
|
||||||
entity = "entity-" + result.get("id")
|
|
||||||
yield SearchResult(
|
|
||||||
id_=entity,
|
|
||||||
title=result["visuals"].get("title"),
|
|
||||||
description=result["visuals"]["description"].get("brief"),
|
|
||||||
label=result["visuals"]["metastringParts"].get("releaseYearRange", {}).get("startYear"),
|
|
||||||
url=f"https://www.disneyplus.com/browse/{entity}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_titles(self) -> Union[Movies, Series]:
|
|
||||||
if not self.title.startswith("entity"):
|
|
||||||
raise ValueError("Invalid input - Use only entity IDs.")
|
|
||||||
|
|
||||||
content = self.get_deeplink(self.title)
|
|
||||||
_type = content["data"]["deeplink"]["actions"][0]["contentType"]
|
|
||||||
|
|
||||||
if _type == "movie":
|
|
||||||
movie = self._movie(self.title)
|
|
||||||
return Movies(movie)
|
|
||||||
|
|
||||||
elif _type == "series":
|
|
||||||
episodes = self._show(self.title)
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
|
||||||
resource_id = title.data.get("resourceId")
|
|
||||||
content_id = title.data["partnerFeed"].get("dmcContentId")
|
|
||||||
content = self.get_video(content_id)
|
|
||||||
playback = content["video"]["mediaMetadata"]["playbackUrls"][0]["href"]
|
|
||||||
|
|
||||||
token = self._refresh()
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"accept": "application/vnd.media-service+json; version=5",
|
|
||||||
"authorization": token,
|
|
||||||
"x-dss-feature-filtering": "true",
|
|
||||||
}
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"playbackId": resource_id,
|
|
||||||
"playback": {
|
|
||||||
"attributes": {
|
|
||||||
"codecs": {
|
|
||||||
"supportsMultiCodecMaster": False,
|
|
||||||
},
|
|
||||||
"protocol": "HTTPS",
|
|
||||||
# "ads": "",
|
|
||||||
"frameRates": [60],
|
|
||||||
"assetInsertionStrategy": "SGAI",
|
|
||||||
"playbackInitializationContext": "ONLINE",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
video_ranges = []
|
|
||||||
audio_types = []
|
|
||||||
|
|
||||||
audio_types.append("ATMOS")
|
|
||||||
audio_types.append("DTS_X")
|
|
||||||
|
|
||||||
if not self.cdm.security_level == 3 and self.range == "DV":
|
|
||||||
video_ranges.append("DOLBY_VISION")
|
|
||||||
|
|
||||||
if not self.cdm.security_level == 3 and self.range == "HDR10":
|
|
||||||
video_ranges.append("HDR10")
|
|
||||||
|
|
||||||
if self.vcodec == "H265":
|
|
||||||
payload["playback"]["attributes"]["codecs"] = {"video": ["h264", "h265"]}
|
|
||||||
|
|
||||||
if audio_types:
|
|
||||||
payload["playback"]["attributes"]["audioTypes"] = audio_types
|
|
||||||
|
|
||||||
if video_ranges:
|
|
||||||
payload["playback"]["attributes"]["videoRanges"] = video_ranges
|
|
||||||
|
|
||||||
if self.cdm.security_level == 3:
|
|
||||||
payload["playback"]["attributes"]["resolution"] = {"max": ["1280x720"]}
|
|
||||||
|
|
||||||
scenario = "ctr-regular" if self.cdm.security_level == 3 else "ctr-high"
|
|
||||||
endpoint = playback.format(scenario=scenario)
|
|
||||||
|
|
||||||
res = self._request("POST", endpoint, payload=payload, headers=headers)
|
|
||||||
self.playback_data[title.id] = self._request(
|
|
||||||
"POST", f"https://disney.playback.edge.bamgrid.com/v7/playback/{scenario}", payload=payload, headers=headers
|
|
||||||
)
|
|
||||||
|
|
||||||
manifest = res["stream"]["complete"][0]["url"]
|
|
||||||
|
|
||||||
tracks = HLS.from_url(url=manifest, session=self.session).to_tracks(language=title.language)
|
|
||||||
for audio in tracks.audio:
|
|
||||||
bitrate = re.search(
|
|
||||||
r"(?<=r/composite_)\d+|\d+(?=_complete.m3u8)",
|
|
||||||
as_list(audio.url)[0],
|
|
||||||
)
|
|
||||||
audio.bitrate = int(bitrate.group()) * 1000
|
|
||||||
if audio.bitrate == 1000_000:
|
|
||||||
# DSNP lies about the Atmos bitrate
|
|
||||||
audio.bitrate = 768_000
|
|
||||||
|
|
||||||
for track in tracks:
|
|
||||||
if track not in tracks.attachments:
|
|
||||||
track.downloader = "N_m3u8DL-RE"
|
|
||||||
track.needs_repack = True
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
|
|
||||||
"""
|
|
||||||
Extract chapter information from the title data if available.
|
|
||||||
Returns chapter markers for intro, credits, and scenes.
|
|
||||||
"""
|
|
||||||
chapters = Chapters()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# First try to get chapters from the new API via playback data
|
|
||||||
if title.id in self.playback_data and "stream" in self.playback_data[title.id]:
|
|
||||||
playback_res = self.playback_data[title.id]
|
|
||||||
|
|
||||||
# Check for editorial markers in playback data
|
|
||||||
if "editorial" in playback_res.get("stream", {}):
|
|
||||||
editorial = playback_res["stream"]["editorial"]
|
|
||||||
|
|
||||||
# Add "Start" chapter if not already present
|
|
||||||
if not any(item.get("offsetMillis") == 0 for item in editorial):
|
|
||||||
chapters.add(Chapter(timestamp=0, name="Start"))
|
|
||||||
|
|
||||||
# Map editorial labels to chapter names
|
|
||||||
mapping = {
|
|
||||||
"recap_start": "Recap",
|
|
||||||
"FFER": "Recap", # First Frame Episode Recap
|
|
||||||
"recap_end": "Scene",
|
|
||||||
"LFER": "Scene", # Last Frame Episode Recap
|
|
||||||
"intro_start": "Title Sequence",
|
|
||||||
"intro_end": "Scene",
|
|
||||||
"FFEI": "Title Sequence", # First Frame Episode Intro
|
|
||||||
"LFEI": "Scene", # Last Frame Episode Intro
|
|
||||||
"FFCB": None, # First Frame Credits Bumper
|
|
||||||
"LFCB": "Scene", # Last Frame Credits Bumper
|
|
||||||
"FFEC": "End Credits", # First Frame End Credits
|
|
||||||
"LFEC": None, # Last Frame End Credits
|
|
||||||
"up_next": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Sort by timestamp to ensure proper scene numbering
|
|
||||||
editorial.sort(key=lambda x: x.get("offsetMillis", 0))
|
|
||||||
|
|
||||||
# Track chapters we've already added by timestamp to avoid duplicates
|
|
||||||
seen_timestamps = set()
|
|
||||||
scene_count = 0
|
|
||||||
|
|
||||||
for marker in editorial:
|
|
||||||
if "label" in marker and "offsetMillis" in marker:
|
|
||||||
timestamp = marker["offsetMillis"]
|
|
||||||
name = mapping.get(marker["label"])
|
|
||||||
|
|
||||||
# Skip if no mapping or already processed timestamp
|
|
||||||
if not name or timestamp in seen_timestamps:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Mark this timestamp as seen
|
|
||||||
seen_timestamps.add(timestamp)
|
|
||||||
|
|
||||||
if name == "Scene":
|
|
||||||
scene_count += 1
|
|
||||||
name = f"Scene {scene_count}"
|
|
||||||
|
|
||||||
chapters.add(Chapter(timestamp=timestamp, name=name))
|
|
||||||
|
|
||||||
# If we found chapters in the playback data, return them
|
|
||||||
if chapters:
|
|
||||||
return chapters
|
|
||||||
|
|
||||||
# If no chapters found in playback data, try the original method
|
|
||||||
content_id = title.data["partnerFeed"].get("dmcContentId")
|
|
||||||
content = self.get_video(content_id)
|
|
||||||
|
|
||||||
# Check for chapter/milestone data
|
|
||||||
video_info = content.get("video", {}).get("milestone", {})
|
|
||||||
|
|
||||||
if not video_info:
|
|
||||||
return chapters
|
|
||||||
|
|
||||||
# Mapping of milestone types to chapter names
|
|
||||||
mapping = {
|
|
||||||
"recap_start": "Recap",
|
|
||||||
"recap_end": "Scene",
|
|
||||||
"intro_start": "Title Sequence",
|
|
||||||
"intro_end": "Scene",
|
|
||||||
"FFEI": "Title Sequence", # First Frame Episode Intro
|
|
||||||
"LFEI": "Scene", # Last Frame Episode Intro
|
|
||||||
"FFCB": None, # First Frame Credits Bumper
|
|
||||||
"LFCB": "Scene", # Last Frame Credits Bumper
|
|
||||||
"FFEC": "End Credits", # First Frame End Credits
|
|
||||||
"LFEC": None, # Last Frame End Credits
|
|
||||||
"up_next": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Flatten the milestone data and sort by start time
|
|
||||||
flattened = []
|
|
||||||
for chapter_type, items in video_info.items():
|
|
||||||
for entry in items:
|
|
||||||
if "milestoneTime" in entry and entry["milestoneTime"]:
|
|
||||||
start = entry["milestoneTime"][0]["startMillis"]
|
|
||||||
flattened.append({"type": chapter_type, "start": start})
|
|
||||||
|
|
||||||
flattened.sort(key=lambda x: x["start"])
|
|
||||||
|
|
||||||
# Create chapters
|
|
||||||
chapter_list = []
|
|
||||||
scene_count = 0
|
|
||||||
for f in flattened:
|
|
||||||
name = mapping.get(f["type"])
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if name == "Scene":
|
|
||||||
scene_count += 1
|
|
||||||
name = f"Scene {scene_count}"
|
|
||||||
|
|
||||||
chapter_list.append(Chapter(timestamp=f["start"], name=name))
|
|
||||||
|
|
||||||
# Add a "Start" chapter at 0 if we have end credits
|
|
||||||
if "FFEC" in video_info and not any(ch.timestamp == 0 for ch in chapter_list):
|
|
||||||
chapter_list.insert(0, Chapter(timestamp=0, name="Start"))
|
|
||||||
|
|
||||||
# Remove duplicates (same time and name)
|
|
||||||
prev_time, prev_name = None, None
|
|
||||||
|
|
||||||
for ch in chapter_list:
|
|
||||||
# Convert timestamp to milliseconds for comparison
|
|
||||||
if isinstance(ch.timestamp, str):
|
|
||||||
ts_parts = ch.timestamp.split(":")
|
|
||||||
hour, minute, second = int(ts_parts[0]), int(ts_parts[1]), float(ts_parts[2])
|
|
||||||
ts_ms = (hour * 3600 + minute * 60 + second) * 1000
|
|
||||||
else:
|
|
||||||
ts_ms = ch.timestamp
|
|
||||||
|
|
||||||
if prev_time is None or (ts_ms != prev_time and ch.name != prev_name):
|
|
||||||
chapters.add(ch)
|
|
||||||
prev_time, prev_name = ts_ms, ch.name
|
|
||||||
|
|
||||||
return chapters
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log.warning(f"Failed to extract chapters: {e}")
|
|
||||||
return chapters
|
|
||||||
|
|
||||||
def get_playready_license(self, *, challenge: bytes, title, track) -> bytes:
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {self._cache.data['token']['accessToken']}",
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
}
|
|
||||||
r = self.session.post(url=self.config["PLAYREADY_LICENSE"], headers=headers, data=challenge)
|
|
||||||
if r.status_code != 200:
|
|
||||||
raise ConnectionError(r.text)
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title, track) -> None:
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {self._cache.data['token']['accessToken']}",
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
}
|
|
||||||
r = self.session.post(url=self.config["LICENSE"], headers=headers, data=challenge)
|
|
||||||
if r.status_code != 200:
|
|
||||||
raise ConnectionError(r.text)
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
# Service specific functions
|
|
||||||
|
|
||||||
def _show(self, title: str) -> Episode:
|
|
||||||
page = self.get_page(title)
|
|
||||||
container = next(x for x in page["containers"] if x.get("type") == "episodes")
|
|
||||||
season_ids = [x.get("id") for x in container["seasons"] if x.get("type") == "season"]
|
|
||||||
|
|
||||||
episodes = []
|
|
||||||
for season in season_ids:
|
|
||||||
endpoint = self.href(
|
|
||||||
self.prd_config["services"]["explore"]["client"]["endpoints"]["getSeason"]["href"],
|
|
||||||
version=self.config["EXPLORE_VERSION"],
|
|
||||||
seasonId=season,
|
|
||||||
)
|
|
||||||
data = self.session.get(endpoint, params={'limit': 999}).json()["data"]["season"]["items"]
|
|
||||||
episodes.extend(data)
|
|
||||||
|
|
||||||
return [
|
|
||||||
Episode(
|
|
||||||
id_=episode.get("id"),
|
|
||||||
service=self.__class__,
|
|
||||||
title=episode["visuals"].get("title"),
|
|
||||||
year=episode["visuals"]["metastringParts"].get("releaseYearRange", {}).get("startYear"),
|
|
||||||
season=int(episode["visuals"].get("seasonNumber", 0)),
|
|
||||||
number=int(episode["visuals"].get("episodeNumber", 0)),
|
|
||||||
name=episode["visuals"].get("episodeTitle"),
|
|
||||||
language=self.get_original_lang(next(x for x in episode["actions"] if x.get("type") == "playback").get("availId")),
|
|
||||||
data=next(x for x in episode["actions"] if x.get("type") == "playback"),
|
|
||||||
)
|
|
||||||
for episode in episodes
|
|
||||||
if episode.get("type") == "view"
|
|
||||||
]
|
|
||||||
|
|
||||||
def _movie(self, title: str) -> Movie:
|
|
||||||
movie = self.get_page(title)
|
|
||||||
|
|
||||||
playback_action = next(x for x in movie["actions"] if x.get("type") == "playback")
|
|
||||||
original_lang = self.get_original_lang(playback_action.get("availId"))
|
|
||||||
|
|
||||||
return [
|
|
||||||
Movie(
|
|
||||||
id_=movie.get("id"),
|
|
||||||
service=self.__class__,
|
|
||||||
name=movie["visuals"].get("title"),
|
|
||||||
year=movie["visuals"]["metastringParts"].get("releaseYearRange", {}).get("startYear"),
|
|
||||||
language=original_lang,
|
|
||||||
data=playback_action,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_original_lang(self, availId):
|
|
||||||
try:
|
|
||||||
title_lang = self.session.get(f'https://disney.api.edge.bamgrid.com/explore/v1.6/playerExperience/{availId}').json()
|
|
||||||
original_lang = title_lang["data"]["playerExperience"]["targetLanguage"]
|
|
||||||
except Exception:
|
|
||||||
original_lang = "en"
|
|
||||||
return original_lang
|
|
||||||
|
|
||||||
def _request(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
endpoint: str,
|
|
||||||
params: dict = None,
|
|
||||||
headers: dict = None,
|
|
||||||
payload: dict = None,
|
|
||||||
) -> Any[dict | str]:
|
|
||||||
_headers = {**self.session.headers, **(headers or {})}
|
|
||||||
|
|
||||||
prep = self.session.prepare_request(Request(method, endpoint, headers=_headers, params=params, json=payload))
|
|
||||||
response = self.session.send(prep)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if data.get("errors"):
|
|
||||||
code = data["errors"][0]["extensions"].get("code")
|
|
||||||
|
|
||||||
if "token.service.unauthorized.client" in code:
|
|
||||||
raise ConnectionError("Unauthorized Client/IP: " + code)
|
|
||||||
if "idp.error.identity.bad-credentials" in code:
|
|
||||||
raise ConnectionError("Bad Credentials: " + code)
|
|
||||||
else:
|
|
||||||
raise ConnectionError(data["errors"])
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
raise ConnectionError("Request failed: {}".format(response.content))
|
|
||||||
|
|
||||||
def get_page(self, title):
|
|
||||||
params = {
|
|
||||||
"disableSmartFocus": "true",
|
|
||||||
"limit": 999,
|
|
||||||
"enhancedContainersLimit": 0,
|
|
||||||
}
|
|
||||||
endpoint = self.href(
|
|
||||||
self.prd_config["services"]["explore"]["client"]["endpoints"]["getPage"]["href"],
|
|
||||||
version=self.config["EXPLORE_VERSION"],
|
|
||||||
pageId=title,
|
|
||||||
)
|
|
||||||
return self._request("GET", endpoint, params=params)["data"]["page"]
|
|
||||||
|
|
||||||
def get_video(self, content_id: str) -> dict:
|
|
||||||
endpoint = self.href(
|
|
||||||
self.prd_config["services"]["content"]["client"]["endpoints"]["getDmcVideo"]["href"], contentId=content_id
|
|
||||||
)
|
|
||||||
return self._request("GET", endpoint)["data"]["DmcVideo"]
|
|
||||||
|
|
||||||
def get_deeplink(self, ref_id: str) -> str:
|
|
||||||
params = {
|
|
||||||
"refId": ref_id,
|
|
||||||
"refIdType": "deeplinkId",
|
|
||||||
}
|
|
||||||
endpoint = "https://disney.content.edge.bamgrid.com/explore/v1.0/deeplink"
|
|
||||||
return self._request("GET", endpoint, params=params)
|
|
||||||
|
|
||||||
def series_bundle(self, series_id: str) -> dict:
|
|
||||||
endpoint = self.href(
|
|
||||||
self.prd_config["services"]["content"]["client"]["endpoints"]["getDmcSeriesBundle"]["href"],
|
|
||||||
encodedSeriesId=series_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.session.get(endpoint).json()["data"]["DmcSeriesBundle"]
|
|
||||||
|
|
||||||
def refresh_token(self, refresh_token: str):
|
|
||||||
payload = {
|
|
||||||
"operationName": "refreshToken",
|
|
||||||
"variables": {
|
|
||||||
"input": {
|
|
||||||
"refreshToken": refresh_token,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"query": queries.REFRESH_TOKEN,
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["refreshToken"]["href"]
|
|
||||||
data = self._request("POST", endpoint, payload=payload, headers={"authorization": self.config["API_KEY"]})
|
|
||||||
return data["extensions"]["sdk"]
|
|
||||||
|
|
||||||
def _refresh(self):
|
|
||||||
if not self._cache.expired:
|
|
||||||
return self._cache.data["token"]["accessToken"]
|
|
||||||
|
|
||||||
profile = self.refresh_token(self._cache.data["token"]["refreshToken"])
|
|
||||||
self._cache.set(profile, expiration=profile["token"]["expiresIn"] - 30)
|
|
||||||
return self._cache.data["token"]["accessToken"]
|
|
||||||
|
|
||||||
def register_device(self) -> dict:
|
|
||||||
payload = {
|
|
||||||
"variables": {
|
|
||||||
"registerDevice": {
|
|
||||||
"applicationRuntime": self.config["APPLICATION_RUNTIME"],
|
|
||||||
"attributes": {
|
|
||||||
"operatingSystem": "Android",
|
|
||||||
"operatingSystemVersion": "8.1.0",
|
|
||||||
},
|
|
||||||
"deviceFamily": self.config["DEVICE_FAMILY"],
|
|
||||||
"deviceLanguage": "en",
|
|
||||||
"deviceProfile": self.config["DEVICE_PROFILE"],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"query": queries.REGISTER_DEVICE,
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["registerDevice"]["href"]
|
|
||||||
data = self._request("POST", endpoint, payload=payload, headers={"authorization": self.config["API_KEY"]})
|
|
||||||
return data["extensions"]["sdk"]["token"]["accessToken"]
|
|
||||||
|
|
||||||
def login(self, email: str, password: str, token: str) -> dict:
|
|
||||||
payload = {
|
|
||||||
"operationName": "loginTv",
|
|
||||||
"variables": {
|
|
||||||
"input": {
|
|
||||||
"email": email,
|
|
||||||
"password": password,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"query": queries.LOGIN,
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
|
|
||||||
data = self._request("POST", endpoint, payload=payload, headers={"authorization": token})
|
|
||||||
return data["extensions"]["sdk"]["token"]
|
|
||||||
|
|
||||||
def href(self, href, **kwargs) -> str:
|
|
||||||
_args = {
|
|
||||||
"apiVersion": "{apiVersion}",
|
|
||||||
"region": self.active_session["location"]["countryCode"],
|
|
||||||
"impliedMaturityRating": 1850,
|
|
||||||
"kidsModeEnabled": "false",
|
|
||||||
"appLanguage": "en-US",
|
|
||||||
"partner": "disney",
|
|
||||||
}
|
|
||||||
_args.update(**kwargs)
|
|
||||||
|
|
||||||
href = href.format(**_args)
|
|
||||||
|
|
||||||
# [3.0, 3.1, 3.2, 5.0, 3.3, 5.1, 6.0, 5.2, 6.1]
|
|
||||||
api_version = "6.1"
|
|
||||||
if "/search/" in href:
|
|
||||||
api_version = "5.1"
|
|
||||||
|
|
||||||
return href.format(apiVersion=api_version)
|
|
||||||
|
|
||||||
def check_email(self, email: str, token: str) -> str:
|
|
||||||
payload = {
|
|
||||||
"operationName": "Check",
|
|
||||||
"variables": {
|
|
||||||
"email": email,
|
|
||||||
},
|
|
||||||
"query": queries.CHECK_EMAIL,
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
|
|
||||||
data = self._request("POST", endpoint, payload=payload, headers={"authorization": token})
|
|
||||||
return data["data"]["check"]["operations"][0]
|
|
||||||
|
|
||||||
def account(self) -> dict:
|
|
||||||
endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"operationName": "EntitledGraphMeQuery",
|
|
||||||
"variables": {},
|
|
||||||
"query": queries.ENTITLEMENTS,
|
|
||||||
}
|
|
||||||
|
|
||||||
data = self._request("POST", endpoint, payload=payload)
|
|
||||||
return data["data"]["me"]
|
|
||||||
|
|
||||||
def switch_profile(self, profile_id: str) -> dict:
|
|
||||||
payload = {
|
|
||||||
"operationName": "switchProfile",
|
|
||||||
"variables": {
|
|
||||||
"input": {
|
|
||||||
"profileId": profile_id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"query": queries.SWITCH_PROFILE,
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
|
|
||||||
data = self._request("POST", endpoint, payload=payload)
|
|
||||||
return data["extensions"]["sdk"]
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
CLIENT_ID: disney-svod-3d9324fc
|
|
||||||
CLIENT_VERSION: "9.10.0"
|
|
||||||
|
|
||||||
API_KEY: "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA"
|
|
||||||
CONFIG_URL: https://bam-sdk-configs.bamgrid.com/bam-sdk/v5.0/disney-svod-3d9324fc/android/v9.10.0/google/tv/prod.json
|
|
||||||
|
|
||||||
PAGE_SIZE_SETS: 15
|
|
||||||
PAGE_SIZE_CONTENT: 30
|
|
||||||
SEARCH_QUERY_TYPE: ge
|
|
||||||
BAM_PARTNER: disney
|
|
||||||
EXPLORE_VERSION: v1.3
|
|
||||||
DEVICE_FAMILY: browser
|
|
||||||
DEVICE_PROFILE: tv
|
|
||||||
APPLICATION_RUNTIME: android
|
|
||||||
|
|
||||||
HEADERS:
|
|
||||||
User-Agent: BAMSDK/v9.10.0 (disney-svod-3d9324fc 2.26.2-rc1.0; v5.0/v9.10.0; android; tv)
|
|
||||||
x-application-version: google
|
|
||||||
x-bamsdk-platform-id: android-tv
|
|
||||||
x-bamsdk-client-id: disney-svod-3d9324fc
|
|
||||||
x-bamsdk-platform: android-tv
|
|
||||||
x-bamsdk-version: "9.10.0"
|
|
||||||
Accept-Encoding: gzip
|
|
||||||
Origin: https://www.disneyplus.com
|
|
||||||
|
|
||||||
LICENSE: https://disney.playback.edge.bamgrid.com/widevine/v1/obtain-license
|
|
||||||
PLAYREADY_LICENSE: https://disney.playback.edge.bamgrid.com/playready/v1/obtain-license.asmx
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,327 +0,0 @@
|
|||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from collections.abc import Generator
|
|
||||||
from datetime import datetime
|
|
||||||
from http.cookiejar import CookieJar
|
|
||||||
from typing import Optional, Union
|
|
||||||
|
|
||||||
import click
|
|
||||||
from langcodes import Language
|
|
||||||
|
|
||||||
from envied.core.constants import AnyTrack
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.manifests import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
|
||||||
from envied.core.tracks import Chapter, Subtitle, Tracks, Video
|
|
||||||
|
|
||||||
|
|
||||||
class EXAMPLE(Service):
|
|
||||||
"""
|
|
||||||
Service code for domain.com
|
|
||||||
Version: 1.0.0
|
|
||||||
|
|
||||||
Authorization: Cookies
|
|
||||||
|
|
||||||
Security: FHD@L3
|
|
||||||
|
|
||||||
Use full URL (for example - https://domain.com/details/20914) or title ID (for example - 20914).
|
|
||||||
"""
|
|
||||||
|
|
||||||
TITLE_RE = r"^(?:https?://?domain\.com/details/)?(?P<title_id>[^/]+)"
|
|
||||||
GEOFENCE = ("US", "UK")
|
|
||||||
NO_SUBTITLES = True
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="EXAMPLE", short_help="https://domain.com")
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.option("-m", "--movie", is_flag=True, default=False, help="Specify if it's a movie")
|
|
||||||
@click.option("-d", "--device", type=str, default="android_tv", help="Select device from the config file")
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, **kwargs):
|
|
||||||
return EXAMPLE(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx, title, movie, device):
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
self.title = title
|
|
||||||
self.movie = movie
|
|
||||||
self.device = device
|
|
||||||
self.cdm = ctx.obj.cdm
|
|
||||||
|
|
||||||
# Get range parameter for HDR support
|
|
||||||
range_param = ctx.parent.params.get("range_")
|
|
||||||
self.range = range_param[0].name if range_param else "SDR"
|
|
||||||
|
|
||||||
if self.config is None:
|
|
||||||
raise Exception("Config is missing!")
|
|
||||||
else:
|
|
||||||
profile_name = ctx.parent.params.get("profile")
|
|
||||||
if profile_name is None:
|
|
||||||
profile_name = "default"
|
|
||||||
self.profile = profile_name
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
if not cookies:
|
|
||||||
raise EnvironmentError("Service requires Cookies for Authentication.")
|
|
||||||
|
|
||||||
jwt_token = next((cookie.value for cookie in cookies if cookie.name == "streamco_token"), None)
|
|
||||||
payload = json.loads(base64.urlsafe_b64decode(jwt_token.split(".")[1] + "==").decode("utf-8"))
|
|
||||||
profile_id = payload.get("profileId", None)
|
|
||||||
self.session.headers.update({"user-agent": self.config["client"][self.device]["user_agent"]})
|
|
||||||
|
|
||||||
cache = self.cache.get(f"tokens_{self.device}_{self.profile}")
|
|
||||||
|
|
||||||
if cache:
|
|
||||||
if cache.data["expires_in"] > int(datetime.now().timestamp()):
|
|
||||||
self.log.info("Using cached tokens")
|
|
||||||
else:
|
|
||||||
self.log.info("Refreshing tokens")
|
|
||||||
|
|
||||||
refresh = self.session.post(
|
|
||||||
url=self.config["endpoints"]["refresh"], data={"refresh_token": cache.data["refresh_data"]}
|
|
||||||
).json()
|
|
||||||
|
|
||||||
cache.set(data=refresh)
|
|
||||||
|
|
||||||
else:
|
|
||||||
self.log.info("Retrieving new tokens")
|
|
||||||
|
|
||||||
token = self.session.post(
|
|
||||||
url=self.config["endpoints"]["login"],
|
|
||||||
data={
|
|
||||||
"token": jwt_token,
|
|
||||||
"profileId": profile_id,
|
|
||||||
},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
cache.set(data=token)
|
|
||||||
|
|
||||||
self.token = cache.data["token"]
|
|
||||||
self.user_id = cache.data["userId"]
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
search = self.session.get(
|
|
||||||
url=self.config["endpoints"]["search"], params={"q": self.title, "token": self.token}
|
|
||||||
).json()
|
|
||||||
|
|
||||||
for result in search["entries"]:
|
|
||||||
yield SearchResult(
|
|
||||||
id_=result["id"],
|
|
||||||
title=result["title"],
|
|
||||||
label="SERIES" if result["programType"] == "series" else "MOVIE",
|
|
||||||
url=result["url"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_titles(self) -> Titles_T:
|
|
||||||
self.title = re.match(self.TITLE_RE, self.title).group(1)
|
|
||||||
|
|
||||||
metadata = self.session.get(
|
|
||||||
url=self.config["endpoints"]["metadata"].format(title_id=self.title), params={"token": self.token}
|
|
||||||
).json()
|
|
||||||
|
|
||||||
if metadata["programType"] == "movie":
|
|
||||||
self.movie = True
|
|
||||||
|
|
||||||
if self.movie:
|
|
||||||
return Movies(
|
|
||||||
[
|
|
||||||
Movie(
|
|
||||||
id_=metadata["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
name=metadata["title"],
|
|
||||||
description=metadata["description"],
|
|
||||||
year=metadata["releaseYear"] if metadata["releaseYear"] > 0 else None,
|
|
||||||
language=Language.find(metadata["languages"][0]),
|
|
||||||
data=metadata,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
episodes = []
|
|
||||||
|
|
||||||
for season in metadata["seasons"]:
|
|
||||||
if "Trailers" not in season["title"]:
|
|
||||||
season_data = self.session.get(url=season["url"], params={"token": self.token}).json()
|
|
||||||
|
|
||||||
for episode in season_data["entries"]:
|
|
||||||
episodes.append(
|
|
||||||
Episode(
|
|
||||||
id_=episode["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=metadata["title"],
|
|
||||||
season=episode["season"],
|
|
||||||
number=episode["episode"],
|
|
||||||
name=episode["title"],
|
|
||||||
description=episode["description"],
|
|
||||||
year=metadata["releaseYear"] if metadata["releaseYear"] > 0 else None,
|
|
||||||
language=Language.find(metadata["languages"][0]),
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
|
||||||
# Handle HYBRID mode by fetching both HDR10 and DV tracks separately
|
|
||||||
if self.range == "HYBRID" and self.cdm.security_level != 3:
|
|
||||||
tracks = Tracks()
|
|
||||||
|
|
||||||
# Get HDR10 tracks
|
|
||||||
hdr10_tracks = self._get_tracks_for_range(title, "HDR10")
|
|
||||||
tracks.add(hdr10_tracks, warn_only=True)
|
|
||||||
|
|
||||||
# Get DV tracks
|
|
||||||
dv_tracks = self._get_tracks_for_range(title, "DV")
|
|
||||||
tracks.add(dv_tracks, warn_only=True)
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
else:
|
|
||||||
# Normal single-range behavior
|
|
||||||
return self._get_tracks_for_range(title, self.range)
|
|
||||||
|
|
||||||
def _get_tracks_for_range(self, title: Title_T, range_override: str = None) -> Tracks:
|
|
||||||
# Use range_override if provided, otherwise use self.range
|
|
||||||
current_range = range_override if range_override else self.range
|
|
||||||
|
|
||||||
# Build API request parameters
|
|
||||||
params = {
|
|
||||||
"token": self.token,
|
|
||||||
"guid": title.id,
|
|
||||||
}
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"type": self.config["client"][self.device]["type"],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add range-specific parameters
|
|
||||||
if current_range == "HDR10":
|
|
||||||
data["video_format"] = "hdr10"
|
|
||||||
elif current_range == "DV":
|
|
||||||
data["video_format"] = "dolby_vision"
|
|
||||||
else:
|
|
||||||
data["video_format"] = "sdr"
|
|
||||||
|
|
||||||
# Only request high-quality HDR content with L1 CDM
|
|
||||||
if current_range in ("HDR10", "DV") and self.cdm.security_level == 3:
|
|
||||||
# L3 CDM - skip HDR content
|
|
||||||
return Tracks()
|
|
||||||
|
|
||||||
streams = self.session.post(
|
|
||||||
url=self.config["endpoints"]["streams"],
|
|
||||||
params=params,
|
|
||||||
data=data,
|
|
||||||
).json()["media"]
|
|
||||||
|
|
||||||
self.license = {
|
|
||||||
"url": streams["drm"]["url"],
|
|
||||||
"data": streams["drm"]["data"],
|
|
||||||
"session": streams["drm"]["session"],
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest_url = streams["url"].split("?")[0]
|
|
||||||
|
|
||||||
self.log.debug(f"Manifest URL: {manifest_url}")
|
|
||||||
tracks = DASH.from_url(url=manifest_url, session=self.session).to_tracks(language=title.language)
|
|
||||||
|
|
||||||
# Set range attributes on video tracks
|
|
||||||
for video in tracks.videos:
|
|
||||||
if current_range == "HDR10":
|
|
||||||
video.range = Video.Range.HDR10
|
|
||||||
elif current_range == "DV":
|
|
||||||
video.range = Video.Range.DV
|
|
||||||
else:
|
|
||||||
video.range = Video.Range.SDR
|
|
||||||
|
|
||||||
# Remove DRM-free ("clear") audio tracks
|
|
||||||
tracks.audio = [
|
|
||||||
track for track in tracks.audio if "clear" not in track.data["dash"]["representation"].get("id")
|
|
||||||
]
|
|
||||||
|
|
||||||
for track in tracks.audio:
|
|
||||||
if track.channels == 6.0:
|
|
||||||
track.channels = 5.1
|
|
||||||
track_label = track.data["dash"]["adaptation_set"].get("label")
|
|
||||||
if track_label and "Audio Description" in track_label:
|
|
||||||
track.descriptive = True
|
|
||||||
|
|
||||||
tracks.subtitles.clear()
|
|
||||||
if streams.get("captions"):
|
|
||||||
for subtitle in streams["captions"]:
|
|
||||||
tracks.add(
|
|
||||||
Subtitle(
|
|
||||||
id_=hashlib.md5(subtitle["url"].encode()).hexdigest()[0:6],
|
|
||||||
url=subtitle["url"],
|
|
||||||
codec=Subtitle.Codec.from_mime("vtt"),
|
|
||||||
language=Language.get(subtitle["language"]),
|
|
||||||
# cc=True if '(cc)' in subtitle['name'] else False,
|
|
||||||
sdh=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not self.movie:
|
|
||||||
title.data["chapters"] = self.session.get(
|
|
||||||
url=self.config["endpoints"]["metadata"].format(title_id=title.id), params={"token": self.token}
|
|
||||||
).json()["chapters"]
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Title_T) -> list[Chapter]:
|
|
||||||
chapters = []
|
|
||||||
|
|
||||||
if title.data.get("chapters", []):
|
|
||||||
for chapter in title.data["chapters"]:
|
|
||||||
if chapter["name"] == "Intro":
|
|
||||||
chapters.append(Chapter(timestamp=chapter["start"], name="Opening"))
|
|
||||||
chapters.append(Chapter(timestamp=chapter["end"]))
|
|
||||||
if chapter["name"] == "Credits":
|
|
||||||
chapters.append(Chapter(timestamp=chapter["start"], name="Credits"))
|
|
||||||
|
|
||||||
return chapters
|
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, **_: any) -> str:
|
|
||||||
"""Return the Widevine service certificate from config, if available."""
|
|
||||||
return self.config.get("certificate")
|
|
||||||
|
|
||||||
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
|
|
||||||
"""Retrieve a PlayReady license for a given track."""
|
|
||||||
|
|
||||||
license_url = self.config["endpoints"].get("playready_license")
|
|
||||||
if not license_url:
|
|
||||||
raise ValueError("PlayReady license endpoint not configured")
|
|
||||||
|
|
||||||
response = self.session.post(
|
|
||||||
url=license_url,
|
|
||||||
data=challenge,
|
|
||||||
headers={
|
|
||||||
"user-agent": self.config["client"][self.device]["license_user_agent"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.content
|
|
||||||
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
|
||||||
license_url = self.license.get("url") or self.config["endpoints"].get("widevine_license")
|
|
||||||
if not license_url:
|
|
||||||
raise ValueError("Widevine license endpoint not configured")
|
|
||||||
|
|
||||||
response = self.session.post(
|
|
||||||
url=license_url,
|
|
||||||
data=challenge,
|
|
||||||
params={
|
|
||||||
"session": self.license.get("session"),
|
|
||||||
"userId": self.user_id,
|
|
||||||
},
|
|
||||||
headers={
|
|
||||||
"dt-custom-data": self.license.get("data"),
|
|
||||||
"user-agent": self.config["client"][self.device]["license_user_agent"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
try:
|
|
||||||
return response.json().get("license")
|
|
||||||
except ValueError:
|
|
||||||
return response.content
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
endpoints:
|
|
||||||
login: https://api.domain.com/v1/login
|
|
||||||
metadata: https://api.domain.com/v1/metadata/{title_id}.json
|
|
||||||
streams: https://api.domain.com/v1/streams
|
|
||||||
playready_license: https://api.domain.com/v1/license/playready
|
|
||||||
widevine_license: https://api.domain.com/v1/license/widevine
|
|
||||||
|
|
||||||
client:
|
|
||||||
android_tv:
|
|
||||||
user_agent: USER_AGENT
|
|
||||||
license_user_agent: LICENSE_USER_AGENT
|
|
||||||
type: DATA
|
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from collections.abc import Generator
|
|
||||||
from http.cookiejar import MozillaCookieJar
|
|
||||||
from typing import Any, Optional, Union
|
|
||||||
|
|
||||||
import click
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from click import Context
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.manifests.dash import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series
|
|
||||||
from envied.core.tracks import Chapter, Chapters, Subtitle, Tracks
|
|
||||||
|
|
||||||
|
|
||||||
class ITV(Service):
|
|
||||||
"""
|
|
||||||
Service code for ITVx streaming service (https://www.itv.com/).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.2
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: Cookies (Optional for free content | Required for premium content)
|
|
||||||
Robustness:
|
|
||||||
L3: 1080p
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Use complete title URL as input (pay attention to the URL format):
|
|
||||||
SERIES: https://www.itv.com/watch/bay-of-fires/10a5270
|
|
||||||
EPISODE: https://www.itv.com/watch/bay-of-fires/10a5270/10a5270a0001
|
|
||||||
FILM: https://www.itv.com/watch/mad-max-beyond-thunderdome/2a7095
|
|
||||||
- Some shows aren't listed as series, only as "Latest episodes"
|
|
||||||
Download by SERIES URL for those titles, not by EPISODE URL
|
|
||||||
|
|
||||||
\b
|
|
||||||
Examples:
|
|
||||||
- SERIES: devine dl -w s01e01 itv https://www.itv.com/watch/bay-of-fires/10a5270
|
|
||||||
- EPISODE: devine dl itv https://www.itv.com/watch/bay-of-fires/10a5270/10a5270a0001
|
|
||||||
- FILM: devine dl itv https://www.itv.com/watch/mad-max-beyond-thunderdome/2a7095
|
|
||||||
|
|
||||||
\b
|
|
||||||
Notes:
|
|
||||||
ITV seem to detect and throttle multiple connections against the server.
|
|
||||||
It's recommended to use requests as downloader, with few workers.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
GEOFENCE = ("gb",)
|
|
||||||
ALIASES = ("itvx",)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="ITV", short_help="https://www.itv.com/", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> ITV:
|
|
||||||
return ITV(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
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)
|
|
||||||
self.authorization = None
|
|
||||||
|
|
||||||
if credential and not cookies:
|
|
||||||
self.log.error(" - Error: This service requires cookies for authentication.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if cookies is not None:
|
|
||||||
self.log.info(f"\n + Cookies for '{self.profile}' profile found, authenticating...")
|
|
||||||
itv_session = next((cookie.value for cookie in cookies if cookie.name == "Itv.Session"), None)
|
|
||||||
if not itv_session:
|
|
||||||
self.log.error(" - Error: Session cookie not found. Cookies may be invalid.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
itv_session = json.loads(itv_session)
|
|
||||||
refresh_token = itv_session["tokens"]["content"].get("refresh_token")
|
|
||||||
if not refresh_token:
|
|
||||||
self.log.error(" - Error: Access tokens not found. Try refreshing your cookies.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
cache = self.cache.get(f"tokens_{self.profile}")
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Host": "auth.prd.user.itv.com",
|
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0",
|
|
||||||
"Accept": "application/vnd.user.auth.v2+json",
|
|
||||||
"Accept-Language": "en-US,en;q=0.8",
|
|
||||||
"Origin": "https://www.itv.com",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
"Referer": "https://www.itv.com/",
|
|
||||||
}
|
|
||||||
|
|
||||||
params = {"refresh": cache.data["refresh_token"]} if cache else {"refresh": refresh_token}
|
|
||||||
|
|
||||||
r = self.session.get(
|
|
||||||
self.config["endpoints"]["refresh"],
|
|
||||||
headers=headers,
|
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
if r.status_code != 200:
|
|
||||||
raise ConnectionError(f"Failed to refresh tokens: {r.text}")
|
|
||||||
|
|
||||||
tokens = r.json()
|
|
||||||
cache.set(tokens)
|
|
||||||
self.log.info(" + Tokens refreshed and placed in cache\n")
|
|
||||||
|
|
||||||
self.authorization = tokens["access_token"]
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
params = {
|
|
||||||
"broadcaster": "itv",
|
|
||||||
"featureSet": "clearkey,outband-webvtt,hls,aes,playready,widevine,fairplay,bbts,progressive,hd,rtmpe",
|
|
||||||
"onlyFree": "false",
|
|
||||||
"platform": "dotcom",
|
|
||||||
"query": self.title,
|
|
||||||
}
|
|
||||||
|
|
||||||
r = self.session.get(self.config["endpoints"]["search"], params=params)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
results = r.json()["results"]
|
|
||||||
if isinstance(results, list):
|
|
||||||
for result in results:
|
|
||||||
special = result["data"].get("specialTitle")
|
|
||||||
standard = result["data"].get("programmeTitle")
|
|
||||||
film = result["data"].get("filmTitle")
|
|
||||||
title = special if special else standard if standard else film
|
|
||||||
tier = result["data"].get("tier")
|
|
||||||
|
|
||||||
slug = self._sanitize(title)
|
|
||||||
|
|
||||||
_id = result["data"]["legacyId"]["apiEncoded"]
|
|
||||||
_id = "_".join(_id.split("_")[:2]).replace("_", "a")
|
|
||||||
_id = re.sub(r"a000\d+", "", _id)
|
|
||||||
|
|
||||||
yield SearchResult(
|
|
||||||
id_=f"https://www.itv.com/watch/{slug}/{_id}",
|
|
||||||
title=title,
|
|
||||||
description=result["data"].get("synopsis"),
|
|
||||||
label=result.get("entityType") + f" {tier}",
|
|
||||||
url=f"https://www.itv.com/watch/{slug}/{_id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_titles(self) -> Union[Movies, Series]:
|
|
||||||
data = self.get_data(self.title)
|
|
||||||
kind = next(
|
|
||||||
(x.get("seriesType") for x in data.get("seriesList") if x.get("seriesType") in ["SERIES", "FILM"]), None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Some shows are not listed as "SERIES" or "FILM", only as "Latest episodes"
|
|
||||||
if not kind and next(
|
|
||||||
(x for x in data.get("seriesList") if x.get("seriesLabel").lower() in ("latest episodes", "other episodes")), None
|
|
||||||
):
|
|
||||||
titles = data["seriesList"][0]["titles"]
|
|
||||||
episodes =[
|
|
||||||
Episode(
|
|
||||||
id_=episode["episodeId"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=data["programme"]["title"],
|
|
||||||
season=episode.get("series") if isinstance(episode.get("series"), int) else 0,
|
|
||||||
number=episode.get("episode") if isinstance(episode.get("episode"), int) else 0,
|
|
||||||
name=episode["episodeTitle"],
|
|
||||||
language="en", # TODO: language detection
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
for episode in titles
|
|
||||||
]
|
|
||||||
# Assign episode numbers to special seasons
|
|
||||||
counter = 1
|
|
||||||
for episode in episodes:
|
|
||||||
if episode.season == 0 and episode.number == 0:
|
|
||||||
episode.number = counter
|
|
||||||
counter += 1
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
if kind == "SERIES" and data.get("episode"):
|
|
||||||
episode = data.get("episode")
|
|
||||||
return Series(
|
|
||||||
[
|
|
||||||
Episode(
|
|
||||||
id_=episode["episodeId"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=data["programme"]["title"],
|
|
||||||
season=episode.get("series") if isinstance(episode.get("series"), int) else 0,
|
|
||||||
number=episode.get("episode") if isinstance(episode.get("episode"), int) else 0,
|
|
||||||
name=episode["episodeTitle"],
|
|
||||||
language="en", # TODO: language detection
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
elif kind == "SERIES":
|
|
||||||
return Series(
|
|
||||||
[
|
|
||||||
Episode(
|
|
||||||
id_=episode["episodeId"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=data["programme"]["title"],
|
|
||||||
season=episode.get("series") if isinstance(episode.get("series"), int) else 0,
|
|
||||||
number=episode.get("episode") if isinstance(episode.get("episode"), int) else 0,
|
|
||||||
name=episode["episodeTitle"],
|
|
||||||
language="en", # TODO: language detection
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
for series in data["seriesList"]
|
|
||||||
if "Latest episodes" not in series["seriesLabel"]
|
|
||||||
for episode in series["titles"]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
elif kind == "FILM":
|
|
||||||
return Movies(
|
|
||||||
[
|
|
||||||
Movie(
|
|
||||||
id_=movie["episodeId"],
|
|
||||||
service=self.__class__,
|
|
||||||
name=data["programme"]["title"],
|
|
||||||
year=movie.get("productionYear"),
|
|
||||||
language="en", # TODO: language detection
|
|
||||||
data=movie,
|
|
||||||
)
|
|
||||||
for movies in data["seriesList"]
|
|
||||||
for movie in movies["titles"]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
|
||||||
playlist = title.data.get("playlistUrl")
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Accept": "application/vnd.itv.vod.playlist.v4+json",
|
|
||||||
"Accept-Language": "en-US,en;q=0.9,da;q=0.8",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"client": {
|
|
||||||
"id": "lg",
|
|
||||||
},
|
|
||||||
"device": {
|
|
||||||
"deviceGroup": "ctv",
|
|
||||||
},
|
|
||||||
"variantAvailability": {
|
|
||||||
"player": "dash",
|
|
||||||
"featureset": [
|
|
||||||
"mpeg-dash",
|
|
||||||
"widevine",
|
|
||||||
"outband-webvtt",
|
|
||||||
"hd",
|
|
||||||
"single-track",
|
|
||||||
],
|
|
||||||
"platformTag": "ctv",
|
|
||||||
"drm": {
|
|
||||||
"system": "widevine",
|
|
||||||
"maxSupported": "L3",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if self.authorization:
|
|
||||||
payload["user"] = {"token": self.authorization}
|
|
||||||
|
|
||||||
r = self.session.post(playlist, headers=headers, json=payload)
|
|
||||||
if r.status_code != 200:
|
|
||||||
raise ConnectionError(r.text)
|
|
||||||
|
|
||||||
data = r.json()
|
|
||||||
video = data["Playlist"]["Video"]
|
|
||||||
subtitles = video.get("Subtitles")
|
|
||||||
self.manifest = video["MediaFiles"][0].get("Href")
|
|
||||||
self.license = video["MediaFiles"][0].get("KeyServiceUrl")
|
|
||||||
|
|
||||||
tracks = DASH.from_url(self.manifest, self.session).to_tracks(title.language)
|
|
||||||
tracks.videos[0].data = data
|
|
||||||
|
|
||||||
if subtitles is not None:
|
|
||||||
for subtitle in subtitles:
|
|
||||||
tracks.add(
|
|
||||||
Subtitle(
|
|
||||||
id_=hashlib.md5(subtitle.get("Href", "").encode()).hexdigest()[0:6],
|
|
||||||
url=subtitle.get("Href", ""),
|
|
||||||
codec=Subtitle.Codec.from_mime(subtitle.get("Href", "")[-3:]),
|
|
||||||
language=title.language,
|
|
||||||
forced=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
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:
|
|
||||||
track = title.tracks.videos[0]
|
|
||||||
if not track.data["Playlist"].get("ContentBreaks"):
|
|
||||||
return Chapters()
|
|
||||||
|
|
||||||
breaks = track.data["Playlist"]["ContentBreaks"]
|
|
||||||
timecodes = [".".join(x.get("TimeCode").rsplit(":", 1)) for x in breaks if x.get("TimeCode") != "00:00:00:000"]
|
|
||||||
|
|
||||||
# End credits are sometimes listed before the last chapter, so we skip those for now
|
|
||||||
return Chapters([Chapter(timecode) for timecode in timecodes])
|
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_widevine_license(self, challenge: bytes, **_: Any) -> bytes:
|
|
||||||
r = self.session.post(url=self.license, data=challenge)
|
|
||||||
if r.status_code != 200:
|
|
||||||
raise ConnectionError(r.text)
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
# Service specific functions
|
|
||||||
|
|
||||||
def get_data(self, url: str) -> dict:
|
|
||||||
# TODO: Find a proper endpoint for this
|
|
||||||
|
|
||||||
r = self.session.get(url)
|
|
||||||
if r.status_code != 200:
|
|
||||||
raise ConnectionError(r.text)
|
|
||||||
|
|
||||||
soup = BeautifulSoup(r.text, "html.parser")
|
|
||||||
props = soup.select_one("#__NEXT_DATA__").text
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(props)
|
|
||||||
except Exception as e:
|
|
||||||
raise ValueError(f"Failed to parse JSON: {e}")
|
|
||||||
|
|
||||||
return data["props"]["pageProps"]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _sanitize(title: str) -> str:
|
|
||||||
title = title.lower()
|
|
||||||
title = title.replace("&", "and")
|
|
||||||
title = re.sub(r"[:;/()]", "", title)
|
|
||||||
title = re.sub(r"[ ]", "-", title)
|
|
||||||
title = re.sub(r"[\\*!?¿,'\"<>|$#`’]", "", title)
|
|
||||||
title = re.sub(rf"[{'.'}]{{2,}}", ".", title)
|
|
||||||
title = re.sub(rf"[{'_'}]{{2,}}", "_", title)
|
|
||||||
title = re.sub(rf"[{'-'}]{{2,}}", "-", title)
|
|
||||||
title = re.sub(rf"[{' '}]{{2,}}", " ", title)
|
|
||||||
return title
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
headers:
|
|
||||||
User-Agent: okhttp/4.9.3
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
login: https://auth.prd.user.itv.com/v2/auth
|
|
||||||
refresh: https://auth.prd.user.itv.com/token
|
|
||||||
search: https://textsearch.prd.oasvc.itv.com/search
|
|
||||||
@@ -1,650 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
from hashlib import md5
|
|
||||||
from typing import Optional, Union, Generator
|
|
||||||
from http.cookiejar import CookieJar
|
|
||||||
|
|
||||||
import click
|
|
||||||
import requests
|
|
||||||
import xmltodict
|
|
||||||
from langcodes import Language
|
|
||||||
|
|
||||||
from envied.core.constants import AnyTrack
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.manifests import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
|
||||||
from envied.core.tracks import Chapter, Chapters, Subtitle, Tracks, Video
|
|
||||||
|
|
||||||
|
|
||||||
class MAX(Service):
|
|
||||||
"""
|
|
||||||
Service code for MAX's streaming service (https://max.com).
|
|
||||||
Version: 1.0.0
|
|
||||||
|
|
||||||
Authorization: Cookies
|
|
||||||
Security: UHD@L1 FHD@L1 HD@L3
|
|
||||||
|
|
||||||
Use full URL or title ID with type.
|
|
||||||
Examples:
|
|
||||||
- https://play.hbomax.com/movie/urn:hbo:movie:GUID
|
|
||||||
- https://play.hbomax.com/show/urn:hbo:series:GUID
|
|
||||||
- movie/GUID
|
|
||||||
- show/GUID
|
|
||||||
|
|
||||||
Note: This service is designed for users who have legal access to MAX content.
|
|
||||||
Ensure you have proper subscription and authentication before use.
|
|
||||||
"""
|
|
||||||
|
|
||||||
ALIASES = ("MAX", "max", "hbomax")
|
|
||||||
GEOFENCE = ("US",)
|
|
||||||
|
|
||||||
TITLE_RE = r"^(?:https?://(?:www\.|play\.)?hbomax\.com/)?(?P<type>[^/]+)/(?P<id>[^/]+)"
|
|
||||||
|
|
||||||
VIDEO_CODEC_MAP = {
|
|
||||||
"H264": ["avc1"],
|
|
||||||
"H265": ["hvc1", "dvh1"]
|
|
||||||
}
|
|
||||||
|
|
||||||
AUDIO_CODEC_MAP = {
|
|
||||||
"AAC": "mp4a",
|
|
||||||
"AC3": "ac-3",
|
|
||||||
"EC3": "ec-3"
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="MAX", short_help="https://max.com")
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.option("-vcodec", "--video-codec", default=None, help="Video codec preference")
|
|
||||||
@click.option("-acodec", "--audio-codec", default=None, help="Audio codec preference")
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, **kwargs):
|
|
||||||
return MAX(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx, title, video_codec, audio_codec):
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
self.title = title
|
|
||||||
self.vcodec = video_codec
|
|
||||||
self.acodec = audio_codec
|
|
||||||
|
|
||||||
# Get range parameter for HDR support
|
|
||||||
range_param = ctx.parent.params.get("range_")
|
|
||||||
self.range = range_param[0].name if range_param else "SDR"
|
|
||||||
|
|
||||||
if self.range == 'HDR10':
|
|
||||||
self.vcodec = "H265"
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
if not cookies:
|
|
||||||
raise EnvironmentError("Service requires Cookies for Authentication.")
|
|
||||||
|
|
||||||
# Extract authentication tokens from cookies
|
|
||||||
try:
|
|
||||||
token = next(cookie.value for cookie in cookies if cookie.name == "st")
|
|
||||||
session_data = next(cookie.value for cookie in cookies if cookie.name == "session")
|
|
||||||
device_id = json.loads(session_data)
|
|
||||||
except (StopIteration, json.JSONDecodeError):
|
|
||||||
raise EnvironmentError("Required authentication cookies not found.")
|
|
||||||
|
|
||||||
# Configure headers based on device type
|
|
||||||
self.session.headers.update({
|
|
||||||
'User-Agent': 'BEAM-Android/1.0.0.104 (SONY/XR-75X95EL)',
|
|
||||||
'Accept': 'application/json, text/plain, */*',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-disco-client': 'SAMSUNGTV:124.0.0.0:beam:4.0.0.118',
|
|
||||||
'x-disco-params': 'realm=bolt,bid=beam,features=ar',
|
|
||||||
'x-device-info': 'beam/4.0.0.118 (Samsung/Samsung-Unknown; Tizen/124.0.0.0; f198a6c1-c582-4725-9935-64eb6b17c3cd/87a996fa-4917-41ae-9b6d-c7f521f0cb78)',
|
|
||||||
'traceparent': '00-315ac07a3de9ad1493956cf1dd5d1313-988e057938681391-01',
|
|
||||||
'tracestate': f'wbd=session:{device_id}',
|
|
||||||
'Origin': 'https://play.hbomax.com',
|
|
||||||
'Referer': 'https://play.hbomax.com/',
|
|
||||||
})
|
|
||||||
|
|
||||||
# Get device token
|
|
||||||
auth_token = self._get_device_token()
|
|
||||||
self.session.headers.update({
|
|
||||||
"x-wbd-session-state": auth_token
|
|
||||||
})
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
"""
|
|
||||||
Search for content on MAX platform.
|
|
||||||
Note: This is a basic implementation - MAX's search API may require additional parameters.
|
|
||||||
"""
|
|
||||||
# Basic search implementation - you may need to adjust based on actual API
|
|
||||||
search_url = "https://default.prd.api.hbomax.com/search"
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = self.session.get(search_url, params={"q": self.title})
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
search_data = response.json()
|
|
||||||
|
|
||||||
# Parse search results - adjust based on actual API response structure
|
|
||||||
for result in search_data.get("results", []):
|
|
||||||
yield SearchResult(
|
|
||||||
id_=result.get("id"),
|
|
||||||
title=result.get("title", "Unknown"),
|
|
||||||
label=result.get("type", "UNKNOWN").upper(),
|
|
||||||
url=f"https://play.hbomax.com/{result.get('type', 'content')}/{result.get('id')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log.warning(f"Search functionality not fully implemented: {e}")
|
|
||||||
# Return empty generator if search fails
|
|
||||||
return
|
|
||||||
yield # This makes it a generator function
|
|
||||||
|
|
||||||
def get_titles(self) -> Titles_T:
|
|
||||||
# Parse title input
|
|
||||||
match = re.match(self.TITLE_RE, self.title)
|
|
||||||
if not match:
|
|
||||||
raise ValueError("Invalid title format. Expected format: type/id or full URL")
|
|
||||||
|
|
||||||
content_type = match.group('type')
|
|
||||||
external_id = match.group('id')
|
|
||||||
|
|
||||||
response = self.session.get(
|
|
||||||
self.config['endpoints']['contentRoutes'] % (content_type, external_id)
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
try:
|
|
||||||
content_data = [x for x in response.json()["included"] if "attributes" in x and "title" in
|
|
||||||
x["attributes"] and x["attributes"]["alias"] == "generic-%s-blueprint-page" % (re.sub(r"-", "", content_type))][0]["attributes"]
|
|
||||||
content_title = content_data["title"]
|
|
||||||
except:
|
|
||||||
content_data = [x for x in response.json()["included"] if "attributes" in x and "alternateId" in
|
|
||||||
x["attributes"] and x["attributes"]["alternateId"] == external_id and x["attributes"].get("originalName")][0]["attributes"]
|
|
||||||
content_title = content_data["originalName"]
|
|
||||||
|
|
||||||
if content_type == "sport" or content_type == "event":
|
|
||||||
included_dt = response.json()["included"]
|
|
||||||
|
|
||||||
for included in included_dt:
|
|
||||||
for key, data in included.items():
|
|
||||||
if key == "attributes":
|
|
||||||
for k, d in data.items():
|
|
||||||
if d == "VOD":
|
|
||||||
event_data = included
|
|
||||||
|
|
||||||
release_date = event_data["attributes"].get("airDate") or event_data["attributes"].get("firstAvailableDate")
|
|
||||||
year = datetime.strptime(release_date, '%Y-%m-%dT%H:%M:%SZ').year
|
|
||||||
|
|
||||||
return Movies([
|
|
||||||
Movie(
|
|
||||||
id_=external_id,
|
|
||||||
service=self.__class__,
|
|
||||||
name=content_title.title(),
|
|
||||||
year=year,
|
|
||||||
data=event_data,
|
|
||||||
)
|
|
||||||
])
|
|
||||||
|
|
||||||
if content_type == "movie" or content_type == "standalone":
|
|
||||||
metadata = self.session.get(
|
|
||||||
url=self.config['endpoints']['moviePages'] % external_id
|
|
||||||
).json()['data']
|
|
||||||
|
|
||||||
try:
|
|
||||||
edit_id = metadata['relationships']['edit']['data']['id']
|
|
||||||
except:
|
|
||||||
for x in response.json()["included"]:
|
|
||||||
if x.get("type") == "video" and x.get("relationships", {}).get("show", {}).get("data", {}).get("id") == external_id:
|
|
||||||
metadata = x
|
|
||||||
|
|
||||||
release_date = metadata["attributes"].get("airDate") or metadata["attributes"].get("firstAvailableDate")
|
|
||||||
year = datetime.strptime(release_date, '%Y-%m-%dT%H:%M:%SZ').year
|
|
||||||
|
|
||||||
return Movies([
|
|
||||||
Movie(
|
|
||||||
id_=external_id,
|
|
||||||
service=self.__class__,
|
|
||||||
name=content_title,
|
|
||||||
year=year,
|
|
||||||
data=metadata,
|
|
||||||
)
|
|
||||||
])
|
|
||||||
|
|
||||||
if content_type in ["show", "mini-series", "topical"]:
|
|
||||||
episodes = []
|
|
||||||
if content_type == "mini-series":
|
|
||||||
alias = "generic-miniseries-page-rail-episodes"
|
|
||||||
elif content_type == "topical":
|
|
||||||
alias = "generic-topical-show-page-rail-episodes"
|
|
||||||
else:
|
|
||||||
alias = "-%s-page-rail-episodes-tabbed-content" % (content_type)
|
|
||||||
|
|
||||||
included_dt = response.json()["included"]
|
|
||||||
|
|
||||||
season_data = [data for included in included_dt for key, data in included.items()
|
|
||||||
if key == "attributes" for k, d in data.items() if alias in str(d).lower()][0]
|
|
||||||
|
|
||||||
season_data = season_data["component"]["filters"][0]
|
|
||||||
|
|
||||||
seasons = [int(season["value"]) for season in season_data["options"]]
|
|
||||||
|
|
||||||
season_parameters = [(int(season["value"]), season["parameter"]) for season in season_data["options"]
|
|
||||||
for season_number in seasons if int(season["value"]) == int(season_number)]
|
|
||||||
|
|
||||||
if not season_parameters:
|
|
||||||
raise ValueError("No seasons found")
|
|
||||||
|
|
||||||
for (value, parameter) in season_parameters:
|
|
||||||
data = self.session.get(
|
|
||||||
url=self.config['endpoints']['showPages'] % (external_id, parameter)
|
|
||||||
).json()
|
|
||||||
|
|
||||||
try:
|
|
||||||
episodes_dt = sorted([dt for dt in data["included"] if "attributes" in dt and "videoType" in
|
|
||||||
dt["attributes"] and dt["attributes"]["videoType"] == "EPISODE"
|
|
||||||
and int(dt["attributes"]["seasonNumber"]) == int(parameter.split("=")[-1])],
|
|
||||||
key=lambda x: x["attributes"]["episodeNumber"])
|
|
||||||
except KeyError:
|
|
||||||
raise ValueError("Season episodes were not found")
|
|
||||||
|
|
||||||
episodes.extend(episodes_dt)
|
|
||||||
|
|
||||||
episode_titles = []
|
|
||||||
release_date = episodes[0]["attributes"].get("airDate") or episodes[0]["attributes"].get("firstAvailableDate")
|
|
||||||
year = datetime.strptime(release_date, '%Y-%m-%dT%H:%M:%SZ').year
|
|
||||||
|
|
||||||
season_map = {int(item[1].split("=")[-1]): item[0] for item in season_parameters}
|
|
||||||
|
|
||||||
for episode in episodes:
|
|
||||||
episode_titles.append(
|
|
||||||
Episode(
|
|
||||||
id_=episode['id'],
|
|
||||||
service=self.__class__,
|
|
||||||
title=content_title,
|
|
||||||
season=season_map.get(episode['attributes'].get('seasonNumber')),
|
|
||||||
number=episode['attributes']['episodeNumber'],
|
|
||||||
name=episode['attributes']['name'],
|
|
||||||
year=year,
|
|
||||||
data=episode
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return Series(episode_titles)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
|
||||||
edit_id = title.data['relationships']['edit']['data']['id']
|
|
||||||
|
|
||||||
response = self.session.post(
|
|
||||||
url=self.config['endpoints']['playbackInfo'],
|
|
||||||
json={
|
|
||||||
'appBundle': 'beam',
|
|
||||||
'consumptionType': 'streaming',
|
|
||||||
'deviceInfo': {
|
|
||||||
'deviceId': '2dec6cb0-eb34-45f9-bbc9-a0533597303c',
|
|
||||||
'browser': {
|
|
||||||
'name': 'chrome',
|
|
||||||
'version': '113.0.0.0',
|
|
||||||
},
|
|
||||||
'make': 'Microsoft',
|
|
||||||
'model': 'XBOX-Unknown',
|
|
||||||
'os': {
|
|
||||||
'name': 'Windows',
|
|
||||||
'version': '113.0.0.0',
|
|
||||||
},
|
|
||||||
'platform': 'XBOX',
|
|
||||||
'deviceType': 'xbox',
|
|
||||||
'player': {
|
|
||||||
'sdk': {
|
|
||||||
'name': 'Beam Player Console',
|
|
||||||
'version': '1.0.2.4',
|
|
||||||
},
|
|
||||||
'mediaEngine': {
|
|
||||||
'name': 'GLUON_BROWSER',
|
|
||||||
'version': '1.20.1',
|
|
||||||
},
|
|
||||||
'playerView': {
|
|
||||||
'height': 1080,
|
|
||||||
'width': 1920,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'editId': edit_id,
|
|
||||||
'capabilities': {
|
|
||||||
'manifests': {
|
|
||||||
'formats': {
|
|
||||||
'dash': {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'codecs': {
|
|
||||||
'video': {
|
|
||||||
'hdrFormats': [
|
|
||||||
'hlg',
|
|
||||||
'hdr10',
|
|
||||||
'dolbyvision5',
|
|
||||||
'dolbyvision8',
|
|
||||||
],
|
|
||||||
'decoders': [
|
|
||||||
{
|
|
||||||
'maxLevel': '6.2',
|
|
||||||
'codec': 'h265',
|
|
||||||
'levelConstraints': {
|
|
||||||
'width': {
|
|
||||||
'min': 1920,
|
|
||||||
'max': 3840,
|
|
||||||
},
|
|
||||||
'height': {
|
|
||||||
'min': 1080,
|
|
||||||
'max': 2160,
|
|
||||||
},
|
|
||||||
'framerate': {
|
|
||||||
'min': 15,
|
|
||||||
'max': 60,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'profiles': [
|
|
||||||
'main',
|
|
||||||
'main10',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'maxLevel': '4.2',
|
|
||||||
'codec': 'h264',
|
|
||||||
'levelConstraints': {
|
|
||||||
'width': {
|
|
||||||
'min': 640,
|
|
||||||
'max': 3840,
|
|
||||||
},
|
|
||||||
'height': {
|
|
||||||
'min': 480,
|
|
||||||
'max': 2160,
|
|
||||||
},
|
|
||||||
'framerate': {
|
|
||||||
'min': 15,
|
|
||||||
'max': 60,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'profiles': [
|
|
||||||
'high',
|
|
||||||
'main',
|
|
||||||
'baseline',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'audio': {
|
|
||||||
'decoders': [
|
|
||||||
{
|
|
||||||
'codec': 'aac',
|
|
||||||
'profiles': [
|
|
||||||
'lc',
|
|
||||||
'he',
|
|
||||||
'hev2',
|
|
||||||
'xhe',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'devicePlatform': {
|
|
||||||
'network': {
|
|
||||||
'lastKnownStatus': {
|
|
||||||
'networkTransportType': 'unknown',
|
|
||||||
},
|
|
||||||
'capabilities': {
|
|
||||||
'protocols': {
|
|
||||||
'http': {
|
|
||||||
'byteRangeRequests': True,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'videoSink': {
|
|
||||||
'lastKnownStatus': {
|
|
||||||
'width': 1290,
|
|
||||||
'height': 2796,
|
|
||||||
},
|
|
||||||
'capabilities': {
|
|
||||||
'colorGamuts': [
|
|
||||||
'standard',
|
|
||||||
'wide',
|
|
||||||
],
|
|
||||||
'hdrFormats': [
|
|
||||||
'dolbyvision',
|
|
||||||
'hdr10plus',
|
|
||||||
'hdr10',
|
|
||||||
'hlg',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'gdpr': False,
|
|
||||||
'firstPlay': False,
|
|
||||||
'playbackSessionId': str(uuid.uuid4()),
|
|
||||||
'applicationSessionId': str(uuid.uuid4()),
|
|
||||||
'userPreferences': {},
|
|
||||||
'features': [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
playback_data = response.json()
|
|
||||||
|
|
||||||
# Get video info for language
|
|
||||||
video_info = next(x for x in playback_data['videos'] if x['type'] == 'main')
|
|
||||||
title.language = Language.get(video_info['defaultAudioSelection']['language'])
|
|
||||||
|
|
||||||
fallback_url = playback_data["fallback"]["manifest"]["url"]
|
|
||||||
fallback_url = fallback_url.replace('fly', 'akm').replace('gcp', 'akm')
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.wv_license_url = playback_data["drm"]["schemes"]["widevine"]["licenseUrl"]
|
|
||||||
except (KeyError, IndexError):
|
|
||||||
self.wv_license_url = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.pr_license_url = playback_data["drm"]["schemes"]["playready"]["licenseUrl"]
|
|
||||||
except (KeyError, IndexError):
|
|
||||||
self.pr_license_url = None
|
|
||||||
|
|
||||||
manifest_url = fallback_url.replace('_fallback', '')
|
|
||||||
self.log.debug(f"MPD URL: {manifest_url}")
|
|
||||||
self.log.debug(f"Fallback URL: {fallback_url}")
|
|
||||||
self.log.debug(f"Widevine License URL: {self.wv_license_url}")
|
|
||||||
self.log.debug(f"PlayReady License URL: {self.pr_license_url}")
|
|
||||||
|
|
||||||
tracks = DASH.from_url(url=manifest_url, session=self.session).to_tracks(language=title.language)
|
|
||||||
|
|
||||||
self.log.debug(tracks)
|
|
||||||
|
|
||||||
tracks.videos = self._dedupe(tracks.videos)
|
|
||||||
tracks.audio = self._dedupe(tracks.audio)
|
|
||||||
|
|
||||||
# Remove partial subs and get VTT subtitles
|
|
||||||
tracks.subtitles.clear()
|
|
||||||
|
|
||||||
subtitles = self._get_subtitles(manifest_url, fallback_url)
|
|
||||||
|
|
||||||
for subtitle in subtitles:
|
|
||||||
tracks.add(
|
|
||||||
Subtitle(
|
|
||||||
id_=md5(subtitle["url"].encode()).hexdigest()[0:6],
|
|
||||||
url=subtitle["url"],
|
|
||||||
codec=Subtitle.Codec.from_mime(subtitle['format']),
|
|
||||||
language=Language.get(subtitle["language"]),
|
|
||||||
forced=subtitle['name'] == 'Forced',
|
|
||||||
sdh=subtitle['name'] == 'SDH'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply codec filters
|
|
||||||
if self.vcodec:
|
|
||||||
tracks.videos = [x for x in tracks.videos if (x.codec or "")[:4] in self.VIDEO_CODEC_MAP[self.vcodec]]
|
|
||||||
|
|
||||||
if self.acodec:
|
|
||||||
tracks.audio = [x for x in tracks.audio if (x.codec or "")[:4] == self.AUDIO_CODEC_MAP[self.acodec]]
|
|
||||||
|
|
||||||
# Set track properties
|
|
||||||
for track in tracks:
|
|
||||||
if isinstance(track, Video):
|
|
||||||
codec = track.data.get("dash", {}).get("representation", {}).get("codecs", "")
|
|
||||||
track.hdr10 = track.range == Video.Range.HDR10
|
|
||||||
track.dv = codec[:4] in ("dvh1", "dvhe")
|
|
||||||
if isinstance(track, Subtitle) and not track.codec:
|
|
||||||
track.codec = Subtitle.Codec.WebVTT
|
|
||||||
|
|
||||||
# Store video info for chapters
|
|
||||||
title.data['info'] = video_info
|
|
||||||
|
|
||||||
# Mark descriptive audio tracks
|
|
||||||
for track in tracks.audio:
|
|
||||||
if hasattr(track, 'data') and track.data.get("dash", {}).get("adaptation_set"):
|
|
||||||
role = track.data["dash"]["adaptation_set"].find("Role")
|
|
||||||
if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
|
|
||||||
track.descriptive = True
|
|
||||||
|
|
||||||
self.log.debug(tracks)
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Title_T) -> Chapters:
|
|
||||||
chapters = []
|
|
||||||
video_info = title.data.get('info', {})
|
|
||||||
if 'annotations' in video_info:
|
|
||||||
chapters.append(Chapter(timestamp=0.0, name='Chapter 1'))
|
|
||||||
chapters.append(Chapter(timestamp=self._convert_timecode(video_info['annotations'][0]['start']), name='Credits'))
|
|
||||||
chapters.append(Chapter(timestamp=self._convert_timecode(video_info['annotations'][0]['end']), name='Chapter 2'))
|
|
||||||
|
|
||||||
return Chapters(chapters)
|
|
||||||
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
|
||||||
if not self.wv_license_url:
|
|
||||||
return None
|
|
||||||
|
|
||||||
response = self.session.post(
|
|
||||||
url=self.wv_license_url,
|
|
||||||
data=challenge
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.content
|
|
||||||
|
|
||||||
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
|
|
||||||
if not self.pr_license_url:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Handle both bytes and string challenge formats
|
|
||||||
if isinstance(challenge, bytes):
|
|
||||||
decoded_challenge = challenge.decode('utf-8')
|
|
||||||
else:
|
|
||||||
decoded_challenge = str(challenge)
|
|
||||||
|
|
||||||
response = self.session.post(
|
|
||||||
url=self.pr_license_url,
|
|
||||||
data=decoded_challenge,
|
|
||||||
headers={
|
|
||||||
'Content-Type': 'text/xml; charset=utf-8',
|
|
||||||
'SOAPAction': 'http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.content
|
|
||||||
|
|
||||||
def _get_device_token(self):
|
|
||||||
response = self.session.post(self.config['endpoints']['bootstrap'])
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.headers.get('x-wbd-session-state')
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _convert_timecode(time_seconds):
|
|
||||||
"""Convert seconds to timestamp."""
|
|
||||||
return float(time_seconds)
|
|
||||||
|
|
||||||
def _get_subtitles(self, mpd_url, fallback_url):
|
|
||||||
base_url = "/".join(fallback_url.split("/")[:-1]) + "/"
|
|
||||||
xml = xmltodict.parse(requests.get(mpd_url).text)
|
|
||||||
|
|
||||||
try:
|
|
||||||
tracks = xml["MPD"]["Period"][0]["AdaptationSet"]
|
|
||||||
except KeyError:
|
|
||||||
tracks = xml["MPD"]["Period"]["AdaptationSet"]
|
|
||||||
|
|
||||||
subs_tracks_js = []
|
|
||||||
for subs_tracks in tracks:
|
|
||||||
if subs_tracks.get('@contentType') == 'text':
|
|
||||||
for x in self._force_instance(subs_tracks, "Representation"):
|
|
||||||
try:
|
|
||||||
path = re.search(r'(t/\w+/)', x["SegmentTemplate"]["@media"])[1]
|
|
||||||
except (AttributeError, KeyError):
|
|
||||||
path = 't/sub/'
|
|
||||||
|
|
||||||
is_sdh = False
|
|
||||||
is_forced = False
|
|
||||||
|
|
||||||
role_value = subs_tracks.get("Role", {}).get("@value", "")
|
|
||||||
|
|
||||||
if role_value == "caption":
|
|
||||||
url = base_url + path + subs_tracks['@lang'] + ('_sdh.vtt' if 'sdh' in subs_tracks.get("Label", "").lower() else '_cc.vtt')
|
|
||||||
is_sdh = True
|
|
||||||
elif role_value == "forced-subtitle":
|
|
||||||
url = base_url + path + subs_tracks['@lang'] + '_forced.vtt'
|
|
||||||
is_forced = True
|
|
||||||
elif role_value == "subtitle":
|
|
||||||
url = base_url + path + subs_tracks['@lang'] + '_sub.vtt'
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
subs_tracks_js.append({
|
|
||||||
"url": url,
|
|
||||||
"format": "vtt",
|
|
||||||
"language": subs_tracks["@lang"],
|
|
||||||
"name": "SDH" if is_sdh else "Forced" if is_forced else "Full",
|
|
||||||
})
|
|
||||||
|
|
||||||
return self._remove_dupe(subs_tracks_js)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _force_instance(data, variable):
|
|
||||||
if isinstance(data[variable], list):
|
|
||||||
return data[variable]
|
|
||||||
else:
|
|
||||||
return [data[variable]]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _remove_dupe(items):
|
|
||||||
seen = set()
|
|
||||||
new_items = []
|
|
||||||
for item in items:
|
|
||||||
url = item['url']
|
|
||||||
if url not in seen:
|
|
||||||
new_items.append(item)
|
|
||||||
seen.add(url)
|
|
||||||
return new_items
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _dedupe(items: list) -> list:
|
|
||||||
if not items:
|
|
||||||
return items
|
|
||||||
if isinstance(items[0].url, list):
|
|
||||||
return items
|
|
||||||
|
|
||||||
# Create a more specific key for deduplication that includes resolution/bitrate
|
|
||||||
seen = {}
|
|
||||||
for item in items:
|
|
||||||
# For video tracks, use codec + resolution + bitrate as key
|
|
||||||
if hasattr(item, 'width') and hasattr(item, 'height'):
|
|
||||||
key = f"{item.codec}_{item.width}x{item.height}_{item.bitrate}"
|
|
||||||
# For audio tracks, use codec + language + bitrate + channels as key
|
|
||||||
elif hasattr(item, 'channels'):
|
|
||||||
key = f"{item.codec}_{item.language}_{item.bitrate}_{item.channels}"
|
|
||||||
# Fallback to URL for other track types
|
|
||||||
else:
|
|
||||||
key = item.url
|
|
||||||
|
|
||||||
# Keep the item if we haven't seen this exact combination
|
|
||||||
if key not in seen:
|
|
||||||
seen[key] = item
|
|
||||||
|
|
||||||
return list(seen.values())
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
endpoints:
|
|
||||||
contentRoutes: 'https://default.prd.api.hbomax.com/cms/routes/%s/%s?include=default'
|
|
||||||
moviePages: 'https://default.prd.api.hbomax.com/content/videos/%s/activeVideoForShow?&include=edit'
|
|
||||||
playbackInfo: 'https://default.prd.api.hbomax.com/any/playback/v1/playbackInfo'
|
|
||||||
showPages: 'https://default.prd.api.hbomax.com/cms/collections/generic-show-page-rail-episodes-tabbed-content?include=default&pf[show.id]=%s&%s'
|
|
||||||
bootstrap: 'https://default.prd.api.hbomax.com/session-context/headwaiter/v1/bootstrap'
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from http.cookiejar import MozillaCookieJar
|
|
||||||
from typing import Any, Optional
|
|
||||||
import re
|
|
||||||
|
|
||||||
import click
|
|
||||||
from click import Context
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Movie, Movies
|
|
||||||
from envied.core.tracks import Chapter, Tracks
|
|
||||||
from envied.core.manifests.dash import DASH
|
|
||||||
|
|
||||||
|
|
||||||
class MTSP(Service):
|
|
||||||
TITLE_RE = r"^(?:https?://(?:www\.)?magentasport\.de/event/[^/]+)?/[0-9]+/(?P<video_id>[0-9]+)"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="MTSP", short_help="https://magentasport.de", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> MTSP:
|
|
||||||
return MTSP(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
cache = self.cache.get(f"session_{credential.sha1}")
|
|
||||||
if cache and not cache.expired:
|
|
||||||
self.session.cookies.update({
|
|
||||||
"session": cache.data,
|
|
||||||
"entitled": "1",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
|
|
||||||
self.log.info("No cached session cookie, logging in...")
|
|
||||||
r = self.session.get(self.config["endpoints"]["login_form"])
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
tid, xsrf_name, xsrf_value = self.get_login_tid_xsrf(r.text)
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"tid": tid,
|
|
||||||
xsrf_name: xsrf_value,
|
|
||||||
"pkc": "",
|
|
||||||
"webauthn_supported": "false",
|
|
||||||
"pw_usr": credential.username
|
|
||||||
}
|
|
||||||
r = self.session.post(self.config["endpoints"]["login_post"], data=data)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
tid, xsrf_name, xsrf_value = self.get_login_tid_xsrf(r.text)
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"tid": tid,
|
|
||||||
xsrf_name: xsrf_value,
|
|
||||||
"hidden_usr": credential.username,
|
|
||||||
"pw_pwd": credential.password,
|
|
||||||
"persist_session_displayed": "1",
|
|
||||||
"persist_session": "on"
|
|
||||||
}
|
|
||||||
r = self.session.post(self.config["endpoints"]["login_post"], data=data)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
session = self.session.cookies.get_dict().get('session')
|
|
||||||
cache.set(session)
|
|
||||||
|
|
||||||
def get_titles(self) -> Movies:
|
|
||||||
video_id = re.match(self.TITLE_RE, self.title).group("video_id")
|
|
||||||
r = self.session.get(self.config["endpoints"]["video_config"].format(video_id=video_id))
|
|
||||||
config = r.json()
|
|
||||||
|
|
||||||
return Movies([Movie(
|
|
||||||
id_=video_id,
|
|
||||||
service=self.__class__,
|
|
||||||
name=config["title"],
|
|
||||||
language="de",
|
|
||||||
data=config,
|
|
||||||
)])
|
|
||||||
|
|
||||||
def get_tracks(self, title: Movie) -> Tracks:
|
|
||||||
r = self.session.post(title.data['streamAccess'])
|
|
||||||
access = r.json()
|
|
||||||
tracks = DASH.from_url(access["data"]["stream"]["dash"]).to_tracks(title.language)
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Movie) -> list[Chapter]:
|
|
||||||
return [
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_login_tid_xsrf(self, html):
|
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
|
||||||
form = soup.find("form", id="login")
|
|
||||||
xsrf = form.find("input", {"name": re.compile("^xsrf_")})
|
|
||||||
tid = form.find("input", {"name": "tid"})
|
|
||||||
return tid.get("value"), xsrf.get('name'), xsrf.get("value")
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
headers:
|
|
||||||
Accept-Language: de-DE,de;q=0.8
|
|
||||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
login_form: https://www.magentasport.de/service/auth/web/login?headto=https://www.magentasport.de/home
|
|
||||||
login_post: https://accounts.login.idm.telekom.com/factorx
|
|
||||||
video_config: https://www.magentasport.de/service/player/v2/videoConfig?videoid={video_id}&partnerid=0&language=de&format=iphone&device=desktop&platform=web&cdn=telekom_cdn&userType=loggedin-entitled
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import tempfile
|
|
||||||
from collections.abc import Generator
|
|
||||||
from typing import Any, Union
|
|
||||||
from urllib.parse import urlparse, urlunparse
|
|
||||||
|
|
||||||
import click
|
|
||||||
import requests
|
|
||||||
from click import Context
|
|
||||||
from pywidevine.cdm import Cdm as WidevineCdm
|
|
||||||
from envied.core.manifests.dash import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series
|
|
||||||
from envied.core.tracks import Chapter, Tracks
|
|
||||||
from envied.core.utils.sslciphers import SSLCiphers
|
|
||||||
|
|
||||||
|
|
||||||
class MY5(Service):
|
|
||||||
"""
|
|
||||||
\b
|
|
||||||
Service code for Channel 5's My5 streaming service (https://channel5.com).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.1
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: None
|
|
||||||
Robustness:
|
|
||||||
L3: 1080p, AAC2.0
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Input for series/films/episodes can be either complete URL or just the slug/path:
|
|
||||||
https://www.channel5.com/the-cuckoo OR the-cuckoo OR the-cuckoo/season-1/episode-1
|
|
||||||
|
|
||||||
\b
|
|
||||||
Known bugs:
|
|
||||||
- The progress bar is broken for certain DASH manifests
|
|
||||||
See issue: https://github.com/devine-dl/devine/issues/106
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
ALIASES = ("channel5", "ch5", "c5")
|
|
||||||
GEOFENCE = ("gb",)
|
|
||||||
TITLE_RE = r"^(?:https?://(?:www\.)?channel5\.com(?:/show)?/)?(?P<id>[a-z0-9-]+)(?:/(?P<sea>[a-z0-9-]+))?(?:/(?P<ep>[a-z0-9-]+))?"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="MY5", short_help="https://channel5.com", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> MY5:
|
|
||||||
return MY5(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
self.session.headers.update({"user-agent": self.config["user_agent"]})
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
params = {
|
|
||||||
"platform": "my5desktop",
|
|
||||||
"friendly": "1",
|
|
||||||
"query": self.title,
|
|
||||||
}
|
|
||||||
|
|
||||||
r = self.session.get(self.config["endpoints"]["search"], params=params)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
results = r.json()
|
|
||||||
for result in results["shows"]:
|
|
||||||
yield SearchResult(
|
|
||||||
id_=result.get("f_name"),
|
|
||||||
title=result.get("title"),
|
|
||||||
description=result.get("s_desc"),
|
|
||||||
label=result.get("genre"),
|
|
||||||
url="https://www.channel5.com/show/" + result.get("f_name"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_titles(self) -> Union[Movies, Series]:
|
|
||||||
title, season, episode = (re.match(self.TITLE_RE, self.title).group(i) for i in ("id", "sea", "ep"))
|
|
||||||
if not title:
|
|
||||||
raise ValueError("Could not parse ID from title - is the URL correct?")
|
|
||||||
|
|
||||||
if season and episode:
|
|
||||||
r = self.session.get(
|
|
||||||
self.config["endpoints"]["single"].format(
|
|
||||||
show=title,
|
|
||||||
season=season,
|
|
||||||
episode=episode,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
episode = r.json()
|
|
||||||
return Series(
|
|
||||||
[
|
|
||||||
Episode(
|
|
||||||
id_=episode.get("id"),
|
|
||||||
service=self.__class__,
|
|
||||||
title=episode.get("sh_title"),
|
|
||||||
season=int(episode.get("sea_num")) if episode.get("sea_num") else 0,
|
|
||||||
number=int(episode.get("ep_num")) if episode.get("ep_num") else 0,
|
|
||||||
name=episode.get("sh_title"),
|
|
||||||
language="en",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
r = self.session.get(self.config["endpoints"]["episodes"].format(show=title))
|
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
|
|
||||||
if data["episodes"][0]["genre"] == "Film":
|
|
||||||
return Movies(
|
|
||||||
[
|
|
||||||
Movie(
|
|
||||||
id_=movie.get("id"),
|
|
||||||
service=self.__class__,
|
|
||||||
year=None,
|
|
||||||
name=movie.get("sh_title"),
|
|
||||||
language="en", # TODO: don't assume
|
|
||||||
)
|
|
||||||
for movie in data.get("episodes")
|
|
||||||
]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return Series(
|
|
||||||
[
|
|
||||||
Episode(
|
|
||||||
id_=episode.get("id"),
|
|
||||||
service=self.__class__,
|
|
||||||
title=episode.get("sh_title"),
|
|
||||||
season=int(episode.get("sea_num")) if episode.get("sea_num") else 0,
|
|
||||||
number=int(episode.get("ep_num")) if episode.get("sea_num") else 0,
|
|
||||||
name=episode.get("title"),
|
|
||||||
language="en", # TODO: don't assume
|
|
||||||
)
|
|
||||||
for episode in data["episodes"]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
|
||||||
self.manifest, self.license = self.get_playlist(title.id)
|
|
||||||
|
|
||||||
tracks = DASH.from_url(self.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: Union[Movie, Episode]) -> list[Chapter]:
|
|
||||||
return []
|
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
|
||||||
return WidevineCdm.common_privacy_cert
|
|
||||||
|
|
||||||
def get_widevine_license(self, challenge: bytes, **_: Any) -> str:
|
|
||||||
r = self.session.post(self.license, data=challenge)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
# Service specific functions
|
|
||||||
|
|
||||||
def get_playlist(self, asset_id: str) -> tuple:
|
|
||||||
session = self.session
|
|
||||||
for prefix in ("https://", "http://"):
|
|
||||||
session.mount(prefix, SSLCiphers())
|
|
||||||
|
|
||||||
cert_binary = base64.b64decode(self.config["certificate"])
|
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as cert_file:
|
|
||||||
cert_file.write(cert_binary)
|
|
||||||
cert_path = cert_file.name
|
|
||||||
try:
|
|
||||||
r = session.get(url=self.config["endpoints"]["auth"].format(title_id=asset_id), cert=cert_path)
|
|
||||||
except requests.RequestException as e:
|
|
||||||
if "Max retries exceeded" in str(e):
|
|
||||||
raise ConnectionError(
|
|
||||||
"Permission denied. If you're behind a VPN/proxy, you might be blocked"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise ConnectionError(f"Failed to request assets: {str(e)}")
|
|
||||||
finally:
|
|
||||||
os.remove(cert_path)
|
|
||||||
|
|
||||||
data = r.json()
|
|
||||||
if not data.get("assets"):
|
|
||||||
raise ValueError(f"Could not find asset: {data}")
|
|
||||||
|
|
||||||
asset = [x for x in data["assets"] if x["drm"] == "widevine"][0]
|
|
||||||
rendition = asset["renditions"][0]
|
|
||||||
mpd_url = rendition["url"]
|
|
||||||
lic_url = asset["keyserver"]
|
|
||||||
|
|
||||||
parse = urlparse(mpd_url)
|
|
||||||
path = parse.path.split("/")
|
|
||||||
path[-1] = path[-1].split("-")[0].split("_")[0]
|
|
||||||
manifest = urlunparse(parse._replace(path="/".join(path)))
|
|
||||||
manifest += ".mpd" if not manifest.endswith("mpd") else ""
|
|
||||||
|
|
||||||
return manifest, lic_url
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
user_agent: Dalvik/2.1.0 (Linux; U; Android 14; SM-S901B Build/UP1A.231005.007)
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
base: https://corona.channel5.com
|
|
||||||
content: https://corona.channel5.com/shows/{show}.json?platform=my5android
|
|
||||||
episodes: https://corona.channel5.com/shows/{show}/episodes.json?platform=my5android
|
|
||||||
single: https://corona.channel5.com/shows/{show}/seasons/{season}/episodes/{episode}.json?platform=my5android
|
|
||||||
auth: https://cassie-auth.channel5.com/api/v2/media/my5androidhydradash/{title_id}.json
|
|
||||||
search: https://corona.channel5.com/shows/search.json
|
|
||||||
|
|
||||||
certificate: |
|
|
||||||
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tDQpNSUlDdXpDQ0FpU2dBd0lCQWdJRVhMU1BGVEFOQmdrcWhraUc5dzBCQVFVRkFE
|
|
||||||
QmxNUXN3Q1FZRFZRUUdFd0pIDQpRakVWTUJNR0ExVUVCd3dNUkdWbVlYVnNkQ0JEYVhSNU1SSXdFQVlEVlFRS0RBbERhR0Z1Ym1W
|
|
||||||
c0lEVXhEekFODQpCZ05WQkFzTUJrTmhjM05wWlRFYU1CZ0dBMVVFQXd3UlEyRnpjMmxsSUUxbFpHbGhJRUYxZEdnd0hoY05NVGt3
|
|
||||||
DQpOREUxTVRRd016QXhXaGNOTWprd05ERTFNVFF3TXpBeFdqQ0JqakVMTUFrR0ExVUVCaE1DUjBJeEVqQVFCZ05WDQpCQW9NQ1VO
|
|
||||||
b1lXNXVaV3dnTlRFWE1CVUdBMVVFQ3d3T1EyRnpjMmxsSUdOc2FXVnVkSE14VWpCUUJnTlZCQU1NDQpTVU5oYzNOcFpTQlRaV3ht
|
|
||||||
TFhOcFoyNWxaQ0JEWlhKMGFXWnBZMkYwWlNCbWIzSWdUWGsxSUVGdVpISnZhV1FnDQpUbVY0ZENCSFpXNGdZMnhwWlc1MElERTFO
|
|
||||||
VFV6TXpZNU9ERXdnWjh3RFFZSktvWklodmNOQVFFQkJRQURnWTBBDQpNSUdKQW9HQkFNbVVTSHFCZ3pwbThXelVHZ2VDSWZvSTI3
|
|
||||||
QlovQmNmWktpbnl5dXFNVlpDNXRLaUtaRWpydFV4DQpoMXFVcDJSSkN3Ui9RcENPQ2RQdFhzMENzekZvd1ByTlY4RHFtUXZqbzY5
|
|
||||||
dlhvTEM3c2RLUjQ1cEFUQU8vY3JLDQorTUFPUXo1VWEyQ1ZrYnY1SCtaMVhWWndqbm1qNGJHZEJHM005b0NzQlVqTEh0bm1nQSty
|
|
||||||
QWdNQkFBR2pUakJNDQpNQjBHQTFVZERnUVdCQlNVVUhrY3JKNUVkVTVWM2ZJbXQra1ljdkdnZFRBTEJnTlZIUThFQkFNQ0E3Z3dD
|
|
||||||
UVlEDQpWUjBUQkFJd0FEQVRCZ05WSFNVRUREQUtCZ2dyQmdFRkJRY0RBakFOQmdrcWhraUc5dzBCQVFVRkFBT0JnUUFpDQpHNi84
|
|
||||||
OUFEaDhEOUs0OXZjeklMQ2pqbGh6bG5US09GM2l1Um0vSjZYaWtxY3RxSDF0a01na0FXcHAwQldBRm9IDQpJbU5WSEtKdTRnZXgy
|
|
||||||
cEtLejNqOVlRNG5EWENQVTdVb0N2aDl5TTNYT0RITWZRT01sZkRtMU9GZkh2QkJvSHNVDQpHSE9EQTkwQi8xcU0xSlFaZzBOVjZi
|
|
||||||
UllrUytCOWdtSFI4dXhtZktrL0E9PQ0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ0KLS0tLS1CRUdJTiBQUklWQVRFIEtFWS0t
|
|
||||||
LS0tDQpNSUlDZHdJQkFEQU5CZ2txaGtpRzl3MEJBUUVGQUFTQ0FtRXdnZ0pkQWdFQUFvR0JBTW1VU0hxQmd6cG04V3pVDQpHZ2VD
|
|
||||||
SWZvSTI3QlovQmNmWktpbnl5dXFNVlpDNXRLaUtaRWpydFV4aDFxVXAyUkpDd1IvUXBDT0NkUHRYczBDDQpzekZvd1ByTlY4RHFt
|
|
||||||
UXZqbzY5dlhvTEM3c2RLUjQ1cEFUQU8vY3JLK01BT1F6NVVhMkNWa2J2NUgrWjFYVlp3DQpqbm1qNGJHZEJHM005b0NzQlVqTEh0
|
|
||||||
bm1nQStyQWdNQkFBRUNnWUFjTVY4SnN6OTFWWnlDaWcreDZTTnpZdlhHDQo3bTd4bFBSeEdqYXlQclZ6eVJ1YmJnNitPKzFoNS9G
|
|
||||||
MFc4SWxwb21oOFdLUDhTMnl0RXBFQmhLbDRHN001WXdqDQp0SCtCVXFNMTNjbFdiQkxuQTZMT2RVeEVDTVhIUktjdHk5UE52UlJQ
|
|
||||||
cU9aV0YycDc5U1BFdFY5Q2o1SXNaVUdNDQpRcHYybk5oN1M2MUZGRVRuSVFKQkFPTXJNd2tnOGQzbksyS0lnVUNrcEtCRHlGTUJj
|
|
||||||
UXN0NG82VkxvVjNjenBwDQpxMW5FWGx4WnduMFh6Ni9GVjRWdTZYTjJLLzQxL2pCeWdTUlFXa05YVThNQ1FRRGpLYXVpdE1UajBM
|
|
||||||
ajU3QkJ3DQppNkNON0VFeUJSSkZaVGRSMDM4ZzkxSEFoUkVXVWpuQ0Vrc1UwcTl4TUNOdnM3OFN4RmQ1ODg5RUJQTnd1RDdvDQor
|
|
||||||
NTM1QWtFQTNwVTNYbHh2WUhQZktKNkR0cWtidlFSdFJoZUZnZVNsdGZzcUtCQVFVVTIwWFRKeEdwL0FWdjE3DQp1OGZxcDQwekpM
|
|
||||||
VEhDa0F4SFpzME9qYVpHcDU0TFFKQWJtM01iUjA1ZFpINnlpdlMxaE5hYW9QR01iMjdZeGJRDQpMS3dHNmd5d3BrbEp4RE1XdHR4
|
|
||||||
VHVYeXVJdlVHMVA5cFRJTThEeUhSeVR3cTU4bjVjeU1XYVFKQkFMVFRwZkVtDQoxdWhCeUd0NEtab3dYM2dhREpVZGU0ZjBwN3Ry
|
|
||||||
RFZGcExDNVJYcVVBQXNBQ2pzTHNYaEFadlovUEEwUDBiU2hmDQp4cUFRa2lnYmNKRXdxdjQ9DQotLS0tLUVORCBQUklWQVRFIEtF
|
|
||||||
WS0tLS0t
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from http.cookiejar import MozillaCookieJar
|
|
||||||
from typing import Any, Optional, Union
|
|
||||||
from functools import partial
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
import re
|
|
||||||
|
|
||||||
import click
|
|
||||||
import webvtt
|
|
||||||
import requests
|
|
||||||
from click import Context
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Movie, Movies, Episode, Series
|
|
||||||
from envied.core.tracks import Track, Chapter, Tracks, Subtitle
|
|
||||||
from envied.core.manifests.hls import HLS
|
|
||||||
|
|
||||||
|
|
||||||
class NebulaSubtitle(Subtitle):
|
|
||||||
STYLE_RE = re.compile('::cue\\(v\\[voice="(.+)"\\]\\) { color: ([^;]+); (.*)}')
|
|
||||||
RGB_RE = re.compile("rgb\\((.+), ?(.+), ?(.+)\\)")
|
|
||||||
|
|
||||||
def download(
|
|
||||||
self,
|
|
||||||
session: requests.Session,
|
|
||||||
prepare_drm: partial,
|
|
||||||
max_workers: Optional[int] = None,
|
|
||||||
progress: Optional[partial] = None
|
|
||||||
):
|
|
||||||
# Track.download chooses file extension based on class name so use
|
|
||||||
# this hack to keep it happy
|
|
||||||
self.__class__.__name__ = "Subtitle"
|
|
||||||
|
|
||||||
# Skip Subtitle.download and use Track.download directly. The pycaption
|
|
||||||
# calls in Subtitle.download are not needed here and mangle the WebVTT
|
|
||||||
# styling Nebula uses
|
|
||||||
Track.download(self, session, prepare_drm, max_workers, progress)
|
|
||||||
|
|
||||||
def convert(self, codec: Subtitle.Codec) -> Path:
|
|
||||||
if codec != Subtitle.Codec.SubRip:
|
|
||||||
return super().convert(codec)
|
|
||||||
|
|
||||||
output_path = self.path.with_suffix(f".{codec.value.lower()}")
|
|
||||||
vtt = webvtt.read(self.path)
|
|
||||||
|
|
||||||
styles = dict()
|
|
||||||
for group in vtt.styles:
|
|
||||||
for style in group.text.splitlines():
|
|
||||||
if match := self.STYLE_RE.match(style):
|
|
||||||
name, color, extra = match.groups()
|
|
||||||
|
|
||||||
if "rgb" in color:
|
|
||||||
r, g, b = self.RGB_RE.match(color).groups()
|
|
||||||
color = "#{0:02x}{1:02x}{2:02x}".format(int(r), int(g), int(b))
|
|
||||||
|
|
||||||
bold = "bold" in extra
|
|
||||||
styles[name.lower()] = {"color": color, "bold": bold}
|
|
||||||
|
|
||||||
count = 1
|
|
||||||
new_subs = []
|
|
||||||
for caption in vtt:
|
|
||||||
soup = BeautifulSoup(caption.raw_text, features="html.parser")
|
|
||||||
|
|
||||||
for tag in soup.find_all("v"):
|
|
||||||
name = " ".join(tag.attrs.keys())
|
|
||||||
|
|
||||||
# Work around a few broken "Abolish Everything" subtitles
|
|
||||||
if ((name == "spectator" and "spectator" not in styles) or
|
|
||||||
(name == "spectators" and "spectators" not in styles)):
|
|
||||||
name = "audience"
|
|
||||||
|
|
||||||
style = styles[name]
|
|
||||||
tag.name = "font"
|
|
||||||
tag.attrs = {"color": style["color"]}
|
|
||||||
|
|
||||||
if style["bold"]:
|
|
||||||
tag.wrap(soup.new_tag("b"))
|
|
||||||
|
|
||||||
text = str(soup)
|
|
||||||
new_subs.append(f"{count}")
|
|
||||||
new_subs.append(f"{caption.start} --> {caption.end}")
|
|
||||||
new_subs.append(f"{text}\n")
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
output_path.write_text("\n".join(new_subs), encoding="utf8")
|
|
||||||
|
|
||||||
self.path = output_path
|
|
||||||
self.codec = codec
|
|
||||||
|
|
||||||
if callable(self.OnConverted):
|
|
||||||
self.OnConverted(codec)
|
|
||||||
|
|
||||||
return output_path
|
|
||||||
|
|
||||||
|
|
||||||
class NBLA(Service):
|
|
||||||
"""
|
|
||||||
Service code for Nebula (https://nebula.tv)
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.0
|
|
||||||
Author: lambda
|
|
||||||
Authorization: Credentials
|
|
||||||
Robustness:
|
|
||||||
Unencrypted: 2160p, AAC2.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
VIDEO_RE = r"https?://(?:www\.)?nebula\.tv/videos/(?P<slug>.+)"
|
|
||||||
CHANNEL_RE = r"^https?://(?:www\.)?nebula\.tv/(?P<slug>.+)"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="NBLA", short_help="https://nebula.tv", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> NBLA:
|
|
||||||
return NBLA(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
cache = self.cache.get(f"key_{credential.sha1}")
|
|
||||||
if not cache or cache.expired:
|
|
||||||
self.log.info("Key is missing or expired, logging in...")
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"email": credential.username,
|
|
||||||
"password": credential.password,
|
|
||||||
}
|
|
||||||
r = self.session.post(self.config["endpoints"]["login"], json=data)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
key = r.json().get("key")
|
|
||||||
cache.set(key)
|
|
||||||
else:
|
|
||||||
key = cache.data
|
|
||||||
|
|
||||||
r = self.session.post(self.config["endpoints"]["authorization"], headers={"Authorization": f"Token {key}"})
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
self.jwt = r.json()["token"]
|
|
||||||
self.session.headers.update({"Authorization": f"Bearer {self.jwt}"})
|
|
||||||
|
|
||||||
def get_titles(self) -> Union[Movies, Series]:
|
|
||||||
if video_match := re.match(self.VIDEO_RE, self.title):
|
|
||||||
r = self.session.get(self.config["endpoints"]["video"].format(slug=video_match.group("slug")))
|
|
||||||
video = r.json()
|
|
||||||
|
|
||||||
# Simplest scenario: This is a video on a non-episodic channel, return it as movie
|
|
||||||
if video["channel_type"] != "episodic":
|
|
||||||
return Movies([
|
|
||||||
Movie(
|
|
||||||
id_=video["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
name=video["title"],
|
|
||||||
year=video["published_at"][0:4],
|
|
||||||
language="en"
|
|
||||||
)
|
|
||||||
])
|
|
||||||
|
|
||||||
# For episodic videos, things are trickier: There is no way to get the season
|
|
||||||
# and episode number from the video endpoint, so we instead have to iterate
|
|
||||||
# through all seasons and filter for the video id.
|
|
||||||
return self.get_content(video["channel_slug"], video_id_filter=video["id"])
|
|
||||||
|
|
||||||
# If the link did not match the video regex, try using it as slug for the content
|
|
||||||
# API to fetch a whole channel/season
|
|
||||||
elif channel_match := re.match(self.CHANNEL_RE, self.title):
|
|
||||||
return self.get_content(channel_match.group("slug"))
|
|
||||||
|
|
||||||
def get_tracks(self, title: Union[Episode, Movie]) -> Tracks:
|
|
||||||
r = self.session.get(self.config["endpoints"]["manifest"].format(video_id=title.id, jwt=self.jwt), allow_redirects=False)
|
|
||||||
manifest_url = r.headers["Location"]
|
|
||||||
tracks = HLS.from_url(manifest_url).to_tracks(title.language)
|
|
||||||
|
|
||||||
subs = []
|
|
||||||
for subtitle in tracks.subtitles:
|
|
||||||
subs.append(NebulaSubtitle(
|
|
||||||
id_=subtitle.id,
|
|
||||||
url=subtitle.url,
|
|
||||||
language=subtitle.language,
|
|
||||||
is_original_lang=subtitle.is_original_lang,
|
|
||||||
descriptor=subtitle.descriptor,
|
|
||||||
name=subtitle.name,
|
|
||||||
codec=subtitle.codec,
|
|
||||||
forced=subtitle.forced,
|
|
||||||
sdh=subtitle.sdh,
|
|
||||||
))
|
|
||||||
|
|
||||||
tracks.subtitles = subs
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Union[Episode, Movie]) -> list[Chapter]:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
pass
|
|
||||||
#self.title
|
|
||||||
r = self.session.get(self.config["endpoints"]["search"], params=params)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
# for result in results["results"]:
|
|
||||||
# yield SearchResult(
|
|
||||||
# id_=result["brand"].get("websafeTitle"),
|
|
||||||
# title=result["brand"].get("title"),
|
|
||||||
# description=result["brand"].get("description"),
|
|
||||||
# label=result.get("label"),
|
|
||||||
# url=result["brand"].get("href"),
|
|
||||||
# )
|
|
||||||
|
|
||||||
### Service specific functions
|
|
||||||
def season_to_episodes(self, channel, season, video_id_filter):
|
|
||||||
try:
|
|
||||||
season_number = int(season["label"])
|
|
||||||
except ValueError:
|
|
||||||
# Some shows such have some non-integer season numbers (Such as
|
|
||||||
# Jet Lag: The Game season 13.5). These are generally listed as specials
|
|
||||||
# (Season 0) on TMDB, so treat them the same way.
|
|
||||||
#
|
|
||||||
# Specials episode numbers will then likely be off, use caution and
|
|
||||||
# check TMDB for manual corrections.
|
|
||||||
season_number = 0
|
|
||||||
self.log.warn(f"Could not extract season information, guessing season {season_number}")
|
|
||||||
|
|
||||||
for episode_number, episode in enumerate(season["episodes"], start=1):
|
|
||||||
if not episode["video"] or (video_id_filter and video_id_filter != episode["video"]["id"]):
|
|
||||||
continue
|
|
||||||
|
|
||||||
yield Episode(
|
|
||||||
id_=episode["video"]["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=channel["title"],
|
|
||||||
name=episode["title"],
|
|
||||||
language="en",
|
|
||||||
year=episode["video"]["published_at"][0:4],
|
|
||||||
season=season_number,
|
|
||||||
number=episode_number,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_content(self, slug, video_id_filter=None):
|
|
||||||
r = self.session.get(self.config["endpoints"]["content"].format(slug=slug))
|
|
||||||
content = r.json()
|
|
||||||
|
|
||||||
if content["type"] == "season":
|
|
||||||
r = self.session.get(self.config["endpoints"]["content"].format(slug=content["video_channel_slug"]))
|
|
||||||
channel = r.json()
|
|
||||||
return Series(self.season_to_episodes(channel, content, video_id_filter))
|
|
||||||
elif content["type"] == "video_channel" and content["channel_type"] == "episodic":
|
|
||||||
episodes = []
|
|
||||||
for season_data in content["episodic"]["seasons"]:
|
|
||||||
# We could also use the generic content endpoint to retrieve
|
|
||||||
# seasons, but this is how the nebula web app does it.
|
|
||||||
r = self.session.get(self.config["endpoints"]["season"].format(id=season_data["id"]))
|
|
||||||
episodes.extend(self.season_to_episodes(content, r.json(), video_id_filter))
|
|
||||||
|
|
||||||
return Series(episodes)
|
|
||||||
elif content["type"] == "video_channel":
|
|
||||||
self.log.error("Non-episodic channel URL passed. Treating it as a show with a single season. If you want to download non-episodic content as a movie, pass the direct video URL instead.")
|
|
||||||
r = self.session.get(self.config["endpoints"]["video_channel_episodes"].format(id=content["id"]))
|
|
||||||
episodes = r.json()['results']
|
|
||||||
|
|
||||||
# Non-episodic channel names tend to have a format of "Creator Name — Show Name"
|
|
||||||
if " — " in content["title"]:
|
|
||||||
show_title = content["title"].split(" — ", maxsplit=1)[1]
|
|
||||||
else:
|
|
||||||
show_title = content["title"]
|
|
||||||
|
|
||||||
season = []
|
|
||||||
episode_number = 0
|
|
||||||
for episode in episodes:
|
|
||||||
if 'trailer' in episode['title'].lower():
|
|
||||||
continue
|
|
||||||
|
|
||||||
episode_number += 1
|
|
||||||
season.append(Episode(
|
|
||||||
id_=episode["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=show_title,
|
|
||||||
name=episode["title"],
|
|
||||||
language="en",
|
|
||||||
year=episode["published_at"][0:4],
|
|
||||||
season=1,
|
|
||||||
number=episode_number,
|
|
||||||
))
|
|
||||||
|
|
||||||
return Series(season)
|
|
||||||
else:
|
|
||||||
self.log.error("Unsupported content type")
|
|
||||||
sys.exit(1)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
headers:
|
|
||||||
Accept-Language: en-US,en;q=0.8
|
|
||||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
login: https://nebula.tv/auth/login/
|
|
||||||
authorization: https://users.api.nebula.app/api/v1/authorization/
|
|
||||||
content: https://content.api.nebula.app/content/{slug}/
|
|
||||||
season: https://content.api.nebula.app/seasons/{id}/
|
|
||||||
video: https://content.api.nebula.app/content/videos/{slug}/
|
|
||||||
video_channel: https://content.api.nebula.app/video_channels/{id}/
|
|
||||||
video_channel_episodes: https://content.api.nebula.app/video_channels/{id}/video_episodes/?ordering=published_at
|
|
||||||
manifest: https://content.api.nebula.app/video_episodes/{video_id}/manifest.m3u8?token={jwt}&app_version=25.2.1&platform=web
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
from .MSLObject import MSLObject
|
|
||||||
|
|
||||||
|
|
||||||
class MSLKeys(MSLObject):
|
|
||||||
def __init__(self, encryption=None, sign=None, rsa=None, mastertoken=None, cdm_session=None):
|
|
||||||
self.encryption = encryption
|
|
||||||
self.sign = sign
|
|
||||||
self.rsa = rsa
|
|
||||||
self.mastertoken = mastertoken
|
|
||||||
self.cdm_session = cdm_session
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import jsonpickle
|
|
||||||
|
|
||||||
|
|
||||||
class MSLObject:
|
|
||||||
def __repr__(self):
|
|
||||||
return "<{} {}>".format(self.__class__.__name__, jsonpickle.encode(self, unpicklable=False))
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
import base64
|
|
||||||
import gzip
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import zlib
|
|
||||||
from datetime import datetime
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
import jsonpickle
|
|
||||||
import requests
|
|
||||||
from Cryptodome.Cipher import AES, PKCS1_OAEP
|
|
||||||
from Cryptodome.Hash import HMAC, SHA256
|
|
||||||
from Cryptodome.PublicKey import RSA
|
|
||||||
from Cryptodome.Random import get_random_bytes
|
|
||||||
from Cryptodome.Util import Padding
|
|
||||||
|
|
||||||
from envied.core.cacher import Cacher
|
|
||||||
|
|
||||||
from .MSLKeys import MSLKeys
|
|
||||||
from .schemes import EntityAuthenticationSchemes # noqa: F401
|
|
||||||
from .schemes import KeyExchangeSchemes
|
|
||||||
from .schemes.EntityAuthentication import EntityAuthentication
|
|
||||||
from .schemes.KeyExchangeRequest import KeyExchangeRequest
|
|
||||||
# from vinetrimmer.utils.widevine.device import RemoteDevice
|
|
||||||
|
|
||||||
class MSL:
|
|
||||||
log = logging.getLogger("MSL")
|
|
||||||
|
|
||||||
def __init__(self, session, endpoint, sender, keys, message_id, user_auth=None):
|
|
||||||
self.session = session
|
|
||||||
self.endpoint = endpoint
|
|
||||||
self.sender = sender
|
|
||||||
self.keys = keys
|
|
||||||
self.user_auth = user_auth
|
|
||||||
self.message_id = message_id
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def handshake(cls, scheme: KeyExchangeSchemes, session: requests.Session, endpoint: str, sender: str, cache: Cacher):
|
|
||||||
cache = cache.get(sender)
|
|
||||||
message_id = random.randint(0, pow(2, 52))
|
|
||||||
msl_keys = MSL.load_cache_data(cache)
|
|
||||||
|
|
||||||
if msl_keys is not None:
|
|
||||||
cls.log.info("Using cached MSL data")
|
|
||||||
else:
|
|
||||||
msl_keys = MSLKeys()
|
|
||||||
if scheme != KeyExchangeSchemes.Widevine:
|
|
||||||
msl_keys.rsa = RSA.generate(2048)
|
|
||||||
|
|
||||||
# if not cdm:
|
|
||||||
# raise cls.log.exit("- No cached data and no CDM specified")
|
|
||||||
|
|
||||||
# if not msl_keys_path:
|
|
||||||
# raise cls.log.exit("- No cached data and no MSL key path specified")
|
|
||||||
|
|
||||||
# Key Exchange Scheme Widevine currently not implemented
|
|
||||||
# if scheme == KeyExchangeSchemes.Widevine:
|
|
||||||
# msl_keys.cdm_session = cdm.open(
|
|
||||||
# pssh=b"\x0A\x7A\x00\x6C\x38\x2B",
|
|
||||||
# raw=True,
|
|
||||||
# offline=True
|
|
||||||
# )
|
|
||||||
# keyrequestdata = KeyExchangeRequest.Widevine(
|
|
||||||
# keyrequest=cdm.get_license_challenge(msl_keys.cdm_session)
|
|
||||||
# )
|
|
||||||
# else:
|
|
||||||
keyrequestdata = KeyExchangeRequest.AsymmetricWrapped(
|
|
||||||
keypairid="superKeyPair",
|
|
||||||
mechanism="JWK_RSA",
|
|
||||||
publickey=msl_keys.rsa.publickey().exportKey(format="DER")
|
|
||||||
)
|
|
||||||
|
|
||||||
data = jsonpickle.encode({
|
|
||||||
"entityauthdata": EntityAuthentication.Unauthenticated(sender),
|
|
||||||
"headerdata": base64.b64encode(MSL.generate_msg_header(
|
|
||||||
message_id=message_id,
|
|
||||||
sender=sender,
|
|
||||||
is_handshake=True,
|
|
||||||
keyrequestdata=keyrequestdata
|
|
||||||
).encode("utf-8")).decode("utf-8"),
|
|
||||||
"signature": ""
|
|
||||||
}, unpicklable=False)
|
|
||||||
data += json.dumps({
|
|
||||||
"payload": base64.b64encode(json.dumps({
|
|
||||||
"messageid": message_id,
|
|
||||||
"data": "",
|
|
||||||
"sequencenumber": 1,
|
|
||||||
"endofmsg": True
|
|
||||||
}).encode("utf-8")).decode("utf-8"),
|
|
||||||
"signature": ""
|
|
||||||
})
|
|
||||||
|
|
||||||
try:
|
|
||||||
r = session.post(
|
|
||||||
url=endpoint,
|
|
||||||
data=data
|
|
||||||
)
|
|
||||||
except requests.HTTPError as e:
|
|
||||||
raise cls.log.exit(f"- Key exchange failed, response data is unexpected: {e.response.text}")
|
|
||||||
|
|
||||||
key_exchange = r.json() # expecting no payloads, so this is fine
|
|
||||||
if "errordata" in key_exchange:
|
|
||||||
raise cls.log.exit("- Key exchange failed: " + json.loads(base64.b64decode(
|
|
||||||
key_exchange["errordata"]
|
|
||||||
).decode())["errormsg"])
|
|
||||||
|
|
||||||
# parse the crypto keys
|
|
||||||
key_response_data = json.JSONDecoder().decode(base64.b64decode(
|
|
||||||
key_exchange["headerdata"]
|
|
||||||
).decode("utf-8"))["keyresponsedata"]
|
|
||||||
|
|
||||||
if key_response_data["scheme"] != str(scheme):
|
|
||||||
raise cls.log.exit("- Key exchange scheme mismatch occurred")
|
|
||||||
|
|
||||||
key_data = key_response_data["keydata"]
|
|
||||||
# if scheme == KeyExchangeSchemes.Widevine:
|
|
||||||
# if isinstance(cdm.device, RemoteDevice):
|
|
||||||
# msl_keys.encryption, msl_keys.sign = cdm.device.exchange(
|
|
||||||
# cdm.sessions[msl_keys.cdm_session],
|
|
||||||
# license_res=key_data["cdmkeyresponse"],
|
|
||||||
# enc_key_id=base64.b64decode(key_data["encryptionkeyid"]),
|
|
||||||
# hmac_key_id=base64.b64decode(key_data["hmackeyid"])
|
|
||||||
# )
|
|
||||||
# cdm.parse_license(msl_keys.cdm_session, key_data["cdmkeyresponse"])
|
|
||||||
# else:
|
|
||||||
# cdm.parse_license(msl_keys.cdm_session, key_data["cdmkeyresponse"])
|
|
||||||
# keys = cdm.get_keys(msl_keys.cdm_session)
|
|
||||||
# msl_keys.encryption = MSL.get_widevine_key(
|
|
||||||
# kid=base64.b64decode(key_data["encryptionkeyid"]),
|
|
||||||
# keys=keys,
|
|
||||||
# permissions=["AllowEncrypt", "AllowDecrypt"]
|
|
||||||
# )
|
|
||||||
# msl_keys.sign = MSL.get_widevine_key(
|
|
||||||
# kid=base64.b64decode(key_data["hmackeyid"]),
|
|
||||||
# keys=keys,
|
|
||||||
# permissions=["AllowSign", "AllowSignatureVerify"]
|
|
||||||
# )
|
|
||||||
# else:
|
|
||||||
cipher_rsa = PKCS1_OAEP.new(msl_keys.rsa)
|
|
||||||
msl_keys.encryption = MSL.base64key_decode(
|
|
||||||
json.JSONDecoder().decode(cipher_rsa.decrypt(
|
|
||||||
base64.b64decode(key_data["encryptionkey"])
|
|
||||||
).decode("utf-8"))["k"]
|
|
||||||
)
|
|
||||||
msl_keys.sign = MSL.base64key_decode(
|
|
||||||
json.JSONDecoder().decode(cipher_rsa.decrypt(
|
|
||||||
base64.b64decode(key_data["hmackey"])
|
|
||||||
).decode("utf-8"))["k"]
|
|
||||||
)
|
|
||||||
msl_keys.mastertoken = key_response_data["mastertoken"]
|
|
||||||
|
|
||||||
MSL.cache_keys(msl_keys, cache)
|
|
||||||
cls.log.info("MSL handshake successful")
|
|
||||||
return cls(
|
|
||||||
session=session,
|
|
||||||
endpoint=endpoint,
|
|
||||||
sender=sender,
|
|
||||||
keys=msl_keys,
|
|
||||||
message_id=message_id
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def load_cache_data(cacher: Cacher):
|
|
||||||
if not cacher or cacher == {}:
|
|
||||||
return None
|
|
||||||
# with open(msl_keys_path, encoding="utf-8") as fd:
|
|
||||||
# msl_keys = jsonpickle.decode(fd.read())
|
|
||||||
msl_keys = jsonpickle.decode(cacher.data)
|
|
||||||
if msl_keys.rsa:
|
|
||||||
# noinspection PyTypeChecker
|
|
||||||
# expects RsaKey, but is a string, this is because jsonpickle can't pickle RsaKey object
|
|
||||||
# so as a workaround it exports to PEM, and then when reading, it imports that PEM back
|
|
||||||
# to an RsaKey :)
|
|
||||||
msl_keys.rsa = RSA.importKey(msl_keys.rsa)
|
|
||||||
# If it's expired or close to, return None as it's unusable
|
|
||||||
if msl_keys.mastertoken and ((datetime.utcfromtimestamp(int(json.JSONDecoder().decode(
|
|
||||||
base64.b64decode(msl_keys.mastertoken["tokendata"]).decode("utf-8")
|
|
||||||
)["expiration"])) - datetime.now()).total_seconds() / 60 / 60) < 10:
|
|
||||||
return None
|
|
||||||
return msl_keys
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def cache_keys(msl_keys, cache: Cacher):
|
|
||||||
# os.makedirs(os.path.dirname(cache), exist_ok=True)
|
|
||||||
if msl_keys.rsa:
|
|
||||||
# jsonpickle can't pickle RsaKey objects :(
|
|
||||||
msl_keys.rsa = msl_keys.rsa.export_key()
|
|
||||||
# with open(cache, "w", encoding="utf-8") as fd:
|
|
||||||
# fd.write()
|
|
||||||
cache.set(jsonpickle.encode(msl_keys))
|
|
||||||
if msl_keys.rsa:
|
|
||||||
# re-import now
|
|
||||||
msl_keys.rsa = RSA.importKey(msl_keys.rsa)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def generate_msg_header(message_id, sender, is_handshake, userauthdata=None, keyrequestdata=None,
|
|
||||||
compression="GZIP"):
|
|
||||||
"""
|
|
||||||
The MSL header carries all MSL data used for entity and user authentication, message encryption
|
|
||||||
and verification, and service tokens. Portions of the MSL header are encrypted.
|
|
||||||
https://github.com/Netflix/msl/wiki/Messages#header-data
|
|
||||||
|
|
||||||
:param message_id: number against which payload chunks are bound to protect against replay.
|
|
||||||
:param sender: ESN
|
|
||||||
:param is_handshake: This flag is set true if the message is a handshake message and will not include any
|
|
||||||
payload chunks. It will include keyrequestdata.
|
|
||||||
:param userauthdata: UserAuthData
|
|
||||||
:param keyrequestdata: KeyRequestData
|
|
||||||
:param compression: Supported compression algorithms.
|
|
||||||
|
|
||||||
:return: The base64 encoded JSON String of the header
|
|
||||||
"""
|
|
||||||
header_data = {
|
|
||||||
"messageid": message_id,
|
|
||||||
"renewable": True, # MUST be True if is_handshake
|
|
||||||
"handshake": is_handshake,
|
|
||||||
"capabilities": {
|
|
||||||
"compressionalgos": [compression] if compression else [],
|
|
||||||
"languages": ["en-US"], # bcp-47
|
|
||||||
"encoderformats": ["JSON"]
|
|
||||||
},
|
|
||||||
"timestamp": int(time.time()),
|
|
||||||
# undocumented or unused:
|
|
||||||
"sender": sender,
|
|
||||||
"nonreplayable": False,
|
|
||||||
"recipient": "Netflix",
|
|
||||||
}
|
|
||||||
if userauthdata:
|
|
||||||
header_data["userauthdata"] = userauthdata
|
|
||||||
if keyrequestdata:
|
|
||||||
header_data["keyrequestdata"] = [keyrequestdata]
|
|
||||||
return jsonpickle.encode(header_data, unpicklable=False)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_widevine_key(cls, kid, keys, permissions):
|
|
||||||
for key in keys:
|
|
||||||
if key.kid != kid:
|
|
||||||
continue
|
|
||||||
if key.type != "OPERATOR_SESSION":
|
|
||||||
cls.log.warning(f"Widevine Key Exchange: Wrong key type (not operator session) key {key}")
|
|
||||||
continue
|
|
||||||
if not set(permissions) <= set(key.permissions):
|
|
||||||
cls.log.warning(f"Widevine Key Exchange: Incorrect permissions, key {key}, needed perms {permissions}")
|
|
||||||
continue
|
|
||||||
return key.key
|
|
||||||
return None
|
|
||||||
|
|
||||||
def send_message(self, endpoint, params, application_data, userauthdata=None):
|
|
||||||
message = self.create_message(application_data, userauthdata)
|
|
||||||
res = self.session.post(url=endpoint, data=message, params=params)
|
|
||||||
header, payload_data = self.parse_message(res.text)
|
|
||||||
if "errordata" in header:
|
|
||||||
raise self.log.exit(
|
|
||||||
"- MSL response message contains an error: {}".format(
|
|
||||||
json.loads(base64.b64decode(header["errordata"].encode("utf-8")).decode("utf-8"))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return header, payload_data
|
|
||||||
|
|
||||||
def create_message(self, application_data, userauthdata=None):
|
|
||||||
self.message_id += 1 # new message must ue a new message id
|
|
||||||
headerdata = self.encrypt(self.generate_msg_header(
|
|
||||||
message_id=self.message_id,
|
|
||||||
sender=self.sender,
|
|
||||||
is_handshake=False,
|
|
||||||
userauthdata=userauthdata
|
|
||||||
))
|
|
||||||
|
|
||||||
header = json.dumps({
|
|
||||||
"headerdata": base64.b64encode(headerdata.encode("utf-8")).decode("utf-8"),
|
|
||||||
"signature": self.sign(headerdata).decode("utf-8"),
|
|
||||||
"mastertoken": self.keys.mastertoken
|
|
||||||
})
|
|
||||||
|
|
||||||
payload_chunks = [self.encrypt(json.dumps({
|
|
||||||
"messageid": self.message_id,
|
|
||||||
"data": self.gzip_compress(json.dumps(application_data).encode("utf-8")).decode("utf-8"),
|
|
||||||
"compressionalgo": "GZIP",
|
|
||||||
"sequencenumber": 1, # todo ; use sequence_number from master token instead?
|
|
||||||
"endofmsg": True
|
|
||||||
}))]
|
|
||||||
|
|
||||||
message = header
|
|
||||||
for payload_chunk in payload_chunks:
|
|
||||||
message += json.dumps({
|
|
||||||
"payload": base64.b64encode(payload_chunk.encode("utf-8")).decode("utf-8"),
|
|
||||||
"signature": self.sign(payload_chunk).decode("utf-8")
|
|
||||||
})
|
|
||||||
|
|
||||||
return message
|
|
||||||
|
|
||||||
def decrypt_payload_chunks(self, payload_chunks):
|
|
||||||
"""
|
|
||||||
Decrypt and extract data from payload chunks
|
|
||||||
|
|
||||||
:param payload_chunks: List of payload chunks
|
|
||||||
:return: json object
|
|
||||||
"""
|
|
||||||
raw_data = ""
|
|
||||||
|
|
||||||
for payload_chunk in payload_chunks:
|
|
||||||
# todo ; verify signature of payload_chunk["signature"] against payload_chunk["payload"]
|
|
||||||
# expecting base64-encoded json string
|
|
||||||
payload_chunk = json.loads(base64.b64decode(payload_chunk["payload"]).decode("utf-8"))
|
|
||||||
# decrypt the payload
|
|
||||||
payload_decrypted = AES.new(
|
|
||||||
key=self.keys.encryption,
|
|
||||||
mode=AES.MODE_CBC,
|
|
||||||
iv=base64.b64decode(payload_chunk["iv"])
|
|
||||||
).decrypt(base64.b64decode(payload_chunk["ciphertext"]))
|
|
||||||
payload_decrypted = Padding.unpad(payload_decrypted, 16)
|
|
||||||
payload_decrypted = json.loads(payload_decrypted.decode("utf-8"))
|
|
||||||
# decode and uncompress data if compressed
|
|
||||||
payload_data = base64.b64decode(payload_decrypted["data"])
|
|
||||||
if payload_decrypted.get("compressionalgo") == "GZIP":
|
|
||||||
payload_data = zlib.decompress(payload_data, 16 + zlib.MAX_WBITS)
|
|
||||||
raw_data += payload_data.decode("utf-8")
|
|
||||||
|
|
||||||
data = json.loads(raw_data)
|
|
||||||
if "error" in data:
|
|
||||||
error = data["error"]
|
|
||||||
error_display = error.get("display")
|
|
||||||
error_detail = re.sub(r" \(E3-[^)]+\)", "", error.get("detail", ""))
|
|
||||||
|
|
||||||
if error_display:
|
|
||||||
self.log.critical(f"- {error_display}")
|
|
||||||
if error_detail:
|
|
||||||
self.log.critical(f"- {error_detail}")
|
|
||||||
|
|
||||||
if not (error_display or error_detail):
|
|
||||||
self.log.critical(f"- {error}")
|
|
||||||
|
|
||||||
# sys.exit(1)
|
|
||||||
|
|
||||||
return data["result"]
|
|
||||||
|
|
||||||
def parse_message(self, message):
|
|
||||||
"""
|
|
||||||
Parse an MSL message into a header and list of payload chunks
|
|
||||||
|
|
||||||
:param message: MSL message
|
|
||||||
:returns: a 2-item tuple containing message and list of payload chunks if available
|
|
||||||
"""
|
|
||||||
parsed_message = json.loads("[{}]".format(message.replace("}{", "},{")))
|
|
||||||
|
|
||||||
header = parsed_message[0]
|
|
||||||
encrypted_payload_chunks = parsed_message[1:] if len(parsed_message) > 1 else []
|
|
||||||
if encrypted_payload_chunks:
|
|
||||||
payload_chunks = self.decrypt_payload_chunks(encrypted_payload_chunks)
|
|
||||||
else:
|
|
||||||
payload_chunks = {}
|
|
||||||
|
|
||||||
return header, payload_chunks
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def gzip_compress(data):
|
|
||||||
out = BytesIO()
|
|
||||||
with gzip.GzipFile(fileobj=out, mode="w") as fd:
|
|
||||||
fd.write(data)
|
|
||||||
return base64.b64encode(out.getvalue())
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def base64key_decode(payload):
|
|
||||||
length = len(payload) % 4
|
|
||||||
if length == 2:
|
|
||||||
payload += "=="
|
|
||||||
elif length == 3:
|
|
||||||
payload += "="
|
|
||||||
elif length != 0:
|
|
||||||
raise ValueError("Invalid base64 string")
|
|
||||||
return base64.urlsafe_b64decode(payload.encode("utf-8"))
|
|
||||||
|
|
||||||
def encrypt(self, plaintext):
|
|
||||||
"""
|
|
||||||
Encrypt the given Plaintext with the encryption key
|
|
||||||
:param plaintext:
|
|
||||||
:return: Serialized JSON String of the encryption Envelope
|
|
||||||
"""
|
|
||||||
iv = get_random_bytes(16)
|
|
||||||
return json.dumps({
|
|
||||||
"ciphertext": base64.b64encode(
|
|
||||||
AES.new(
|
|
||||||
self.keys.encryption,
|
|
||||||
AES.MODE_CBC,
|
|
||||||
iv
|
|
||||||
).encrypt(
|
|
||||||
Padding.pad(plaintext.encode("utf-8"), 16)
|
|
||||||
)
|
|
||||||
).decode("utf-8"),
|
|
||||||
"keyid": "{}_{}".format(self.sender, json.loads(
|
|
||||||
base64.b64decode(self.keys.mastertoken["tokendata"]).decode("utf-8")
|
|
||||||
)["sequencenumber"]),
|
|
||||||
"sha256": "AA==",
|
|
||||||
"iv": base64.b64encode(iv).decode("utf-8")
|
|
||||||
})
|
|
||||||
|
|
||||||
def sign(self, text):
|
|
||||||
"""
|
|
||||||
Calculates the HMAC signature for the given text with the current sign key and SHA256
|
|
||||||
:param text:
|
|
||||||
:return: Base64 encoded signature
|
|
||||||
"""
|
|
||||||
return base64.b64encode(HMAC.new(self.keys.sign, text.encode("utf-8"), SHA256).digest())
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
from .. import EntityAuthenticationSchemes
|
|
||||||
from ..MSLObject import MSLObject
|
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyPep8Naming
|
|
||||||
class EntityAuthentication(MSLObject):
|
|
||||||
def __init__(self, scheme, authdata):
|
|
||||||
"""
|
|
||||||
Data used to identify and authenticate the entity associated with a message.
|
|
||||||
https://github.com/Netflix/msl/wiki/Entity-Authentication-%28Configuration%29
|
|
||||||
|
|
||||||
:param scheme: Entity Authentication Scheme identifier
|
|
||||||
:param authdata: Entity Authentication data
|
|
||||||
"""
|
|
||||||
self.scheme = str(scheme)
|
|
||||||
self.authdata = authdata
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def Unauthenticated(cls, identity):
|
|
||||||
"""
|
|
||||||
The unauthenticated entity authentication scheme does not provide encryption or authentication and only
|
|
||||||
identifies the entity. Therefore entity identities can be harvested and spoofed. The benefit of this
|
|
||||||
authentication scheme is that the entity has control over its identity. This may be useful if the identity is
|
|
||||||
derived from or related to other data, or if retaining the identity is desired across state resets or in the
|
|
||||||
event of MSL errors requiring entity re-authentication.
|
|
||||||
"""
|
|
||||||
return cls(
|
|
||||||
scheme=EntityAuthenticationSchemes.Unauthenticated,
|
|
||||||
authdata={"identity": identity}
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def Widevine(cls, devtype, keyrequest):
|
|
||||||
"""
|
|
||||||
The Widevine entity authentication scheme is used by devices with the Widevine CDM. It does not provide
|
|
||||||
encryption or authentication and only identifies the entity. Therefore entity identities can be harvested
|
|
||||||
and spoofed. The entity identity is composed from the provided device type and Widevine key request data. The
|
|
||||||
Widevine CDM properties can be extracted from the key request data.
|
|
||||||
|
|
||||||
When coupled with the Widevine key exchange scheme, the entity identity can be cryptographically validated by
|
|
||||||
comparing the entity authentication key request data against the key exchange key request data.
|
|
||||||
|
|
||||||
Note that the local entity will not know its entity identity when using this scheme.
|
|
||||||
|
|
||||||
> Devtype
|
|
||||||
|
|
||||||
An arbitrary value identifying the device type the local entity wishes to assume. The data inside the Widevine
|
|
||||||
key request may be optionally used to validate the claimed device type.
|
|
||||||
|
|
||||||
:param devtype: Local entity device type
|
|
||||||
:param keyrequest: Widevine key request
|
|
||||||
"""
|
|
||||||
return cls(
|
|
||||||
scheme=EntityAuthenticationSchemes.Widevine,
|
|
||||||
authdata={
|
|
||||||
"devtype": devtype,
|
|
||||||
"keyrequest": keyrequest
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import base64
|
|
||||||
|
|
||||||
from .. import KeyExchangeSchemes
|
|
||||||
from ..MSLObject import MSLObject
|
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyPep8Naming
|
|
||||||
class KeyExchangeRequest(MSLObject):
|
|
||||||
def __init__(self, scheme, keydata):
|
|
||||||
"""
|
|
||||||
Session key exchange data from a requesting entity.
|
|
||||||
https://github.com/Netflix/msl/wiki/Key-Exchange-%28Configuration%29
|
|
||||||
|
|
||||||
:param scheme: Key Exchange Scheme identifier
|
|
||||||
:param keydata: Key Request data
|
|
||||||
"""
|
|
||||||
self.scheme = str(scheme)
|
|
||||||
self.keydata = keydata
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def AsymmetricWrapped(cls, keypairid, mechanism, publickey):
|
|
||||||
"""
|
|
||||||
Asymmetric wrapped key exchange uses a generated ephemeral asymmetric key pair for key exchange. It will
|
|
||||||
typically be used when there is no other data or keys from which to base secure key exchange.
|
|
||||||
|
|
||||||
This mechanism provides perfect forward secrecy but does not guarantee that session keys will only be available
|
|
||||||
to the requesting entity if the requesting MSL stack has been modified to perform the operation on behalf of a
|
|
||||||
third party.
|
|
||||||
|
|
||||||
> Key Pair ID
|
|
||||||
|
|
||||||
The key pair ID is included as a sanity check.
|
|
||||||
|
|
||||||
> Mechanism & Public Key
|
|
||||||
|
|
||||||
The following mechanisms are associated public key formats are currently supported.
|
|
||||||
|
|
||||||
Field Public Key Format Description
|
|
||||||
RSA SPKI RSA-OAEP encrypt/decrypt
|
|
||||||
ECC SPKI ECIES encrypt/decrypt
|
|
||||||
JWEJS_RSA SPKI RSA-OAEP JSON Web Encryption JSON Serialization
|
|
||||||
JWE_RSA SPKI RSA-OAEP JSON Web Encryption Compact Serialization
|
|
||||||
JWK_RSA SPKI RSA-OAEP JSON Web Key
|
|
||||||
JWK_RSAES SPKI RSA PKCS#1 JSON Web Key
|
|
||||||
|
|
||||||
:param keypairid: key pair ID
|
|
||||||
:param mechanism: asymmetric key type
|
|
||||||
:param publickey: public key
|
|
||||||
"""
|
|
||||||
return cls(
|
|
||||||
scheme=KeyExchangeSchemes.AsymmetricWrapped,
|
|
||||||
keydata={
|
|
||||||
"keypairid": keypairid,
|
|
||||||
"mechanism": mechanism,
|
|
||||||
"publickey": base64.b64encode(publickey).decode("utf-8")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def Widevine(cls, keyrequest):
|
|
||||||
"""
|
|
||||||
Google Widevine provides a secure key exchange mechanism. When requested the Widevine component will issue a
|
|
||||||
one-time use key request. The Widevine server library can be used to authenticate the request and return
|
|
||||||
randomly generated symmetric keys in a protected key response bound to the request and Widevine client library.
|
|
||||||
The key response also specifies the key identities, types and their permitted usage.
|
|
||||||
|
|
||||||
The Widevine key request also contains a model identifier and a unique device identifier with an expectation of
|
|
||||||
long-term persistence. These values are available from the Widevine client library and can be retrieved from
|
|
||||||
the key request by the Widevine server library.
|
|
||||||
|
|
||||||
The Widevine client library will protect the returned keys from inspection or misuse.
|
|
||||||
|
|
||||||
:param keyrequest: Base64-encoded Widevine CDM license challenge (PSSH: b'\x0A\x7A\x00\x6C\x38\x2B')
|
|
||||||
"""
|
|
||||||
if not isinstance(keyrequest, str):
|
|
||||||
keyrequest = base64.b64encode(keyrequest).decode()
|
|
||||||
return cls(
|
|
||||||
scheme=KeyExchangeSchemes.Widevine,
|
|
||||||
keydata={"keyrequest": keyrequest}
|
|
||||||
)
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
from ..MSLObject import MSLObject
|
|
||||||
from . import UserAuthenticationSchemes
|
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyPep8Naming
|
|
||||||
class UserAuthentication(MSLObject):
|
|
||||||
def __init__(self, scheme, authdata):
|
|
||||||
"""
|
|
||||||
Data used to identify and authenticate the user associated with a message.
|
|
||||||
https://github.com/Netflix/msl/wiki/User-Authentication-%28Configuration%29
|
|
||||||
|
|
||||||
:param scheme: User Authentication Scheme identifier
|
|
||||||
:param authdata: User Authentication data
|
|
||||||
"""
|
|
||||||
self.scheme = str(scheme)
|
|
||||||
self.authdata = authdata
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def EmailPassword(cls, email, password):
|
|
||||||
"""
|
|
||||||
Email and password is a standard user authentication scheme in wide use.
|
|
||||||
|
|
||||||
:param email: user email address
|
|
||||||
:param password: user password
|
|
||||||
"""
|
|
||||||
return cls(
|
|
||||||
scheme=UserAuthenticationSchemes.EmailPassword,
|
|
||||||
authdata={
|
|
||||||
"email": email,
|
|
||||||
"password": password
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def NetflixIDCookies(cls, netflixid, securenetflixid):
|
|
||||||
"""
|
|
||||||
Netflix ID HTTP cookies are used when the user has previously logged in to a web site. Possession of the
|
|
||||||
cookies serves as proof of user identity, in the same manner as they do when communicating with the web site.
|
|
||||||
|
|
||||||
The Netflix ID cookie and Secure Netflix ID cookie are HTTP cookies issued by the Netflix web site after
|
|
||||||
subscriber login. The Netflix ID cookie is encrypted and identifies the subscriber and analogous to a
|
|
||||||
subscriber’s username. The Secure Netflix ID cookie is tied to a Netflix ID cookie and only sent over HTTPS
|
|
||||||
and analogous to a subscriber’s password.
|
|
||||||
|
|
||||||
In some cases the Netflix ID and Secure Netflix ID cookies will be unavailable to the MSL stack or application.
|
|
||||||
If either or both of the Netflix ID or Secure Netflix ID cookies are absent in the above data structure the
|
|
||||||
HTTP cookie headers will be queried for it; this is only acceptable when HTTPS is used as the underlying
|
|
||||||
transport protocol.
|
|
||||||
|
|
||||||
:param netflixid: Netflix ID cookie
|
|
||||||
:param securenetflixid: Secure Netflix ID cookie
|
|
||||||
"""
|
|
||||||
return cls(
|
|
||||||
scheme=UserAuthenticationSchemes.NetflixIDCookies,
|
|
||||||
authdata={
|
|
||||||
"netflixid": netflixid,
|
|
||||||
"securenetflixid": securenetflixid
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
from enum import Enum
|
|
||||||
|
|
||||||
|
|
||||||
class Scheme(Enum):
|
|
||||||
def __str__(self):
|
|
||||||
return str(self.value)
|
|
||||||
|
|
||||||
|
|
||||||
class EntityAuthenticationSchemes(Scheme):
|
|
||||||
"""https://github.com/Netflix/msl/wiki/Entity-Authentication-%28Configuration%29"""
|
|
||||||
Unauthenticated = "NONE"
|
|
||||||
Widevine = "WIDEVINE"
|
|
||||||
|
|
||||||
|
|
||||||
class UserAuthenticationSchemes(Scheme):
|
|
||||||
"""https://github.com/Netflix/msl/wiki/User-Authentication-%28Configuration%29"""
|
|
||||||
EmailPassword = "EMAIL_PASSWORD"
|
|
||||||
NetflixIDCookies = "NETFLIXID"
|
|
||||||
|
|
||||||
|
|
||||||
class KeyExchangeSchemes(Scheme):
|
|
||||||
"""https://github.com/Netflix/msl/wiki/Key-Exchange-%28Configuration%29"""
|
|
||||||
AsymmetricWrapped = "ASYMMETRIC_WRAPPED"
|
|
||||||
Widevine = "WIDEVINE"
|
|
||||||
@@ -1,978 +0,0 @@
|
|||||||
import base64
|
|
||||||
from datetime import datetime
|
|
||||||
import json
|
|
||||||
from math import e
|
|
||||||
|
|
||||||
import random
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import typing
|
|
||||||
from uuid import UUID
|
|
||||||
import click
|
|
||||||
import re
|
|
||||||
from typing import List, Literal, Optional, Set, Union, Tuple
|
|
||||||
from http.cookiejar import CookieJar
|
|
||||||
from itertools import zip_longest
|
|
||||||
from Crypto.Random import get_random_bytes
|
|
||||||
|
|
||||||
import jsonpickle
|
|
||||||
from pymp4.parser import Box
|
|
||||||
from pywidevine import PSSH, Cdm
|
|
||||||
import requests
|
|
||||||
from langcodes import Language
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from envied.core.constants import AnyTrack
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.drm.widevine import Widevine
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Titles_T, Title_T
|
|
||||||
from envied.core.titles.episode import Episode, Series
|
|
||||||
from envied.core.titles.movie import Movie, Movies
|
|
||||||
from envied.core.titles.title import Title
|
|
||||||
from envied.core.tracks import Tracks, Chapters, Hybrid
|
|
||||||
from envied.core.tracks.audio import Audio
|
|
||||||
from envied.core.tracks.chapter import Chapter
|
|
||||||
from envied.core.tracks.subtitle import Subtitle
|
|
||||||
from envied.core.tracks.track import Track
|
|
||||||
from envied.core.tracks.video import Video
|
|
||||||
from envied.core.utils.collections import flatten, as_list
|
|
||||||
|
|
||||||
from envied.core.tracks.attachment import Attachment
|
|
||||||
from envied.core.drm.playready import PlayReady
|
|
||||||
from envied.core.titles.song import Song
|
|
||||||
from envied.utils.base62 import decode
|
|
||||||
from .MSL import MSL, KeyExchangeSchemes
|
|
||||||
from .MSL.schemes.UserAuthentication import UserAuthentication
|
|
||||||
|
|
||||||
class NF(Service):
|
|
||||||
"""
|
|
||||||
Service for https://netflix.com
|
|
||||||
Version: 1.0.0
|
|
||||||
|
|
||||||
Authorization: Cookies
|
|
||||||
Security: UHD@SL3000/L1 FHD@SL3000/L1
|
|
||||||
"""
|
|
||||||
TITLE_RE = [
|
|
||||||
r"^(?:https?://(?:www\.)?netflix\.com(?:/[a-z0-9]{2})?/(?:title/|watch/|.+jbv=))?(?P<id>\d+)",
|
|
||||||
r"^https?://(?:www\.)?unogs\.com/title/(?P<id>\d+)",
|
|
||||||
]
|
|
||||||
ALIASES= ("NF", "Netflix")
|
|
||||||
NF_LANG_MAP = {
|
|
||||||
"es": "es-419",
|
|
||||||
"pt": "pt-PT",
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="Netflix", short_help="https://netflix.com")
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.option("-drm", "--drm-system", type=click.Choice(["widevine", "playready"], case_sensitive=False),
|
|
||||||
default="widevine",
|
|
||||||
help="which drm system to use")
|
|
||||||
@click.option("-p", "--profile", type=click.Choice(["MPL", "HPL", "QC", "MPL+HPL", "MPL+HPL+QC", "MPL+QC"], case_sensitive=False),
|
|
||||||
default=None,
|
|
||||||
help="H.264 profile to use. Default is best available.")
|
|
||||||
@click.option("--meta-lang", type=str, help="Language to use for metadata")
|
|
||||||
@click.option("-ht","--hydrate-track", is_flag=True, default=False, help="Hydrate missing audio and subtitle.")
|
|
||||||
@click.option("-hb", "--high-bitrate", is_flag=True, default=False, help="Get more video bitrate")
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, **kwargs):
|
|
||||||
return NF(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx: click.Context, title: str, drm_system: Literal["widevine", "playready"], profile: str, meta_lang: str, hydrate_track: bool, high_bitrate: bool):
|
|
||||||
super().__init__(ctx)
|
|
||||||
# General
|
|
||||||
self.title = title
|
|
||||||
self.profile = profile
|
|
||||||
self.meta_lang = meta_lang
|
|
||||||
self.hydrate_track = hydrate_track
|
|
||||||
self.drm_system = drm_system
|
|
||||||
self.profiles: List[str] = []
|
|
||||||
self.requested_profiles: List[str] = []
|
|
||||||
self.high_bitrate = high_bitrate
|
|
||||||
|
|
||||||
# MSL
|
|
||||||
self.esn = self.cache.get("ESN")
|
|
||||||
self.msl: Optional[MSL] = None
|
|
||||||
self.userauthdata = None
|
|
||||||
|
|
||||||
# Download options
|
|
||||||
self.range = ctx.parent.params.get("range_") or [Video.Range.SDR]
|
|
||||||
self.vcodec = ctx.parent.params.get("vcodec") or Video.Codec.AVC # Defaults to H264
|
|
||||||
self.acodec : Audio.Codec = ctx.parent.params.get("acodec") or Audio.Codec.EC3
|
|
||||||
self.quality: List[int] = ctx.parent.params.get("quality")
|
|
||||||
self.audio_only = ctx.parent.params.get("audio_only")
|
|
||||||
self.subs_only = ctx.parent.params.get("subs_only")
|
|
||||||
self.chapters_only = ctx.parent.params.get("chapters_only")
|
|
||||||
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
# Configure first before download
|
|
||||||
self.log.debug("Authenticating Netflix service")
|
|
||||||
auth = super().authenticate(cookies, credential)
|
|
||||||
if not cookies:
|
|
||||||
raise EnvironmentError("Service requires Cookies for Authentication.")
|
|
||||||
self.configure()
|
|
||||||
return auth
|
|
||||||
|
|
||||||
def get_titles(self) -> Titles_T:
|
|
||||||
metadata = self.get_metadata(self.title)
|
|
||||||
# self.log.info(f"Metadata: {jsonpickle.encode(metadata, indent=2)}")
|
|
||||||
if "video" not in metadata:
|
|
||||||
self.log.error(f"Failed to get metadata: {metadata}")
|
|
||||||
sys.exit(1)
|
|
||||||
titles: Titles_T | None = None
|
|
||||||
if metadata["video"]["type"] == "movie":
|
|
||||||
movie = Movie(
|
|
||||||
id_=self.title,
|
|
||||||
name=metadata["video"]["title"],
|
|
||||||
year=metadata["video"]["year"],
|
|
||||||
service=self.__class__,
|
|
||||||
data=metadata["video"],
|
|
||||||
description=metadata["video"]["synopsis"]
|
|
||||||
)
|
|
||||||
titles = Movies([
|
|
||||||
movie
|
|
||||||
])
|
|
||||||
else:
|
|
||||||
# self.log.info(f"Episodes: {jsonpickle.encode(episodes, indent=2)}")
|
|
||||||
|
|
||||||
episode_list: List[Episode] = []
|
|
||||||
for season in metadata["video"]["seasons"]:
|
|
||||||
for episode in season["episodes"]:
|
|
||||||
episode_list.append(
|
|
||||||
Episode(
|
|
||||||
id_=self.title,
|
|
||||||
title=metadata["video"]["title"],
|
|
||||||
year=season["year"],
|
|
||||||
service=self.__class__,
|
|
||||||
season=season["seq"],
|
|
||||||
number=episode["seq"],
|
|
||||||
name=episode["title"],
|
|
||||||
data=episode,
|
|
||||||
description=episode["synopsis"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
titles = Series(episode_list)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return titles
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
|
||||||
tracks = Tracks()
|
|
||||||
|
|
||||||
def mark_repack(track_group):
|
|
||||||
# mark videos + audio
|
|
||||||
for t in track_group.videos + track_group.audio:
|
|
||||||
t.needs_repack = True
|
|
||||||
|
|
||||||
# mark subtitles
|
|
||||||
for t in getattr(track_group, "subtitles", []):
|
|
||||||
t.needs_repack = True
|
|
||||||
|
|
||||||
# -------------------------------
|
|
||||||
# Parse manifests / fetch tracks
|
|
||||||
# -------------------------------
|
|
||||||
if self.vcodec == Video.Codec.AVC:
|
|
||||||
try:
|
|
||||||
manifest = self.get_manifest(title, self.profiles)
|
|
||||||
movie_track = self.manifest_as_tracks(manifest, title, self.hydrate_track)
|
|
||||||
mark_repack(movie_track)
|
|
||||||
tracks.add(movie_track)
|
|
||||||
|
|
||||||
if self.profile is not None:
|
|
||||||
self.log.info(f"Requested profiles: {self.profile}")
|
|
||||||
else:
|
|
||||||
qc_720_profile = [
|
|
||||||
x for x in self.config["profiles"]["video"][self.vcodec.extension.upper()]["QC"]
|
|
||||||
if "l40" not in x and 720 in self.quality
|
|
||||||
]
|
|
||||||
|
|
||||||
# QC profiles
|
|
||||||
qc_manifest = self.get_manifest(
|
|
||||||
title,
|
|
||||||
qc_720_profile if 720 in self.quality
|
|
||||||
else self.config["profiles"]["video"][self.vcodec.extension.upper()]["QC"]
|
|
||||||
)
|
|
||||||
qc_tracks = self.manifest_as_tracks(qc_manifest, title, False)
|
|
||||||
mark_repack(qc_tracks)
|
|
||||||
tracks.add(qc_tracks.videos)
|
|
||||||
|
|
||||||
# MPL Profiles
|
|
||||||
mpl_manifest = self.get_manifest(
|
|
||||||
title,
|
|
||||||
[x for x in self.config["profiles"]["video"][self.vcodec.extension.upper()]["MPL"]
|
|
||||||
if "l40" not in x]
|
|
||||||
)
|
|
||||||
mpl_tracks = self.manifest_as_tracks(mpl_manifest, title, False)
|
|
||||||
mark_repack(mpl_tracks)
|
|
||||||
tracks.add(mpl_tracks.videos)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log.error(e)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# HEVC / DV / HDR mode
|
|
||||||
if self.high_bitrate:
|
|
||||||
splitted_profiles = self.split_profiles(self.profiles)
|
|
||||||
for index, profile_list in enumerate(splitted_profiles):
|
|
||||||
try:
|
|
||||||
self.log.debug(f"Index: {index}. Getting profiles: {profile_list}")
|
|
||||||
manifest = self.get_manifest(title, profile_list)
|
|
||||||
manifest_tracks = self.manifest_as_tracks(
|
|
||||||
manifest,
|
|
||||||
title,
|
|
||||||
self.hydrate_track if index == 0 else False
|
|
||||||
)
|
|
||||||
mark_repack(manifest_tracks)
|
|
||||||
tracks.add(manifest_tracks if index == 0 else manifest_tracks.videos)
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
self.log.error(f"Error getting profile: {profile_list}. Skipping")
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
manifest = self.get_manifest(title, self.profiles)
|
|
||||||
manifest_tracks = self.manifest_as_tracks(manifest, title, self.hydrate_track)
|
|
||||||
mark_repack(manifest_tracks)
|
|
||||||
tracks.add(manifest_tracks)
|
|
||||||
except Exception as e:
|
|
||||||
self.log.error(e)
|
|
||||||
|
|
||||||
# --------------------------------------------------------
|
|
||||||
# 🧩 HYBRID DV+HDR Injection (copied from 1st script)
|
|
||||||
# --------------------------------------------------------
|
|
||||||
video_ranges = [v.range for v in tracks.videos]
|
|
||||||
has_dv = Video.Range.DV in video_ranges
|
|
||||||
has_hdr10 = Video.Range.HDR10 in video_ranges
|
|
||||||
has_hdr10p = Video.Range.HDR10P in video_ranges
|
|
||||||
|
|
||||||
if self.range[0] == Video.Range.HYBRID and has_hdr10 and (has_dv or has_hdr10p):
|
|
||||||
try:
|
|
||||||
self.log.info("Performing HYBRID DV+HDR injection...")
|
|
||||||
|
|
||||||
hdr_video = next((v for v in tracks.videos if v.range == Video.Range.HDR10), None)
|
|
||||||
dv_video = next((v for v in tracks.videos if v.range in (Video.Range.DV, Video.Range.HDR10P)), None)
|
|
||||||
|
|
||||||
if not hdr_video or not dv_video:
|
|
||||||
raise Exception("Missing HDR10 or DV video track for hybrid merge")
|
|
||||||
|
|
||||||
# Ensure both files exist before injection
|
|
||||||
def ensure_local_file(video):
|
|
||||||
if not getattr(video, "path", None) or not os.path.exists(video.path):
|
|
||||||
temp_path = config.directories.temp / f"{video.id}.hevc"
|
|
||||||
self.log.info(f"Downloading temporary stream for {video.range} → {temp_path.name}")
|
|
||||||
with self.session.get(video.url, stream=True) as r:
|
|
||||||
r.raise_for_status()
|
|
||||||
with open(temp_path, "wb") as f:
|
|
||||||
for chunk in r.iter_content(chunk_size=1024 * 1024):
|
|
||||||
f.write(chunk)
|
|
||||||
video.path = temp_path
|
|
||||||
return video.path
|
|
||||||
|
|
||||||
ensure_local_file(hdr_video)
|
|
||||||
ensure_local_file(dv_video)
|
|
||||||
|
|
||||||
# Perform hybrid merge
|
|
||||||
Hybrid([hdr_video, dv_video], self.__class__.__name__.lower())
|
|
||||||
|
|
||||||
injected_path = config.directories.temp / "HDR10-DV.hevc"
|
|
||||||
self.log.info(f"Hybrid file created → {injected_path}")
|
|
||||||
|
|
||||||
# Replace HDR10 with merged track
|
|
||||||
hdr_video.range = Video.Range.DV
|
|
||||||
hdr_video.path = injected_path
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log.warning(f"Hybrid injection failed: {e}")
|
|
||||||
|
|
||||||
# --------------------------------------------------------
|
|
||||||
# Disable proxy for all tracks
|
|
||||||
# --------------------------------------------------------
|
|
||||||
for track in tracks:
|
|
||||||
track.needs_proxy = False
|
|
||||||
|
|
||||||
# --------------------------------------------------------
|
|
||||||
# Add Attachments + Save poster
|
|
||||||
# --------------------------------------------------------
|
|
||||||
try:
|
|
||||||
if isinstance(title, Movie):
|
|
||||||
poster_url = title.data["boxart"][0]["url"]
|
|
||||||
else:
|
|
||||||
poster_url = title.data["stills"][0]["url"]
|
|
||||||
|
|
||||||
# Temp directory
|
|
||||||
temp_dir = Path(self.config.get("directories", {}).get("Downloads", "./Downloads"))
|
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
poster_path = temp_dir / "poster.jpg"
|
|
||||||
|
|
||||||
# Save poster locally
|
|
||||||
try:
|
|
||||||
resp = requests.get(poster_url, timeout=15)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
with open(poster_path, "wb") as f:
|
|
||||||
f.write(resp.content)
|
|
||||||
except Exception as e:
|
|
||||||
self.log.error(f"Failed to save poster.jpg: {e}")
|
|
||||||
|
|
||||||
# Create attachment
|
|
||||||
attachment = Attachment.from_url(url=poster_url)
|
|
||||||
attachment.filename = str(poster_path)
|
|
||||||
tracks.add(attachment)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log.error(f"Failed to add attachments: {e}")
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def split_profiles(self, profiles: List[str]) -> List[List[str]]:
|
|
||||||
"""
|
|
||||||
Split profiles based on codec level ranges and also DV/HDR groups for HYBRID mode.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# Patterns for profile splitting
|
|
||||||
# -----------------------------
|
|
||||||
if self.vcodec == Video.Codec.AVC:
|
|
||||||
level_patterns = ["l30", "l31", "l40"]
|
|
||||||
else:
|
|
||||||
level_patterns = ["L30", "L31", "L40", "L41", "L50", "L51"]
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# HYBRID MODE — Add DV/HDR splits
|
|
||||||
# -----------------------------
|
|
||||||
dv_patterns = ["DV", "dv"]
|
|
||||||
hdr10_patterns = ["HDR10", "hdr10"]
|
|
||||||
hdr10p_patterns = ["HDR10P", "hdr10p"]
|
|
||||||
|
|
||||||
result: List[List[str]] = []
|
|
||||||
used = set()
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# Group DV profiles first
|
|
||||||
# -----------------------------
|
|
||||||
if self.range[0] == Video.Range.HYBRID:
|
|
||||||
dv_group = [p for p in profiles if any(tag in p for tag in dv_patterns)]
|
|
||||||
if dv_group:
|
|
||||||
result.append(dv_group)
|
|
||||||
used.update(dv_group)
|
|
||||||
|
|
||||||
# Group HDR10 profiles
|
|
||||||
hdr10_group = [p for p in profiles if any(tag in p for tag in hdr10_patterns)]
|
|
||||||
if hdr10_group:
|
|
||||||
result.append(hdr10_group)
|
|
||||||
used.update(hdr10_group)
|
|
||||||
|
|
||||||
# Group HDR10+ profiles
|
|
||||||
hdr10p_group = [p for p in profiles if any(tag in p for tag in hdr10p_patterns)]
|
|
||||||
if hdr10p_group:
|
|
||||||
result.append(hdr10p_group)
|
|
||||||
used.update(hdr10p_group)
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# Normal HEVC/H264 Level Splitting
|
|
||||||
# -----------------------------
|
|
||||||
for pattern in level_patterns:
|
|
||||||
group = [p for p in profiles if (pattern in p and p not in used)]
|
|
||||||
if group:
|
|
||||||
result.append(group)
|
|
||||||
used.update(group)
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# Any remaining profiles
|
|
||||||
# -----------------------------
|
|
||||||
leftover = [p for p in profiles if p not in used]
|
|
||||||
if leftover:
|
|
||||||
result.append(leftover)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def get_chapters(self, title: Title_T) -> Chapters:
|
|
||||||
chapters: Chapters = Chapters()
|
|
||||||
# self.log.info(f"Title data: {title.data}")
|
|
||||||
credits = title.data["skipMarkers"]["credit"]
|
|
||||||
if credits["start"] > 0 and credits["end"] > 0:
|
|
||||||
chapters.add(Chapter(
|
|
||||||
timestamp=credits["start"], # Milliseconds
|
|
||||||
name="Intro"
|
|
||||||
))
|
|
||||||
chapters.add(
|
|
||||||
Chapter(
|
|
||||||
timestamp=credits["end"], # Milliseconds
|
|
||||||
name="Part 01"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
chapters.add(Chapter(
|
|
||||||
timestamp=float(title.data["creditsOffset"]), # this is seconds, needed to assign to float
|
|
||||||
name="Outro"
|
|
||||||
))
|
|
||||||
|
|
||||||
return chapters
|
|
||||||
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title: Movie | Episode | Song, track: AnyTrack) -> bytes | str | None:
|
|
||||||
if not self.msl:
|
|
||||||
self.log.error(f"MSL Client is not intialized!")
|
|
||||||
sys.exit(1)
|
|
||||||
application_data = {
|
|
||||||
"version": 2,
|
|
||||||
"url": track.data["license_url"],
|
|
||||||
"id": int(time.time() * 10000),
|
|
||||||
"esn": self.esn.data,
|
|
||||||
"languages": ["en-US"],
|
|
||||||
# "uiVersion": "shakti-v9dddfde5",
|
|
||||||
"clientVersion": "6.0026.291.011",
|
|
||||||
"params": [{
|
|
||||||
"sessionId": base64.b64encode(get_random_bytes(16)).decode("utf-8"),
|
|
||||||
"clientTime": int(time.time()),
|
|
||||||
"challengeBase64": base64.b64encode(challenge).decode("utf-8"),
|
|
||||||
"xid": str(int((int(time.time()) + 0.1612) * 1000)),
|
|
||||||
}],
|
|
||||||
"echo": "sessionId"
|
|
||||||
}
|
|
||||||
header, payload_data = self.msl.send_message(
|
|
||||||
endpoint=self.config["endpoints"]["license"],
|
|
||||||
params={
|
|
||||||
"reqAttempt": 1,
|
|
||||||
"reqName": "license",
|
|
||||||
},
|
|
||||||
application_data=application_data,
|
|
||||||
userauthdata=self.userauthdata
|
|
||||||
)
|
|
||||||
if not payload_data:
|
|
||||||
self.log.error(f" - Failed to get license: {header['message']} [{header['code']}]")
|
|
||||||
sys.exit(1)
|
|
||||||
if "error" in payload_data[0]:
|
|
||||||
error = payload_data[0]["error"]
|
|
||||||
error_display = error.get("display")
|
|
||||||
error_detail = re.sub(r" \(E3-[^)]+\)", "", error.get("detail", ""))
|
|
||||||
|
|
||||||
if error_display:
|
|
||||||
self.log.critical(f" - {error_display}")
|
|
||||||
if error_detail:
|
|
||||||
self.log.critical(f" - {error_detail}")
|
|
||||||
|
|
||||||
if not (error_display or error_detail):
|
|
||||||
self.log.critical(f" - {error}")
|
|
||||||
|
|
||||||
sys.exit(1)
|
|
||||||
return payload_data[0]["licenseResponseBase64"]
|
|
||||||
|
|
||||||
def get_playready_license(self, *, challenge: bytes, title: Movie | Episode | Song, track: AnyTrack) -> bytes | str | None:
|
|
||||||
return None
|
|
||||||
# return super().get_widevine_license(challenge=challenge, title=title, track=track)
|
|
||||||
|
|
||||||
def configure(self):
|
|
||||||
# -----------------------------
|
|
||||||
# Profiles selection
|
|
||||||
# -----------------------------
|
|
||||||
if self.profile is None:
|
|
||||||
self.profiles = self.config["profiles"]["video"][self.vcodec.extension.upper()]
|
|
||||||
|
|
||||||
if self.profile is not None:
|
|
||||||
self.requested_profiles = self.profile.split('+')
|
|
||||||
self.log.info(f"Requested profile: {self.requested_profiles}")
|
|
||||||
else:
|
|
||||||
self.requested_profiles = self.config["profiles"]["video"][self.vcodec.extension.upper()]
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# Validate codec support
|
|
||||||
# -----------------------------
|
|
||||||
if self.vcodec.extension.upper() not in self.config["profiles"]["video"]:
|
|
||||||
raise ValueError(f"Video Codec {self.vcodec} is not supported by Netflix")
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# HYBRID MODE FIX
|
|
||||||
# -----------------------------
|
|
||||||
if self.range[0] == Video.Range.HYBRID:
|
|
||||||
# Only allowed for HEVC
|
|
||||||
if self.vcodec != Video.Codec.HEVC:
|
|
||||||
self.log.error("HYBRID mode is only supported for HEVC codec.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
self.log.info("HYBRID mode detected → Skipping standard range validation")
|
|
||||||
# Skip all range validation completely
|
|
||||||
else:
|
|
||||||
# Normal validation path (non-HYBRID)
|
|
||||||
if self.range[0].name not in list(self.config["profiles"]["video"][self.vcodec.extension.upper()].keys()) \
|
|
||||||
and self.vcodec not in (Video.Codec.AVC, Video.Codec.VP9):
|
|
||||||
|
|
||||||
self.log.error(f"Video range {self.range[0].name} is not supported by Video Codec: {self.vcodec}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if len(self.range) > 1:
|
|
||||||
self.log.error("Multiple video range is not supported right now.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if self.vcodec == Video.Codec.AVC and self.range[0] != Video.Range.SDR:
|
|
||||||
self.log.error("H.264 Video Codec only supports SDR")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# Final profile resolution
|
|
||||||
# -----------------------------
|
|
||||||
self.profiles = self.get_profiles()
|
|
||||||
|
|
||||||
self.log.info("Initializing a MSL client")
|
|
||||||
self.get_esn()
|
|
||||||
scheme = KeyExchangeSchemes.AsymmetricWrapped
|
|
||||||
self.log.info(f"Scheme: {scheme}")
|
|
||||||
|
|
||||||
self.msl = MSL.handshake(
|
|
||||||
scheme=scheme,
|
|
||||||
session=self.session,
|
|
||||||
endpoint=self.config["endpoints"]["manifest"],
|
|
||||||
sender=self.esn.data,
|
|
||||||
cache=self.cache.get("MSL")
|
|
||||||
)
|
|
||||||
|
|
||||||
cookie = self.session.cookies.get_dict()
|
|
||||||
self.userauthdata = UserAuthentication.NetflixIDCookies(
|
|
||||||
netflixid=cookie["NetflixId"],
|
|
||||||
securenetflixid=cookie["SecureNetflixId"]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_profiles(self):
|
|
||||||
result_profiles = []
|
|
||||||
|
|
||||||
# -------------------------------
|
|
||||||
# AVC logic (unchanged)
|
|
||||||
# -------------------------------
|
|
||||||
if self.vcodec == Video.Codec.AVC:
|
|
||||||
if self.requested_profiles is not None:
|
|
||||||
for req in self.requested_profiles:
|
|
||||||
result_profiles.extend(
|
|
||||||
flatten(list(self.config["profiles"]["video"][self.vcodec.extension.upper()][req]))
|
|
||||||
)
|
|
||||||
return result_profiles
|
|
||||||
|
|
||||||
result_profiles.extend(
|
|
||||||
flatten(list(self.config["profiles"]["video"][self.vcodec.extension.upper()].values()))
|
|
||||||
)
|
|
||||||
return result_profiles
|
|
||||||
|
|
||||||
# -------------------------------
|
|
||||||
# VP9 logic (unchanged)
|
|
||||||
# -------------------------------
|
|
||||||
if self.vcodec == Video.Codec.VP9 and self.range[0] != Video.Range.HDR10:
|
|
||||||
result_profiles.extend(
|
|
||||||
self.config["profiles"]["video"][self.vcodec.extension.upper()].values()
|
|
||||||
)
|
|
||||||
return result_profiles
|
|
||||||
|
|
||||||
# -------------------------------
|
|
||||||
# HEVC Hybrid mode (FIXED)
|
|
||||||
# -------------------------------
|
|
||||||
if self.vcodec == Video.Codec.HEVC and self.range[0] == Video.Range.HYBRID:
|
|
||||||
self.log.info("HYBRID mode detected → Using HDR10 + DV profiles")
|
|
||||||
|
|
||||||
hevc_profiles = self.config["profiles"]["video"][self.vcodec.extension.upper()]
|
|
||||||
|
|
||||||
result_profiles = []
|
|
||||||
|
|
||||||
# 1. HDR10 FIRST
|
|
||||||
if "HDR10" in hevc_profiles:
|
|
||||||
result_profiles += hevc_profiles["HDR10"]
|
|
||||||
|
|
||||||
# 2. HDR10P (some titles use this instead of HDR10)
|
|
||||||
if "HDR10P" in hevc_profiles:
|
|
||||||
result_profiles += hevc_profiles["HDR10P"]
|
|
||||||
|
|
||||||
# 3. DV LAST (IMPORTANT!)
|
|
||||||
if "DV" in hevc_profiles:
|
|
||||||
result_profiles += hevc_profiles["DV"]
|
|
||||||
|
|
||||||
return result_profiles
|
|
||||||
|
|
||||||
# -------------------------------
|
|
||||||
# Normal HEVC (non HYBRID)
|
|
||||||
# -------------------------------
|
|
||||||
for profiles in self.config["profiles"]["video"][self.vcodec.extension.upper()]:
|
|
||||||
for r in self.range:
|
|
||||||
if r in profiles:
|
|
||||||
result_profiles.extend(
|
|
||||||
self.config["profiles"]["video"][self.vcodec.extension.upper()][r.name]
|
|
||||||
)
|
|
||||||
|
|
||||||
self.log.debug(f"Result_profiles: {result_profiles}")
|
|
||||||
return result_profiles
|
|
||||||
|
|
||||||
def get_esn(self):
|
|
||||||
ESN_GEN = "".join(random.choice("0123456789ABCDEF") for _ in range(30))
|
|
||||||
esn_value = f"NFCDIE-03-{ESN_GEN}"
|
|
||||||
# Check if ESN is expired or doesn't exist
|
|
||||||
if self.esn.data is None or self.esn.data == {} or (hasattr(self.esn, 'expired') and self.esn.expired):
|
|
||||||
# Set new ESN with 6-hour expiration
|
|
||||||
self.esn.set(esn_value, 1 * 60 * 60) # 6 hours in seconds
|
|
||||||
self.log.info(f"Generated new ESN with 1-hour expiration")
|
|
||||||
else:
|
|
||||||
self.log.info(f"Using cached ESN.")
|
|
||||||
self.log.info(f"ESN: {self.esn.data}")
|
|
||||||
|
|
||||||
|
|
||||||
def get_metadata(self, title_id: str):
|
|
||||||
"""
|
|
||||||
Obtain Metadata information about a title by it's ID.
|
|
||||||
:param title_id: Title's ID.
|
|
||||||
:returns: Title Metadata.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
metadata = self.session.get(
|
|
||||||
self.config["endpoints"]["metadata"].format(build_id="release"),
|
|
||||||
params={
|
|
||||||
"movieid": title_id,
|
|
||||||
"drmSystem": self.config["configuration"]["drm_system"],
|
|
||||||
"isWatchlistEnabled": False,
|
|
||||||
"isShortformEnabled": False,
|
|
||||||
"languages": self.meta_lang
|
|
||||||
}
|
|
||||||
).json()
|
|
||||||
except requests.HTTPError as e:
|
|
||||||
if e.response.status_code == 500:
|
|
||||||
self.log.warning(
|
|
||||||
" - Recieved a HTTP 500 error while getting metadata, deleting cached reactContext data"
|
|
||||||
)
|
|
||||||
# self.cache.
|
|
||||||
# os.unlink(self.get_cache("web_data.json"))
|
|
||||||
# return self.get_metadata(self, title_id)
|
|
||||||
raise Exception(f"Error getting metadata: {e}")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
self.log.error(" - Failed to get metadata, title might not be available in your region.")
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
if "status" in metadata and metadata["status"] == "error":
|
|
||||||
self.log.error(
|
|
||||||
f" - Failed to get metadata, cookies might be expired. ({metadata['message']})"
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
return metadata
|
|
||||||
|
|
||||||
def get_manifest(self, title: Title_T, video_profiles: List[str], required_text_track_id: Optional[str] = None, required_audio_track_id: Optional[str] = None):
|
|
||||||
audio_profiles = self.config["profiles"]["audio"].values()
|
|
||||||
video_profiles = sorted(set(flatten(as_list(
|
|
||||||
video_profiles,
|
|
||||||
audio_profiles,
|
|
||||||
self.config["profiles"]["video"]["H264"]["BPL"] if self.vcodec == Video.Codec.AVC else [],
|
|
||||||
self.config["profiles"]["subtitles"],
|
|
||||||
))))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
self.log.debug("Profiles:\n\t" + "\n\t".join(video_profiles))
|
|
||||||
|
|
||||||
if not self.msl:
|
|
||||||
raise Exception("MSL Client is not intialized.")
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"reqAttempt": 1,
|
|
||||||
"reqPriority": 10,
|
|
||||||
"reqName": "manifest",
|
|
||||||
}
|
|
||||||
_, payload_chunks = self.msl.send_message(
|
|
||||||
endpoint=self.config["endpoints"]["manifest"],
|
|
||||||
params=params,
|
|
||||||
application_data={
|
|
||||||
"version": 2,
|
|
||||||
"url": "manifest",
|
|
||||||
"id": int(time.time()),
|
|
||||||
"esn": self.esn.data,
|
|
||||||
"languages": ["en-US"],
|
|
||||||
"clientVersion": "6.0026.291.011",
|
|
||||||
"params": {
|
|
||||||
"clientVersion": "6.0051.090.911",
|
|
||||||
"challenge": self.config["payload_challenge_pr"] if self.drm_system == 'playready' else self.config["payload_challenge"],
|
|
||||||
"challanges": {
|
|
||||||
"default": self.config["payload_challenge_pr"] if self.drm_system == 'playready' else self.config["payload_challenge"]
|
|
||||||
},
|
|
||||||
"contentPlaygraph": ["v2"],
|
|
||||||
"deviceSecurityLevel": "3000",
|
|
||||||
"drmVersion": 25,
|
|
||||||
"desiredVmaf": "plus_lts",
|
|
||||||
"desiredSegmentVmaf": "plus_lts",
|
|
||||||
"flavor": "STANDARD", # ? PRE_FETCH, SUPPLEMENTAL
|
|
||||||
"drmType": self.drm_system,
|
|
||||||
"imageSubtitleHeight": 1080,
|
|
||||||
"isBranching": False,
|
|
||||||
"isNonMember": False,
|
|
||||||
"isUIAutoPlay": False,
|
|
||||||
"licenseType": "standard",
|
|
||||||
"liveAdsCapability": "replace",
|
|
||||||
"liveMetadataFormat": "INDEXED_SEGMENT_TEMPLATE",
|
|
||||||
"manifestVersion": "v2",
|
|
||||||
"osName": "windows",
|
|
||||||
"osVersion": "10.0",
|
|
||||||
"platform": "138.0.0.0",
|
|
||||||
"profilesGroups": [{
|
|
||||||
"name": "default",
|
|
||||||
"profiles": video_profiles
|
|
||||||
}],
|
|
||||||
"profiles": video_profiles,
|
|
||||||
"preferAssistiveAudio": False,
|
|
||||||
"requestSegmentVmaf": False,
|
|
||||||
"requiredAudioTrackId": required_audio_track_id, # This is for getting missing audio tracks (value get from `new_track_id``)
|
|
||||||
"requiredTextTrackId": required_text_track_id, # This is for getting missing subtitle. (value get from `new_track_id``)
|
|
||||||
"supportsAdBreakHydration": False,
|
|
||||||
"supportsNetflixMediaEvents": True,
|
|
||||||
"supportsPartialHydration": True, # This is important if you want get available all tracks. but you must fetch each missing url tracks with "requiredAudioTracksId" or "requiredTextTrackId"
|
|
||||||
"supportsPreReleasePin": True,
|
|
||||||
"supportsUnequalizedDownloadables": True,
|
|
||||||
"supportsWatermark": True,
|
|
||||||
"titleSpecificData": {
|
|
||||||
title.data.get("episodeId", title.data["id"]): {"unletterboxed": False}
|
|
||||||
},
|
|
||||||
"type": "standard", # ? PREPARE
|
|
||||||
"uiPlatform": "SHAKTI",
|
|
||||||
"uiVersion": "shakti-v49577320",
|
|
||||||
"useBetterTextUrls": True,
|
|
||||||
"useHttpsStreams": True,
|
|
||||||
"usePsshBox": True,
|
|
||||||
"videoOutputInfo": [{
|
|
||||||
# todo ; make this return valid, but "secure" values, maybe it helps
|
|
||||||
"type": "DigitalVideoOutputDescriptor",
|
|
||||||
"outputType": "unknown",
|
|
||||||
"supportedHdcpVersions": self.config["configuration"]["supported_hdcp_versions"],
|
|
||||||
"isHdcpEngaged": self.config["configuration"]["is_hdcp_engaged"]
|
|
||||||
}],
|
|
||||||
"viewableId": title.data.get("episodeId", title.data["id"]),
|
|
||||||
"xid": str(int((int(time.time()) + 0.1612) * 1000)),
|
|
||||||
"showAllSubDubTracks": True,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
userauthdata=self.userauthdata
|
|
||||||
)
|
|
||||||
if "errorDetails" in payload_chunks:
|
|
||||||
raise Exception(f"Manifest call failed: {payload_chunks['errorDetails']}")
|
|
||||||
# with open(f"./manifest_{"+".join(video_profiles)}.json", mode='w') as r:
|
|
||||||
# r.write(jsonpickle.encode(payload_chunks, indent=4))
|
|
||||||
return payload_chunks
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_original_language(manifest) -> Language:
|
|
||||||
for language in manifest["audio_tracks"]:
|
|
||||||
if language["languageDescription"].endswith(" [Original]"):
|
|
||||||
return Language.get(language["language"])
|
|
||||||
# e.g. get `en` from "A:1:1;2;en;0;|V:2:1;[...]"
|
|
||||||
return Language.get(manifest["defaultTrackOrderList"][0]["mediaId"].split(";")[2])
|
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, *, challenge: bytes, title: Movie | Episode | Song, track: AnyTrack) -> bytes | str:
|
|
||||||
return self.config["certificate"]
|
|
||||||
|
|
||||||
def manifest_as_tracks(self, manifest, title: Title_T, hydrate_tracks = False) -> Tracks:
|
|
||||||
|
|
||||||
tracks = Tracks()
|
|
||||||
original_language = self.get_original_language(manifest)
|
|
||||||
self.log.debug(f"Original language: {original_language}")
|
|
||||||
license_url = manifest["links"]["license"]["href"]
|
|
||||||
# self.log.info(f"Video: {jsonpickle.encode(manifest["video_tracks"], indent=2)}")
|
|
||||||
# self.log.info()
|
|
||||||
for video in reversed(manifest["video_tracks"][0]["streams"]):
|
|
||||||
# self.log.info(video)
|
|
||||||
id = video["downloadable_id"]
|
|
||||||
# self.log.info(f"Adding video {video["res_w"]}x{video["res_h"]}, bitrate: {(float(video["framerate_value"]) / video["framerate_scale"]) if "framerate_value" in video else None} with profile {video["content_profile"]}. kid: {video["drmHeaderId"]}")
|
|
||||||
tracks.add(
|
|
||||||
Video(
|
|
||||||
id_=video["downloadable_id"],
|
|
||||||
url=video["urls"][0]["url"],
|
|
||||||
codec=Video.Codec.from_netflix_profile(video["content_profile"]),
|
|
||||||
bitrate=video["bitrate"] * 1000,
|
|
||||||
width=video["res_w"],
|
|
||||||
height=video["res_h"],
|
|
||||||
fps=(float(video["framerate_value"]) / video["framerate_scale"]) if "framerate_value" in video else None,
|
|
||||||
language=Language.get(original_language),
|
|
||||||
edition=video["content_profile"],
|
|
||||||
range_=self.parse_video_range_from_profile(video["content_profile"]),
|
|
||||||
drm=[Widevine(
|
|
||||||
pssh=PSSH(
|
|
||||||
# Box.parse(
|
|
||||||
# Box.build(
|
|
||||||
# dict(
|
|
||||||
# type=b"pssh",
|
|
||||||
# version=0,
|
|
||||||
# flags=0,
|
|
||||||
# system_ID=Cdm.uuid,
|
|
||||||
# init_data=b"\x12\x10" + UUID(hex=video["drmHeaderId"]).bytes
|
|
||||||
# )
|
|
||||||
# )
|
|
||||||
# )
|
|
||||||
manifest["video_tracks"][0]["drmHeader"]["bytes"]
|
|
||||||
),
|
|
||||||
kid=video["drmHeaderId"]
|
|
||||||
)],
|
|
||||||
data={
|
|
||||||
'license_url': license_url
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Audio
|
|
||||||
|
|
||||||
# store unavailable tracks for hydrating later
|
|
||||||
unavailable_audio_tracks: List[Tuple[str, str]] = []
|
|
||||||
for index, audio in enumerate(manifest["audio_tracks"]):
|
|
||||||
if len(audio["streams"]) < 1:
|
|
||||||
# This
|
|
||||||
# self.log.debug(f"Audio lang {audio["languageDescription"]} is available but no stream available.")
|
|
||||||
unavailable_audio_tracks.append((audio["new_track_id"], audio["id"])) # Assign to `unavailable_subtitle` for request missing audio tracks later
|
|
||||||
continue
|
|
||||||
# self.log.debug(f"Adding audio lang: {audio["language"]} with profile: {audio["content_profile"]}")
|
|
||||||
is_original_lang = audio["language"] == original_language.language
|
|
||||||
# self.log.info(f"is audio {audio["languageDescription"]} original language: {is_original_lang}")
|
|
||||||
for stream in audio["streams"]:
|
|
||||||
tracks.add(
|
|
||||||
Audio(
|
|
||||||
id_=stream["downloadable_id"],
|
|
||||||
url=stream["urls"][0]["url"],
|
|
||||||
codec=Audio.Codec.from_netflix_profile(stream["content_profile"]),
|
|
||||||
language=Language.get(self.NF_LANG_MAP.get(audio["language"]) or audio["language"]),
|
|
||||||
is_original_lang=is_original_lang,
|
|
||||||
bitrate=stream["bitrate"] * 1000,
|
|
||||||
channels=stream["channels"],
|
|
||||||
descriptive=audio.get("rawTrackType", "").lower() == "assistive",
|
|
||||||
name="[Original]" if Language.get(audio["language"]).language == original_language.language else None,
|
|
||||||
joc=6 if "atmos" in stream["content_profile"] else None
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Subtitle
|
|
||||||
unavailable_subtitle: List[Tuple[str, str]] = []
|
|
||||||
for index, subtitle in enumerate(manifest["timedtexttracks"]):
|
|
||||||
if "isNoneTrack" in subtitle and subtitle["isNoneTrack"] == True:
|
|
||||||
continue
|
|
||||||
if subtitle["hydrated"] == False:
|
|
||||||
# This subtitles is there but has to request stream first
|
|
||||||
unavailable_subtitle.append((subtitle["new_track_id"], subtitle["id"])) # Assign to `unavailable_subtitle` for request missing subtitles later
|
|
||||||
# self.log.debug(f"Audio language: {subtitle["languageDescription"]} id: {subtitle["new_track_id"]} is not hydrated.")
|
|
||||||
|
|
||||||
continue
|
|
||||||
|
|
||||||
if subtitle["languageDescription"] == 'Off':
|
|
||||||
# I don't why this subtitles is requested, i consider for skip these subtitles for now
|
|
||||||
continue
|
|
||||||
# pass
|
|
||||||
|
|
||||||
id = list(subtitle["downloadableIds"].values())
|
|
||||||
language = Language.get(subtitle["language"])
|
|
||||||
profile = next(iter(subtitle["ttDownloadables"].keys()))
|
|
||||||
tt_downloadables = next(iter(subtitle["ttDownloadables"].values()))
|
|
||||||
is_original_lang = subtitle["language"] == original_language.language
|
|
||||||
# self.log.info(f"is subtitle {subtitle["languageDescription"]} original language {is_original_lang}")
|
|
||||||
# self.log.info(f"ddd")
|
|
||||||
tracks.add(
|
|
||||||
Subtitle(
|
|
||||||
id_=id[0],
|
|
||||||
url=tt_downloadables["urls"][0]["url"],
|
|
||||||
codec=Subtitle.Codec.from_netflix_profile(profile),
|
|
||||||
language=language,
|
|
||||||
forced=subtitle["isForcedNarrative"],
|
|
||||||
cc=subtitle["rawTrackType"] == "closedcaptions",
|
|
||||||
sdh=subtitle["trackVariant"] == 'STRIPPED_SDH' if "trackVariant" in subtitle else False,
|
|
||||||
is_original_lang=is_original_lang,
|
|
||||||
name=("[Original]" if language.language == original_language.language else None or "[Dubbing]" if "trackVariant" in subtitle and subtitle["trackVariant"] == "DUBTITLE" else None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if hydrate_tracks == False:
|
|
||||||
return tracks
|
|
||||||
# Hydrate missing tracks
|
|
||||||
self.log.info(f"Getting all missing audio and subtitle tracks")
|
|
||||||
for audio_hydration, subtitle_hydration in zip_longest(unavailable_audio_tracks, unavailable_subtitle, fillvalue=("N/A", "N/A")):
|
|
||||||
# self.log.info(f"Audio hydration: {audio_hydration}")
|
|
||||||
manifest = self.get_manifest(title, self.profiles, subtitle_hydration[0], audio_hydration[0])
|
|
||||||
|
|
||||||
audios = next(item for item in manifest["audio_tracks"] if 'id' in item and item["id"] == audio_hydration[1])
|
|
||||||
subtitles = next(item for item in manifest["timedtexttracks"] if 'id' in item and item["id"] == subtitle_hydration[1])
|
|
||||||
for stream in audios["streams"]:
|
|
||||||
if audio_hydration[0] == 'N/A' and audio_hydration[1] == 'N/A':
|
|
||||||
# self.log.info(f"Skipping not available hydrated audio tracks")
|
|
||||||
continue
|
|
||||||
tracks.add(
|
|
||||||
Audio(
|
|
||||||
id_=stream["downloadable_id"],
|
|
||||||
url=stream["urls"][0]["url"],
|
|
||||||
codec=Audio.Codec.from_netflix_profile(stream["content_profile"]),
|
|
||||||
language=Language.get(self.NF_LANG_MAP.get(audios["language"]) or audios["language"]),
|
|
||||||
is_original_lang=stream["language"] == original_language.language,
|
|
||||||
bitrate=stream["bitrate"] * 1000,
|
|
||||||
channels=stream["channels"],
|
|
||||||
descriptive=audios.get("rawTrackType", "").lower() == "assistive",
|
|
||||||
name="[Original]" if Language.get(audios["language"]).language == original_language.language else None,
|
|
||||||
joc=6 if "atmos" in stream["content_profile"] else None
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# self.log.info(jsonpickle.encode(subtitles, indent=2))
|
|
||||||
# sel
|
|
||||||
|
|
||||||
if subtitle_hydration[0] == 'N/A':
|
|
||||||
# self.log.info(f"Skipping not available hydrated subtitle tracks")
|
|
||||||
continue
|
|
||||||
id = list(subtitles["downloadableIds"].values())
|
|
||||||
language = Language.get(subtitles["language"])
|
|
||||||
profile = next(iter(subtitles["ttDownloadables"].keys()))
|
|
||||||
tt_downloadables = next(iter(subtitles["ttDownloadables"].values()))
|
|
||||||
tracks.add(
|
|
||||||
Subtitle(
|
|
||||||
id_=id[0],
|
|
||||||
url=tt_downloadables["urls"][0]["url"],
|
|
||||||
codec=Subtitle.Codec.from_netflix_profile(profile),
|
|
||||||
language=language,
|
|
||||||
forced=subtitles["isForcedNarrative"],
|
|
||||||
cc=subtitles["rawTrackType"] == "closedcaptions",
|
|
||||||
sdh=subtitles["trackVariant"] == 'STRIPPED_SDH' if "trackVariant" in subtitles else False,
|
|
||||||
is_original_lang=subtitles["language"] == original_language.language,
|
|
||||||
name=("[Original]" if language.language == original_language.language else None or "[Dubbing]" if "trackVariant" in subtitle and subtitle["trackVariant"] == "DUBTITLE" else None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
|
|
||||||
def parse_video_range_from_profile(self, profile: str) -> Video.Range:
|
|
||||||
"""
|
|
||||||
Parse the video range from a Netflix profile string.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile (str): The Netflix profile string (e.g., "hevc-main10-L30-dash-cenc")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Video.Range: The corresponding Video.Range enum value
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> parse_video_range_from_profile("hevc-main10-L30-dash-cenc")
|
|
||||||
<Video.Range.SDR: 'SDR'>
|
|
||||||
>>> parse_video_range_from_profile("hevc-dv5-main10-L30-dash-cenc")
|
|
||||||
<Video.Range.DV: 'DV'>
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Get video profiles from config
|
|
||||||
video_profiles = self.config.get("profiles", {}).get("video", {})
|
|
||||||
|
|
||||||
# Search through all codecs and ranges to find the profile
|
|
||||||
for codec, ranges in video_profiles.items():
|
|
||||||
# if codec == 'H264':
|
|
||||||
# return Video.Range.SDR # for H264 video always return SDR
|
|
||||||
for range_name, profiles in ranges.items():
|
|
||||||
# self.log.info(f"Checking range {range_name}")
|
|
||||||
if profile in profiles:
|
|
||||||
# Return the corresponding Video.Range enum value
|
|
||||||
try:
|
|
||||||
# self.log.info(f"Found {range_name}")
|
|
||||||
return Video.Range(range_name)
|
|
||||||
except ValueError:
|
|
||||||
# If range_name is not a valid Video.Range, return SDR as default
|
|
||||||
self.log.debug(f"Video range is not valid {range_name}")
|
|
||||||
return Video.Range.SDR
|
|
||||||
|
|
||||||
# If profile not found, return SDR as default
|
|
||||||
return Video.Range.SDR
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,254 +0,0 @@
|
|||||||
import json
|
|
||||||
import re
|
|
||||||
from http.cookiejar import CookieJar
|
|
||||||
from typing import Optional
|
|
||||||
from langcodes import Language
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from envied.core.constants import AnyTrack
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.manifests import DASH
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
|
||||||
from envied.core.tracks import Chapter, Tracks, Subtitle
|
|
||||||
|
|
||||||
|
|
||||||
class NPO(Service):
|
|
||||||
"""
|
|
||||||
Service code for NPO Start (npo.nl)
|
|
||||||
Version: 1.0.0
|
|
||||||
|
|
||||||
Authorization: optional cookies (free/paid content supported)
|
|
||||||
Security: FHD @ L3 (Widevine)
|
|
||||||
|
|
||||||
Supports:
|
|
||||||
• Series ↦ https://npo.nl/start/serie/{slug}
|
|
||||||
• Movies ↦ https://npo.nl/start/video/{slug}
|
|
||||||
|
|
||||||
Only supports widevine at the moment
|
|
||||||
|
|
||||||
Note: Movie that is inside in a series (e.g.
|
|
||||||
https://npo.nl/start/serie/zappbios/.../zappbios-captain-nova/afspelen)
|
|
||||||
can be downloaded as movies by converting the URL to:
|
|
||||||
https://npo.nl/start/video/zappbios-captain-nova
|
|
||||||
"""
|
|
||||||
|
|
||||||
TITLE_RE = (
|
|
||||||
r"^(?:https?://(?:www\.)?npo\.nl/start/)?"
|
|
||||||
r"(?:(?P<type>video|serie)/(?P<slug>[^/]+)"
|
|
||||||
r"(?:/afleveringen)?"
|
|
||||||
r"(?:/seizoen-(?P<season>[^/]+)/(?P<episode>[^/]+)/afspelen)?)?$"
|
|
||||||
)
|
|
||||||
GEOFENCE = ("NL",)
|
|
||||||
NO_SUBTITLES = False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="NPO", short_help="https://npo.nl")
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, **kwargs):
|
|
||||||
return NPO(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx, title: str):
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
m = re.match(self.TITLE_RE, title)
|
|
||||||
if not m:
|
|
||||||
raise ValueError(
|
|
||||||
f"Unsupported NPO URL: {title}\n"
|
|
||||||
"Use /video/slug for movies or /serie/slug for series."
|
|
||||||
)
|
|
||||||
|
|
||||||
self.slug = m.group("slug")
|
|
||||||
self.kind = m.group("type") or "video"
|
|
||||||
self.season_slug = m.group("season")
|
|
||||||
self.episode_slug = m.group("episode")
|
|
||||||
|
|
||||||
if self.config is None:
|
|
||||||
raise EnvironmentError("Missing service config.")
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
if not cookies:
|
|
||||||
self.log.info("No cookies, proceeding anonymously.")
|
|
||||||
return
|
|
||||||
|
|
||||||
token = next((c.value for c in cookies if c.name == "__Secure-next-auth.session-token"), None)
|
|
||||||
if not token:
|
|
||||||
self.log.info("No session token, proceeding unauthenticated.")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.session.headers.update({
|
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/143.0",
|
|
||||||
"Origin": "https://npo.nl",
|
|
||||||
"Referer": "https://npo.nl/",
|
|
||||||
})
|
|
||||||
|
|
||||||
r = self.session.get("https://npo.nl/start/api/domain/user-profiles", cookies=cookies)
|
|
||||||
if r.ok and isinstance(r.json(), list) and r.json():
|
|
||||||
self.log.info(f"NPO login OK, profiles: {[p['name'] for p in r.json()]}")
|
|
||||||
else:
|
|
||||||
self.log.warning("NPO auth check failed.")
|
|
||||||
|
|
||||||
def _get_build_id(self, slug: str) -> str:
|
|
||||||
"""Fetch buildId from the actual video/series page."""
|
|
||||||
url = f"https://npo.nl/start/{'video' if self.kind == 'video' else 'serie'}/{slug}"
|
|
||||||
r = self.session.get(url)
|
|
||||||
r.raise_for_status()
|
|
||||||
match = re.search(r'<script id="__NEXT_DATA__" type="application/json">({.*?})</script>', r.text, re.DOTALL)
|
|
||||||
if not match:
|
|
||||||
raise RuntimeError("Failed to extract __NEXT_DATA__")
|
|
||||||
data = json.loads(match.group(1))
|
|
||||||
return data["buildId"]
|
|
||||||
|
|
||||||
def get_titles(self) -> Titles_T:
|
|
||||||
build_id = self._get_build_id(self.slug)
|
|
||||||
|
|
||||||
if self.kind == "serie":
|
|
||||||
url = self.config["endpoints"]["metadata_series"].format(build_id=build_id, slug=self.slug)
|
|
||||||
else:
|
|
||||||
url = self.config["endpoints"]["metadata"].format(build_id=build_id, slug=self.slug)
|
|
||||||
|
|
||||||
resp = self.session.get(url)
|
|
||||||
resp.raise_for_status()
|
|
||||||
queries = resp.json()["pageProps"]["dehydratedState"]["queries"]
|
|
||||||
|
|
||||||
def get_data(fragment: str):
|
|
||||||
return next((q["state"]["data"] for q in queries if fragment in str(q.get("queryKey", ""))), None)
|
|
||||||
|
|
||||||
if self.kind == "serie":
|
|
||||||
series_data = get_data("series:detail-")
|
|
||||||
if not series_data:
|
|
||||||
raise ValueError("Series metadata not found")
|
|
||||||
|
|
||||||
episodes = []
|
|
||||||
seasons = get_data("series:seasons-") or []
|
|
||||||
for season in seasons:
|
|
||||||
eps = get_data(f"programs:season-{season['guid']}") or []
|
|
||||||
for e in eps:
|
|
||||||
episodes.append(
|
|
||||||
Episode(
|
|
||||||
id_=e["guid"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=series_data["title"],
|
|
||||||
season=int(season["seasonKey"]),
|
|
||||||
number=int(e["programKey"]),
|
|
||||||
name=e["title"],
|
|
||||||
description=(e.get("synopsis", {}) or {}).get("long", ""),
|
|
||||||
language=Language.get("nl"),
|
|
||||||
data=e,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
# Movie
|
|
||||||
item = get_data("program:detail-") or queries[0]["state"]["data"]
|
|
||||||
synopsis = item.get("synopsis", {})
|
|
||||||
desc = synopsis.get("long") or synopsis.get("short", "") if isinstance(synopsis, dict) else str(synopsis)
|
|
||||||
year = (int(item["firstBroadcastDate"]) // 31536000 + 1970) if item.get("firstBroadcastDate") else None
|
|
||||||
|
|
||||||
return Movies([
|
|
||||||
Movie(
|
|
||||||
id_=item["guid"],
|
|
||||||
service=self.__class__,
|
|
||||||
name=item["title"],
|
|
||||||
description=desc,
|
|
||||||
year=year,
|
|
||||||
language=Language.get("nl"),
|
|
||||||
data=item,
|
|
||||||
)
|
|
||||||
])
|
|
||||||
|
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
|
||||||
product_id = title.data.get("productId")
|
|
||||||
if not product_id:
|
|
||||||
raise ValueError("no productId detected.")
|
|
||||||
|
|
||||||
# Get JWT
|
|
||||||
token_url = self.config["endpoints"]["player_token"].format(product_id=product_id)
|
|
||||||
r_tok = self.session.get(token_url, headers={"Referer": f"https://npo.nl/start/video/{self.slug}"})
|
|
||||||
r_tok.raise_for_status()
|
|
||||||
jwt = r_tok.json()["jwt"]
|
|
||||||
|
|
||||||
# Request stream
|
|
||||||
r_stream = self.session.post(
|
|
||||||
self.config["endpoints"]["streams"],
|
|
||||||
json={
|
|
||||||
"profileName": "dash",
|
|
||||||
"drmType": "widevine",
|
|
||||||
"referrerUrl": f"https://npo.nl/start/video/{self.slug}",
|
|
||||||
"ster": {"identifier": "npo-app-desktop", "deviceType": 4, "player": "web"},
|
|
||||||
},
|
|
||||||
headers={
|
|
||||||
"Authorization": jwt,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Origin": "https://npo.nl",
|
|
||||||
"Referer": f"https://npo.nl/start/video/{self.slug}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
r_stream.raise_for_status()
|
|
||||||
data = r_stream.json()
|
|
||||||
|
|
||||||
if "error" in data:
|
|
||||||
raise PermissionError(f"Stream error: {data['error']}")
|
|
||||||
|
|
||||||
stream = data["stream"]
|
|
||||||
manifest_url = stream.get("streamURL") or stream.get("url")
|
|
||||||
if not manifest_url:
|
|
||||||
raise ValueError("No stream URL in response")
|
|
||||||
|
|
||||||
is_unencrypted = "unencrypted" in manifest_url.lower() or not any(k in stream for k in ["drmToken", "token"])
|
|
||||||
|
|
||||||
# Parse DASH
|
|
||||||
tracks = DASH.from_url(manifest_url, session=self.session).to_tracks(language=title.language)
|
|
||||||
|
|
||||||
# Subtitles
|
|
||||||
subtitles = []
|
|
||||||
for sub in data.get("assets", {}).get("subtitles", []):
|
|
||||||
lang = sub.get("iso", "und")
|
|
||||||
subtitles.append(
|
|
||||||
Subtitle(
|
|
||||||
id_=sub.get("name", lang),
|
|
||||||
url=sub["location"].strip(),
|
|
||||||
language=Language.get(lang),
|
|
||||||
is_original_lang=lang == "nl",
|
|
||||||
codec=Subtitle.Codec.WebVTT,
|
|
||||||
name=sub.get("name", "Unknown"),
|
|
||||||
forced=False,
|
|
||||||
sdh=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
tracks.subtitles = subtitles
|
|
||||||
|
|
||||||
# DRM
|
|
||||||
if is_unencrypted:
|
|
||||||
for tr in tracks.videos + tracks.audio:
|
|
||||||
if hasattr(tr, "drm") and tr.drm:
|
|
||||||
tr.drm.clear()
|
|
||||||
else:
|
|
||||||
self.drm_token = stream.get("drmToken") or stream.get("token") or stream.get("drm_token")
|
|
||||||
if not self.drm_token:
|
|
||||||
raise ValueError(f"No DRM token found. Available keys: {list(stream.keys())}")
|
|
||||||
|
|
||||||
for tr in tracks.videos + tracks.audio:
|
|
||||||
if getattr(tr, "drm", None):
|
|
||||||
tr.drm.license = lambda challenge, **kw: self.get_widevine_license(
|
|
||||||
challenge=challenge, title=title, track=tr
|
|
||||||
)
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Title_T) -> list[Chapter]:
|
|
||||||
return []
|
|
||||||
|
|
||||||
def get_widevine_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
|
|
||||||
if not self.drm_token:
|
|
||||||
raise ValueError("DRM token not set – login or paid content may be required.")
|
|
||||||
r = self.session.post(
|
|
||||||
self.config["endpoints"]["widevine_license"],
|
|
||||||
params={"custom_data": self.drm_token},
|
|
||||||
data=challenge,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.content
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
endpoints:
|
|
||||||
metadata: "https://npo.nl/start/_next/data/{build_id}/video/{slug}.json"
|
|
||||||
metadata_series: "https://npo.nl/start/_next/data/{build_id}/serie/{slug}.json"
|
|
||||||
metadata_episode: "https://npo.nl/start/_next/data/{build_id}/serie/{series_slug}/seizoen-{season_slug}/{episode_slug}.json"
|
|
||||||
streams: "https://prod.npoplayer.nl/stream-link"
|
|
||||||
player_token: "https://npo.nl/start/api/domain/player-token?productId={product_id}"
|
|
||||||
widevine_license: "https://npo-drm-gateway.samgcloud.nepworldwide.nl/authentication"
|
|
||||||
homepage: "https://npo.nl/start"
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from http.cookiejar import MozillaCookieJar
|
|
||||||
from typing import Any, Optional, Union
|
|
||||||
from functools import partial
|
|
||||||
from pathlib import Path
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
|
|
||||||
import click
|
|
||||||
import isodate
|
|
||||||
from click import Context
|
|
||||||
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Movie, Movies, Episode, Series
|
|
||||||
from envied.core.tracks import Track, Chapter, Tracks, Video, Audio, Subtitle
|
|
||||||
from envied.core.manifests.hls import HLS
|
|
||||||
from envied.core.manifests.dash import DASH
|
|
||||||
from rich.console import Console
|
|
||||||
|
|
||||||
|
|
||||||
class NRK(Service):
|
|
||||||
"""
|
|
||||||
Service code for NRK TV (https://tv.nrk.no)
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.0
|
|
||||||
Author: lambda
|
|
||||||
Authorization: None
|
|
||||||
Robustness:
|
|
||||||
Unencrypted: 1080p, DD5.1
|
|
||||||
"""
|
|
||||||
|
|
||||||
GEOFENCE = ("no",)
|
|
||||||
TITLE_RE = r"^https://tv.nrk.no/serie/fengselseksperimentet/sesong/1/episode/(?P<content_id>.+)$"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="NRK", short_help="https://tv.nrk.no", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> NRK:
|
|
||||||
return NRK(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_titles(self) -> Union[Movies, Series]:
|
|
||||||
match = re.match(self.TITLE_RE, self.title)
|
|
||||||
if match:
|
|
||||||
content_id = match.group("content_id")
|
|
||||||
EPISODE = True
|
|
||||||
MOVIE = False
|
|
||||||
else:
|
|
||||||
content_id = self.title.split('/')[-1]
|
|
||||||
MOVIE = True
|
|
||||||
EPISODE = False
|
|
||||||
|
|
||||||
r = self.session.get(self.config["endpoints"]["content"].format(content_id=content_id))
|
|
||||||
item = r.json()
|
|
||||||
# development only
|
|
||||||
#console = Console()
|
|
||||||
#console.print_json(data=item)
|
|
||||||
if EPISODE:
|
|
||||||
episode, name = item["programInformation"]["titles"]["title"].split(". ", maxsplit=1)
|
|
||||||
return Series([Episode(
|
|
||||||
id_=content_id,
|
|
||||||
service=self.__class__,
|
|
||||||
language="nb",
|
|
||||||
year=item["moreInformation"]["productionYear"],
|
|
||||||
title=item["_links"]["seriesPage"]["title"],
|
|
||||||
name=name,
|
|
||||||
season=item["_links"]["season"]["name"],
|
|
||||||
number=episode,
|
|
||||||
)])
|
|
||||||
if MOVIE:
|
|
||||||
name = item["programInformation"]["titles"]["title"]
|
|
||||||
return Movies([Movie(
|
|
||||||
id_ = content_id,
|
|
||||||
service=self.__class__,
|
|
||||||
name = name,
|
|
||||||
|
|
||||||
year = item["moreInformation"]["productionYear"],
|
|
||||||
language="nb",
|
|
||||||
data = None,
|
|
||||||
description = None,)])
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_tracks(self, title: Union[Episode, Movie]) -> Tracks:
|
|
||||||
r = self.session.get(self.config["endpoints"]["manifest"].format(content_id=title.id))
|
|
||||||
manifest = r.json()
|
|
||||||
tracks = Tracks()
|
|
||||||
|
|
||||||
for asset in manifest["playable"]["assets"]:
|
|
||||||
if asset["format"] == "HLS":
|
|
||||||
tracks += Tracks(HLS.from_url(asset["url"], session=self.session).to_tracks("nb"))
|
|
||||||
|
|
||||||
|
|
||||||
for sub in manifest["playable"]["subtitles"]:
|
|
||||||
tracks.add(Subtitle(
|
|
||||||
codec=Subtitle.Codec.WebVTT,
|
|
||||||
language=sub["language"],
|
|
||||||
url=sub["webVtt"],
|
|
||||||
sdh=sub["type"] == "ttv",
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
for track in tracks:
|
|
||||||
track.needs_proxy = True
|
|
||||||
|
|
||||||
# if isinstance(track, Audio) and track.channels == 6.0:
|
|
||||||
# track.channels = 5.1
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Union[Episode, Movie]) -> list[Chapter]:
|
|
||||||
r = self.session.get(self.config["endpoints"]["metadata"].format(content_id=title.id))
|
|
||||||
sdi = r.json()["skipDialogInfo"]
|
|
||||||
|
|
||||||
chapters = []
|
|
||||||
if sdi["endIntroInSeconds"]:
|
|
||||||
if sdi["startIntroInSeconds"]:
|
|
||||||
chapters.append(Chapter(timestamp=0))
|
|
||||||
|
|
||||||
chapters |= [
|
|
||||||
Chapter(timestamp=sdi["startIntroInSeconds"], name="Intro"),
|
|
||||||
Chapter(timestamp=sdi["endIntroInSeconds"])
|
|
||||||
]
|
|
||||||
|
|
||||||
if sdi["startCreditsInSeconds"]:
|
|
||||||
if not chapters:
|
|
||||||
chapters.append(Chapter(timestamp=0))
|
|
||||||
|
|
||||||
credits = isodate.parse_duration(sdi["startCredits"])
|
|
||||||
chapters.append(Chapter(credits.total_seconds(), name="Credits"))
|
|
||||||
|
|
||||||
return chapters
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
headers:
|
|
||||||
Accept-Language: nb-NO,de;q=0.8
|
|
||||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
content: https://psapi.nrk.no/tv/catalog/programs/{content_id}?contentGroup=adults&ageRestriction=None
|
|
||||||
metadata: https://psapi.nrk.no/playback/metadata/program/{content_id}
|
|
||||||
# manifest: https://psapi.nrk.no/playback/manifest/program/{content_id}
|
|
||||||
manifest: https://psapi.nrk.no/playback/manifest/program/{content_id}?eea-portability=true&ageRestriction=None
|
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from http.cookiejar import CookieJar
|
|
||||||
from typing import Optional, Union
|
|
||||||
|
|
||||||
import click
|
|
||||||
from langcodes import Language
|
|
||||||
|
|
||||||
from envied.core.constants import AnyTrack
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.manifests import DASH
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
|
||||||
from envied.core.tracks import Chapters, Tracks, Video
|
|
||||||
|
|
||||||
|
|
||||||
class PCOK(Service):
|
|
||||||
"""
|
|
||||||
Service code for NBC's Peacock streaming service (https://peacocktv.com).
|
|
||||||
Version: 1.0.0
|
|
||||||
|
|
||||||
Authorization: Cookies
|
|
||||||
Security: UHD@-- FHD@SL|L3
|
|
||||||
|
|
||||||
Tips: - The library of contents can be viewed without logging in at https://www.peacocktv.com/stream/tv
|
|
||||||
See the footer for links to movies, news, etc. A US IP is required to view.
|
|
||||||
"""
|
|
||||||
|
|
||||||
ALIASES = ("PCOK", "peacock")
|
|
||||||
GEOFENCE = ("US",)
|
|
||||||
TITLE_RE = [
|
|
||||||
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?P<id>movies/[a-z0-9/./-]+/[a-f0-9-]+)",
|
|
||||||
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?P<id>tv/[a-z0-9/./-]+/[a-f0-9-]+)",
|
|
||||||
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?P<id>tv/[a-z0-9-/.]+/\d+)",
|
|
||||||
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?P<id>news/[a-z0-9/./-]+/[a-f0-9-]+)",
|
|
||||||
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?P<id>news/[a-z0-9-/.]+/\d+)",
|
|
||||||
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?P<id>-/[a-z0-9-/.]+/\d+)",
|
|
||||||
r"(?:https?://(?:www\.)?peacocktv\.com/stream-tv/)?(?P<id>[a-z0-9-/.]+)",
|
|
||||||
]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="PCOK", short_help="https://peacocktv.com")
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.option("-m", "--movie", is_flag=True, default=False, help="Title is a movie.")
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, **kwargs):
|
|
||||||
return PCOK(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx, title, movie):
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
self.title = title
|
|
||||||
self.movie = movie
|
|
||||||
self.cdm = ctx.obj.cdm
|
|
||||||
|
|
||||||
range_param = ctx.parent.params.get("range_")
|
|
||||||
self.range = range_param[0].name if range_param else "SDR"
|
|
||||||
|
|
||||||
vcodec_param = ctx.parent.params.get("vcodec")
|
|
||||||
self.vcodec = vcodec_param if vcodec_param else "H264"
|
|
||||||
|
|
||||||
if self.config is None:
|
|
||||||
raise Exception("Config is missing!")
|
|
||||||
|
|
||||||
profile_name = ctx.parent.params.get("profile")
|
|
||||||
if profile_name is None:
|
|
||||||
profile_name = "default"
|
|
||||||
self.profile = profile_name
|
|
||||||
|
|
||||||
self.hmac_key = None
|
|
||||||
self.tokens = None
|
|
||||||
self.license_api = None
|
|
||||||
self.license_bt = None
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
if not cookies:
|
|
||||||
raise EnvironmentError("Service requires Cookies for Authentication.")
|
|
||||||
|
|
||||||
self.session.headers.update({"Origin": "https://www.peacocktv.com"})
|
|
||||||
self.log.info("Getting Peacock Client configuration")
|
|
||||||
|
|
||||||
if self.config["client"]["platform"] != "PC":
|
|
||||||
self.service_config = self.session.get(
|
|
||||||
url=self.config["endpoints"]["config"].format(
|
|
||||||
territory=self.config["client"]["territory"],
|
|
||||||
provider=self.config["client"]["provider"],
|
|
||||||
proposition=self.config["client"]["proposition"],
|
|
||||||
device=self.config["client"]["platform"],
|
|
||||||
version=self.config["client"]["config_version"],
|
|
||||||
)
|
|
||||||
).json()
|
|
||||||
|
|
||||||
self.hmac_key = bytes(self.config["security"]["signature_hmac_key_v4"], "utf-8")
|
|
||||||
self.log.info("Getting Authorization Tokens")
|
|
||||||
self.tokens = self.get_tokens()
|
|
||||||
self.log.info("Verifying Authorization Tokens")
|
|
||||||
if not self.verify_tokens():
|
|
||||||
raise EnvironmentError("Failed! Cookies might be outdated.")
|
|
||||||
|
|
||||||
def get_titles(self) -> Titles_T:
|
|
||||||
# Parse title from various URL formats
|
|
||||||
import re
|
|
||||||
|
|
||||||
title_id = self.title
|
|
||||||
for pattern in self.TITLE_RE:
|
|
||||||
match = re.search(pattern, self.title)
|
|
||||||
if match:
|
|
||||||
title_id = match.group("id")
|
|
||||||
break
|
|
||||||
|
|
||||||
# Handle stream-tv redirects
|
|
||||||
if "/" not in title_id:
|
|
||||||
r = self.session.get(self.config["endpoints"]["stream_tv"].format(title_id=title_id))
|
|
||||||
match = re.search(r"/watch/asset(/[^']+)", r.text)
|
|
||||||
if match:
|
|
||||||
title_id = match.group(1)
|
|
||||||
else:
|
|
||||||
raise ValueError("Title ID not found or invalid")
|
|
||||||
|
|
||||||
if not title_id.startswith("/"):
|
|
||||||
title_id = f"/{title_id}"
|
|
||||||
|
|
||||||
if title_id.startswith("/movies/"):
|
|
||||||
self.movie = True
|
|
||||||
|
|
||||||
res = self.session.get(
|
|
||||||
url=self.config["endpoints"]["node"],
|
|
||||||
params={
|
|
||||||
"slug": title_id,
|
|
||||||
"represent": "(items(items))"
|
|
||||||
},
|
|
||||||
headers={
|
|
||||||
"Accept": "*",
|
|
||||||
"Referer": f"https://www.peacocktv.com/watch/asset{title_id}",
|
|
||||||
"X-SkyOTT-Device": self.config["client"]["device"],
|
|
||||||
"X-SkyOTT-Platform": self.config["client"]["platform"],
|
|
||||||
"X-SkyOTT-Proposition": self.config["client"]["proposition"],
|
|
||||||
"X-SkyOTT-Provider": self.config["client"]["provider"],
|
|
||||||
"X-SkyOTT-Territory": self.config["client"]["territory"],
|
|
||||||
"X-SkyOTT-Language": "en"
|
|
||||||
}
|
|
||||||
).json()
|
|
||||||
|
|
||||||
if self.movie:
|
|
||||||
return Movies([
|
|
||||||
Movie(
|
|
||||||
id_=title_id,
|
|
||||||
service=self.__class__,
|
|
||||||
name=res["attributes"]["title"],
|
|
||||||
year=res["attributes"]["year"],
|
|
||||||
data=res,
|
|
||||||
)
|
|
||||||
])
|
|
||||||
else:
|
|
||||||
episodes = []
|
|
||||||
for season in res["relationships"]["items"]["data"]:
|
|
||||||
for episode in season["relationships"]["items"]["data"]:
|
|
||||||
episodes.append(episode)
|
|
||||||
|
|
||||||
episode_titles = []
|
|
||||||
for x in episodes:
|
|
||||||
episode_titles.append(
|
|
||||||
Episode(
|
|
||||||
id_=title_id,
|
|
||||||
service=self.__class__,
|
|
||||||
title=res["attributes"]["title"],
|
|
||||||
season=x["attributes"].get("seasonNumber"),
|
|
||||||
number=x["attributes"].get("episodeNumber"),
|
|
||||||
name=x["attributes"].get("title"),
|
|
||||||
year=x["attributes"].get("year"),
|
|
||||||
data=x
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return Series(episode_titles)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
|
||||||
supported_colour_spaces = ["SDR"]
|
|
||||||
|
|
||||||
if self.range == "HDR10":
|
|
||||||
self.log.info("Switched dynamic range to HDR10")
|
|
||||||
supported_colour_spaces = ["HDR10"]
|
|
||||||
elif self.range == "DV":
|
|
||||||
self.log.info("Switched dynamic range to DV")
|
|
||||||
supported_colour_spaces = ["DolbyVision"]
|
|
||||||
|
|
||||||
content_id = title.data["attributes"]["formats"]["HD"]["contentId"]
|
|
||||||
variant_id = title.data["attributes"]["providerVariantId"]
|
|
||||||
|
|
||||||
sky_headers = {
|
|
||||||
"X-SkyOTT-Agent": ".".join([
|
|
||||||
self.config["client"]["proposition"].lower(),
|
|
||||||
self.config["client"]["device"].lower(),
|
|
||||||
self.config["client"]["platform"].lower()
|
|
||||||
]),
|
|
||||||
"X-SkyOTT-PinOverride": "false",
|
|
||||||
"X-SkyOTT-Provider": self.config["client"]["provider"],
|
|
||||||
"X-SkyOTT-Territory": self.config["client"]["territory"],
|
|
||||||
"X-SkyOTT-UserToken": self.tokens["userToken"]
|
|
||||||
}
|
|
||||||
|
|
||||||
body = json.dumps({
|
|
||||||
"device": {
|
|
||||||
"capabilities": [
|
|
||||||
{
|
|
||||||
"protection": "PLAYREADY",
|
|
||||||
"container": "ISOBMFF",
|
|
||||||
"transport": "DASH",
|
|
||||||
"acodec": "AAC",
|
|
||||||
"vcodec": "H265" if self.vcodec == "H265" else "H264",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"protection": "WIDEVINE",
|
|
||||||
"container": "ISOBMFF",
|
|
||||||
"transport": "DASH",
|
|
||||||
"acodec": "AAC",
|
|
||||||
"vcodec": "H265" if self.vcodec == "H265" else "H264",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"maxVideoFormat": "UHD" if self.vcodec == "H265" else "HD",
|
|
||||||
"supportedColourSpaces": supported_colour_spaces,
|
|
||||||
"model": self.config["client"]["platform"],
|
|
||||||
"hdcpEnabled": "true"
|
|
||||||
},
|
|
||||||
"client": {
|
|
||||||
"thirdParties": ["FREEWHEEL", "YOSPACE"]
|
|
||||||
},
|
|
||||||
"contentId": content_id,
|
|
||||||
"providerVariantId": variant_id,
|
|
||||||
"parentalControlPin": "null"
|
|
||||||
}, separators=(",", ":"))
|
|
||||||
|
|
||||||
manifest = self.session.post(
|
|
||||||
url=self.config["endpoints"]["vod"],
|
|
||||||
data=body,
|
|
||||||
headers=dict(**sky_headers, **{
|
|
||||||
"Accept": "application/vnd.playvod.v1+json",
|
|
||||||
"Content-Type": "application/vnd.playvod.v1+json",
|
|
||||||
"X-Sky-Signature": self.create_signature_header(
|
|
||||||
method="POST",
|
|
||||||
path="/video/playouts/vod",
|
|
||||||
sky_headers=sky_headers,
|
|
||||||
body=body,
|
|
||||||
timestamp=int(time.time())
|
|
||||||
)
|
|
||||||
})
|
|
||||||
).json()
|
|
||||||
|
|
||||||
if "errorCode" in manifest:
|
|
||||||
raise ValueError(f"An error occurred: {manifest['description']} [{manifest['errorCode']}]")
|
|
||||||
|
|
||||||
self.license_api = manifest["protection"]["licenceAcquisitionUrl"]
|
|
||||||
self.license_bt = manifest["protection"]["licenceToken"]
|
|
||||||
|
|
||||||
tracks = DASH.from_url(
|
|
||||||
url=manifest["asset"]["endpoints"][0]["url"],
|
|
||||||
session=self.session
|
|
||||||
).to_tracks(language=Language.get("en"))
|
|
||||||
|
|
||||||
# Set HDR attributes
|
|
||||||
for video in tracks.videos:
|
|
||||||
if supported_colour_spaces == ["HDR10"]:
|
|
||||||
video.range = Video.Range.HDR10
|
|
||||||
elif supported_colour_spaces == ["DolbyVision"]:
|
|
||||||
video.range = Video.Range.DV
|
|
||||||
else:
|
|
||||||
video.range = Video.Range.SDR
|
|
||||||
|
|
||||||
# Fix audio description language
|
|
||||||
for track in tracks.audio:
|
|
||||||
if track.language.territory == "AD":
|
|
||||||
track.language.territory = None
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Title_T) -> Chapters:
|
|
||||||
"""Get chapters for the title. Peacock doesn't typically provide chapter data."""
|
|
||||||
return Chapters([])
|
|
||||||
|
|
||||||
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
|
|
||||||
"""Retrieve a PlayReady license for a given track."""
|
|
||||||
if not self.license_api:
|
|
||||||
return None
|
|
||||||
|
|
||||||
response = self.session.post(
|
|
||||||
url=self.license_api,
|
|
||||||
headers={
|
|
||||||
"Accept": "*",
|
|
||||||
"X-Sky-Signature": self.create_signature_header(
|
|
||||||
method="POST",
|
|
||||||
path="/" + self.license_api.split("://", 2)[1].split("/", 1)[1],
|
|
||||||
sky_headers={},
|
|
||||||
body="",
|
|
||||||
timestamp=int(time.time())
|
|
||||||
)
|
|
||||||
},
|
|
||||||
data=challenge
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.content
|
|
||||||
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
|
||||||
"""Retrieve a Widevine license for a given track."""
|
|
||||||
if not self.license_api:
|
|
||||||
return None
|
|
||||||
|
|
||||||
response = self.session.post(
|
|
||||||
url=self.license_api,
|
|
||||||
headers={
|
|
||||||
"Accept": "*",
|
|
||||||
"X-Sky-Signature": self.create_signature_header(
|
|
||||||
method="POST",
|
|
||||||
path="/" + self.license_api.split("://", 1)[1].split("/", 1)[1],
|
|
||||||
sky_headers={},
|
|
||||||
body="",
|
|
||||||
timestamp=int(time.time())
|
|
||||||
)
|
|
||||||
},
|
|
||||||
data=challenge
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.content
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def calculate_sky_header_md5(headers):
|
|
||||||
if len(headers.items()) > 0:
|
|
||||||
headers_str = "\n".join(f"{x[0].lower()}: {x[1]}" for x in headers.items()) + "\n"
|
|
||||||
else:
|
|
||||||
headers_str = "{}"
|
|
||||||
return str(hashlib.md5(headers_str.encode()).hexdigest())
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def calculate_body_md5(body):
|
|
||||||
return str(hashlib.md5(body.encode()).hexdigest())
|
|
||||||
|
|
||||||
def calculate_signature(self, msg):
|
|
||||||
digest = hmac.new(self.hmac_key, bytes(msg, "utf-8"), hashlib.sha1).digest()
|
|
||||||
return str(base64.b64encode(digest), "utf-8")
|
|
||||||
|
|
||||||
def create_signature_header(self, method, path, sky_headers, body, timestamp):
|
|
||||||
data = "\n".join([
|
|
||||||
method.upper(),
|
|
||||||
path,
|
|
||||||
"",
|
|
||||||
self.config["client"]["client_sdk"],
|
|
||||||
"1.0",
|
|
||||||
self.calculate_sky_header_md5(sky_headers),
|
|
||||||
str(timestamp),
|
|
||||||
self.calculate_body_md5(body)
|
|
||||||
]) + "\n"
|
|
||||||
|
|
||||||
signature_hmac = self.calculate_signature(data)
|
|
||||||
|
|
||||||
return self.config["security"]["signature_format"].format(
|
|
||||||
client=self.config["client"]["client_sdk"],
|
|
||||||
signature=signature_hmac,
|
|
||||||
timestamp=timestamp
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_tokens(self):
|
|
||||||
# Try to get cached tokens
|
|
||||||
cache = self.cache.get(f"tokens_{self.profile}_{self.config['client']['id']}")
|
|
||||||
|
|
||||||
if cache and cache.data.get("tokenExpiryTime"):
|
|
||||||
tokens_expiration = cache.data.get("tokenExpiryTime")
|
|
||||||
if datetime.strptime(tokens_expiration, "%Y-%m-%dT%H:%M:%S.%fZ") > datetime.now():
|
|
||||||
return cache.data
|
|
||||||
|
|
||||||
# Get all SkyOTT headers
|
|
||||||
sky_headers = {
|
|
||||||
"X-SkyOTT-Agent": ".".join([
|
|
||||||
self.config["client"]["proposition"],
|
|
||||||
self.config["client"]["device"],
|
|
||||||
self.config["client"]["platform"]
|
|
||||||
]).lower(),
|
|
||||||
"X-SkyOTT-Device": self.config["client"]["device"],
|
|
||||||
"X-SkyOTT-Platform": self.config["client"]["platform"],
|
|
||||||
"X-SkyOTT-Proposition": self.config["client"]["proposition"],
|
|
||||||
"X-SkyOTT-Provider": self.config["client"]["provider"],
|
|
||||||
"X-SkyOTT-Territory": self.config["client"]["territory"]
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Call personas endpoint to get the accounts personaId
|
|
||||||
personas = self.session.get(
|
|
||||||
url=self.config["endpoints"]["personas"],
|
|
||||||
headers=dict(**sky_headers, **{
|
|
||||||
"Accept": "application/vnd.persona.v1+json",
|
|
||||||
"Content-Type": "application/vnd.persona.v1+json",
|
|
||||||
"X-SkyOTT-TokenType": self.config["client"]["auth_scheme"]
|
|
||||||
})
|
|
||||||
).json()
|
|
||||||
except Exception as e:
|
|
||||||
raise EnvironmentError(f"Unable to get persona ID: {e}")
|
|
||||||
|
|
||||||
persona = personas["personas"][0]["personaId"]
|
|
||||||
|
|
||||||
# Craft the body data
|
|
||||||
body = json.dumps({
|
|
||||||
"auth": {
|
|
||||||
"authScheme": self.config["client"]["auth_scheme"],
|
|
||||||
"authIssuer": self.config["client"]["auth_issuer"],
|
|
||||||
"provider": self.config["client"]["provider"],
|
|
||||||
"providerTerritory": self.config["client"]["territory"],
|
|
||||||
"proposition": self.config["client"]["proposition"],
|
|
||||||
"personaId": persona
|
|
||||||
},
|
|
||||||
"device": {
|
|
||||||
"type": self.config["client"]["device"],
|
|
||||||
"platform": self.config["client"]["platform"],
|
|
||||||
"id": self.config["client"]["id"],
|
|
||||||
"drmDeviceId": self.config["client"]["drm_device_id"]
|
|
||||||
}
|
|
||||||
}, separators=(",", ":"))
|
|
||||||
|
|
||||||
# Get the tokens
|
|
||||||
tokens = self.session.post(
|
|
||||||
url=self.config["endpoints"]["tokens"],
|
|
||||||
headers=dict(**sky_headers, **{
|
|
||||||
"Accept": "application/vnd.tokens.v1+json",
|
|
||||||
"Content-Type": "application/vnd.tokens.v1+json",
|
|
||||||
"X-Sky-Signature": self.create_signature_header(
|
|
||||||
method="POST",
|
|
||||||
path="/auth/tokens",
|
|
||||||
sky_headers=sky_headers,
|
|
||||||
body=body,
|
|
||||||
timestamp=int(time.time())
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
data=body
|
|
||||||
).json()
|
|
||||||
|
|
||||||
# Cache the tokens
|
|
||||||
if not cache:
|
|
||||||
cache = self.cache.get(f"tokens_{self.profile}_{self.config['client']['id']}")
|
|
||||||
cache.set(data=tokens)
|
|
||||||
|
|
||||||
return tokens
|
|
||||||
|
|
||||||
def verify_tokens(self):
|
|
||||||
"""Verify the tokens by calling the /auth/users/me endpoint"""
|
|
||||||
sky_headers = {
|
|
||||||
"X-SkyOTT-Device": self.config["client"]["device"],
|
|
||||||
"X-SkyOTT-Platform": self.config["client"]["platform"],
|
|
||||||
"X-SkyOTT-Proposition": self.config["client"]["proposition"],
|
|
||||||
"X-SkyOTT-Provider": self.config["client"]["provider"],
|
|
||||||
"X-SkyOTT-Territory": self.config["client"]["territory"],
|
|
||||||
"X-SkyOTT-UserToken": self.tokens["userToken"]
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.session.get(
|
|
||||||
url=self.config["endpoints"]["me"],
|
|
||||||
headers=dict(**sky_headers, **{
|
|
||||||
"Accept": "application/vnd.userinfo.v2+json",
|
|
||||||
"Content-Type": "application/vnd.userinfo.v2+json",
|
|
||||||
"X-Sky-Signature": self.create_signature_header(
|
|
||||||
method="GET",
|
|
||||||
path="/auth/users/me",
|
|
||||||
sky_headers=sky_headers,
|
|
||||||
body="",
|
|
||||||
timestamp=int(time.time())
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
endpoints:
|
|
||||||
stream_tv: 'https://www.peacocktv.com/stream-tv/{title_id}'
|
|
||||||
config: 'https://config.clients.peacocktv.com/{territory}/{provider}/{proposition}/{device}/PROD/{version}/config.json'
|
|
||||||
login: 'https://rango.id.peacocktv.com/signin/service/international'
|
|
||||||
personas: 'https://persona.id.peacocktv.com/persona-store/personas'
|
|
||||||
tokens: 'https://ovp.peacocktv.com/auth/tokens'
|
|
||||||
me: 'https://ovp.peacocktv.com/auth/users/me'
|
|
||||||
node: 'https://atom.peacocktv.com/adapter-calypso/v3/query/node'
|
|
||||||
vod: 'https://ovp.peacocktv.com/video/playouts/vod'
|
|
||||||
|
|
||||||
client:
|
|
||||||
config_version: '1.0.8'
|
|
||||||
territory: 'US'
|
|
||||||
provider: 'NBCU'
|
|
||||||
proposition: 'NBCUOTT'
|
|
||||||
platform: 'ANDROID' # PC, ANDROID
|
|
||||||
device: 'TABLET' # COMPUTER, TABLET
|
|
||||||
id: 'Jcvf1y0whKOI29vRXcJy'
|
|
||||||
drm_device_id: 'UNKNOWN'
|
|
||||||
client_sdk: 'NBCU-WEB-v4' # NBCU-ANDROID-v3 NBCU-ANDRTV-v4
|
|
||||||
auth_scheme: 'MESSO'
|
|
||||||
auth_issuer: 'NOWTV'
|
|
||||||
|
|
||||||
security:
|
|
||||||
signature_hmac_key_v4: 'FvT9VtwvhtSZvqnExMsvDDTEvBqR3HdsMcBFtWYV'
|
|
||||||
signature_hmac_key_v6: 'izU6EJqqu6DOhOWSk5X4p9dod3fNqH7vzKtYDK8d'
|
|
||||||
signature_format: 'SkyOTT client="{client}",signature="{signature}",timestamp="{timestamp}",version="1.0"'
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import uuid
|
|
||||||
from collections.abc import Generator
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from http.cookiejar import MozillaCookieJar
|
|
||||||
from typing import Any, Optional
|
|
||||||
from urllib.parse import quote, urljoin, urlparse
|
|
||||||
|
|
||||||
import click
|
|
||||||
from click import Context
|
|
||||||
from requests import Request
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.manifests import DASH, HLS
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series
|
|
||||||
from envied.core.tracks import Chapter, Chapters, Tracks
|
|
||||||
|
|
||||||
|
|
||||||
class PLEX(Service):
|
|
||||||
"""
|
|
||||||
\b
|
|
||||||
Service code for Plex's free streaming service (https://watch.plex.tv/).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.4
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: None
|
|
||||||
Geofence: API and downloads are locked into whatever region the user is in
|
|
||||||
Robustness:
|
|
||||||
L3: 720p, AAC2.0
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Input should be complete URL:
|
|
||||||
SHOW: https://watch.plex.tv/show/taboo-2017
|
|
||||||
EPISODE: https://watch.plex.tv/show/taboo-2017/season/1/episode/1
|
|
||||||
MOVIE: https://watch.plex.tv/movie/the-longest-yard
|
|
||||||
"""
|
|
||||||
|
|
||||||
ALIASES = ("plextv",)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="PLEX", short_help="https://watch.plex.tv/", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> PLEX:
|
|
||||||
return PLEX(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
|
|
||||||
self.session.headers.update(
|
|
||||||
{
|
|
||||||
"accept": "application/json",
|
|
||||||
"x-plex-client-identifier": str(uuid.uuid4()),
|
|
||||||
"x-plex-language": "en",
|
|
||||||
"x-plex-product": "Plex Mediaverse",
|
|
||||||
"x-plex-provider-version": "6.5.0",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
user = self._request("POST", self.config["endpoints"]["user"])
|
|
||||||
if not (auth_token := user.get("authToken")):
|
|
||||||
raise ValueError(f"PLEX authentication failed: {user}")
|
|
||||||
|
|
||||||
self.auth_token = auth_token
|
|
||||||
self.session.headers.update({"x-plex-token": self.auth_token})
|
|
||||||
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
results = self._request(
|
|
||||||
"GET", "https://discover.provider.plex.tv/library/search",
|
|
||||||
params={
|
|
||||||
"searchTypes": "movies,tv",
|
|
||||||
"searchProviders": "discover,plexAVOD,plexFAST",
|
|
||||||
"includeMetadata": 1,
|
|
||||||
"filterPeople": 1,
|
|
||||||
"limit": 10,
|
|
||||||
"query": quote(self.title),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
for result in results["MediaContainer"]["SearchResults"]:
|
|
||||||
if "free on demand" not in result.get("title", "").lower():
|
|
||||||
continue
|
|
||||||
|
|
||||||
for result in result["SearchResult"]:
|
|
||||||
kind = result.get("Metadata", {}).get("type")
|
|
||||||
slug = result.get("Metadata", {}).get("slug")
|
|
||||||
|
|
||||||
yield SearchResult(
|
|
||||||
id_=f"https://watch.plex.tv/{kind}/{slug}",
|
|
||||||
title=result.get("Metadata", {}).get("title"),
|
|
||||||
description=result.get("Metadata", {}).get("description"),
|
|
||||||
label=kind,
|
|
||||||
url=f"https://watch.plex.tv/{kind}/{slug}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_titles(self) -> Movies | Series:
|
|
||||||
url_pattern = re.compile(
|
|
||||||
r"^https://watch.plex.tv/"
|
|
||||||
r"(?:[a-z]{2}(?:-[A-Z]{2})?/)??"
|
|
||||||
r"(?P<type>movie|show)/"
|
|
||||||
r"(?P<id>[\w-]+)"
|
|
||||||
r"(?P<url_path>(/season/\d+/episode/\d+))?"
|
|
||||||
)
|
|
||||||
|
|
||||||
match = url_pattern.match(self.title)
|
|
||||||
if not match:
|
|
||||||
raise ValueError(f"Could not parse ID from title: {self.title}")
|
|
||||||
|
|
||||||
kind, guid, url_path = (match.group(i) for i in ("type", "id", "url_path"))
|
|
||||||
|
|
||||||
if kind == "show":
|
|
||||||
if url_path is not None:
|
|
||||||
path = urlparse(self.title).path
|
|
||||||
url = re.sub(r"/[a-z]{2}(?:-[A-Z]{2})?/", "/", path)
|
|
||||||
episode = self._episode(url)
|
|
||||||
return Series(episode)
|
|
||||||
|
|
||||||
episodes = self._series(guid)
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
elif kind == "movie":
|
|
||||||
movie = self._movie(guid)
|
|
||||||
return Movies(movie)
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Could not parse content type from title: {self.title}")
|
|
||||||
|
|
||||||
def get_tracks(self, title: Movie | Episode) -> Tracks:
|
|
||||||
dash_media = next((x for x in title.data.get("Media", []) if x.get("protocol", "").lower() == "dash"), None)
|
|
||||||
if not dash_media:
|
|
||||||
hls_media = next((x for x in title.data.get("Media", []) if x.get("protocol", "").lower() == "hls"), None)
|
|
||||||
|
|
||||||
media = dash_media or hls_media
|
|
||||||
if not media:
|
|
||||||
raise ValueError("Failed to find either DASH or HLS media")
|
|
||||||
|
|
||||||
manifest = DASH if dash_media else HLS
|
|
||||||
|
|
||||||
media_key = media.get("id")
|
|
||||||
has_drm = media.get("drm")
|
|
||||||
|
|
||||||
if has_drm:
|
|
||||||
manifest_url = (
|
|
||||||
self.config["endpoints"]["base_url"]
|
|
||||||
+ self.config["endpoints"]["manifest_drm"].format(media_key, self.auth_token)
|
|
||||||
)
|
|
||||||
title.data["license_url"] = (
|
|
||||||
self.config["endpoints"]["base_url"]
|
|
||||||
+ self.config["endpoints"]["license"].format(media_key, self.auth_token)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
manifest_url = (
|
|
||||||
self.config["endpoints"]["base_url"]
|
|
||||||
+ self.config["endpoints"]["manifest_clear"].format(media_key, self.auth_token)
|
|
||||||
)
|
|
||||||
title.data["license_url"] = None
|
|
||||||
|
|
||||||
tracks = manifest.from_url(manifest_url, self.session).to_tracks(language="en")
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Movie | Episode) -> Chapters:
|
|
||||||
if not (markers := title.data.get("Marker")):
|
|
||||||
try:
|
|
||||||
metadata = self._request(
|
|
||||||
"POST", "/playQueues",
|
|
||||||
params={
|
|
||||||
"uri": self.config["endpoints"]["provider"] + title.data.get("key"),
|
|
||||||
"type": "video",
|
|
||||||
"continuous": "1",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
markers = next((
|
|
||||||
x.get("Marker") for x in metadata.get("MediaContainer", {}).get("Metadata", [])
|
|
||||||
if x.get("key") == title.data.get("key")), [])
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log.debug("Failed to fetch markers: %s", e)
|
|
||||||
return Chapters()
|
|
||||||
|
|
||||||
if not markers:
|
|
||||||
return Chapters()
|
|
||||||
|
|
||||||
chapters = []
|
|
||||||
for cue in markers:
|
|
||||||
if cue.get("startTimeOffset", 0) > 0:
|
|
||||||
chapters.append(Chapter(name=cue.get("type", "").title(), timestamp=cue.get("startTimeOffset")))
|
|
||||||
|
|
||||||
return Chapters(chapters)
|
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title: Movie | Episode, track: Any) -> bytes | str | None:
|
|
||||||
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
|
|
||||||
|
|
||||||
def _fetch_season(self, url: str) -> list:
|
|
||||||
return self._request("GET", url).get("MediaContainer", {}).get("Metadata", [])
|
|
||||||
|
|
||||||
def _series(self, guid: str) -> list[Episode]:
|
|
||||||
data = self._request("GET", f"/library/metadata/show:{guid}")
|
|
||||||
|
|
||||||
meta_key = data.get("MediaContainer", {}).get("Metadata", [])[0].get("key")
|
|
||||||
if not meta_key:
|
|
||||||
raise ValueError("Failed to find metadata for title")
|
|
||||||
|
|
||||||
series = self._request("GET", f"{self.config['endpoints']['base_url']}/{meta_key}")
|
|
||||||
|
|
||||||
seasons = [
|
|
||||||
self.config["endpoints"]["base_url"] + item.get("key")
|
|
||||||
for item in series.get("MediaContainer", {}).get("Metadata", [])
|
|
||||||
if item.get("type") == "season"
|
|
||||||
]
|
|
||||||
if not seasons:
|
|
||||||
raise ValueError("Failed to find seasons for title")
|
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
||||||
results = list(executor.map(self._fetch_season, seasons))
|
|
||||||
|
|
||||||
episodes = [
|
|
||||||
Episode(
|
|
||||||
id_=episode.get("ratingKey"),
|
|
||||||
service=self.__class__,
|
|
||||||
name=episode.get("title"),
|
|
||||||
season=int(episode.get("parentIndex", 0)),
|
|
||||||
number=int(episode.get("index", 0)),
|
|
||||||
title=re.sub(r"\s*\(\d{4}\)", "", episode.get("grandparentTitle", "")),
|
|
||||||
# year=episode.get("year"),
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
for season in results
|
|
||||||
for episode in season
|
|
||||||
if episode.get("type") == "episode"
|
|
||||||
]
|
|
||||||
|
|
||||||
return episodes
|
|
||||||
|
|
||||||
def _movie(self, guid: str) -> Movie:
|
|
||||||
data = self._request("GET", f"/library/metadata/movie:{guid}")
|
|
||||||
movie = data.get("MediaContainer", {}).get("Metadata", [])[0]
|
|
||||||
if not movie:
|
|
||||||
raise ValueError(f"Could not find any data for ID {guid}")
|
|
||||||
|
|
||||||
movies = [
|
|
||||||
Movie(
|
|
||||||
id_=movie.get("ratingKey"),
|
|
||||||
service=self.__class__,
|
|
||||||
name=movie.get("title"),
|
|
||||||
year=movie.get("year"),
|
|
||||||
data=movie,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
return movies
|
|
||||||
|
|
||||||
def _episode(self, path: str) -> Episode:
|
|
||||||
data = self._request("GET", self.config["endpoints"]["screen"] + path)
|
|
||||||
meta_key = data.get("actions", [])[0].get("data", {}).get("key")
|
|
||||||
if not meta_key:
|
|
||||||
raise ValueError("Failed to find metadata for title")
|
|
||||||
|
|
||||||
metadata = self._request(
|
|
||||||
"POST", "/playQueues",
|
|
||||||
params={
|
|
||||||
"uri": self.config["endpoints"]["provider"] + meta_key,
|
|
||||||
"type": "video",
|
|
||||||
"continuous": "1",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
episode = next((x for x in metadata.get("MediaContainer", {}).get("Metadata", []) if x.get("key") == meta_key), None)
|
|
||||||
if not episode:
|
|
||||||
raise ValueError("Failed to find metadata for title")
|
|
||||||
|
|
||||||
episodes = [
|
|
||||||
Episode(
|
|
||||||
id_=episode.get("ratingKey"),
|
|
||||||
service=self.__class__,
|
|
||||||
name=episode.get("title"),
|
|
||||||
season=int(episode.get("parentIndex", 0)),
|
|
||||||
number=int(episode.get("index", 0)),
|
|
||||||
title=re.sub(r"\s*\(\d{4}\)", "", episode.get("grandparentTitle", "")),
|
|
||||||
# year=episode.get("year"),
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
return episodes
|
|
||||||
|
|
||||||
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 not in (200, 201, 426):
|
|
||||||
raise ConnectionError(f"{response.text}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
return json.loads(response.content)
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return response.text
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
headers:
|
|
||||||
User-Agent: Mozilla/5.0 (Linux; Android 11; Smart TV Build/AR2101; wv)
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
base_url: https://vod.provider.plex.tv
|
|
||||||
user: https://plex.tv/api/v2/users/anonymous
|
|
||||||
screen: https://luma.plex.tv/api/screen
|
|
||||||
provider: provider://tv.plex.provider.vod
|
|
||||||
manifest_clear: /library/parts/{}?includeAllStreams=1&X-Plex-Product=Plex+Mediaverse&X-Plex-Token={}
|
|
||||||
manifest_drm: /library/parts/{}?includeAllStreams=1&X-Plex-Product=Plex+Mediaverse&X-Plex-Token={}&X-Plex-DRM=widevine
|
|
||||||
license: /library/parts/{}/license?X-Plex-Token={}&X-Plex-DRM=widevine
|
|
||||||
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import uuid
|
|
||||||
from collections.abc import Generator
|
|
||||||
from http.cookiejar import CookieJar
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
import click
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.manifests import DASH, HLS
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
|
||||||
from envied.core.tracks import Chapters, Tracks
|
|
||||||
|
|
||||||
|
|
||||||
class PLUTO(Service):
|
|
||||||
"""
|
|
||||||
\b
|
|
||||||
Service code for Pluto TV on demand streaming service (https://pluto.tv/)
|
|
||||||
Credit to @wks_uwu for providing an alternative API, making the codebase much cleaner
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.2
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: None
|
|
||||||
Robustness:
|
|
||||||
Widevine:
|
|
||||||
L3: 1080p, AAC2.0
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Input can be complete title URL or just the path:
|
|
||||||
SERIES: /series/65ce4e5003fa740013793127/details
|
|
||||||
EPISODE: /series/65ce4e5003fa740013793127/season/1/episode/662c2af0a9f2d200131ba731
|
|
||||||
MOVIE: /movies/635c1e430888bc001ad01a9b/details
|
|
||||||
- Use --lang LANG_RANGE option to request non-English tracks
|
|
||||||
- Use --hls to request HLS instead of DASH:
|
|
||||||
devine dl pluto URL --hls
|
|
||||||
|
|
||||||
\b
|
|
||||||
Notes:
|
|
||||||
- Both DASH(widevine) and HLS(AES) are looked for in the API.
|
|
||||||
- DASH is prioritized over HLS since the latter doesn't have 1080p. If DASH has audio/subtitle issues,
|
|
||||||
you can try using HLS with the --hls flag.
|
|
||||||
- Pluto use 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.
|
|
||||||
- With the variations in manifests, and the inconsistency in the API, the language is set as "en" by default
|
|
||||||
for all tracks, no matter what region you're in.
|
|
||||||
You can manually set the language in the get_titles() function if you want to change it.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
ALIASES = ("plu", "plutotv")
|
|
||||||
TITLE_RE = (
|
|
||||||
r"^"
|
|
||||||
r"(?:https?://(?:www\.)?pluto\.tv(?:/[a-z]{2})?)?"
|
|
||||||
r"(?:/on-demand)?"
|
|
||||||
r"/(?P<type>movies|series)"
|
|
||||||
r"/(?P<id>[a-z0-9-]+)"
|
|
||||||
r"(?:(?:/season/(\d+)/episode/(?P<episode>[a-z0-9-]+)))?"
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="PLUTO", short_help="https://pluto.tv/", help=__doc__)
|
|
||||||
@click.option("--hls", is_flag=True, help="Request HLS instead of DASH")
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, **kwargs):
|
|
||||||
return PLUTO(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx, title, hls=False):
|
|
||||||
super().__init__(ctx)
|
|
||||||
self.title = title
|
|
||||||
self.force_hls = hls
|
|
||||||
|
|
||||||
def authenticate(
|
|
||||||
self,
|
|
||||||
cookies: Optional[CookieJar] = None,
|
|
||||||
credential: Optional[Credential] = None,
|
|
||||||
) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
|
|
||||||
self.session.params = {
|
|
||||||
"appName": "web",
|
|
||||||
"appVersion": "na",
|
|
||||||
"clientID": str(uuid.uuid1()),
|
|
||||||
"deviceDNT": 0,
|
|
||||||
"deviceId": "unknown",
|
|
||||||
"clientModelNumber": "na",
|
|
||||||
"serverSideAds": "false",
|
|
||||||
"deviceMake": "unknown",
|
|
||||||
"deviceModel": "web",
|
|
||||||
"deviceType": "web",
|
|
||||||
"deviceVersion": "unknown",
|
|
||||||
"sid": str(uuid.uuid1()),
|
|
||||||
"drmCapabilities": "widevine:L3",
|
|
||||||
}
|
|
||||||
|
|
||||||
info = self.session.get(self.config["endpoints"]["auth"]).json()
|
|
||||||
self.token = info["sessionToken"]
|
|
||||||
self.region = info["session"].get("activeRegion", "").lower()
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
params = {
|
|
||||||
"q": self.title,
|
|
||||||
"limit": "100",
|
|
||||||
}
|
|
||||||
|
|
||||||
r = self.session.get(
|
|
||||||
self.config["endpoints"]["search"].format(query=self.title),
|
|
||||||
headers={"Authorization": f"Bearer {self.token}"},
|
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
results = r.json()
|
|
||||||
|
|
||||||
for result in results["data"]:
|
|
||||||
if result.get("type") not in ["timeline", "channel"]:
|
|
||||||
content = result.get("id")
|
|
||||||
kind = result.get("type")
|
|
||||||
kind = "movies" if kind == "movie" else "series"
|
|
||||||
|
|
||||||
yield SearchResult(
|
|
||||||
id_=f"/{kind}/{content}/details",
|
|
||||||
title=result.get("name"),
|
|
||||||
description=result.get("synopsis"),
|
|
||||||
label=result.get("type"),
|
|
||||||
url=f"https://pluto.tv/{self.region}/on-demand/{kind}/{content}/details",
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_titles(self) -> Titles_T:
|
|
||||||
try:
|
|
||||||
kind, content_id, episode_id = (
|
|
||||||
re.match(self.TITLE_RE, self.title).group(i) for i in ("type", "id", "episode")
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
raise ValueError("Could not parse ID from title - is the URL correct?")
|
|
||||||
|
|
||||||
if kind == "series" and episode_id:
|
|
||||||
r = self.session.get(self.config["endpoints"]["series"].format(season_id=content_id))
|
|
||||||
if not r.ok:
|
|
||||||
raise ConnectionError(f"{r.json().get('message')}")
|
|
||||||
|
|
||||||
data = r.json()
|
|
||||||
return Series(
|
|
||||||
[
|
|
||||||
Episode(
|
|
||||||
id_=episode.get("_id"),
|
|
||||||
service=self.__class__,
|
|
||||||
title=data.get("name"),
|
|
||||||
season=int(episode.get("season")),
|
|
||||||
number=int(episode.get("number")),
|
|
||||||
name=episode.get("name"),
|
|
||||||
year=None,
|
|
||||||
language="en", # self.region,
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
for series in data["seasons"]
|
|
||||||
for episode in series["episodes"]
|
|
||||||
if episode.get("_id") == episode_id
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
elif kind == "series":
|
|
||||||
r = self.session.get(self.config["endpoints"]["series"].format(season_id=content_id))
|
|
||||||
if not r.ok:
|
|
||||||
raise ConnectionError(f"{r.json().get('message')}")
|
|
||||||
|
|
||||||
data = r.json()
|
|
||||||
return Series(
|
|
||||||
[
|
|
||||||
Episode(
|
|
||||||
id_=episode.get("_id"),
|
|
||||||
service=self.__class__,
|
|
||||||
title=data.get("name"),
|
|
||||||
season=int(episode.get("season")),
|
|
||||||
number=int(episode.get("number")),
|
|
||||||
name=episode.get("name"),
|
|
||||||
year=self.year(episode),
|
|
||||||
language="en", # self.region,
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
for series in data["seasons"]
|
|
||||||
for episode in series["episodes"]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
elif kind == "movies":
|
|
||||||
url = self.config["endpoints"]["movie"].format(video_id=content_id)
|
|
||||||
r = self.session.get(url, headers={"Authorization": f"Bearer {self.token}"})
|
|
||||||
if not r.ok:
|
|
||||||
raise ConnectionError(f"{r.json().get('message')}")
|
|
||||||
|
|
||||||
data = r.json()
|
|
||||||
return Movies(
|
|
||||||
[
|
|
||||||
Movie(
|
|
||||||
id_=movie.get("_id"),
|
|
||||||
service=self.__class__,
|
|
||||||
name=movie.get("name"),
|
|
||||||
language="en", # self.region,
|
|
||||||
data=movie,
|
|
||||||
year=self.year(movie),
|
|
||||||
)
|
|
||||||
for movie in data
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
|
||||||
url = self.config["endpoints"]["episodes"].format(episode_id=title.id)
|
|
||||||
episode = self.session.get(url).json()
|
|
||||||
|
|
||||||
sources = next((item.get("sources") for item in episode if not self.bumpers(item.get("name", ""))), None)
|
|
||||||
|
|
||||||
if not sources:
|
|
||||||
raise ValueError("Unable to find manifest for this title")
|
|
||||||
|
|
||||||
hls = next((x.get("file") for x in sources if x.get("type").lower() == "hls"), None)
|
|
||||||
dash = next((x.get("file") for x in sources if x.get("type").lower() == "dash"), None)
|
|
||||||
|
|
||||||
if dash and not self.force_hls:
|
|
||||||
self.license = self.config["endpoints"]["license"]
|
|
||||||
manifest = dash.replace("https://siloh.pluto.tv", "http://silo-hybrik.pluto.tv.s3.amazonaws.com")
|
|
||||||
tracks = DASH.from_url(manifest, self.session).to_tracks(language=title.language)
|
|
||||||
|
|
||||||
for track in tracks.audio:
|
|
||||||
role = track.data["dash"]["adaptation_set"].find("Role")
|
|
||||||
if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
|
|
||||||
track.descriptive = True
|
|
||||||
|
|
||||||
else:
|
|
||||||
self.license = None
|
|
||||||
m3u8_url = hls.replace("https://siloh.pluto.tv", "http://silo-hybrik.pluto.tv.s3.amazonaws.com")
|
|
||||||
manifest = self.clean_manifest(self.session.get(m3u8_url).text)
|
|
||||||
tracks = HLS.from_text(manifest, m3u8_url).to_tracks(language=title.language)
|
|
||||||
|
|
||||||
# Remove separate AD audio tracks
|
|
||||||
for track in tracks.audio:
|
|
||||||
tracks.audio.remove(track)
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Title_T) -> Chapters:
|
|
||||||
return Chapters()
|
|
||||||
|
|
||||||
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 clean_manifest(text: str) -> str:
|
|
||||||
# Remove fairplay entries
|
|
||||||
index = text.find('#PLUTO-DRM:ID="fairplay')
|
|
||||||
if index == -1:
|
|
||||||
return text
|
|
||||||
else:
|
|
||||||
end_of_previous_line = text.rfind("\n", 0, index)
|
|
||||||
if end_of_previous_line == -1:
|
|
||||||
return ""
|
|
||||||
else:
|
|
||||||
return text[:end_of_previous_line]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def bumpers(text: str) -> bool:
|
|
||||||
ads = (
|
|
||||||
"Pluto_TV_OandO",
|
|
||||||
"_ad",
|
|
||||||
"creative",
|
|
||||||
"Bumper",
|
|
||||||
"Promo",
|
|
||||||
"WarningCard",
|
|
||||||
)
|
|
||||||
|
|
||||||
return any(ad in text for ad in ads)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def year(data: dict) -> Optional[int]:
|
|
||||||
title_year = (int(match.group(1)) if (match := re.search(r"\((\d{4})\)", data.get("name", ""))) else None)
|
|
||||||
slug_year = (int(match.group(1)) if (match := re.search(r"\b(\d{4})\b", data.get("slug", ""))) else None)
|
|
||||||
return None if title_year else slug_year
|
|
||||||
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
endpoints:
|
|
||||||
auth: https://boot.pluto.tv/v4/start
|
|
||||||
search: https://service-media-search.clusters.pluto.tv/v1/search
|
|
||||||
series: https://service-vod.clusters.pluto.tv/v3/vod/series/{season_id}/seasons
|
|
||||||
episodes: http://api.pluto.tv/v2/episodes/{episode_id}/clips.json
|
|
||||||
movie: https://service-vod.clusters.pluto.tv/v4/vod/items?ids={video_id}
|
|
||||||
license: https://service-concierge.clusters.pluto.tv/v1/wv/alt
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import json
|
|
||||||
import re
|
|
||||||
from typing import Optional
|
|
||||||
from http.cookiejar import CookieJar
|
|
||||||
from langcodes import Language
|
|
||||||
import click
|
|
||||||
|
|
||||||
from envied.core.constants import AnyTrack
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.manifests import DASH
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Movie, Movies, Title_T, Titles_T
|
|
||||||
from envied.core.tracks import Tracks
|
|
||||||
|
|
||||||
|
|
||||||
class PTHS(Service):
|
|
||||||
"""
|
|
||||||
Service code for Pathé Thuis (pathe-thuis.nl)
|
|
||||||
Version: 1.0.0
|
|
||||||
|
|
||||||
Security: SD @ L3 (Widevine)
|
|
||||||
FHD @ L1
|
|
||||||
Authorization: Cookies or authentication token
|
|
||||||
|
|
||||||
Supported:
|
|
||||||
• Movies → https://www.pathe-thuis.nl/film/{id}
|
|
||||||
|
|
||||||
Note:
|
|
||||||
Pathé Thuis does not have episodic content, only movies.
|
|
||||||
"""
|
|
||||||
|
|
||||||
TITLE_RE = (
|
|
||||||
r"^(?:https?://(?:www\.)?pathe-thuis\.nl/film/)?(?P<id>\d+)(?:/[^/]+)?$"
|
|
||||||
)
|
|
||||||
GEOFENCE = ("NL",)
|
|
||||||
NO_SUBTITLES = True
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="PTHS", short_help="https://www.pathe-thuis.nl")
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, **kwargs):
|
|
||||||
return PTHS(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx, title: str):
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
m = re.match(self.TITLE_RE, title)
|
|
||||||
if not m:
|
|
||||||
raise ValueError(
|
|
||||||
f"Unsupported Pathé Thuis URL or ID: {title}\n"
|
|
||||||
"Use e.g. https://www.pathe-thuis.nl/film/30591"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.movie_id = m.group("id")
|
|
||||||
self.drm_token = None
|
|
||||||
|
|
||||||
if self.config is None:
|
|
||||||
raise EnvironmentError("Missing service config for Pathé Thuis.")
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
|
|
||||||
if not cookies:
|
|
||||||
self.log.warning("No cookies provided, proceeding unauthenticated.")
|
|
||||||
return
|
|
||||||
|
|
||||||
token = next((c.value for c in cookies if c.name == "authenticationToken"), None)
|
|
||||||
if not token:
|
|
||||||
self.log.info("No authenticationToken cookie found, unauthenticated mode.")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.session.headers.update({
|
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0",
|
|
||||||
"X-Pathe-Device-Identifier": "web-widevine-1",
|
|
||||||
"X-Pathe-Auth-Session-Token": token,
|
|
||||||
})
|
|
||||||
self.log.info("Authentication token successfully attached to session.")
|
|
||||||
|
|
||||||
|
|
||||||
def get_titles(self) -> Titles_T:
|
|
||||||
url = self.config["endpoints"]["metadata"].format(movie_id=self.movie_id)
|
|
||||||
r = self.session.get(url)
|
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
|
|
||||||
movie = Movie(
|
|
||||||
id_=str(data["id"]),
|
|
||||||
service=self.__class__,
|
|
||||||
name=data["name"],
|
|
||||||
description=data.get("intro", ""),
|
|
||||||
year=data.get("year"),
|
|
||||||
language=Language.get(data.get("language", "en")),
|
|
||||||
data=data,
|
|
||||||
)
|
|
||||||
return Movies([movie])
|
|
||||||
|
|
||||||
|
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
|
||||||
ticket_id = self._get_ticket_id(title)
|
|
||||||
url = self.config["endpoints"]["ticket"].format(ticket_id=ticket_id)
|
|
||||||
|
|
||||||
r = self.session.get(url)
|
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
stream = data["stream"]
|
|
||||||
|
|
||||||
manifest_url = stream.get("url") or stream.get("drmurl")
|
|
||||||
if not manifest_url:
|
|
||||||
raise ValueError("No stream manifest URL found.")
|
|
||||||
|
|
||||||
self.drm_token = stream["token"]
|
|
||||||
self.license_url = stream["rawData"]["licenseserver"]
|
|
||||||
|
|
||||||
tracks = DASH.from_url(manifest_url, session=self.session).to_tracks(language=title.language)
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
|
|
||||||
def _get_ticket_id(self, title: Title_T) -> str:
|
|
||||||
"""Fetch the user's owned ticket ID if present."""
|
|
||||||
data = title.data
|
|
||||||
for t in (data.get("tickets") or []):
|
|
||||||
if t.get("playable") and str(t.get("movieId")) == str(self.movie_id):
|
|
||||||
return str(t["id"])
|
|
||||||
raise ValueError("No valid ticket found for this movie. Ensure purchase or login.")
|
|
||||||
|
|
||||||
|
|
||||||
def get_chapters(self, title: Title_T):
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def get_widevine_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
|
|
||||||
if not self.license_url or not self.drm_token:
|
|
||||||
raise ValueError("Missing license URL or token.")
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
"Authorization": f"Bearer {self.drm_token}",
|
|
||||||
}
|
|
||||||
|
|
||||||
params = {"custom_data": self.drm_token}
|
|
||||||
|
|
||||||
r = self.session.post(self.license_url, params=params, data=challenge, headers=headers)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
if not r.content:
|
|
||||||
raise ValueError("Empty license response, likely invalid or expired token.")
|
|
||||||
return r.content
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
endpoints:
|
|
||||||
metadata: "https://www.pathe-thuis.nl/api/movies/{movie_id}?include=editions"
|
|
||||||
ticket: "https://www.pathe-thuis.nl/api/tickets/{ticket_id}"
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
A collection of non-premium services for envied.
|
|
||||||
|
|
||||||
## Usage:
|
|
||||||
Clone repository:
|
|
||||||
|
|
||||||
`git clone https://github.com/stabbedbybrick/services.git`
|
|
||||||
|
|
||||||
Add folder to `envied.yaml`:
|
|
||||||
|
|
||||||
```
|
|
||||||
directories:
|
|
||||||
services: "path/to/services"
|
|
||||||
```
|
|
||||||
See help text for each service:
|
|
||||||
|
|
||||||
`unshackle dl SERVICE --help`
|
|
||||||
|
|
||||||
## Notes:
|
|
||||||
Some versions of the dependencies work better than others. These are the recommended versions as of 25/11/11:
|
|
||||||
|
|
||||||
- Shaka Packager: [v2.6.1](https://github.com/shaka-project/shaka-packager/releases/tag/v2.6.1)
|
|
||||||
- CCExtractor: [v0.93](https://github.com/CCExtractor/ccextractor/releases/tag/v0.93)
|
|
||||||
- MKVToolNix: [latest](https://mkvtoolnix.download/downloads.html)
|
|
||||||
- FFmpeg: [latest](https://ffmpeg.org/download.html)
|
|
||||||
@@ -1,791 +0,0 @@
|
|||||||
import base64
|
|
||||||
from copy import copy
|
|
||||||
import datetime
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from typing import Optional
|
|
||||||
from aiohttp import CookieJar
|
|
||||||
from pymediainfo import MediaInfo
|
|
||||||
from langcodes import Language
|
|
||||||
import click
|
|
||||||
import urllib.parse
|
|
||||||
from requests import HTTPError
|
|
||||||
from envied.core.config import config
|
|
||||||
from envied.core.constants import AnyTrack
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.manifests.dash import DASH
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Title_T
|
|
||||||
from envied.core.titles.episode import Episode, Series
|
|
||||||
from envied.core.titles.movie import Movie, Movies
|
|
||||||
from envied.core.tracks.audio import Audio
|
|
||||||
from envied.core.tracks.chapters import Chapters
|
|
||||||
from envied.core.tracks.subtitle import Subtitle
|
|
||||||
from envied.core.tracks.tracks import Tracks
|
|
||||||
from envied.core.tracks.video import Video
|
|
||||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
|
||||||
|
|
||||||
class RKTN(Service):
|
|
||||||
"""
|
|
||||||
Service code for Rakuten's Rakuten TV streaming service (https://rakuten.tv).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Authorization: Credentials
|
|
||||||
Security: FHD-UHD@L1, SD-FHD@L3; with trick
|
|
||||||
|
|
||||||
\b
|
|
||||||
Maximum of 3 audio tracks, otherwise will fail because Rakuten blocks more than 3 requests.
|
|
||||||
Subtitles requests expires fast, so together with video and audio it will fail.
|
|
||||||
If you want subs, use -S or -na -nv -nc, and download the rest separately.
|
|
||||||
|
|
||||||
\b
|
|
||||||
Command for Titles with no SDR (if not set range to HDR10 it will fail):
|
|
||||||
uv run unshackle dl -r HDR10 [OPTIONS] RKTN -m https://www.rakuten.tv/...
|
|
||||||
|
|
||||||
\b
|
|
||||||
TODO: - TV Shows are not yet supported as there's 0 TV Shows to purchase, rent, or watch in my region
|
|
||||||
|
|
||||||
\b
|
|
||||||
NOTES: - Only movies are supported as my region's Rakuten has no TV shows available to purchase at all
|
|
||||||
"""
|
|
||||||
|
|
||||||
ALIASES = ["RakutenTV", "rakuten", "rakutentv"]
|
|
||||||
TITLE_RE = r"^(?:https?://(?:www\.)?rakuten\.tv/([a-z]+/|)movies(?:/[a-z]{2})?/)(?P<id>[a-z0-9-]+)"
|
|
||||||
LANG_MAP = {
|
|
||||||
"es": "es-ES",
|
|
||||||
"pt": "pt-PT",
|
|
||||||
}
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="RakutenTV", short_help="https://rakuten.tv")
|
|
||||||
@click.argument("title", type=str, required=False)
|
|
||||||
@click.option(
|
|
||||||
"-dev",
|
|
||||||
"--device",
|
|
||||||
default=None,
|
|
||||||
type=click.Choice(
|
|
||||||
[
|
|
||||||
"web", # Device: Web Browser - Maximum Quality: 720p - DRM: Widevine
|
|
||||||
"android", # Device: Android Phone - Maximum Quality: 720p - DRM: Widevine
|
|
||||||
"atvui40", # Device: AndroidTV - Maximum Quality: 2160p - DRM: Widevine
|
|
||||||
"lgui40", # Device: LG SMART TV - Maximum Quality: 2160p - DRM: Playready
|
|
||||||
"smui40", # Device: Samsung SMART TV - Maximum Quality: 2160p - DRM: Playready
|
|
||||||
],
|
|
||||||
case_sensitive=True,
|
|
||||||
),
|
|
||||||
help="The device you want to make requests with.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"-m", "--movie", is_flag=True, default=False, help="Title is a movie."
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"-dal", "--desired-audio-language", type=str, default="SPA,ENG", help="Select desired audio language tracks for this title. Default SPA,ENG. Separate multiple languages with a comma."
|
|
||||||
)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, **kwargs):
|
|
||||||
return RKTN(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx, title, device, movie, desired_audio_language):
|
|
||||||
super().__init__(ctx)
|
|
||||||
#self.parse_title(ctx, title)
|
|
||||||
self.title = title
|
|
||||||
self.cdm = ctx.obj.cdm
|
|
||||||
self.playready = isinstance(self.cdm, PlayReadyCdm)
|
|
||||||
self.desired_audio_language = desired_audio_language
|
|
||||||
self.range = ctx.parent.params.get("range_")[0].name or "SDR"
|
|
||||||
self.vcodec = ctx.parent.params.get("vcodec") or Video.Codec.AVC # Defaults to H264
|
|
||||||
self.resolution = "UHD" if (self.vcodec.extension.lower() == "h265" or self.range in ['HYBRID', 'HDR10', 'HDR10P', 'DV']) else "FHD"
|
|
||||||
self.device = "lgui40" if self.playready else "android"
|
|
||||||
self.movie = movie or "movies" in title
|
|
||||||
self.audio_languages = []
|
|
||||||
|
|
||||||
# set a custom device if provided
|
|
||||||
if device is not None:
|
|
||||||
self.device = device
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
if not credential:
|
|
||||||
raise EnvironmentError("Service requires Credentials for Authentication.")
|
|
||||||
|
|
||||||
self.session.headers.update(
|
|
||||||
{
|
|
||||||
"Origin": "https://rakuten.tv/",
|
|
||||||
"User-Agent": "Mozilla/5.0 (Linux; Android 11; SHIELD Android TV Build/RQ1A.210105.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/99.0.4844.88 Mobile Safari/537.36",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_titles(self):
|
|
||||||
self.pair_device()
|
|
||||||
|
|
||||||
if self.movie:
|
|
||||||
endpoint = self.config["endpoints"]["title"]
|
|
||||||
else:
|
|
||||||
endpoint = self.config["endpoints"]["show"]
|
|
||||||
|
|
||||||
params = urllib.parse.urlencode(
|
|
||||||
{
|
|
||||||
"classification_id": self.classification_id,
|
|
||||||
"device_identifier": self.config["clients"][self.device][
|
|
||||||
"device_identifier"
|
|
||||||
],
|
|
||||||
"device_serial": self.config["clients"][self.device]["device_serial"],
|
|
||||||
"locale": self.locale,
|
|
||||||
"market_code": self.market_code,
|
|
||||||
"session_uuid": self.session_uuid,
|
|
||||||
"timestamp": f"{int(datetime.datetime.now().timestamp())}005",
|
|
||||||
"support_closed_captions": "true",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
title_url = endpoint.format(
|
|
||||||
title_id=self.title
|
|
||||||
) + params
|
|
||||||
|
|
||||||
|
|
||||||
title = self.session.get(url=title_url).json()
|
|
||||||
|
|
||||||
if "errors" in title:
|
|
||||||
error = title["errors"][0]
|
|
||||||
if error["code"] == "error.not_found":
|
|
||||||
self.log.error(f"Title [{self.title}] was not found on this account.")
|
|
||||||
else:
|
|
||||||
self.log.error(
|
|
||||||
f"Unable to get title info: {error['message']} [{error['code']}]"
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
title = self.get_info(title["data"])
|
|
||||||
|
|
||||||
if self.movie:
|
|
||||||
|
|
||||||
return Movies(
|
|
||||||
[
|
|
||||||
Movie(
|
|
||||||
id_=self.title,
|
|
||||||
service=self.__class__,
|
|
||||||
name=title["title"],
|
|
||||||
year=title["year"],
|
|
||||||
language="en",
|
|
||||||
data=title,
|
|
||||||
description=title["plot"],
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
episodes_list = []
|
|
||||||
#title_ep = self.get_info(title["data"]['episodes'])
|
|
||||||
for season in title["tv_show"]["seasons"]:
|
|
||||||
data_season = endpoint.format(
|
|
||||||
title_id=season["id"]
|
|
||||||
) + params
|
|
||||||
|
|
||||||
data = self.session.get(url=data_season).json()
|
|
||||||
|
|
||||||
if "errors" in data:
|
|
||||||
error = data["errors"][0]
|
|
||||||
if error["code"] == "error.not_found":
|
|
||||||
self.log.error(f"Season [{season['id']}] was not found on this account.")
|
|
||||||
else:
|
|
||||||
self.log.error(
|
|
||||||
f"Unable to get title info: {error['message']} [{error['code']}]"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
for episode in data["data"]["episodes"]:
|
|
||||||
episodes_list.append(
|
|
||||||
Episode(
|
|
||||||
id_=episode["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=episode["tv_show_title"],
|
|
||||||
season=episode["season_number"],
|
|
||||||
number=episode["number"],
|
|
||||||
name=episode["title"] or episode['display_name'],
|
|
||||||
description=episode["short_plot"],
|
|
||||||
year=episode["year"],
|
|
||||||
language="en",
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return Series(episodes_list)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
|
||||||
# Obtener tracks para todos los idiomas de audio disponibles
|
|
||||||
all_tracks = None
|
|
||||||
|
|
||||||
for audio_lang in self.audio_languages:
|
|
||||||
self.log.info(f"Getting tracks for audio language: {audio_lang}")
|
|
||||||
|
|
||||||
# Obtener stream info para este idioma específico
|
|
||||||
stream_info = self.get_avod(audio_lang, title) if self.kind == "avod" else self.get_me(audio_lang, title)
|
|
||||||
|
|
||||||
if "errors" in stream_info:
|
|
||||||
error = stream_info["errors"][0]
|
|
||||||
if "error.streaming.no_active_right" in stream_info["errors"][0]["code"]:
|
|
||||||
self.log.error(
|
|
||||||
" x You don't have the rights for this content\n You need to rent or buy it first"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.log.error(
|
|
||||||
f" - Failed to get track info: {error['message']} [{error['code']}]"
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
stream_info = stream_info["data"]["stream_infos"][0]
|
|
||||||
|
|
||||||
if all_tracks is None:
|
|
||||||
# Primera iteración: crear el objeto tracks principal
|
|
||||||
self.license_url = stream_info["license_url"]
|
|
||||||
|
|
||||||
all_tracks = DASH.from_url(url=stream_info["url"], session=self.session).to_tracks(language=title.language)
|
|
||||||
|
|
||||||
# Procesar subtítulos (solo una vez)
|
|
||||||
subtitle_tracks = []
|
|
||||||
for subtitle in stream_info.get("all_subtitles", []):
|
|
||||||
subtitle_tracks += [
|
|
||||||
Subtitle(
|
|
||||||
id_=hashlib.md5(subtitle["url"].encode()).hexdigest()[0:6],
|
|
||||||
url=subtitle["url"],
|
|
||||||
codec=Subtitle.Codec.from_mime(subtitle["format"]),
|
|
||||||
forced=subtitle["forced"],
|
|
||||||
language=subtitle["locale"],
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
all_tracks.add(subtitle_tracks)
|
|
||||||
else:
|
|
||||||
# Iteraciones adicionales: obtener tracks de audio adicionales
|
|
||||||
temp_tracks = DASH.from_url(url=stream_info["url"], session=self.session).to_tracks(language=title.language)
|
|
||||||
|
|
||||||
# Agregar solo los tracks de audio nuevos
|
|
||||||
for audio_track in temp_tracks.audio:
|
|
||||||
# Verificar que no sea duplicado basado en el idioma y codec
|
|
||||||
is_duplicate = False
|
|
||||||
for existing_audio in all_tracks.audio:
|
|
||||||
if (existing_audio.language == audio_track.language and
|
|
||||||
existing_audio.codec == audio_track.codec):
|
|
||||||
is_duplicate = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if not is_duplicate:
|
|
||||||
all_tracks.audio.append(audio_track)
|
|
||||||
|
|
||||||
# Procesar HDR para videos
|
|
||||||
for video in all_tracks.videos:
|
|
||||||
if "HDR10" in video.url:
|
|
||||||
video.range = Video.Range.HDR10
|
|
||||||
|
|
||||||
# Aplicar el método append_tracks mejorado
|
|
||||||
self.append_tracks(all_tracks)
|
|
||||||
|
|
||||||
return all_tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Title_T) -> Chapters:
|
|
||||||
|
|
||||||
return Chapters([])
|
|
||||||
|
|
||||||
def get_me(self, audio_language=None, title: Title_T = None):
|
|
||||||
# Si no se especifica idioma, usar el primero disponible
|
|
||||||
if audio_language is None:
|
|
||||||
audio_language = self.audio_languages[0]
|
|
||||||
|
|
||||||
stream_info_url = self.config["endpoints"]["manifest"].format(
|
|
||||||
kind="me"
|
|
||||||
) + urllib.parse.urlencode(
|
|
||||||
{
|
|
||||||
"audio_language": audio_language, # Usar el idioma especificado
|
|
||||||
"audio_quality": "5.1", # Will get better audio in different request to make sure it wont error
|
|
||||||
"classification_id": self.classification_id,
|
|
||||||
"content_id": title.id,
|
|
||||||
"content_type": "movies" if self.movie else "episodes",
|
|
||||||
"device_identifier": self.config["clients"][self.device][
|
|
||||||
"device_identifier"
|
|
||||||
],
|
|
||||||
"device_serial": "not_implemented",
|
|
||||||
"device_stream_audio_quality": "5.1",
|
|
||||||
"device_stream_hdr_type": self.hdr_type,
|
|
||||||
"device_stream_video_quality": self.resolution,
|
|
||||||
"device_uid": "affa434b-8b7c-4ff3-a15e-df1fe500e71e",
|
|
||||||
"device_year": self.config["clients"][self.device]["device_year"],
|
|
||||||
"disable_dash_legacy_packages": "false",
|
|
||||||
"gdpr_consent": self.config["gdpr_consent"],
|
|
||||||
"gdpr_consent_opt_out": 0,
|
|
||||||
"hdr_type": self.hdr_type,
|
|
||||||
"ifa_subscriber_id": self.ifa_subscriber_id,
|
|
||||||
"locale": self.locale,
|
|
||||||
"market_code": self.market_code,
|
|
||||||
"player": self.config["clients"][self.device]["player"],
|
|
||||||
"player_height": 1080,
|
|
||||||
"player_width": 1920,
|
|
||||||
"publisher_provided_id": "046f58b1-d89b-4fa4-979b-a9bcd6d78a76",
|
|
||||||
"session_uuid": self.session_uuid,
|
|
||||||
"strict_video_quality": "false",
|
|
||||||
"subtitle_formats": ["vtt"],
|
|
||||||
"subtitle_language": "MIS",
|
|
||||||
"timestamp": f"{int(datetime.datetime.now().timestamp())}122",
|
|
||||||
"video_type": "stream",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
stream_info_url += "&signature=" + self.generate_signature(stream_info_url)
|
|
||||||
return self.session.post(
|
|
||||||
url=stream_info_url,
|
|
||||||
).json()
|
|
||||||
|
|
||||||
def get_avod(self, audio_language=None, title: Title_T = None):
|
|
||||||
# Si no se especifica idioma, usar el primero disponible
|
|
||||||
if audio_language is None:
|
|
||||||
audio_language = self.audio_languages[0]
|
|
||||||
|
|
||||||
stream_info_url = self.config["endpoints"]["manifest"].format(
|
|
||||||
kind="avod"
|
|
||||||
) + urllib.parse.urlencode(
|
|
||||||
{
|
|
||||||
"device_stream_video_quality": self.resolution,
|
|
||||||
"device_identifier": self.config["clients"][self.device][
|
|
||||||
"device_identifier"
|
|
||||||
],
|
|
||||||
"market_code": self.market_code,
|
|
||||||
"session_uuid": self.session_uuid,
|
|
||||||
"timestamp": f"{int(datetime.datetime.now().timestamp())}122",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
stream_info_url += "&signature=" + self.generate_signature(stream_info_url)
|
|
||||||
return self.session.post(
|
|
||||||
url=stream_info_url,
|
|
||||||
data={
|
|
||||||
"hdr_type": self.hdr_type,
|
|
||||||
"audio_quality": "5.1", # Will get better audio in different request to make sure it wont error
|
|
||||||
"app_version": self.config["clients"][self.device]["app_version"],
|
|
||||||
"content_id": title.id,
|
|
||||||
"video_quality": self.resolution,
|
|
||||||
"audio_language": audio_language, # Usar el idioma especificado
|
|
||||||
"video_type": "stream",
|
|
||||||
"device_serial": self.config["clients"][self.device]["device_serial"],
|
|
||||||
"content_type": "movies" if self.movie else "episodes",
|
|
||||||
"classification_id": self.classification_id,
|
|
||||||
"subtitle_language": "MIS",
|
|
||||||
"player": self.config["clients"][self.device]["player"],
|
|
||||||
},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
def generate_signature(self, url):
|
|
||||||
up = urllib.parse.urlparse(url)
|
|
||||||
digester = hmac.new(
|
|
||||||
self.access_token.encode(),
|
|
||||||
f"POST{up.path}{up.query}".encode(),
|
|
||||||
hashlib.sha1,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
base64.b64encode(digester.digest())
|
|
||||||
.decode("utf-8")
|
|
||||||
.replace("+", "-")
|
|
||||||
.replace("/", "_")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def append_tracks(self, tracks):
|
|
||||||
"""
|
|
||||||
Busca y agrega tracks adicionales de video y audio que no están en el manifest.
|
|
||||||
"""
|
|
||||||
if not tracks.videos:
|
|
||||||
self.log.warning("No video tracks found, skipping append_tracks")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Buscar tracks de video adicionales
|
|
||||||
self._append_video_tracks(tracks)
|
|
||||||
|
|
||||||
# Buscar tracks de audio adicionales
|
|
||||||
self._append_audio_tracks(tracks)
|
|
||||||
|
|
||||||
|
|
||||||
def _append_video_tracks(self, tracks):
|
|
||||||
"""Busca y agrega tracks de video adicionales para H.264."""
|
|
||||||
if not tracks.videos:
|
|
||||||
return
|
|
||||||
|
|
||||||
codec = tracks.videos[0].codec
|
|
||||||
|
|
||||||
# Solo buscar tracks adicionales para H.264
|
|
||||||
if codec != Video.Codec.AVC:
|
|
||||||
self.log.debug(f"Skipping video track search (codec: {codec.name}, only works for AVC/H.264)")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Extraer el patrón del codec de la URL
|
|
||||||
url_pattern = tracks.videos[-1].url
|
|
||||||
codec_match = re.search(r'(avc1|h264)-(\d+)', url_pattern, re.IGNORECASE)
|
|
||||||
|
|
||||||
if not codec_match:
|
|
||||||
self.log.debug("Could not find codec pattern in URL for video track search")
|
|
||||||
return
|
|
||||||
|
|
||||||
codec_prefix = codec_match.group(1) # "avc1" o "h264"
|
|
||||||
self.log.info(f"Searching for additional H.264 video tracks (pattern: {codec_prefix})...")
|
|
||||||
|
|
||||||
|
|
||||||
# Usar el directorio temp de Unshackle
|
|
||||||
temp_file = os.path.join(str(config.directories.temp), "video_test.mp4")
|
|
||||||
|
|
||||||
|
|
||||||
tracks_found = 0
|
|
||||||
|
|
||||||
for n in range(100):
|
|
||||||
# Generar URL del siguiente track
|
|
||||||
current_number = len(tracks.videos) + 1
|
|
||||||
ismv = re.sub(
|
|
||||||
rf"{codec_prefix}-\d+",
|
|
||||||
rf"{codec_prefix}-{current_number}",
|
|
||||||
tracks.videos[-1].url,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verificar si existe
|
|
||||||
try:
|
|
||||||
response = self.session.head(ismv, timeout=5)
|
|
||||||
if response.status_code != 200:
|
|
||||||
self.log.debug(f"Video track search ended at index {current_number}")
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
self.log.debug(f"Video track search failed: {e}")
|
|
||||||
break
|
|
||||||
|
|
||||||
# Crear copia del último video track
|
|
||||||
video = copy(tracks.videos[-1])
|
|
||||||
video.url = ismv
|
|
||||||
video.id_ = hashlib.md5(ismv.encode()).hexdigest()[:16]
|
|
||||||
|
|
||||||
# Descargar chunk para obtener info con MediaInfo
|
|
||||||
try:
|
|
||||||
with open(temp_file, "wb") as chunkfile:
|
|
||||||
data = self.session.get(
|
|
||||||
url=ismv,
|
|
||||||
headers={"Range": "bytes=0-50000"},
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
chunkfile.write(data.content)
|
|
||||||
|
|
||||||
# Parsear con MediaInfo
|
|
||||||
info = MediaInfo.parse(temp_file)
|
|
||||||
|
|
||||||
if not info.video_tracks:
|
|
||||||
self.log.debug(f"No video info found for track {current_number}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
video_info = info.video_tracks[0]
|
|
||||||
video.height = video_info.height
|
|
||||||
video.width = video_info.width
|
|
||||||
video.bitrate = video_info.maximum_bit_rate or video_info.bit_rate
|
|
||||||
|
|
||||||
# Agregar el track
|
|
||||||
tracks.videos.append(video)
|
|
||||||
tracks_found += 1
|
|
||||||
self.log.info(
|
|
||||||
f" + Added video track #{current_number}: "
|
|
||||||
f"{video.width}x{video.height} @ {video.bitrate} bps"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log.warning(f"Failed to process video track {current_number}: {e}")
|
|
||||||
break
|
|
||||||
finally:
|
|
||||||
if os.path.exists(temp_file):
|
|
||||||
os.remove(temp_file)
|
|
||||||
|
|
||||||
if tracks_found > 0:
|
|
||||||
self.log.info(f"Total additional video tracks found: {tracks_found}")
|
|
||||||
|
|
||||||
|
|
||||||
def _append_audio_tracks(self, tracks):
|
|
||||||
"""Busca y agrega tracks de audio adicionales para todos los idiomas seleccionados."""
|
|
||||||
if not tracks.audio:
|
|
||||||
self.log.warning("No audio tracks found to use as base")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not hasattr(self, 'audio_languages') or not self.audio_languages:
|
|
||||||
self.log.debug("No audio languages configured")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.log.info(f"Searching for additional audio tracks in languages: {self.audio_languages}")
|
|
||||||
|
|
||||||
# Codecs a probar (en orden de preferencia)
|
|
||||||
codecs_to_try = ["ec-3", "ac-3", "dts", "mp4a"]
|
|
||||||
|
|
||||||
# Usar el directorio temp de Unshackle
|
|
||||||
temp_file = os.path.join(str(config.directories.temp), "audio_test.mp4")
|
|
||||||
|
|
||||||
|
|
||||||
base_audio = tracks.audio[0]
|
|
||||||
base_url = base_audio.url
|
|
||||||
|
|
||||||
tracks_found = 0
|
|
||||||
|
|
||||||
for language in self.audio_languages:
|
|
||||||
for codec_name in codecs_to_try:
|
|
||||||
# Generar URL del track
|
|
||||||
# Patrón: audio-{LANG}-{CODEC}-{NUMBER}
|
|
||||||
isma = re.sub(
|
|
||||||
r"audio-[a-zA-Z]{2,3}-[a-z0-9\-]+-\d+",
|
|
||||||
f"audio-{language.lower()}-{codec_name}-1",
|
|
||||||
base_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verificar si existe
|
|
||||||
try:
|
|
||||||
response = self.session.head(isma, timeout=5)
|
|
||||||
if response.status_code != 200:
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Verificar si ya existe (evitar duplicados)
|
|
||||||
if any(audio.url == isma for audio in tracks.audio):
|
|
||||||
self.log.debug(f"Audio track already exists: {language}-{codec_name}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Crear nuevo track de audio
|
|
||||||
audio = copy(base_audio)
|
|
||||||
audio.url = isma
|
|
||||||
audio.id_ = hashlib.md5(isma.encode()).hexdigest()[:16]
|
|
||||||
|
|
||||||
# Mapear idioma
|
|
||||||
mapped_lang = self.LANG_MAP.get(language, language)
|
|
||||||
audio.language = Language.get(mapped_lang)
|
|
||||||
|
|
||||||
# Determinar si es idioma original
|
|
||||||
if tracks.videos:
|
|
||||||
audio.is_original_lang = (
|
|
||||||
audio.language.language == tracks.videos[0].language.language
|
|
||||||
)
|
|
||||||
|
|
||||||
# Obtener información del track con MediaInfo
|
|
||||||
try:
|
|
||||||
with open(temp_file, "wb") as bytetest:
|
|
||||||
data = self.session.get(
|
|
||||||
url=isma,
|
|
||||||
headers={"Range": "bytes=0-50000"},
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
bytetest.write(data.content)
|
|
||||||
|
|
||||||
info = MediaInfo.parse(temp_file)
|
|
||||||
|
|
||||||
if not info.audio_tracks:
|
|
||||||
self.log.debug(f"No audio info found for {language}-{codec_name}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
audio_info = info.audio_tracks[0]
|
|
||||||
audio.bitrate = audio_info.bit_rate
|
|
||||||
|
|
||||||
# Detectar canales basado en codec
|
|
||||||
if codec_name in ["ec-3", "ac-3", "dts"]:
|
|
||||||
audio.channels = audio_info.channel_s or "5.1"
|
|
||||||
else: # mp4a (AAC)
|
|
||||||
audio.channels = audio_info.channel_s or "2.0"
|
|
||||||
|
|
||||||
# Actualizar codec
|
|
||||||
# Para Unshackle, necesitas mantener el formato correcto
|
|
||||||
audio.codec = Audio.Codec.from_codecs(codec_name)
|
|
||||||
|
|
||||||
# Agregar el track
|
|
||||||
tracks.audio.append(audio)
|
|
||||||
tracks_found += 1
|
|
||||||
|
|
||||||
self.log.info(
|
|
||||||
f" + Added audio track: {audio.language.display_name()} "
|
|
||||||
f"[{codec_name.upper()}] - {audio.channels}ch @ {audio.bitrate} bps"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log.debug(f"Failed to process audio {language}-{codec_name}: {e}")
|
|
||||||
finally:
|
|
||||||
if os.path.exists(temp_file):
|
|
||||||
os.remove(temp_file)
|
|
||||||
|
|
||||||
if tracks_found > 0:
|
|
||||||
self.log.info(f"Total additional audio tracks found: {tracks_found}")
|
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, **kwargs):
|
|
||||||
return self.config["certificate"]
|
|
||||||
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
|
|
||||||
res = self.session.post(
|
|
||||||
url=self.license_url,
|
|
||||||
data=challenge,
|
|
||||||
)
|
|
||||||
|
|
||||||
if "errors" in res.text:
|
|
||||||
res = res.json()
|
|
||||||
if res["errors"][0]["message"] == "HttpException: Forbidden":
|
|
||||||
self.log.error(
|
|
||||||
" x This CDM is not eligible to decrypt this\n"
|
|
||||||
" content or has been blacklisted by RakutenTV"
|
|
||||||
)
|
|
||||||
elif res["errors"][0]["message"] == "HttpException: An error happened":
|
|
||||||
self.log.error(
|
|
||||||
" x This CDM seems to be revoked and\n"
|
|
||||||
" therefore it can't decrypt this content",
|
|
||||||
)
|
|
||||||
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
return res.content
|
|
||||||
|
|
||||||
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
|
|
||||||
res = self.session.post(
|
|
||||||
url=self.license_url,
|
|
||||||
data=challenge,
|
|
||||||
)
|
|
||||||
|
|
||||||
if "errors" in res.text:
|
|
||||||
res = res.json()
|
|
||||||
if res["errors"][0]["message"] == "HttpException: Forbidden":
|
|
||||||
self.log.error(
|
|
||||||
" x This CDM is not eligible to decrypt this\n"
|
|
||||||
" content or has been blacklisted by RakutenTV"
|
|
||||||
)
|
|
||||||
elif res["errors"][0]["message"] == "HttpException: An error happened":
|
|
||||||
self.log.error(
|
|
||||||
" x This CDM seems to be revoked and\n"
|
|
||||||
" therefore it can't decrypt this content",
|
|
||||||
)
|
|
||||||
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
return res.content
|
|
||||||
|
|
||||||
|
|
||||||
def pair_device(self):
|
|
||||||
# TODO: Make this return the tokens, move print out of the func
|
|
||||||
# log.info_("Logging into RakutenTV as an Android device")
|
|
||||||
if not self.credential:
|
|
||||||
self.log.error(" - No credentials provided, unable to log in.")
|
|
||||||
sys.exit(1)
|
|
||||||
try:
|
|
||||||
res = self.session.post(
|
|
||||||
url=self.config["endpoints"]["auth"],
|
|
||||||
params={
|
|
||||||
"device_identifier": self.config["clients"][self.device][
|
|
||||||
"device_identifier"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
data={
|
|
||||||
"app_version": self.config["clients"][self.device]["app_version"],
|
|
||||||
"device_metadata[uid]": self.config["clients"][self.device][
|
|
||||||
"device_serial"
|
|
||||||
],
|
|
||||||
"device_metadata[os]": self.config["clients"][self.device][
|
|
||||||
"device_os"
|
|
||||||
],
|
|
||||||
"device_metadata[model]": self.config["clients"][self.device][
|
|
||||||
"device_model"
|
|
||||||
],
|
|
||||||
"device_metadata[year]": self.config["clients"][self.device][
|
|
||||||
"device_year"
|
|
||||||
],
|
|
||||||
"device_serial": self.config["clients"][self.device][
|
|
||||||
"device_serial"
|
|
||||||
],
|
|
||||||
"device_metadata[trusted_uid]": False,
|
|
||||||
"device_metadata[brand]": self.config["clients"][self.device][
|
|
||||||
"device_brand"
|
|
||||||
],
|
|
||||||
"classification_id": 69,
|
|
||||||
"user[password]": self.credential.password,
|
|
||||||
"device_metadata[app_version]": self.config["clients"][self.device][
|
|
||||||
"app_version"
|
|
||||||
],
|
|
||||||
"user[username]": self.credential.username,
|
|
||||||
"device_metadata[serial_number]": self.config["clients"][
|
|
||||||
self.device
|
|
||||||
]["device_serial"],
|
|
||||||
},
|
|
||||||
).json()
|
|
||||||
except HTTPError as e:
|
|
||||||
if e.response.status_code == 403:
|
|
||||||
self.log.error(
|
|
||||||
" - Rakuten returned a 403 (FORBIDDEN) error. "
|
|
||||||
"This could be caused by your IP being detected as a proxy, or regional issues. Cannot continue."
|
|
||||||
)
|
|
||||||
if "errors" in res:
|
|
||||||
error = res["errors"][0]
|
|
||||||
if "exception.forbidden_vpn" in error["code"]:
|
|
||||||
self.log.error(" x RakutenTV is detecting this VPN or Proxy")
|
|
||||||
else:
|
|
||||||
self.log.error(f" - Login failed: {error['message']} [{error['code']}]")
|
|
||||||
self.access_token = res["data"]["user"]["access_token"]
|
|
||||||
self.ifa_subscriber_id = res["data"]["user"]["avod_profile"][
|
|
||||||
"ifa_subscriber_id"
|
|
||||||
]
|
|
||||||
self.session_uuid = res["data"]["user"]["session_uuid"]
|
|
||||||
self.classification_id = res["data"]["user"]["profile"]["classification"]["id"]
|
|
||||||
self.locale = res["data"]["market"]["locale"]
|
|
||||||
self.market_code = res["data"]["market"]["code"]
|
|
||||||
|
|
||||||
def get_info(self, title):
|
|
||||||
self.kind = title["labels"]["purchase_types"][0]["kind"]
|
|
||||||
|
|
||||||
# self.available_resolutions = [x for x in title["labels"]["video_qualities"]]
|
|
||||||
# if any(x["abbr"] == "UHD" for x in title["labels"]["video_qualities"]):
|
|
||||||
# self.resolution = "UHD"
|
|
||||||
# elif any(x["abbr"] == "FHD" for x in title["labels"]["video_qualities"]):
|
|
||||||
# self.resolution = "FHD"
|
|
||||||
# elif any(x["abbr"] == "HD" for x in title["labels"]["video_qualities"]):
|
|
||||||
# self.resolution = "HD"
|
|
||||||
# else:
|
|
||||||
# self.resolution = "SD"
|
|
||||||
|
|
||||||
self.available_hdr_types = [x for x in title["labels"]["hdr_types"]]
|
|
||||||
if any(x["abbr"] == "HDR10_PLUS" for x in self.available_hdr_types) and any(
|
|
||||||
x["abbr"] == "HDR10_PLUS"
|
|
||||||
for x in title["view_options"]["support"]["hdr_types"]
|
|
||||||
):
|
|
||||||
self.hdr_type = "HDR10_PLUS"
|
|
||||||
elif any(x["abbr"] == "DOLBY_VISION" for x in self.available_hdr_types) and any(
|
|
||||||
x["abbr"] == "DOLBY_VISION"
|
|
||||||
for x in title["view_options"]["support"]["hdr_types"]
|
|
||||||
):
|
|
||||||
self.hdr_type = "DOLBY_VISION"
|
|
||||||
elif any(x["abbr"] == "HDR10" for x in self.available_hdr_types) and any(
|
|
||||||
x["abbr"] == "HDR10" for x in title["view_options"]["support"]["hdr_types"]
|
|
||||||
):
|
|
||||||
self.hdr_type = "HDR10"
|
|
||||||
|
|
||||||
else:
|
|
||||||
self.hdr_type = "NONE"
|
|
||||||
|
|
||||||
# Obtener view_options desde title o episodes
|
|
||||||
view_options = title.get("episodes", [{}])[0].get("view_options") or title.get("view_options")
|
|
||||||
|
|
||||||
# FIJO: Obtener TODOS los idiomas de audio disponibles
|
|
||||||
if len(view_options["private"]["offline_streams"]) == 1:
|
|
||||||
# Caso 1: Un solo stream con múltiples idiomas
|
|
||||||
self.audio_languages = [
|
|
||||||
x["abbr"]
|
|
||||||
for x in view_options["private"]["streams"][0]["audio_languages"]
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
# Caso 2: Múltiples streams, obtener todos los idiomas únicos
|
|
||||||
all_audio_languages = []
|
|
||||||
for stream in view_options["private"]["streams"]:
|
|
||||||
for audio_lang in stream["audio_languages"]:
|
|
||||||
if audio_lang["abbr"] not in all_audio_languages:
|
|
||||||
all_audio_languages.append(audio_lang["abbr"])
|
|
||||||
self.audio_languages = all_audio_languages
|
|
||||||
|
|
||||||
# # TODO: Look up only for languages chosen by the user
|
|
||||||
# print(f"\nAvailable audio languages: {', '.join(self.audio_languages)}")
|
|
||||||
# selected = input("Type your desired languages, maximum of 3, UPPER CASE (ex: ENG,SPA,FRA): ")
|
|
||||||
|
|
||||||
selected_langs = [lang.strip() for lang in self.desired_audio_language.split(",") if lang.strip() in self.audio_languages]
|
|
||||||
if not selected_langs:
|
|
||||||
self.log.error("No selected language. Exiting.")
|
|
||||||
self.audio_languages = selected_langs
|
|
||||||
|
|
||||||
# Log para debug
|
|
||||||
self.log.info(f"Selected audio languages: {self.audio_languages}")
|
|
||||||
|
|
||||||
return title
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
certificate: |
|
|
||||||
CAUSxwUKwQIIAxIQFwW5F8wSBIaLBjM6L3cqjBiCtIKSBSKOAjCCAQoCggEBAJntWzsyfateJO/DtiqVtZhSCtW8yzdQPgZFuBTYdrjfQFEEQa2M462xG
|
|
||||||
7iMTnJaXkqeB5UpHVhYQCOn4a8OOKkSeTkwCGELbxWMh4x+Ib/7/up34QGeHleB6KRfRiY9FOYOgFioYHrc4E+shFexN6jWfM3rM3BdmDoh+07svUoQyk
|
|
||||||
dJDKR+ql1DghjduvHK3jOS8T1v+2RC/THhv0CwxgTRxLpMlSCkv5fuvWCSmvzu9Vu69WTi0Ods18Vcc6CCuZYSC4NZ7c4kcHCCaA1vZ8bYLErF8xNEkKd
|
|
||||||
O7DevSy8BDFnoKEPiWC8La59dsPxebt9k+9MItHEbzxJQAZyfWgkCAwEAAToUbGljZW5zZS53aWRldmluZS5jb20SgAOuNHMUtag1KX8nE4j7e7jLUnfS
|
|
||||||
SYI83dHaMLkzOVEes8y96gS5RLknwSE0bv296snUE5F+bsF2oQQ4RgpQO8GVK5uk5M4PxL/CCpgIqq9L/NGcHc/N9XTMrCjRtBBBbPneiAQwHL2zNMr80
|
|
||||||
NQJeEI6ZC5UYT3wr8+WykqSSdhV5Cs6cD7xdn9qm9Nta/gr52u/DLpP3lnSq8x2/rZCR7hcQx+8pSJmthn8NpeVQ/ypy727+voOGlXnVaPHvOZV+WRvWC
|
|
||||||
q5z3CqCLl5+Gf2Ogsrf9s2LFvE7NVV2FvKqcWTw4PIV9Sdqrd+QLeFHd/SSZiAjjWyWOddeOrAyhb3BHMEwg2T7eTo/xxvF+YkPj89qPwXCYcOxF+6gjo
|
|
||||||
mPwzvofcJOxkJkoMmMzcFBDopvab5tDQsyN9UPLGhGC98X/8z8QSQ+spbJTYLdgFenFoGq47gLwDS6NWYYQSqzE3Udf2W7pzk4ybyG4PHBYV3s4cyzdq8amvtE/sNSdOKReuHpfQ=
|
|
||||||
|
|
||||||
gdpr_consent: |
|
|
||||||
CPGeIEAPV65UAADABBNLCGCsAP_AAH_AAAAAHrsXZCpcBSlgYCpoAIoAKIAUEAAAgyAAABAAAoABCAAAIAQAgAAgIAAAAAAAAAAAIAJAAQAAAAEAAAAAAA
|
|
||||||
AAAAAIIACAAAAAIABAAAAAAAAACAAAAAAAAAAAAAAEAAAAgABAABAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAgZ8xdkKlwFKWBgKGgAigAogBQQAACDIAAA
|
|
||||||
EAACAAAIAAAgBACAACAAAAAAAAAAAAAgAgABAAAAAQAAAAAAAAAAAAggAAAAAAAgAEAAAAAAAAAAAAAAAAAAAAAAAAQAAACAAEAAEAAAAAAQAA.YAAAAAAAA8DA
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
title: https://gizmo.rakuten.tv/v3/movies/{title_id}?
|
|
||||||
show: https://gizmo.rakuten.tv/v3/seasons/{title_id}?
|
|
||||||
manifest: https://gizmo.rakuten.tv/v3/{kind}/streamings?
|
|
||||||
auth: https://gizmo.rakuten.tv/v3/me/login_or_wuaki_link
|
|
||||||
clients:
|
|
||||||
web:
|
|
||||||
app_version: v3.0.11
|
|
||||||
device_identifier: web
|
|
||||||
device_serial: 6cc3584a-c182-4cc1-9f8d-b90e4ed76de9
|
|
||||||
player: web:DASH-CENC:WVM
|
|
||||||
device_os: Windows 10
|
|
||||||
device_model: GENERIC
|
|
||||||
device_year: 2019
|
|
||||||
device_brand: chrome
|
|
||||||
device_sdk: 100.0.4896
|
|
||||||
android:
|
|
||||||
app_version: 3.22.0
|
|
||||||
device_identifier: android
|
|
||||||
device_serial: 3187ad6c-4d1c-4cbb-9c59-8396d054eb2a
|
|
||||||
player: android:DASH-CENC
|
|
||||||
device_os: Android
|
|
||||||
device_model: SM-A105FN
|
|
||||||
device_year: 2021
|
|
||||||
device_brand: Samsung
|
|
||||||
device_sdk: ""
|
|
||||||
atvui40:
|
|
||||||
app_version: v2.77.0
|
|
||||||
device_identifier: atvui40
|
|
||||||
device_serial: 0424814603535001d1b1
|
|
||||||
player: atvui40:DASH-CENC:WVM
|
|
||||||
device_os: Android TV UI 40
|
|
||||||
device_model: SHIELD Android TV
|
|
||||||
device_year: 1970
|
|
||||||
device_brand: NVIDIA
|
|
||||||
device_sdk: ""
|
|
||||||
lgui40:
|
|
||||||
app_version: v2.77.0
|
|
||||||
device_identifier: lgui40
|
|
||||||
device_serial: 203WRMD8U920
|
|
||||||
player: lgui40:DASH-CENC:PR
|
|
||||||
device_os: LG UI 40
|
|
||||||
device_model: OLED65C11LB
|
|
||||||
device_year: 2021
|
|
||||||
device_brand: LG
|
|
||||||
device_sdk: ""
|
|
||||||
smui40:
|
|
||||||
app_version: v2.77.0
|
|
||||||
device_identifier: smui40
|
|
||||||
device_serial: 6cc3584a-c182-4cc1-9f8d-b90e4ed76de9
|
|
||||||
player: smtvui40:DASH-CENC:WVM
|
|
||||||
device_os: Samsung UI 40
|
|
||||||
device_model: QE43Q60RATXXH
|
|
||||||
device_year: 2019
|
|
||||||
device_brand: Samsung
|
|
||||||
device_sdk: ""
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from collections.abc import Generator
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from http.cookiejar import CookieJar
|
|
||||||
from typing import Any, Optional
|
|
||||||
from urllib.parse import unquote, urlparse
|
|
||||||
|
|
||||||
import click
|
|
||||||
import requests
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.manifests import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
|
||||||
from envied.core.tracks import Chapter, Tracks
|
|
||||||
|
|
||||||
|
|
||||||
class ROKU(Service):
|
|
||||||
"""
|
|
||||||
Service code for The Roku Channel (https://therokuchannel.roku.com)
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.2
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: Cookies
|
|
||||||
Robustness:
|
|
||||||
Widevine:
|
|
||||||
L3: 1080p, DD5.1
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Use complete title/episode URL or id as input:
|
|
||||||
https://therokuchannel.roku.com/details/e05fc677ab9c5d5e8332f123770697b9/paddington
|
|
||||||
OR
|
|
||||||
e05fc677ab9c5d5e8332f123770697b9
|
|
||||||
- Supports movies, series, and single episodes
|
|
||||||
- Search is geofenced
|
|
||||||
"""
|
|
||||||
|
|
||||||
GEOFENCE = ("us",)
|
|
||||||
TITLE_RE = r"^(?:https?://(?:www.)?therokuchannel.roku.com/(?:details|watch)/)?(?P<id>[a-z0-9-]+)"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="ROKU", short_help="https://therokuchannel.roku.com", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, **kwargs):
|
|
||||||
return ROKU(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx, title):
|
|
||||||
self.title = re.match(self.TITLE_RE, title).group("id")
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
self.license: str
|
|
||||||
|
|
||||||
def authenticate(
|
|
||||||
self,
|
|
||||||
cookies: Optional[CookieJar] = None,
|
|
||||||
credential: Optional[Credential] = None,
|
|
||||||
) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
if cookies is not None:
|
|
||||||
self.session.cookies.update(cookies)
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
token = self.session.get(self.config["endpoints"]["token"]).json()["csrf"]
|
|
||||||
|
|
||||||
headers = {"csrf-token": token}
|
|
||||||
payload = {"query": self.title}
|
|
||||||
|
|
||||||
r = self.session.post(self.config["endpoints"]["search"], headers=headers, json=payload)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
results = r.json()
|
|
||||||
for result in results["view"]:
|
|
||||||
if result["content"]["type"] not in ["zone", "provider"]:
|
|
||||||
_id = result["content"].get("meta", {}).get("id")
|
|
||||||
_desc = result["content"].get("descriptions", {})
|
|
||||||
|
|
||||||
label = f'{result["content"].get("type")} ({result["content"].get("releaseYear")})'
|
|
||||||
if result["content"].get("viewOptions"):
|
|
||||||
label += f' ({result["content"]["viewOptions"][0].get("priceDisplay")})'
|
|
||||||
|
|
||||||
title = re.sub(r"^-|-$", "", re.sub(r"\W+", "-", result["content"].get("title").lower()))
|
|
||||||
|
|
||||||
yield SearchResult(
|
|
||||||
id_=_id,
|
|
||||||
title=title,
|
|
||||||
description=_desc["250"]["text"] if _desc.get("250") else None,
|
|
||||||
label=label,
|
|
||||||
url=f"https://therokuchannel.roku.com/details/{_id}/{title}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_titles(self) -> Titles_T:
|
|
||||||
data = self.session.get(self.config["endpoints"]["content"] + self.title).json()
|
|
||||||
if not data["isAvailable"]:
|
|
||||||
self.log.error("This title is temporarily unavailable or expired")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if data["type"] in ["movie", "tvspecial"]:
|
|
||||||
return Movies(
|
|
||||||
[
|
|
||||||
Movie(
|
|
||||||
id_=data["meta"]["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
name=data["title"],
|
|
||||||
year=data["releaseYear"],
|
|
||||||
language=data["viewOptions"][0]["media"].get("originalAudioLanguage", "en"),
|
|
||||||
data=data,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
elif data["type"] == "series":
|
|
||||||
episodes = self.fetch_episodes(data)
|
|
||||||
return Series(
|
|
||||||
[
|
|
||||||
Episode(
|
|
||||||
id_=episode["meta"]["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=data["title"],
|
|
||||||
season=int(episode["seasonNumber"]),
|
|
||||||
number=int(episode["episodeNumber"]),
|
|
||||||
name=episode["title"],
|
|
||||||
year=data["releaseYear"],
|
|
||||||
language=episode["viewOptions"][0]["media"].get("originalAudioLanguage", "en"),
|
|
||||||
data=data,
|
|
||||||
)
|
|
||||||
for episode in episodes
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
elif data["type"] == "episode":
|
|
||||||
return Series(
|
|
||||||
[
|
|
||||||
Episode(
|
|
||||||
id_=data["meta"]["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=data["title"],
|
|
||||||
season=int(data["seasonNumber"]),
|
|
||||||
number=int(data["episodeNumber"]),
|
|
||||||
name=data["title"],
|
|
||||||
year=data["releaseYear"],
|
|
||||||
language=data["viewOptions"][0]["media"].get("originalAudioLanguage", "en"),
|
|
||||||
data=data,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
|
||||||
token = self.session.get(self.config["endpoints"]["token"]).json()["csrf"]
|
|
||||||
|
|
||||||
options = title.data["viewOptions"]
|
|
||||||
subscription = options[0].get("license", "").lower()
|
|
||||||
authenticated = next((x for x in options if x.get("isAuthenticated")), None)
|
|
||||||
|
|
||||||
if subscription == "subscription" and not authenticated:
|
|
||||||
self.log.error("This title is only available to subscribers")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
play_id = authenticated.get("playId") if authenticated else options[0].get("playId")
|
|
||||||
provider_id = authenticated.get("providerId") if authenticated else options[0].get("providerId")
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"csrf-token": token,
|
|
||||||
}
|
|
||||||
payload = {
|
|
||||||
"rokuId": title.id,
|
|
||||||
"playId": play_id,
|
|
||||||
"mediaFormat": "mpeg-dash",
|
|
||||||
"drmType": "widevine",
|
|
||||||
"quality": "fhd",
|
|
||||||
"providerId": provider_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
r = self.session.post(
|
|
||||||
self.config["endpoints"]["vod"],
|
|
||||||
headers=headers,
|
|
||||||
json=payload,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
videos = r.json()["playbackMedia"]["videos"]
|
|
||||||
self.license = next(
|
|
||||||
(
|
|
||||||
x["drmParams"]["licenseServerURL"]
|
|
||||||
for x in videos
|
|
||||||
if x.get("drmParams") and x["drmParams"]["keySystem"] == "Widevine"
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
url = next((x["url"] for x in videos if x["streamFormat"] == "dash"), None)
|
|
||||||
if url and "origin" in urlparse(url).query:
|
|
||||||
url = unquote(urlparse(url).query.split("=")[1]).split("?")[0]
|
|
||||||
|
|
||||||
tracks = DASH.from_url(url=url).to_tracks(language=title.language)
|
|
||||||
tracks.videos[0].data["playbackMedia"] = r.json()["playbackMedia"]
|
|
||||||
|
|
||||||
for track in tracks.audio:
|
|
||||||
label = track.data["dash"]["adaptation_set"].find("Label")
|
|
||||||
if label is not None and "description" in label.text:
|
|
||||||
track.descriptive = True
|
|
||||||
|
|
||||||
for track in tracks.subtitles:
|
|
||||||
label = track.data["dash"]["adaptation_set"].find("Label")
|
|
||||||
if label is not None and "caption" in label.text:
|
|
||||||
track.cc = True
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Title_T) -> list[Chapter]:
|
|
||||||
track = title.tracks.videos[0]
|
|
||||||
|
|
||||||
chapters = []
|
|
||||||
if track.data.get("playbackMedia", {}).get("adBreaks"):
|
|
||||||
timestamps = sorted(track.data["playbackMedia"]["adBreaks"])
|
|
||||||
chapters = [Chapter(name=f"Chapter {i + 1:02}", timestamp=ad.split(".")[0]) for i, ad in enumerate(timestamps)]
|
|
||||||
|
|
||||||
if track.data.get("playbackMedia", {}).get("creditCuePoints"):
|
|
||||||
start = next((
|
|
||||||
x.get("start") for x in track.data["playbackMedia"]["creditCuePoints"] if x.get("start") != 0), None)
|
|
||||||
if start:
|
|
||||||
chapters.append(
|
|
||||||
Chapter(
|
|
||||||
name="Credits",
|
|
||||||
timestamp=datetime.fromtimestamp((start / 1000), tz=timezone.utc).strftime("%H:%M:%S.%f")[:-3],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return chapters
|
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
|
||||||
return # WidevineCdm.common_privacy_cert
|
|
||||||
|
|
||||||
def get_widevine_license(self, challenge: bytes, **_: Any) -> bytes:
|
|
||||||
r = self.session.post(url=self.license, data=challenge)
|
|
||||||
if r.status_code != 200:
|
|
||||||
self.log.error(r.text)
|
|
||||||
sys.exit(1)
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
# service specific functions
|
|
||||||
|
|
||||||
def fetch_episode(self, episode: dict) -> json:
|
|
||||||
try:
|
|
||||||
r = self.session.get(self.config["endpoints"]["content"] + episode["meta"]["id"])
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
self.log.error(f"An error occurred while fetching episode {episode['meta']['id']}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def fetch_episodes(self, data: dict) -> list:
|
|
||||||
"""TODO: Switch to async once https proxies are fully supported"""
|
|
||||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
||||||
tasks = list(executor.map(self.fetch_episode, data["episodes"]))
|
|
||||||
return [task for task in tasks if task is not None]
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
endpoints:
|
|
||||||
content: https://therokuchannel.roku.com/api/v2/homescreen/content/https%3A%2F%2Fcontent.sr.roku.com%2Fcontent%2Fv1%2Froku-trc%2F
|
|
||||||
vod: https://therokuchannel.roku.com/api/v3/playback
|
|
||||||
token: https://therokuchannel.roku.com/api/v1/csrf
|
|
||||||
search: https://therokuchannel.roku.com/api/v1/search
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from collections.abc import Generator
|
|
||||||
from typing import Any, Optional, Union
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
import click
|
|
||||||
from requests import Request
|
|
||||||
from envied.core.constants import AnyTrack
|
|
||||||
from envied.core.manifests import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
|
||||||
from envied.core.tracks import Chapter, Chapters, Tracks
|
|
||||||
from envied.core.utils.xml import load_xml
|
|
||||||
|
|
||||||
|
|
||||||
class RTE(Service):
|
|
||||||
"""
|
|
||||||
\b
|
|
||||||
Service code for RTE Player streaming service (https://www.rte.ie/player/).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.1
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: None
|
|
||||||
Robustness:
|
|
||||||
Widevine:
|
|
||||||
L3: 1080p, AAC2.0
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Input (pay attention to the URL format):
|
|
||||||
SERIES: https://www.rte.ie/player/series/crossfire/10003928-00-0000
|
|
||||||
EPISODE: https://www.rte.ie/player/series/crossfire/10003928-00-0000?epguid=AQ10003929-01-0001
|
|
||||||
MOVIE: https://www.rte.ie/player/movie/glass/360230440380
|
|
||||||
|
|
||||||
\b
|
|
||||||
Notes:
|
|
||||||
- Since some content is accessible worldwide, geofence is deactivated.
|
|
||||||
- Using an IE IP-address is recommended to access everything.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
# GEOFENCE = ("ie",)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="RTE", short_help="https://www.rte.ie/player/", help=__doc__)
|
|
||||||
@click.argument("title", type=str, required=False)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, **kwargs) -> RTE:
|
|
||||||
return RTE(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx, title):
|
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
self.base_url = self.config["endpoints"]["base_url"]
|
|
||||||
self.feed = self.config["endpoints"]["feed"]
|
|
||||||
self.license = self.config["endpoints"]["license"]
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
params = {
|
|
||||||
"byProgramType": "Series|Movie",
|
|
||||||
"q": f"title:({self.title})",
|
|
||||||
"range": "0-40",
|
|
||||||
"schema": "2.15",
|
|
||||||
"sort": "rte$rank|desc",
|
|
||||||
"gzip": "true",
|
|
||||||
"omitInvalidFields": "true",
|
|
||||||
}
|
|
||||||
results = self._request(f"{self.feed}/f/1uC-gC/rte-prd-prd-search", params=params)["entries"]
|
|
||||||
|
|
||||||
for result in results:
|
|
||||||
link = "https://www.rte.ie/player/{}/{}/{}"
|
|
||||||
series = result.get("plprogram$programType").lower() == "series"
|
|
||||||
_id = result.get("guid") if series else result.get("id").split("/")[-1]
|
|
||||||
_title = result.get("title") if series else result.get("plprogram$longTitle")
|
|
||||||
_type = result.get("plprogram$programType")
|
|
||||||
|
|
||||||
title = _title.format(_type, _title, _id).lower()
|
|
||||||
title = re.sub(r"\W+", "-", title)
|
|
||||||
title = re.sub(r"^-|-$", "", title)
|
|
||||||
|
|
||||||
yield SearchResult(
|
|
||||||
id_=link.format(_type, title, _id),
|
|
||||||
title=_title,
|
|
||||||
description=result.get("plprogram$shortDescription"),
|
|
||||||
label=_type,
|
|
||||||
url=link.format(_type, title, _id),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_titles(self) -> Titles_T:
|
|
||||||
title_re = (
|
|
||||||
r"https://www\.rte\.ie/player"
|
|
||||||
r"/(?P<type>series|movie)"
|
|
||||||
r"/(?P<slug>[a-zA-Z0-9_-]+)"
|
|
||||||
r"/(?P<id>[a-zA-Z0-9_\-=?]+)/?$"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
kind, _, title_id = (re.match(title_re, self.title).group(i) for i in ("type", "slug", "id"))
|
|
||||||
except Exception:
|
|
||||||
raise ValueError("- Could not parse ID from input")
|
|
||||||
|
|
||||||
episode = title_id.split("=")[1] if "epguid" in title_id else None
|
|
||||||
|
|
||||||
if episode:
|
|
||||||
episode = self._episode(title_id, episode)
|
|
||||||
return Series(episode)
|
|
||||||
|
|
||||||
elif kind == "movie":
|
|
||||||
movie = self._movie(title_id)
|
|
||||||
return Movies(movie)
|
|
||||||
|
|
||||||
elif kind == "series":
|
|
||||||
episodes = self._show(title_id)
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
|
||||||
self.token, self.account = self.get_config()
|
|
||||||
media = title.data["plprogramavailability$media"][0].get("plmedia$publicUrl")
|
|
||||||
if not media:
|
|
||||||
raise ValueError("Could not find any streams - is the title still available?")
|
|
||||||
|
|
||||||
manifest, self.pid = self.get_manifest(media)
|
|
||||||
tracks = DASH.from_url(manifest, self.session).to_tracks(language=title.language)
|
|
||||||
for track in tracks.audio:
|
|
||||||
role = track.data["dash"]["adaptation_set"].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: Episode) -> Chapters:
|
|
||||||
if not title.data.get("rte$chapters"):
|
|
||||||
return Chapters()
|
|
||||||
|
|
||||||
timecodes = [x for x in title.data["rte$chapters"]]
|
|
||||||
chapters = [Chapter(timestamp=float(x)) for x in timecodes]
|
|
||||||
|
|
||||||
if title.data.get("rte$creditStart"):
|
|
||||||
chapters.append(Chapter(name="Credits", timestamp=float(title.data["rte$creditStart"])))
|
|
||||||
|
|
||||||
return chapters
|
|
||||||
|
|
||||||
def certificate(self, **_):
|
|
||||||
return None # will use common privacy cert
|
|
||||||
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
|
||||||
params = {
|
|
||||||
"token": self.token,
|
|
||||||
"account": self.account,
|
|
||||||
"form": "json",
|
|
||||||
"schema": "1.0",
|
|
||||||
}
|
|
||||||
payload = {
|
|
||||||
"getWidevineLicense": {
|
|
||||||
"releasePid": self.pid,
|
|
||||||
"widevineChallenge": base64.b64encode(challenge).decode("utf-8"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r = self.session.post(url=self.license, params=params, json=payload)
|
|
||||||
if not r.ok:
|
|
||||||
raise ConnectionError(f"License request failed: {r.text}")
|
|
||||||
|
|
||||||
return r.json()["getWidevineLicenseResponse"]["license"]
|
|
||||||
|
|
||||||
# Service specific functions
|
|
||||||
|
|
||||||
def _movie(self, title: str) -> Movie:
|
|
||||||
params = {"count": "true", "entries": "true", "byId": title}
|
|
||||||
data = self._request("/mpx/1uC-gC/rte-prd-prd-all-programs", params=params)["entries"]
|
|
||||||
|
|
||||||
return [
|
|
||||||
Movie(
|
|
||||||
id_=movie["guid"],
|
|
||||||
service=self.__class__,
|
|
||||||
name=movie.get("plprogram$longTitle"),
|
|
||||||
year=movie.get("plprogram$year"),
|
|
||||||
language=movie["plprogram$languages"][0] if movie.get("plprogram$languages") else "eng",
|
|
||||||
data=movie,
|
|
||||||
)
|
|
||||||
for movie in data
|
|
||||||
]
|
|
||||||
|
|
||||||
def _show(self, title: str) -> Episode:
|
|
||||||
entry = self._request("/mpx/1uC-gC/rte-prd-prd-all-movies-series?byGuid={}".format(title))["entries"][0]["id"]
|
|
||||||
data = self._request("/mpx/1uC-gC/rte-prd-prd-all-programs?bySeriesId={}".format(entry.split("/")[-1]))["entries"]
|
|
||||||
|
|
||||||
return [
|
|
||||||
Episode(
|
|
||||||
id_=episode.get("guid"),
|
|
||||||
title=episode.get("plprogram$longTitle"),
|
|
||||||
season=episode.get("plprogram$tvSeasonNumber") or 0,
|
|
||||||
number=episode.get("plprogram$tvSeasonEpisodeNumber") or 0,
|
|
||||||
name=episode.get("description"),
|
|
||||||
language=episode["plprogram$languages"][0] if episode.get("plprogram$languages") else "eng",
|
|
||||||
service=self.__class__,
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
for episode in data
|
|
||||||
if episode["plprogram$programType"] == "episode"
|
|
||||||
]
|
|
||||||
|
|
||||||
def _episode(self, title: str, guid: str) -> Episode:
|
|
||||||
title = title.split("?")[0]
|
|
||||||
entry = self._request("/mpx/1uC-gC/rte-prd-prd-all-movies-series?byGuid={}".format(title))["entries"][0]["id"]
|
|
||||||
data = self._request("/mpx/1uC-gC/rte-prd-prd-all-programs?bySeriesId={}".format(entry.split("/")[-1]))["entries"]
|
|
||||||
|
|
||||||
return [
|
|
||||||
Episode(
|
|
||||||
id_=episode.get("guid"),
|
|
||||||
title=episode.get("plprogram$longTitle"),
|
|
||||||
season=episode.get("plprogram$tvSeasonNumber") or 0,
|
|
||||||
number=episode.get("plprogram$tvSeasonEpisodeNumber") or 0,
|
|
||||||
name=episode.get("description"),
|
|
||||||
language=episode["plprogram$languages"][0] if episode.get("plprogram$languages") else "eng",
|
|
||||||
service=self.__class__,
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
for episode in data
|
|
||||||
if episode["plprogram$programType"] == "episode" and episode.get("guid") == guid
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_config(self):
|
|
||||||
token = self._request("/servicelayer/api/anonymouslogin")["mpx_token"]
|
|
||||||
account = self._request("/wordpress/wp-content/uploads/standard/web/config.json")["mpx_config"]["account_id"]
|
|
||||||
return token, account
|
|
||||||
|
|
||||||
def get_manifest(self, media_url: str) -> str:
|
|
||||||
try:
|
|
||||||
res = self._request(
|
|
||||||
media_url,
|
|
||||||
params={
|
|
||||||
"formats": "MPEG-DASH",
|
|
||||||
"auth": self.token,
|
|
||||||
"assetTypes": "default:isl",
|
|
||||||
"tracking": "true",
|
|
||||||
"format": "SMIL",
|
|
||||||
"iu": "/3014/RTE_Player_VOD/Android_Phone/NotRegistered",
|
|
||||||
"policy": "168602703",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
root = load_xml(res)
|
|
||||||
video = root.xpath("//switch/video")
|
|
||||||
manifest = video[0].get("src")
|
|
||||||
|
|
||||||
elem = root.xpath("//switch/ref")
|
|
||||||
value = elem[0].find(".//param[@name='trackingData']").get("value")
|
|
||||||
pid = re.search(r"pid=([^|]+)", value).group(1)
|
|
||||||
|
|
||||||
return manifest, pid
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise ValueError(
|
|
||||||
f"Request for manifest failed: {e}.\n"
|
|
||||||
"Content may be geo-restricted to IE"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _request(self, api: str, params: dict = None, headers: dict = None) -> Any[dict | str]:
|
|
||||||
url = urljoin(self.base_url, api)
|
|
||||||
self.session.headers.update(self.config["headers"])
|
|
||||||
|
|
||||||
if params:
|
|
||||||
self.session.params.update(params)
|
|
||||||
if headers:
|
|
||||||
self.session.headers.update(headers)
|
|
||||||
|
|
||||||
prep = self.session.prepare_request(Request("GET", url))
|
|
||||||
|
|
||||||
response = self.session.send(prep)
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise ConnectionError(
|
|
||||||
f"Status: {response.status_code} - {response.url}\n"
|
|
||||||
"Content may be geo-restricted to IE"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return json.loads(response.content)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return response.text
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
headers:
|
|
||||||
user-agent: Dalvik/2.1.0 (Linux; U; Android 13; SM-A536E Build/RSR1.210722.013.A2)
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
base_url: https://www.rte.ie
|
|
||||||
feed: https://feed.entertainment.tv.theplatform.eu
|
|
||||||
license: https://widevine.entitlement.eu.theplatform.com/wv/web/ModularDrm
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
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 envied.core.credential import Credential
|
|
||||||
from envied.core.manifests.dash import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series
|
|
||||||
from envied.core.tracks import Chapter, Chapters, Tracks
|
|
||||||
|
|
||||||
|
|
||||||
class SEVEN(Service):
|
|
||||||
"""
|
|
||||||
Service code for 7Plus streaming service (https://7plus.com.au/).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.0
|
|
||||||
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")), {})
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
headers:
|
|
||||||
user-agent: "7plus/5.25.1 (Linux;Android 8.1.0) ExoPlayerLib/2.11.7"
|
|
||||||
x-swm-apikey: "kGcrNnuPClrkynfnKwG8IA/NhVG6ut5nPEdWF2jscvE="
|
|
||||||
|
|
||||||
PLATFORM_ID: "androidtv"
|
|
||||||
PLATFORM_VERSION: "5.25.0.0"
|
|
||||||
API_VERSION: "5.9.0.0"
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
import base64
|
|
||||||
import re
|
|
||||||
from http.cookiejar import CookieJar
|
|
||||||
from typing import Optional, Union
|
|
||||||
import click
|
|
||||||
import requests
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.constants import AnyTrack
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.titles import Song, Album, Title_T
|
|
||||||
from envied.core.tracks import Audio, Tracks
|
|
||||||
from envied.core.drm import DRM_T, Widevine
|
|
||||||
from envied.utils import base62
|
|
||||||
from pywidevine.pssh import PSSH
|
|
||||||
|
|
||||||
class SPOT(Service):
|
|
||||||
"""
|
|
||||||
Service code for Spotify
|
|
||||||
Written by ToonsHub
|
|
||||||
|
|
||||||
Reference: https://github.com/glomatico/spotify-aac-downloader
|
|
||||||
|
|
||||||
Authorization: Cookies (Free - 128kbps and Premium - 256kbps)
|
|
||||||
Security: AAC@L3
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Static method, this method belongs to the class
|
|
||||||
@staticmethod
|
|
||||||
|
|
||||||
# The command name, must much the service tag (and by extension the service folder)
|
|
||||||
@click.command(name="SPOT", short_help="https://open.spotify.com", help=__doc__)
|
|
||||||
|
|
||||||
# Using track/playlist/album/artist page URL
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
|
|
||||||
# Pass the context back to the CLI with arguments
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, **kwargs):
|
|
||||||
return SPOT(ctx, **kwargs)
|
|
||||||
|
|
||||||
# Accept the CLI arguments by overriding the constructor (The __init__() method)
|
|
||||||
def __init__(self, ctx, title):
|
|
||||||
|
|
||||||
# Pass the title argument to self so it's accessable across all methods
|
|
||||||
self.title = title
|
|
||||||
self.is_premium = False
|
|
||||||
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
|
|
||||||
# Defining an authinticate function
|
|
||||||
def authenticate(self, cookies: Optional[CookieJar], credential: Optional[Credential] = None):
|
|
||||||
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
|
|
||||||
# Check for cookies
|
|
||||||
if not cookies:
|
|
||||||
raise Exception("Cookies are required for performing this action.")
|
|
||||||
|
|
||||||
# Authenticate using Cookies
|
|
||||||
self.session.headers.update(
|
|
||||||
{
|
|
||||||
'accept': 'application/json',
|
|
||||||
'accept-language': 'en',
|
|
||||||
"app-platform": "WebPlayer",
|
|
||||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.session.cookies.update(cookies)
|
|
||||||
home_page = self.session.get("https://open.spotify.com/").content
|
|
||||||
token = re.search(r'accessToken":"(.*?)"', home_page).group(1)
|
|
||||||
self.is_premium = re.search(r'isPremium":(.*?),', home_page).group(1) == 'true'
|
|
||||||
self.session.headers.update(
|
|
||||||
{
|
|
||||||
"authorization": f"Bearer {token}",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Function to determine the type of collection
|
|
||||||
def getCollectionTypeAndId(self):
|
|
||||||
|
|
||||||
_type = self.title.split("open.spotify.com/")[1].split("/")[0]
|
|
||||||
_id = self.title.split(_type + "/")[1].split("?")[0]
|
|
||||||
return _type, _id
|
|
||||||
|
|
||||||
# Defining a function to return titles
|
|
||||||
def get_titles(self):
|
|
||||||
|
|
||||||
songs = []
|
|
||||||
_type, _id = self.getCollectionTypeAndId()
|
|
||||||
|
|
||||||
if _type == 'album':
|
|
||||||
album = self.session.get(self.config['endpoints']['albums'].format(id=_id)).json()
|
|
||||||
album_next_url = album["tracks"]["next"]
|
|
||||||
while album_next_url is not None:
|
|
||||||
album_next = self.session.get(album_next_url).json()
|
|
||||||
album["tracks"]["items"].extend(album_next["items"])
|
|
||||||
album_next_url = album_next["next"]
|
|
||||||
|
|
||||||
# Get the episode metadata by iterating through each season id
|
|
||||||
for song in album["tracks"]["items"]:
|
|
||||||
|
|
||||||
# Set a class for each song
|
|
||||||
song_class = Song(
|
|
||||||
id_=song["id"],
|
|
||||||
name=song["name"],
|
|
||||||
artist=", ".join([ artist["name"] for artist in song["artists"] ]),
|
|
||||||
album=album["name"],
|
|
||||||
track=song["track_number"],
|
|
||||||
disc=song["disc_number"],
|
|
||||||
year=int(album["release_date"][:4].strip()),
|
|
||||||
service=self.__class__
|
|
||||||
)
|
|
||||||
|
|
||||||
# Append it to the list
|
|
||||||
songs.append(song_class)
|
|
||||||
|
|
||||||
elif _type == "playlist":
|
|
||||||
playlist = self.session.get(
|
|
||||||
self.config['endpoints']['playlists'].format(id=_id)
|
|
||||||
).json()
|
|
||||||
playlist_next_url = playlist["tracks"]["next"]
|
|
||||||
while playlist_next_url is not None:
|
|
||||||
playlist_next = self.session.get(playlist_next_url).json()
|
|
||||||
playlist["tracks"]["items"].extend(playlist_next["items"])
|
|
||||||
playlist_next_url = playlist_next["next"]
|
|
||||||
|
|
||||||
# Get the episode metadata by iterating through each season id
|
|
||||||
for song in playlist["tracks"]["items"]:
|
|
||||||
|
|
||||||
song = song["track"]
|
|
||||||
# Set a class for each song
|
|
||||||
song_class = Song(
|
|
||||||
id_=song["id"],
|
|
||||||
name=song["name"],
|
|
||||||
artist=", ".join([ artist["name"] for artist in song["artists"] ]),
|
|
||||||
album=song["album"]["name"],
|
|
||||||
track=song["track_number"],
|
|
||||||
disc=song["disc_number"],
|
|
||||||
year=int(song["album"]["release_date"][:4].strip()),
|
|
||||||
service=self.__class__
|
|
||||||
)
|
|
||||||
|
|
||||||
# Append it to the list
|
|
||||||
songs.append(song_class)
|
|
||||||
|
|
||||||
elif _type == "artist":
|
|
||||||
playlist = self.session.get(
|
|
||||||
self.config['endpoints']['artists'].format(id=_id)
|
|
||||||
).json()
|
|
||||||
|
|
||||||
# Get the episode metadata by iterating through each season id
|
|
||||||
for song in playlist["tracks"]:
|
|
||||||
|
|
||||||
# Set a class for each song
|
|
||||||
song_class = Song(
|
|
||||||
id_=song["id"],
|
|
||||||
name=song["name"],
|
|
||||||
artist=", ".join([ artist["name"] for artist in song["artists"] ]),
|
|
||||||
album=song["album"]["name"],
|
|
||||||
track=song["track_number"],
|
|
||||||
disc=song["disc_number"],
|
|
||||||
year=int(song["album"]["release_date"][:4].strip()),
|
|
||||||
service=self.__class__
|
|
||||||
)
|
|
||||||
|
|
||||||
# Append it to the list
|
|
||||||
songs.append(song_class)
|
|
||||||
|
|
||||||
elif _type == "track":
|
|
||||||
song = self.session.get(
|
|
||||||
self.config['endpoints']['tracks'].format(id=_id)
|
|
||||||
).json()
|
|
||||||
|
|
||||||
# Set a class for each song
|
|
||||||
song_class = Song(
|
|
||||||
id_=song["id"],
|
|
||||||
name=song["name"],
|
|
||||||
artist=", ".join([ artist["name"] for artist in song["artists"] ]),
|
|
||||||
album=song["album"]["name"],
|
|
||||||
track=song["track_number"],
|
|
||||||
disc=song["disc_number"],
|
|
||||||
year=int(song["album"]["release_date"][:4].strip()),
|
|
||||||
service=self.__class__
|
|
||||||
)
|
|
||||||
|
|
||||||
# Append it to the list
|
|
||||||
songs.append(song_class)
|
|
||||||
|
|
||||||
return Album(songs)
|
|
||||||
|
|
||||||
# Get DRM
|
|
||||||
def get_spotify_drm(self) -> DRM_T:
|
|
||||||
pssh = requests.get(
|
|
||||||
self.config['endpoints']['pssh'].format(file_id=self.file_id)
|
|
||||||
).json()["pssh"]
|
|
||||||
return Widevine(
|
|
||||||
pssh=PSSH(pssh)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Defining a function to get tracks
|
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
|
||||||
|
|
||||||
self.audio_quality = "MP4_256_DUAL" if self.is_premium else "MP4_128_DUAL"
|
|
||||||
|
|
||||||
# Get FileID
|
|
||||||
gid = hex(base62.decode(title.id, base62.CHARSET_INVERTED))[2:].zfill(32)
|
|
||||||
metadata = self.session.get(
|
|
||||||
self.config['endpoints']['metadata'].format(gid=gid)
|
|
||||||
).json()
|
|
||||||
audio_files = metadata.get("file")
|
|
||||||
if audio_files is None:
|
|
||||||
if metadata.get("alternative") is not None:
|
|
||||||
audio_files = metadata["alternative"][0]["file"]
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
self.file_id = next(
|
|
||||||
i["file_id"] for i in audio_files if i["format"] == self.audio_quality
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get stream URL
|
|
||||||
stream_url = self.session.get(
|
|
||||||
self.config['endpoints']['stream'].format(file_id=self.file_id)
|
|
||||||
).json()["cdnurl"][0] # Can change index to get different server
|
|
||||||
|
|
||||||
# Get & Set DRM
|
|
||||||
drm = [self.get_spotify_drm()]
|
|
||||||
|
|
||||||
# Set the tracks
|
|
||||||
tracks = Tracks()
|
|
||||||
tracks.add(Audio(
|
|
||||||
url=stream_url,
|
|
||||||
drm=drm,
|
|
||||||
codec=Audio.Codec.AAC,
|
|
||||||
language=metadata.get("language_of_performance", ["en"])[0],
|
|
||||||
bitrate=256000 if self.is_premium else 128000,
|
|
||||||
channels=2
|
|
||||||
))
|
|
||||||
|
|
||||||
# Return the tracks
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
# Defining a function to get chapters
|
|
||||||
def get_chapters(self, title):
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Defining a function to get widevine license keys
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
|
||||||
|
|
||||||
# Send the post request to the license server
|
|
||||||
license_raw = self.session.post(
|
|
||||||
self.config['endpoints']['license'],
|
|
||||||
data=challenge
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return the license
|
|
||||||
return base64.b64encode(license_raw.content).decode()
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
endpoints:
|
|
||||||
albums: https://api.spotify.com/v1/albums/{id}
|
|
||||||
playlists: https://api.spotify.com/v1/playlists/{id}
|
|
||||||
artists: https://api.spotify.com/v1/artists/{id}/top-tracks
|
|
||||||
tracks: https://api.spotify.com/v1/tracks/{id}
|
|
||||||
pssh: https://seektables.scdn.co/seektable/{file_id}.json
|
|
||||||
metadata: https://spclient.wg.spotify.com/metadata/4/track/{gid}?market=from_token
|
|
||||||
stream: https://gue1-spclient.spotify.com/storage-resolve/v2/files/audio/interactive/11/{file_id}?version=10000000&product=9&platform=39&alt=json
|
|
||||||
license: https://gae2-spclient.spotify.com/widevine-license/v1/audio/license
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
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 lxml import etree
|
|
||||||
from envied.core.manifests.dash import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series
|
|
||||||
from envied.core.tracks import Chapter, Chapters, Tracks
|
|
||||||
|
|
||||||
|
|
||||||
class STV(Service):
|
|
||||||
"""
|
|
||||||
Service code for STV Player streaming service (https://player.stv.tv/).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.1
|
|
||||||
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", "").lstrip("0123456789. ").lstrip(),
|
|
||||||
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", "").lstrip("0123456789. ").lstrip(),
|
|
||||||
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")
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,565 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import concurrent.futures
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from collections.abc import Generator
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from http.cookiejar import MozillaCookieJar
|
|
||||||
from typing import Any, Optional, Union
|
|
||||||
|
|
||||||
import click
|
|
||||||
import m3u8
|
|
||||||
from click import Context
|
|
||||||
from langcodes import Language
|
|
||||||
from requests import Request
|
|
||||||
from envied.core.config import config
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.downloaders import requests
|
|
||||||
from envied.core.manifests import HLS
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series
|
|
||||||
from envied.core.tracks import Chapter, Chapters, Subtitle, Tracks, Video
|
|
||||||
|
|
||||||
|
|
||||||
class TEN(Service):
|
|
||||||
"""
|
|
||||||
\b
|
|
||||||
Service code for 10Play streaming service (https://10.com.au/).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.2
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: credentials
|
|
||||||
Geofence: AU (API and downloads)
|
|
||||||
Robustness:
|
|
||||||
AES: 1080p, AAC2.0
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Input should be complete URL:
|
|
||||||
SHOW: https://10.com.au/australian-survivor
|
|
||||||
EPISODE: https://10.com.au/australian-survivor/episodes/season-11-australia-v-the-world/episode-9/tpv250831fxatm
|
|
||||||
MOVIE: https://10.com.au/a-quiet-place
|
|
||||||
- Non-standard programmes (e.g. game shows/sports) have very inconsistent episode number labels. It's recommended to use episode URLs for those.
|
|
||||||
|
|
||||||
\b
|
|
||||||
Notes:
|
|
||||||
- 10Play 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.
|
|
||||||
- Since 1080p streams require some manipulation of the manifest, n_m3u8dl_re downloader is required.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
GEOFENCE = ("au",)
|
|
||||||
ALIASES = (
|
|
||||||
"10play",
|
|
||||||
"tenplay",
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="TEN", short_help="https://10.com.au/", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> TEN:
|
|
||||||
return TEN(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
if config.downloader != "n_m3u8dl_re":
|
|
||||||
self.log.error(" - Error: n_m3u8dl_re downloader is required for this service.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
query = self.endpoints["searchApiEndpoint"] + self.title
|
|
||||||
|
|
||||||
results = self._request("GET", query)
|
|
||||||
|
|
||||||
for result in results:
|
|
||||||
clean_title = self._sanitize(result.get("title"))
|
|
||||||
yield SearchResult(
|
|
||||||
id_=f"https://10.com.au/{clean_title}",
|
|
||||||
title=result.get("title"),
|
|
||||||
description=result.get("abstractShowDescription"),
|
|
||||||
label=result.get("subtitle", "").split("|")[-1].strip(),
|
|
||||||
url=f"https://10.com.au/{clean_title}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def authenticate(
|
|
||||||
self,
|
|
||||||
cookies: Optional[MozillaCookieJar] = None,
|
|
||||||
credential: Optional[Credential] = None,
|
|
||||||
) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
if not credential:
|
|
||||||
raise EnvironmentError("Service requires Credentials for Authentication.")
|
|
||||||
|
|
||||||
self.session.headers.update(self.config["headers"])
|
|
||||||
self.endpoints = self._request(
|
|
||||||
"GET", self.config["endpoints"]["config"], params={"SystemName": "tvos"}
|
|
||||||
)
|
|
||||||
|
|
||||||
cache = self.cache.get(f"tokens_{credential.sha1}")
|
|
||||||
|
|
||||||
if cache and not cache.expired:
|
|
||||||
self.log.info(" + Using cached Tokens...")
|
|
||||||
tokens = cache.data
|
|
||||||
elif cache and cache.expired:
|
|
||||||
self.log.info(" + Refreshing expired Tokens...")
|
|
||||||
payload = {
|
|
||||||
"alternativeToken": cache.data["alternativeToken"],
|
|
||||||
"refreshToken": cache.data["refreshToken"],
|
|
||||||
}
|
|
||||||
tokens = self._request(
|
|
||||||
"POST", self.endpoints["authConfig"]["refreshToken"], json=payload
|
|
||||||
)
|
|
||||||
cache.set(tokens, expiration=tokens["expiresIn"])
|
|
||||||
else:
|
|
||||||
self.log.info(" + Logging in...")
|
|
||||||
headers = {
|
|
||||||
"accept": "application/json, text/plain, */*",
|
|
||||||
"content-type": "application/json",
|
|
||||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
|
|
||||||
"origin": "https://10.com.au",
|
|
||||||
"referer": "https://10.com.au/",
|
|
||||||
}
|
|
||||||
login = self._request(
|
|
||||||
"POST",
|
|
||||||
self.config["endpoints"]["auth"],
|
|
||||||
headers=headers,
|
|
||||||
json={"email": credential.username, "password": credential.password},
|
|
||||||
)
|
|
||||||
access_token = login.get("jwt", {}).get("accessToken")
|
|
||||||
if not access_token:
|
|
||||||
raise ValueError(
|
|
||||||
"Failed to authenticate with credentials: " + login.text
|
|
||||||
)
|
|
||||||
|
|
||||||
identifier = str(uuid.uuid4())
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"deviceIdentifier": identifier,
|
|
||||||
"machine": "Hisense",
|
|
||||||
"system": "vidaa",
|
|
||||||
"systemVersion": "U6",
|
|
||||||
"platform": "vidaa",
|
|
||||||
"appVersion": "v1",
|
|
||||||
"ipAddress": "string",
|
|
||||||
}
|
|
||||||
device = self._request(
|
|
||||||
"POST", self.endpoints["authConfig"]["generateCode"], json=payload
|
|
||||||
)
|
|
||||||
|
|
||||||
code = device.get("code")
|
|
||||||
expiry = device.get("expiry")
|
|
||||||
if not code or not expiry:
|
|
||||||
raise ValueError("Failed to generate device code: " + device.text)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"accept": "application/json, text/plain, */*",
|
|
||||||
"authorization": f"Bearer {access_token}",
|
|
||||||
"content-type": "application/json",
|
|
||||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
|
|
||||||
"origin": "https://10.com.au",
|
|
||||||
"referer": "https://10.com.au/activate",
|
|
||||||
}
|
|
||||||
activate = self._request(
|
|
||||||
"POST",
|
|
||||||
self.endpoints["activateApiEndpoint"],
|
|
||||||
headers=headers,
|
|
||||||
json={"code": code},
|
|
||||||
)
|
|
||||||
if not activate:
|
|
||||||
raise ValueError("Failed to activate device")
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"code": code,
|
|
||||||
"deviceIdentifier": identifier,
|
|
||||||
"expiry": expiry,
|
|
||||||
}
|
|
||||||
auth = self._request(
|
|
||||||
"POST",
|
|
||||||
self.endpoints["authConfig"]["validateCode"],
|
|
||||||
json={
|
|
||||||
"code": code,
|
|
||||||
"deviceIdentifier": identifier,
|
|
||||||
"expiry": expiry,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
tokens = auth.get("jwt")
|
|
||||||
tokens["identifier"] = identifier
|
|
||||||
|
|
||||||
self.log.info(" + User successfully logged in, TV device activated")
|
|
||||||
|
|
||||||
cache.set(tokens, expiration=tokens.get("expiresIn"))
|
|
||||||
|
|
||||||
self.access_token = tokens.get("alternativeToken")
|
|
||||||
self.session.headers.update({"authorization": f"Bearer {self.access_token}"})
|
|
||||||
|
|
||||||
def get_titles(self) -> Union[Movies, Series]:
|
|
||||||
url_pattern = re.compile(
|
|
||||||
r"^https://10\.com\.au/(?:[a-z0-9-]+)"
|
|
||||||
r"(?:/episodes/(?:season-)?(?P<season>[a-z0-9-]+)/(?:episode-)?(?P<episode>[a-z0-9-]+)/(?P<id>[a-z0-9]+))?$"
|
|
||||||
)
|
|
||||||
|
|
||||||
match = url_pattern.match(self.title)
|
|
||||||
if not match:
|
|
||||||
raise ValueError(f"Could not parse ID from title: {self.title}")
|
|
||||||
|
|
||||||
matches = match.groupdict()
|
|
||||||
|
|
||||||
if not matches.get("id"):
|
|
||||||
show_id = self._get_html(self.title)
|
|
||||||
content = self._shows(show_id)
|
|
||||||
|
|
||||||
if "movie" in content.get("subtitle", "").lower():
|
|
||||||
movies = self._movie(content)
|
|
||||||
return Movies(movies)
|
|
||||||
|
|
||||||
else:
|
|
||||||
episodes = self._series(content)
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
else:
|
|
||||||
episodes = self._episode(matches.get("id"))
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
|
||||||
playback_url = title.data.get("playbackApiEndpoint")
|
|
||||||
if not playback_url:
|
|
||||||
raise ValueError("Could not find playback URL for this title")
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"device": "Tv",
|
|
||||||
"platform": "vidaa",
|
|
||||||
"appVersion": "v1",
|
|
||||||
}
|
|
||||||
|
|
||||||
r = self.session.get(playback_url, params=params)
|
|
||||||
if not r.ok:
|
|
||||||
raise ValueError("Failed to get playback data: " + r.text)
|
|
||||||
|
|
||||||
|
|
||||||
dai_auth = r.headers.get("X-DAI-AUTH")
|
|
||||||
video_id = r.headers.get("x-dai-video-id")
|
|
||||||
if dai_auth is not None:
|
|
||||||
payload = {"auth-token": dai_auth}
|
|
||||||
|
|
||||||
playback_data = r.json()
|
|
||||||
|
|
||||||
video_id = playback_data.get("dai", {}).get("videoId")
|
|
||||||
source_id = playback_data.get("dai", {}).get("contentSourceId", "2690006")
|
|
||||||
if not video_id or not source_id:
|
|
||||||
raise ValueError("Failed to get video ID: " + r.text)
|
|
||||||
|
|
||||||
dai_stream = f"https://dai.google.com/ondemand/v1/hls/content/{source_id}/vid/{video_id}/stream"
|
|
||||||
|
|
||||||
stream_data = self._request("POST", dai_stream, data=payload)
|
|
||||||
|
|
||||||
title.data["chapters"] = stream_data.get("time_events_url")
|
|
||||||
# program_language = Language.find(stream_data["customFields"].get("program_language", "en"))
|
|
||||||
|
|
||||||
manifest_url = stream_data.get("stream_manifest")
|
|
||||||
tracks = HLS.from_url(manifest_url, self.session).to_tracks(language="en")
|
|
||||||
|
|
||||||
tracks = self._add_tracks(tracks)
|
|
||||||
|
|
||||||
for track in tracks:
|
|
||||||
track.OnSegmentFilter = lambda x: re.search(r"redirector.googlevideo.com", x.uri)
|
|
||||||
track.downloader_args = {"--ad-keyword": "redirector.googlevideo.com"}
|
|
||||||
|
|
||||||
if isinstance(track, Subtitle):
|
|
||||||
track.downloader = requests
|
|
||||||
|
|
||||||
# if caption := stream_data.get("subtitles", [])[0].get("webvtt"):
|
|
||||||
# tracks.add(
|
|
||||||
# Subtitle(
|
|
||||||
# id_=hashlib.md5(caption.encode()).hexdigest()[0:6],
|
|
||||||
# url=caption,
|
|
||||||
# codec=Subtitle.Codec.from_mime(caption[-3:]),
|
|
||||||
# language=stream_data.get("subtitles", [])[0].get("language", "en"),
|
|
||||||
# )
|
|
||||||
# )
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
|
|
||||||
if not title.data.get("chapters"):
|
|
||||||
return Chapters()
|
|
||||||
|
|
||||||
events = self._request("GET", title.data["chapters"])
|
|
||||||
cue_points = events.get("cuepoints")
|
|
||||||
if not cue_points:
|
|
||||||
return Chapters()
|
|
||||||
|
|
||||||
chapters = []
|
|
||||||
for cue in cue_points:
|
|
||||||
chapters.append(Chapter(timestamp=float(cue["start_float"])))
|
|
||||||
chapters.append(Chapter(timestamp=float(cue["end_float"])))
|
|
||||||
|
|
||||||
return Chapters(chapters)
|
|
||||||
|
|
||||||
# Service specific
|
|
||||||
|
|
||||||
def _head_request(self, url: str) -> int:
|
|
||||||
try:
|
|
||||||
return self.session.head(url, timeout=10).status_code
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def _check_and_add_track(
|
|
||||||
self, best_track: Video, quality_info: dict, source_bitrate: int
|
|
||||||
) -> Video | None:
|
|
||||||
playlist_uri = best_track.data["hls"]["playlist"].uri
|
|
||||||
playlist_text = self.session.get(playlist_uri).text
|
|
||||||
|
|
||||||
string_to_replace = f"-{source_bitrate}"
|
|
||||||
replacement_string = f"-{quality_info['bitrate']}"
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
for line in playlist_text.splitlines():
|
|
||||||
if "redirector.googlevideo.com" in line:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if string_to_replace in line:
|
|
||||||
line = line.replace(string_to_replace, replacement_string)
|
|
||||||
|
|
||||||
lines.append(line)
|
|
||||||
|
|
||||||
modified_playlist_text = "\n".join(lines)
|
|
||||||
playlist_obj = m3u8.loads(modified_playlist_text)
|
|
||||||
|
|
||||||
if not playlist_obj.segments:
|
|
||||||
return None
|
|
||||||
|
|
||||||
first_segment = playlist_obj.segments[0].uri
|
|
||||||
if self._head_request(first_segment) == 200:
|
|
||||||
playlist_file = config.directories.cache / "TEN" / f"playlist_{quality_info['quality']}.m3u8"
|
|
||||||
playlist_obj.dump(playlist_file)
|
|
||||||
|
|
||||||
video = Video(
|
|
||||||
id_=f"{best_track.id}-{quality_info['quality']}",
|
|
||||||
url=best_track.url,
|
|
||||||
height=quality_info["height"],
|
|
||||||
width=quality_info["width"],
|
|
||||||
bitrate=quality_info["bitrate"],
|
|
||||||
language=best_track.language,
|
|
||||||
codec=best_track.codec,
|
|
||||||
range_=best_track.range,
|
|
||||||
fps=best_track.fps,
|
|
||||||
descriptor=best_track.descriptor,
|
|
||||||
data=best_track.data.copy(),
|
|
||||||
from_file=playlist_file,
|
|
||||||
)
|
|
||||||
return video
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _add_tracks(self, tracks: Tracks) -> Tracks:
|
|
||||||
if not tracks.videos:
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
best_track = max(tracks.videos, key=lambda t: t.height or 0)
|
|
||||||
|
|
||||||
source_bitrate = {
|
|
||||||
1080: "5000000",
|
|
||||||
720: "3000000",
|
|
||||||
540: "1500000",
|
|
||||||
360: "750000",
|
|
||||||
}.get(best_track.height)
|
|
||||||
|
|
||||||
all_qualities = [
|
|
||||||
{"quality": "540p", "bitrate": 1500000, "height": 540, "width": 960},
|
|
||||||
{"quality": "720p", "bitrate": 3000000, "height": 720, "width": 1280},
|
|
||||||
{"quality": "1080p", "bitrate": 5000000, "height": 1080, "width": 1920},
|
|
||||||
]
|
|
||||||
|
|
||||||
qualities_to_check = [
|
|
||||||
q for q in all_qualities if q["height"] > best_track.height
|
|
||||||
]
|
|
||||||
|
|
||||||
if not qualities_to_check:
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=len(qualities_to_check)) as executor:
|
|
||||||
future_to_track = {
|
|
||||||
executor.submit(self._check_and_add_track, best_track, quality, source_bitrate): quality
|
|
||||||
for quality in qualities_to_check
|
|
||||||
}
|
|
||||||
|
|
||||||
for future in concurrent.futures.as_completed(future_to_track):
|
|
||||||
new_track = future.result()
|
|
||||||
if new_track:
|
|
||||||
tracks.add(new_track)
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
|
|
||||||
def _shows(self, show_id: str) -> dict:
|
|
||||||
show = self._request("GET", f'{self.endpoints["showsApiEndpoint"]}/{show_id}')
|
|
||||||
|
|
||||||
return show[0] if isinstance(show, list) else show
|
|
||||||
|
|
||||||
def _fetch_episode(self, url: str) -> list:
|
|
||||||
return self._request("GET", url)
|
|
||||||
|
|
||||||
def _series(self, content: dict) -> Episode:
|
|
||||||
season_list = content.get("seasons")
|
|
||||||
if not season_list:
|
|
||||||
raise ValueError("Could not find a season list for this title")
|
|
||||||
|
|
||||||
seasons = [
|
|
||||||
season.get("menuItems", [])[0].get("apiEndpoint")
|
|
||||||
for season in season_list
|
|
||||||
if season.get("menuItems", [])
|
|
||||||
and season.get("menuItems", [])[0].get("menuTitle", "").lower()
|
|
||||||
== "episodes"
|
|
||||||
]
|
|
||||||
|
|
||||||
if not seasons:
|
|
||||||
raise ValueError("Could not find a season list for this title")
|
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
||||||
results = list(executor.map(self._fetch_episode, seasons))
|
|
||||||
|
|
||||||
titles = []
|
|
||||||
for result in results:
|
|
||||||
for episode in result:
|
|
||||||
ep_number = episode.get("episode")
|
|
||||||
sea_number = episode.get("season")
|
|
||||||
titles.append(
|
|
||||||
Episode(
|
|
||||||
id_=episode.get("id"),
|
|
||||||
service=self.__class__,
|
|
||||||
name=episode.get("vodTitle", "").split(" - ")[-1],
|
|
||||||
season=int(sea_number) if sea_number and sea_number.isdigit() else 0,
|
|
||||||
number=int(ep_number) if ep_number and ep_number.isdigit() else 0,
|
|
||||||
title=episode.get("tvShow"),
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return titles
|
|
||||||
|
|
||||||
def _movie(self, data: dict) -> Movie:
|
|
||||||
endpoint = next(
|
|
||||||
(
|
|
||||||
season.get("menuItems", [])[0].get("apiEndpoint")
|
|
||||||
for season in data.get("seasons", [])
|
|
||||||
if season.get("menuItems", [])
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not endpoint:
|
|
||||||
raise ValueError("Could not find an endpoint for this title")
|
|
||||||
|
|
||||||
movie = self._request("GET", endpoint)[0]
|
|
||||||
|
|
||||||
return [
|
|
||||||
Movie(
|
|
||||||
id_=movie.get("id"),
|
|
||||||
service=self.__class__,
|
|
||||||
name=movie.get("title"),
|
|
||||||
year=movie.get("season"),
|
|
||||||
data=movie,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def _episode(self, video_id: str) -> Episode:
|
|
||||||
data = self._request("GET", f"{self.endpoints['videosApiEndpoint']}/{video_id}")
|
|
||||||
|
|
||||||
ep_number = data.get("episode")
|
|
||||||
sea_number = data.get("season")
|
|
||||||
return [
|
|
||||||
Episode(
|
|
||||||
id_=data.get("id"),
|
|
||||||
service=self.__class__,
|
|
||||||
name=data.get("vodTitle", "").split(" - ")[-1],
|
|
||||||
season=int(sea_number) if sea_number and sea_number.isdigit() else 0,
|
|
||||||
number=int(ep_number) if ep_number and ep_number.isdigit() else 0,
|
|
||||||
title=data.get("tvShow"),
|
|
||||||
data=data,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def _get_html(self, url: str) -> Optional[str]:
|
|
||||||
page = self.session.get(url).text
|
|
||||||
pattern = re.compile(r"const showPageData = ({.*?});", re.DOTALL)
|
|
||||||
|
|
||||||
match = pattern.search(page)
|
|
||||||
if not match:
|
|
||||||
raise ValueError(
|
|
||||||
" - Failed to parse HTML. Page Data not found in the source code."
|
|
||||||
)
|
|
||||||
|
|
||||||
page_data = match.group(1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(page_data)
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
raise json.JSONDecodeError(f"Failed to parse JSON: {e}")
|
|
||||||
|
|
||||||
show_id = data.get("video", {}).get("showUrlCode")
|
|
||||||
if not show_id:
|
|
||||||
raise ValueError(" - showUrlCode not found in the source code.")
|
|
||||||
|
|
||||||
return show_id
|
|
||||||
|
|
||||||
def _signature_header(self, url: str) -> str:
|
|
||||||
timestamp = int(time.time())
|
|
||||||
message = f"{timestamp}:{url}".encode("utf-8")
|
|
||||||
api_key = bytes.fromhex(self.config["api_key"])
|
|
||||||
signature = hmac.new(api_key, message, hashlib.sha256).hexdigest()
|
|
||||||
return f"{timestamp}_{signature}"
|
|
||||||
|
|
||||||
def _auth_header(self) -> str:
|
|
||||||
now_utc = datetime.now(timezone.utc)
|
|
||||||
timestamp_str = now_utc.strftime("%Y%m%d%H%M%S")
|
|
||||||
encoded_bytes = base64.b64encode(timestamp_str.encode("utf-8"))
|
|
||||||
return encoded_bytes.decode("ascii")
|
|
||||||
|
|
||||||
def _request(self, method: str, url: str, **kwargs: Any) -> Any[dict | str]:
|
|
||||||
if method == "GET":
|
|
||||||
self.session.headers.update(
|
|
||||||
{
|
|
||||||
"X-N10-SIG": self._signature_header(url),
|
|
||||||
"tp-acceptfeature": "v1/fw;v1/drm;v2/live",
|
|
||||||
"tp-platform": "UAP",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
elif method == "POST":
|
|
||||||
self.session.headers.update({"X-Network-Ten-Auth": self._auth_header()})
|
|
||||||
|
|
||||||
prep = self.session.prepare_request(Request(method, url, **kwargs))
|
|
||||||
|
|
||||||
response = self.session.send(prep)
|
|
||||||
if response.status_code not in (200, 201):
|
|
||||||
raise ConnectionError(f"{response.text}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
return json.loads(response.content)
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return True if "true" in response.text else False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _sanitize(title: str) -> str:
|
|
||||||
title = title.lower()
|
|
||||||
title = title.replace("&", "and")
|
|
||||||
title = re.sub(r"[:;/()]", "", title)
|
|
||||||
title = re.sub(r"[ ]", "-", title)
|
|
||||||
title = re.sub(r"[\\*!?¿,'\"<>|$#`’]", "", title)
|
|
||||||
title = re.sub(rf"[{'.'}]{{2,}}", ".", title)
|
|
||||||
title = re.sub(rf"[{'_'}]{{2,}}", "_", title)
|
|
||||||
title = re.sub(rf"[{'-'}]{{2,}}", "-", title)
|
|
||||||
title = re.sub(rf"[{' '}]{{2,}}", " ", title)
|
|
||||||
return title
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
headers:
|
|
||||||
User-Agent: 10play/7.4.0.500325 Android UAP
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
config: https://10.com.au/api/v1/config
|
|
||||||
auth: https://10.com.au/api/user/auth
|
|
||||||
query: https://vod.ten.com.au/api/videos/bcquery # androidapps-v2
|
|
||||||
|
|
||||||
api_key: "b918ff793563080c5821c89ee6c415c363cb36d369db1020369ac4b405a0211d"
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from collections.abc import Generator
|
|
||||||
from http.cookiejar import MozillaCookieJar
|
|
||||||
from typing import Any, Optional, Union
|
|
||||||
import click
|
|
||||||
from click import Context
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.manifests.dash import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series
|
|
||||||
from envied.core.tracks import Chapters, Subtitle, Tracks
|
|
||||||
import requests
|
|
||||||
|
|
||||||
def get_widevine_license_url(manifest_str):
|
|
||||||
# Try JSON first
|
|
||||||
try:
|
|
||||||
data = json.loads(manifest_str)
|
|
||||||
for source in data.get("sources", []):
|
|
||||||
widevine = source.get("key_systems", {}).get("com.wienvied.alpha")
|
|
||||||
if widevine and "license_url" in widevine:
|
|
||||||
return widevine["license_url"]
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fallback for XML style
|
|
||||||
match = re.search(r'bc:licenseAcquisitionUrl="([^"]+)"', manifest_str)
|
|
||||||
if match:
|
|
||||||
return match.group(1)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class TPTV(Service):
|
|
||||||
"""
|
|
||||||
Service code for TPTVencore streaming service (https://www.TPTVencore.co.uk/).
|
|
||||||
|
|
||||||
\b
|
|
||||||
version 1.0.4
|
|
||||||
Date: June 2025
|
|
||||||
Author: A_n_g_e_l_a
|
|
||||||
Authorization: email/password for service in envied.yaml
|
|
||||||
Robustness:
|
|
||||||
DRM free... with rare exceptions
|
|
||||||
|
|
||||||
\b
|
|
||||||
Note:
|
|
||||||
TPTV will not allow the usual -w S01-S04 syntax as TPTV is eclictic in what it serves.
|
|
||||||
Series and episodes carry little meaning on this platform.
|
|
||||||
|
|
||||||
It is not possible to remove S00E00 from the end of a video title - envied insists.
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
Use complete url in all cases.
|
|
||||||
SERIES: https://tptvencore.co.uk/collection/1717422888871355373
|
|
||||||
Note: TPTV do not specify Series and Episodes numbers in any meaningful and organized way.
|
|
||||||
They MAY sometimes be in the program title, but often incomplete.
|
|
||||||
FILM: https://tptvencore.co.uk/product/the-importance-of-being-earnest-6290333578001
|
|
||||||
EPISODE: https://tptvencore.co.uk/product/sherlock-holmes---the-case-of-greystone-inscription-s1-ep16-6282604132001
|
|
||||||
|
|
||||||
\b
|
|
||||||
Examples:
|
|
||||||
SERIES: https://tptvencore.co.uk/collection/1717422888871355373
|
|
||||||
EPISODE: https://tptvencore.co.uk/product/sherlock-holmes---the-case-of-greystone-inscription-s1-ep16-6282604132001
|
|
||||||
FILM: https://tptvencore.co.uk/product/the-importance-of-being-earnest-6290333578001
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
GEOFENCE = ("gb",)
|
|
||||||
ALIASES = ("TPTVencore",)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="TPTV", short_help="https://www.tptvencore.co.uk/", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> TPTV:
|
|
||||||
return TPTV(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
self.profile = ctx.parent.params.get("profile")
|
|
||||||
if not self.profile:
|
|
||||||
self.profile = "default"
|
|
||||||
self.session = requests.session()
|
|
||||||
self.session.headers.update(self.config["headers"])
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
if not credential:
|
|
||||||
raise EnvironmentError("Service requires Credentials for Authentication.")
|
|
||||||
|
|
||||||
cache = self.cache.get(f"tokens_{credential.sha1}")
|
|
||||||
# first contact
|
|
||||||
fc_headers = {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0',
|
|
||||||
'Accept': '*/*',
|
|
||||||
'Accept-Language': 'en-GB,en;q=0.5',
|
|
||||||
'api-key': 'zq5pyPd0RTbNg3Fyj52PrkKL9c2Af38HHh4itgZTKDaCzjAyhd',
|
|
||||||
'Referer': 'https://tptvencore.co.uk/',
|
|
||||||
'tenant': 'encore',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Origin': 'https://tptvencore.co.uk',
|
|
||||||
'DNT': '1',
|
|
||||||
'Connection': 'keep-alive',
|
|
||||||
'Sec-Fetch-Dest': 'empty',
|
|
||||||
'Sec-Fetch-Mode': 'cors',
|
|
||||||
'Sec-Fetch-Site': 'cross-site',
|
|
||||||
'Priority': 'u=0',
|
|
||||||
|
|
||||||
}
|
|
||||||
payload = {}
|
|
||||||
r = self.session.post(self.config["endpoints"]["session"], headers=fc_headers, json=payload)
|
|
||||||
if r.status_code != 200:
|
|
||||||
raise ConnectionError
|
|
||||||
else:
|
|
||||||
session_id = r.json()['id']
|
|
||||||
self.session.headers.update({'session': session_id})
|
|
||||||
|
|
||||||
# login
|
|
||||||
if cache and not cache.expired:
|
|
||||||
# cached
|
|
||||||
self.log.info(" + Using cached Tokens...")
|
|
||||||
tokens = cache.data
|
|
||||||
else:
|
|
||||||
self.log.info(" + Logging in...")
|
|
||||||
payload = {"email": credential.username, "password": credential.password}
|
|
||||||
|
|
||||||
r = self.session.post(
|
|
||||||
self.config["endpoints"]["login"],
|
|
||||||
headers=self.session.headers,
|
|
||||||
json={
|
|
||||||
"email": credential.username,
|
|
||||||
"password": credential.password,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
res = r.json()
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise ValueError(f"Failed to refresh tokens: {r.text}")
|
|
||||||
|
|
||||||
tokens = res
|
|
||||||
self.log.info(" + Acquired tokens...")
|
|
||||||
|
|
||||||
# cache.set(tokens)
|
|
||||||
|
|
||||||
self.authorization = tokens
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
query = self.title.replace(" ", "+")
|
|
||||||
search_url = self.config["endpoints"]["search"].replace("{query}", query)
|
|
||||||
session_id = self.session.headers['session']
|
|
||||||
headers = {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0',
|
|
||||||
'Accept': '*/*',
|
|
||||||
'Accept-Language': 'en-GB,en;q=0.5',
|
|
||||||
'Referer': 'https://tptvencore.co.uk/',
|
|
||||||
'session': session_id,
|
|
||||||
'tenant': 'encore',
|
|
||||||
'Origin': 'https://tptvencore.co.uk',
|
|
||||||
'DNT': '1',
|
|
||||||
'Connection': 'keep-alive',
|
|
||||||
'Sec-Fetch-Dest': 'empty',
|
|
||||||
'Sec-Fetch-Mode': 'cors',
|
|
||||||
'Sec-Fetch-Site': 'cross-site',
|
|
||||||
'Priority': 'u=4',
|
|
||||||
}
|
|
||||||
r = self.session.get(search_url, headers=headers)
|
|
||||||
if r.status_code != 200:
|
|
||||||
raise ConnectionError(f"Search failed with {r.status_code}: {r.text}")
|
|
||||||
|
|
||||||
results = r.json()["data"]
|
|
||||||
myitems = []
|
|
||||||
if isinstance(results, list):
|
|
||||||
for result in results:
|
|
||||||
# do lookup on list item product or collection
|
|
||||||
if result.startswith('collection_'):
|
|
||||||
id = result.replace('collection_','')
|
|
||||||
collection_url = f"https://prod.suggestedtv.com/api/client/v1/collection/by-reference/{id}?extend=label"
|
|
||||||
response = self.session.get(collection_url, headers=headers)
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
for item in data['children']:
|
|
||||||
myitems.append(item['id'].replace('product_',''))
|
|
||||||
else:
|
|
||||||
print(f"Error: {response.status_code} - {response.text}")
|
|
||||||
else:
|
|
||||||
myitems.append(result.replace('product_',''))
|
|
||||||
mystring = ",".join(myitems)
|
|
||||||
|
|
||||||
continued_search_url = "https://prod.suggestedtv.com/api/client/v1/product?ids=" + mystring + "&extend=label"
|
|
||||||
|
|
||||||
response = self.session.get(continued_search_url, headers=headers)
|
|
||||||
response.raise_for_status
|
|
||||||
results = response.json()
|
|
||||||
if isinstance(results, list):
|
|
||||||
items = results
|
|
||||||
elif isinstance(results, dict) and "data" in results:
|
|
||||||
items = results["data"]
|
|
||||||
else:
|
|
||||||
raise ValueError("Unexpected search result format")
|
|
||||||
|
|
||||||
for item in items:
|
|
||||||
try:
|
|
||||||
yield SearchResult(
|
|
||||||
id_=item.get("reference"),
|
|
||||||
title=item.get("name"),
|
|
||||||
description=item.get("description", "").replace('\n', ' '),
|
|
||||||
label=item.get("type", ""),
|
|
||||||
url=f"https://tptvencore.co.uk/product/{item.get('reference')}",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to yield item: {e}")
|
|
||||||
|
|
||||||
def get_titles(self) -> Union[Movies, Series]:
|
|
||||||
data = self.get_data(self.title)
|
|
||||||
ids = ",".join(data)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0',
|
|
||||||
'Accept': '*/*',
|
|
||||||
'Accept-Language': 'en-GB,en;q=0.5',
|
|
||||||
'Referer': 'https://tptvencore.co.uk/',
|
|
||||||
'tenant': 'encore',
|
|
||||||
'Origin': 'https://tptvencore.co.uk',
|
|
||||||
'DNT': '1',
|
|
||||||
'Connection': 'keep-alive',
|
|
||||||
'Sec-Fetch-Dest': 'empty',
|
|
||||||
'Sec-Fetch-Mode': 'cors',
|
|
||||||
'Sec-Fetch-Site': 'cross-site',
|
|
||||||
'Priority': 'u=4',
|
|
||||||
}
|
|
||||||
session_id = self.session.headers['session']
|
|
||||||
headers['session'] = session_id
|
|
||||||
params = {
|
|
||||||
'ids': ids,
|
|
||||||
'extend': 'label',
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.session.get('https://prod.suggestedtv.com/api/client/v1/product', params=params, headers=headers)
|
|
||||||
if response.status_code == 200:
|
|
||||||
mydata=json.loads(response.text)
|
|
||||||
titles = mydata['data']
|
|
||||||
episodes =[
|
|
||||||
Episode(
|
|
||||||
id_=episode["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=episode["name"],
|
|
||||||
season=0,
|
|
||||||
number=0,
|
|
||||||
name = '',
|
|
||||||
language="en", # TODO: language detection
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
for episode in titles
|
|
||||||
]
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
|
|
||||||
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
|
||||||
playlist = f"https://edge.api.brightcove.com/playback/v1/accounts/6272132012001/videos/{title.data.get('id')}"
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0',
|
|
||||||
'Accept': '*/*',
|
|
||||||
'Accept-Language': 'en-GB,en;q=0.5',
|
|
||||||
|
|
||||||
'Referer': 'https://tptvencore.co.uk/',
|
|
||||||
'BCOV-Policy': 'BCpkADawqM1yq3Go9abHJ4lBZ0wrYStC-pS1W01hdlACHxsiIz9AvQXy1wa3iqyd6yVJLXLZnZjFkKI2BCJjbtxiJqyPMZjIezEWKrI1TTSbugkD6dAXs7Ucxq09P9zQ8ZRU4ZjTa83VFhiL',
|
|
||||||
'Origin': 'https://tptvencore.co.uk',
|
|
||||||
'DNT': '1',
|
|
||||||
'Connection': 'keep-alive',
|
|
||||||
'Sec-Fetch-Dest': 'empty',
|
|
||||||
'Sec-Fetch-Mode': 'cors',
|
|
||||||
'Sec-Fetch-Site': 'cross-site',
|
|
||||||
'Priority': 'u=4',
|
|
||||||
}
|
|
||||||
|
|
||||||
r = requests.get(playlist, headers=headers)
|
|
||||||
if r.status_code != 200:
|
|
||||||
raise ConnectionError(r.text)
|
|
||||||
|
|
||||||
data = r.json()
|
|
||||||
|
|
||||||
self.manifest = data["sources"][2].get("src")
|
|
||||||
|
|
||||||
tracks = DASH.from_url(self.manifest, self.session).to_tracks(title.language)
|
|
||||||
tracks.videos[0].data = data
|
|
||||||
|
|
||||||
# odd couple of DRM vids found
|
|
||||||
|
|
||||||
self.license = get_widevine_license_url(r.text)
|
|
||||||
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
|
|
||||||
|
|
||||||
return Chapters()
|
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_widevine_license(self, challenge: bytes, **_: Any) -> bytes:
|
|
||||||
r = self.session.post(url=self.license, data=challenge)
|
|
||||||
if r.status_code != 200:
|
|
||||||
raise ConnectionError(r.text)
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
def get_data(self, url: str) -> dict:
|
|
||||||
self.session.headers.update({'tenant': 'encore'})
|
|
||||||
if 'collection' in url:
|
|
||||||
prod_id = url.split('/')[-1]
|
|
||||||
url = f"https://prod.suggestedtv.com/api/client/v1/collection/by-reference/{prod_id}?extend=label"
|
|
||||||
r = self.session.get(url)
|
|
||||||
if r.status_code != 200:
|
|
||||||
raise ConnectionError(r.text)
|
|
||||||
|
|
||||||
myjson = r.json()
|
|
||||||
children = myjson.get('children')
|
|
||||||
product_links = []
|
|
||||||
for child in children:
|
|
||||||
product_links.append(child['id']) if 'product' in child.get('classification') else None
|
|
||||||
return product_links
|
|
||||||
elif 'product' in url: # single item
|
|
||||||
prod_id = url.split('-')[-1]
|
|
||||||
return [prod_id]
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
headers:
|
|
||||||
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0
|
|
||||||
Accept: '*/*'
|
|
||||||
Accept-Language: en-GBen;q=0.5
|
|
||||||
Access-Control-Request-Method: POST
|
|
||||||
Access-Control-Request-Headers: baggagecontent-typesentry-tracesessiontenant
|
|
||||||
Referer: https://tptvencore.co.uk/
|
|
||||||
Origin: https://tptvencore.co.uk
|
|
||||||
DNT: '1'
|
|
||||||
Connection: keep-alive
|
|
||||||
Sec-Fetch-Dest: empty
|
|
||||||
Sec-Fetch-Mode: cors
|
|
||||||
Sec-Fetch-Site: cross-site
|
|
||||||
Priority: u=4
|
|
||||||
|
|
||||||
session:
|
|
||||||
api-key: zq5pyPd0RTbNg3Fyj52PrkKL9c2Af38HHh4itgZTKDaCzjAyhd
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
login: https://prod.suggestedtv.com/api/client/v1/session/login
|
|
||||||
session: https://prod.suggestedtv.com/api/client/v1/session
|
|
||||||
search: https://prod.suggestedtv.com/api/client/v2/search/{query}
|
|
||||||
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import uuid
|
|
||||||
from collections.abc import Generator
|
|
||||||
from http.cookiejar import MozillaCookieJar
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
import click
|
|
||||||
from langcodes import Language
|
|
||||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.downloaders import aria2c, requests
|
|
||||||
from envied.core.manifests import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
|
||||||
from envied.core.tracks import Audio, Chapter, Chapters, Subtitle, Track, Tracks
|
|
||||||
|
|
||||||
|
|
||||||
class TUBI(Service):
|
|
||||||
"""
|
|
||||||
Service code for TubiTV streaming service (https://tubitv.com/)
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.5
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: Cookies (Optional)
|
|
||||||
Geofence: Locked to whatever region the user is in (API only)
|
|
||||||
Robustness:
|
|
||||||
Widevine:
|
|
||||||
L3: 1080p, AAC2.0
|
|
||||||
PlayReady:
|
|
||||||
SL2000: 1080p, AAC2.0
|
|
||||||
Clear:
|
|
||||||
1080p, AAC2.0
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Input can be complete title URL or just the path:
|
|
||||||
/series/300001423/gotham
|
|
||||||
/tv-shows/200024793/s01-e01-pilot
|
|
||||||
/movies/589279/the-outsiders
|
|
||||||
- Use '-v H.265' to request HEVC tracks.
|
|
||||||
|
|
||||||
\b
|
|
||||||
Notes:
|
|
||||||
- Authentication is currently not required, but cookies are used if provided.
|
|
||||||
- If 1080p exists, it's currently only available as H.265.
|
|
||||||
- Unshackle fails to mux properly when n_m3u8dl_re is used, so aria2c is forced as downloader.
|
|
||||||
- Search is currently disabled.
|
|
||||||
"""
|
|
||||||
|
|
||||||
TITLE_RE = r"^(?:https?://(?:www\.)?tubitv\.com?)?/(?P<type>movies|series|tv-shows)/(?P<id>[a-z0-9-]+)"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="TUBI", short_help="https://tubitv.com/", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, **kwargs):
|
|
||||||
return TUBI(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx, title):
|
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
cdm = ctx.obj.cdm
|
|
||||||
self.drm_system = "playready" if isinstance(cdm, PlayReadyCdm) else "widevine"
|
|
||||||
|
|
||||||
vcodec = ctx.parent.params.get("vcodec")
|
|
||||||
self.vcodec = "H264" if vcodec is None else "H265"
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
self.auth_token = None
|
|
||||||
if cookies is not None:
|
|
||||||
self.auth_token = next((cookie.value for cookie in cookies if cookie.name == "at"), None)
|
|
||||||
self.session.headers.update({"Authorization": f"Bearer {self.auth_token}"})
|
|
||||||
|
|
||||||
# Disable search for now
|
|
||||||
# def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
# params = {
|
|
||||||
# "search": self.title,
|
|
||||||
# "include_linear": "true",
|
|
||||||
# "include_channels": "false",
|
|
||||||
# "is_kids_mode": "false",
|
|
||||||
# }
|
|
||||||
|
|
||||||
# r = self.session.get(self.config["endpoints"]["search"], params=params)
|
|
||||||
# r.raise_for_status()
|
|
||||||
# results = r.json()
|
|
||||||
# from devine.core.console import console
|
|
||||||
# console.print(results)
|
|
||||||
# exit()
|
|
||||||
|
|
||||||
# for result in results:
|
|
||||||
# label = "series" if result["type"] == "s" else "movies" if result["type"] == "v" else result["type"]
|
|
||||||
# title = (
|
|
||||||
# result.get("title", "")
|
|
||||||
# .lower()
|
|
||||||
# .replace(" ", "-")
|
|
||||||
# .replace(":", "")
|
|
||||||
# .replace("(", "")
|
|
||||||
# .replace(")", "")
|
|
||||||
# .replace(".", "")
|
|
||||||
# )
|
|
||||||
# yield SearchResult(
|
|
||||||
# id_=f"https://tubitv.com/{label}/{result.get('id')}/{title}",
|
|
||||||
# title=result.get("title"),
|
|
||||||
# description=result.get("description"),
|
|
||||||
# label=label,
|
|
||||||
# url=f"https://tubitv.com/{label}/{result.get('id')}/{title}",
|
|
||||||
# )
|
|
||||||
|
|
||||||
def get_titles(self) -> Titles_T:
|
|
||||||
try:
|
|
||||||
kind, content_id = (re.match(self.TITLE_RE, self.title).group(i) for i in ("type", "id"))
|
|
||||||
except Exception:
|
|
||||||
raise ValueError("Could not parse ID from title - is the URL correct?")
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"app_id": "tubitv",
|
|
||||||
"platform": "web", # web, android, androidtv
|
|
||||||
"device_id": str(uuid.uuid4()),
|
|
||||||
"content_id": content_id,
|
|
||||||
"limit_resolutions[]": [
|
|
||||||
"h264_1080p",
|
|
||||||
"h265_1080p",
|
|
||||||
],
|
|
||||||
"video_resources[]": [
|
|
||||||
"dash_widevine_nonclearlead",
|
|
||||||
"dash_playready_psshv0",
|
|
||||||
"dash",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
if kind == "tv-shows":
|
|
||||||
content = self.session.get(self.config["endpoints"]["content"], params=params)
|
|
||||||
content.raise_for_status()
|
|
||||||
series_id = "0" + content.json().get("series_id")
|
|
||||||
params.update({"content_id": int(series_id)})
|
|
||||||
data = self.session.get(self.config["endpoints"]["content"], params=params).json()
|
|
||||||
|
|
||||||
return Series(
|
|
||||||
[
|
|
||||||
Episode(
|
|
||||||
id_=episode["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=data["title"],
|
|
||||||
season=int(season.get("id", 0)),
|
|
||||||
number=int(episode.get("episode_number", 0)),
|
|
||||||
name=episode["title"].split("-")[1],
|
|
||||||
year=data.get("year"),
|
|
||||||
language=Language.find(episode.get("lang", "en")).to_alpha3(),
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
for season in data["children"]
|
|
||||||
for episode in season["children"]
|
|
||||||
if episode["id"] == content_id
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if kind == "series":
|
|
||||||
r = self.session.get(self.config["endpoints"]["content"], params=params)
|
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
|
|
||||||
return Series(
|
|
||||||
[
|
|
||||||
Episode(
|
|
||||||
id_=episode["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=data["title"],
|
|
||||||
season=int(season.get("id", 0)),
|
|
||||||
number=int(episode.get("episode_number", 0)),
|
|
||||||
name=episode["title"].split("-")[1],
|
|
||||||
year=data.get("year"),
|
|
||||||
language=Language.find(episode.get("lang") or "en").to_alpha3(),
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
for season in data["children"]
|
|
||||||
for episode in season["children"]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if kind == "movies":
|
|
||||||
r = self.session.get(self.config["endpoints"]["content"], params=params)
|
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
return Movies(
|
|
||||||
[
|
|
||||||
Movie(
|
|
||||||
id_=data["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
year=data.get("year"),
|
|
||||||
name=data["title"],
|
|
||||||
language=Language.find(data.get("lang", "en")).to_alpha3(),
|
|
||||||
data=data,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
|
||||||
if not (resources := title.data.get("video_resources")):
|
|
||||||
self.log.error(" - Failed to obtain video resources. Check geography settings.")
|
|
||||||
self.log.info(f"Title is available in: {title.data.get('country')}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
codecs = [x.get("codec") for x in resources]
|
|
||||||
if not any(self.vcodec in x for x in codecs):
|
|
||||||
raise ValueError(f"Could not find a {self.vcodec} video resource for this title")
|
|
||||||
|
|
||||||
resource = next((
|
|
||||||
x for x in resources
|
|
||||||
if self.drm_system in x.get("type", "") and self.vcodec in x.get("codec", "")
|
|
||||||
), None) or next((
|
|
||||||
x for x in resources
|
|
||||||
if self.drm_system not in x.get("type", "") and
|
|
||||||
"dash" in x.get("type", "") and
|
|
||||||
self.vcodec in x.get("codec", "")
|
|
||||||
), None)
|
|
||||||
if not resource:
|
|
||||||
raise ValueError("Could not find a video resource for this title")
|
|
||||||
|
|
||||||
manifest = resource.get("manifest", {}).get("url")
|
|
||||||
if not manifest:
|
|
||||||
raise ValueError("Could not find a manifest for this title")
|
|
||||||
|
|
||||||
title.data["license_url"] = resource.get("license_server", {}).get("url")
|
|
||||||
|
|
||||||
tracks = DASH.from_url(url=manifest, session=self.session).to_tracks(language=title.language)
|
|
||||||
for track in tracks:
|
|
||||||
rep_base = track.data["dash"]["representation"].find("BaseURL")
|
|
||||||
if rep_base is not None:
|
|
||||||
base_url = os.path.dirname(track.url)
|
|
||||||
track_base = rep_base.text
|
|
||||||
track.url = f"{base_url}/{track_base}"
|
|
||||||
track.descriptor = Track.Descriptor.URL
|
|
||||||
track.downloader = aria2c
|
|
||||||
|
|
||||||
if isinstance(track, Audio):
|
|
||||||
role = track.data["dash"]["adaptation_set"].find("Role")
|
|
||||||
if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
|
|
||||||
track.descriptive = True
|
|
||||||
|
|
||||||
if title.data.get("subtitles"):
|
|
||||||
tracks.add(
|
|
||||||
Subtitle(
|
|
||||||
id_=hashlib.md5(title.data["subtitles"][0]["url"].encode()).hexdigest()[0:6],
|
|
||||||
url=title.data["subtitles"][0]["url"],
|
|
||||||
codec=Subtitle.Codec.from_mime(title.data["subtitles"][0]["url"][-3:]),
|
|
||||||
language=title.data["subtitles"][0].get("lang_alpha3", title.language),
|
|
||||||
downloader=requests,
|
|
||||||
is_original_lang=True,
|
|
||||||
forced=False,
|
|
||||||
sdh=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Title_T) -> Chapters:
|
|
||||||
if not (cue_points := title.data.get("credit_cuepoints")):
|
|
||||||
return Chapters()
|
|
||||||
|
|
||||||
chapters = []
|
|
||||||
if cue_points.get("recap_start"):
|
|
||||||
chapters.append(Chapter(name="Recap", timestamp=float(cue_points["recap_start"])))
|
|
||||||
if cue_points.get("intro_start") and cue_points.get("intro_end"):
|
|
||||||
chapters.append(Chapter(name="Intro", timestamp=float(cue_points["intro_start"])))
|
|
||||||
chapters.append(Chapter(timestamp=float(cue_points["intro_end"])))
|
|
||||||
if cue_points.get("early_credits_start"):
|
|
||||||
chapters.append(Chapter(name="Early Credits", timestamp=float(cue_points["early_credits_start"])))
|
|
||||||
if cue_points.get("postlude"):
|
|
||||||
chapters.append(Chapter(name="End Credits", timestamp=float(cue_points["postlude"])))
|
|
||||||
|
|
||||||
return sorted(chapters, key=lambda x: x.timestamp)
|
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> bytes | str | None:
|
|
||||||
if not (license_url := title.data.get("license_url")):
|
|
||||||
return None
|
|
||||||
|
|
||||||
r = self.session.post(url=license_url, data=challenge)
|
|
||||||
if r.status_code != 200:
|
|
||||||
raise ConnectionError(r.text)
|
|
||||||
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
def get_playready_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> bytes | str | None:
|
|
||||||
if not (license_url := title.data.get("license_url")):
|
|
||||||
return None
|
|
||||||
|
|
||||||
r = self.session.post(url=license_url, data=challenge)
|
|
||||||
if r.status_code != 200:
|
|
||||||
raise ConnectionError(r.text)
|
|
||||||
|
|
||||||
return r.content
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
endpoints:
|
|
||||||
content: https://uapi.adrise.tv/cms/content # https://content-cdn.production-public.tubi.io/api/v2/content
|
|
||||||
search: https://search.production-public.tubi.io/api/v1/search
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from collections.abc import Generator
|
|
||||||
from datetime import timedelta
|
|
||||||
from http.cookiejar import MozillaCookieJar
|
|
||||||
from typing import Any, Optional, Union
|
|
||||||
from urllib.parse import urljoin, urlparse
|
|
||||||
|
|
||||||
import click
|
|
||||||
from click import Context
|
|
||||||
from lxml import etree
|
|
||||||
from pywidevine.cdm import Cdm as WidevineCdm
|
|
||||||
from requests import Request
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.manifests.dash import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series
|
|
||||||
from envied.core.tracks import Chapters, Tracks
|
|
||||||
|
|
||||||
|
|
||||||
class TVNZ(Service):
|
|
||||||
"""
|
|
||||||
\b
|
|
||||||
Service code for TVNZ streaming service (https://www.tvnz.co.nz).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.2
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: Credentials
|
|
||||||
Robustness:
|
|
||||||
L3: 1080p, AAC2.0
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Input can be comlete URL or path:
|
|
||||||
SHOW: /shows/tulsa-king
|
|
||||||
EPISODE: /shows/tulsa-king/episodes/s1-e1
|
|
||||||
MOVIE: /shows/the-revenant
|
|
||||||
SPORT: /sport/tennis/wta-tour/guadalajara-open-final
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
GEOFENCE = ("nz",)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="TVNZ", short_help="https://www.tvnz.co.nz", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> TVNZ:
|
|
||||||
return TVNZ(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(),
|
|
||||||
"includeTypes": "all",
|
|
||||||
}
|
|
||||||
|
|
||||||
results = self._request("GET", "/api/v1/android/play/search", params=params)["results"]
|
|
||||||
|
|
||||||
for result in results:
|
|
||||||
yield SearchResult(
|
|
||||||
id_=result["page"].get("url"),
|
|
||||||
title=result.get("title"),
|
|
||||||
description=result.get("synopsis"),
|
|
||||||
label=result.get("type"),
|
|
||||||
url="https://www.tvnz.co.nz" + result["page"].get("url"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
super().authenticate(cookies, credential)
|
|
||||||
if not credential:
|
|
||||||
raise EnvironmentError("Service requires Credentials for Authentication.")
|
|
||||||
|
|
||||||
cache = self.cache.get(f"tokens_{credential.sha1}")
|
|
||||||
|
|
||||||
if cache and not cache.expired:
|
|
||||||
self.log.info(" + Using cached Tokens...")
|
|
||||||
tokens = cache.data
|
|
||||||
else:
|
|
||||||
self.log.info(" + Logging in...")
|
|
||||||
payload = {"email": credential.username, "password": credential.password, "keepMeLoggedIn": True}
|
|
||||||
|
|
||||||
response = self.session.post(
|
|
||||||
self.config["endpoints"]["base_api"] + "/api/v1/androidtv/consumer/login", json=payload
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
if not response.headers.get("aat"):
|
|
||||||
raise ValueError("Failed to authenticate: " + response.text)
|
|
||||||
|
|
||||||
tokens = {
|
|
||||||
"access_token": response.headers.get("aat"),
|
|
||||||
"aft_token": response.headers.get("aft"), # ?
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.set(tokens, expiration=response.headers.get("aat_expires_in"))
|
|
||||||
|
|
||||||
self.session.headers.update({"Authorization": "Bearer {}".format(tokens["access_token"])})
|
|
||||||
|
|
||||||
# Disable SSL verification due to issues with newer versions of requests library.
|
|
||||||
self.session.verify = False
|
|
||||||
|
|
||||||
def get_titles(self) -> Union[Movies, Series]:
|
|
||||||
try:
|
|
||||||
path = urlparse(self.title).path
|
|
||||||
except Exception as e:
|
|
||||||
raise ValueError("Could not parse ID from title: {}".format(e))
|
|
||||||
|
|
||||||
page = self._request("GET", "/api/v4/androidtv/play/page/{}".format(path))
|
|
||||||
|
|
||||||
if page["layout"].get("video"):
|
|
||||||
title = page.get("title", "").replace("Episodes", "")
|
|
||||||
video = self._request("GET", page["layout"]["video"].get("href"))
|
|
||||||
episodes = self._episode(video, title)
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
else:
|
|
||||||
module = page["layout"]["slots"]["main"]["modules"][0]
|
|
||||||
label = module.get("label", "")
|
|
||||||
lists = module.get("lists")
|
|
||||||
title = page.get("title", "").replace(label, "")
|
|
||||||
|
|
||||||
seasons = [x.get("href") for x in lists]
|
|
||||||
|
|
||||||
episodes = []
|
|
||||||
for season in seasons:
|
|
||||||
data = self._request("GET", season)
|
|
||||||
episodes.extend([x for x in data["_embedded"].values()])
|
|
||||||
|
|
||||||
while data.get("nextPage"):
|
|
||||||
data = self._request("GET", data["nextPage"])
|
|
||||||
episodes.extend([x for x in data["_embedded"].values()])
|
|
||||||
|
|
||||||
if label in ("Episodes", "Stream"):
|
|
||||||
episodes = self._show(episodes, title)
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
elif label in ("Movie", "Movies"):
|
|
||||||
movie = self._movie(episodes, title)
|
|
||||||
return Movies(movie)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
|
||||||
metadata = title.data.get("publisherMetadata") or title.data.get("media")
|
|
||||||
if not metadata:
|
|
||||||
self.log.error("Unable to find metadata for this episode")
|
|
||||||
return
|
|
||||||
|
|
||||||
source = metadata.get("type") or metadata.get("source")
|
|
||||||
video_id = metadata.get("brightcoveVideoId") or metadata.get("id")
|
|
||||||
account_id = metadata.get("brightcoveAccountId") or metadata.get("accountId")
|
|
||||||
playback = title.data.get("playbackHref", "")
|
|
||||||
|
|
||||||
self.drm_token = None
|
|
||||||
if source != "brightcove":
|
|
||||||
data = self._request("GET", playback)
|
|
||||||
self.license = (
|
|
||||||
data["encryption"]["licenseServers"]["widevine"]
|
|
||||||
if data["encryption"].get("drmEnabled")
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
self.drm_token = data["encryption"].get("drmToken")
|
|
||||||
source_manifest = data["streaming"]["dash"].get("url")
|
|
||||||
|
|
||||||
else:
|
|
||||||
data = self._request(
|
|
||||||
"GET", self.config["endpoints"]["brightcove"].format(account_id, video_id),
|
|
||||||
headers={"BCOV-POLICY": self.config["policy"]},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.license = next((
|
|
||||||
x["key_systems"]["com.widevine.alpha"]["license_url"]
|
|
||||||
for x in data["sources"]
|
|
||||||
if x.get("key_systems").get("com.widevine.alpha")),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
source_manifest = next((
|
|
||||||
x["src"] for x in data["sources"]
|
|
||||||
if x.get("key_systems").get("com.widevine.alpha")),
|
|
||||||
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:
|
|
||||||
return Chapters()
|
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
|
||||||
return WidevineCdm.common_privacy_cert
|
|
||||||
|
|
||||||
def get_widevine_license(self, challenge: bytes, **_: Any) -> str:
|
|
||||||
if not self.license:
|
|
||||||
return None
|
|
||||||
|
|
||||||
headers = {"Authorization": f"Bearer {self.drm_token}"} if self.drm_token else self.session.headers
|
|
||||||
r = self.session.post(self.license, headers=headers, data=challenge)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
# Service specific
|
|
||||||
|
|
||||||
def _show(self, episodes: list, title: str) -> Episode:
|
|
||||||
return [
|
|
||||||
Episode(
|
|
||||||
id_=episode.get("videoId"),
|
|
||||||
service=self.__class__,
|
|
||||||
title=title,
|
|
||||||
season=int(episode.get("seasonNumber")) if episode.get("seasonNumber") else 0,
|
|
||||||
number=int(episode.get("episodeNumber")) if episode.get("episodeNumber") else 0,
|
|
||||||
name=episode.get("title"),
|
|
||||||
language="en",
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
for episode in episodes
|
|
||||||
]
|
|
||||||
|
|
||||||
def _movie(self, movies: list, title: str) -> Movie:
|
|
||||||
return [
|
|
||||||
Movie(
|
|
||||||
id_=movie.get("videoId"),
|
|
||||||
service=self.__class__,
|
|
||||||
name=title,
|
|
||||||
year=None,
|
|
||||||
language="en",
|
|
||||||
data=movie,
|
|
||||||
)
|
|
||||||
for movie in movies
|
|
||||||
]
|
|
||||||
|
|
||||||
def _episode(self, video: dict, title: str) -> Episode:
|
|
||||||
kind = video.get("type")
|
|
||||||
name = video.get("title")
|
|
||||||
|
|
||||||
if kind == "sportVideo" and video.get("_embedded"):
|
|
||||||
_type = next((x for x in video["_embedded"].values() if x.get("type") == "competition"), None)
|
|
||||||
title = _type.get("title") if _type else title
|
|
||||||
name = video.get("title", "") + " " + video.get("phase", "")
|
|
||||||
|
|
||||||
return [
|
|
||||||
Episode(
|
|
||||||
id_=video.get("videoId"),
|
|
||||||
service=self.__class__,
|
|
||||||
title=title,
|
|
||||||
season=int(video.get("seasonNumber")) if video.get("seasonNumber") else 0,
|
|
||||||
number=int(video.get("episodeNumber")) if video.get("episodeNumber") else 0,
|
|
||||||
name=name,
|
|
||||||
language="en",
|
|
||||||
data=video,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def _request(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
api: str,
|
|
||||||
params: dict = None,
|
|
||||||
headers: dict = None,
|
|
||||||
payload: dict = None,
|
|
||||||
) -> Any[dict | str]:
|
|
||||||
url = urljoin(self.config["endpoints"]["base_api"], api)
|
|
||||||
if headers:
|
|
||||||
self.session.headers.update(headers)
|
|
||||||
|
|
||||||
prep = self.session.prepare_request(Request(method, url, params=params, json=payload))
|
|
||||||
response = self.session.send(prep)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(response.content)
|
|
||||||
|
|
||||||
if data.get("message"):
|
|
||||||
raise ConnectionError(f"{response.status_code} - {data.get('message')}")
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise ConnectionError("Request failed: {} - {}".format(response.status_code, response.text))
|
|
||||||
|
|
||||||
def trim_duration(self, 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, self.session).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")
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
headers:
|
|
||||||
User-Agent: "AndroidTV/!/!"
|
|
||||||
x-tvnz-api-client-id: "androidtv/!.!.!"
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
base_api: "https://apis-public-prod.tech.tvnz.co.nz"
|
|
||||||
brightcove: "https://edge.api.brightcove.com/playback/v1/accounts/{}/videos/{}"
|
|
||||||
|
|
||||||
policy: "BCpkADawqM0IurzupiJKMb49WkxM__ngDMJ3GOQBhN2ri2Ci_lHwDWIpf4sLFc8bANMc-AVGfGR8GJNgxGqXsbjP1gHsK2Fpkoj6BSpwjrKBnv1D5l5iGPvVYCo"
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from collections.abc import Generator
|
|
||||||
from datetime import timedelta
|
|
||||||
from typing import Any, Union
|
|
||||||
|
|
||||||
import click
|
|
||||||
from click import Context
|
|
||||||
from lxml import etree
|
|
||||||
from envied.core.manifests.dash import DASH
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series
|
|
||||||
from envied.core.tracks import Chapter, Chapters, Tracks
|
|
||||||
|
|
||||||
|
|
||||||
class UKTV(Service):
|
|
||||||
"""
|
|
||||||
Service code for 'U' (formerly UKTV Play) streaming service (https://u.co.uk/).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.1
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: None
|
|
||||||
Robustness:
|
|
||||||
L3: 1080p
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Use complete title URL as input:
|
|
||||||
SERIES: https://u.co.uk/shows/love-me/watch-online
|
|
||||||
EPISODE: https://u.co.uk/shows/love-me/series-1/episode-1/6355269425112
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
GEOFENCE = ("gb",)
|
|
||||||
ALIASES = ("uktvplay", "u",)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="UKTV", short_help="https://u.co.uk/", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> UKTV:
|
|
||||||
return UKTV(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
self.session.headers.update({"user-agent": "okhttp/4.7.2"})
|
|
||||||
self.base = self.config["endpoints"]["base"]
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
r = self.session.get(self.base + f"search/?q={self.title}")
|
|
||||||
r.raise_for_status()
|
|
||||||
results = r.json()
|
|
||||||
|
|
||||||
for result in results:
|
|
||||||
link = "https://u.co.uk/shows/{}/watch-online"
|
|
||||||
|
|
||||||
yield SearchResult(
|
|
||||||
id_=link.format(result.get("slug")),
|
|
||||||
title=result.get("name"),
|
|
||||||
description=result.get("synopsis"),
|
|
||||||
label=result.get("type"),
|
|
||||||
url=link.format(result.get("slug")),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_titles(self) -> Union[Movies, Series]:
|
|
||||||
slug, video = self.parse_title(self.title)
|
|
||||||
|
|
||||||
r = self.session.get(self.base + f"brand/?slug={slug}")
|
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
|
|
||||||
series = [series["id"] for series in data["series"]]
|
|
||||||
seasons = [self.session.get(self.base + f"series/?id={i}").json() for i in series]
|
|
||||||
|
|
||||||
if video:
|
|
||||||
episodes = [
|
|
||||||
Episode(
|
|
||||||
id_=episode.get("video_id"),
|
|
||||||
service=self.__class__,
|
|
||||||
title=episode.get("brand_name"),
|
|
||||||
season=int(episode.get("series_number", 0)),
|
|
||||||
number=int(episode.get("episode_number", 0)),
|
|
||||||
name=episode.get("name"),
|
|
||||||
language="en",
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
for season in seasons
|
|
||||||
for episode in season["episodes"]
|
|
||||||
if int(episode.get("video_id")) == int(video)
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
episodes = [
|
|
||||||
Episode(
|
|
||||||
id_=episode.get("video_id"),
|
|
||||||
service=self.__class__,
|
|
||||||
title=episode.get("brand_name"),
|
|
||||||
season=int(episode.get("series_number", 0)),
|
|
||||||
number=int(episode.get("episode_number", 0)),
|
|
||||||
name=episode.get("name"),
|
|
||||||
language="en",
|
|
||||||
data=episode,
|
|
||||||
)
|
|
||||||
for season in seasons
|
|
||||||
for episode in season["episodes"]
|
|
||||||
]
|
|
||||||
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
|
||||||
r = self.session.get(
|
|
||||||
self.config["endpoints"]["playback"].format(id=title.id),
|
|
||||||
headers=self.config["headers"],
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
|
|
||||||
self.license = next((
|
|
||||||
x["key_systems"]["com.widevine.alpha"]["license_url"]
|
|
||||||
for x in data["sources"]
|
|
||||||
if x.get("key_systems").get("com.widevine.alpha")),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
source_manifest = next((
|
|
||||||
x["src"] for x in data["sources"]
|
|
||||||
if x.get("key_systems").get("com.widevine.alpha")),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not self.license or not source_manifest:
|
|
||||||
raise ValueError("Failed to get license or manifest")
|
|
||||||
|
|
||||||
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:
|
|
||||||
chapters = []
|
|
||||||
if title.data.get("credits_cuepoint"):
|
|
||||||
chapters = [Chapter(name="Credits", timestamp=title.data.get("credits_cuepoint"))]
|
|
||||||
|
|
||||||
return Chapters(chapters)
|
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_widevine_license(self, challenge: bytes, **_: Any) -> bytes:
|
|
||||||
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]:
|
|
||||||
title_re = (
|
|
||||||
r"^(?:https?://(?:www\.)?u\.co.uk/shows/)?"
|
|
||||||
r"(?P<slug>[a-z0-9-]+)(?:/[a-z0-9-]+/[a-z0-9-]+/(?P<vid>[0-9-]+))?"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
slug, video = (re.match(title_re, title).group(i) for i in ("slug", "vid"))
|
|
||||||
except Exception:
|
|
||||||
raise ValueError("Could not parse ID from title - is the URL correct?")
|
|
||||||
|
|
||||||
return slug, video
|
|
||||||
|
|
||||||
@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")
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
headers:
|
|
||||||
BCOV-POLICY: BCpkADawqM2ZEz-kf0i2xEP9VuhJF_DB5boH7YAeSx5EHDSNFFl4QUoHZ3bKLQ9yWboSOBNyvZKm4HiZrqMNRxXm-laTAnmls1QOL7_kUM3Eij4KjQMz0epMs3WIedg64fnRxQTX6XubGE9p
|
|
||||||
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://vschedules.uktv.co.uk/vod/
|
|
||||||
playback: https://edge.api.brightcove.com/playback/v1/accounts/1242911124001/videos/{id}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
import logging
|
|
||||||
from abc import ABCMeta, abstractmethod
|
|
||||||
from http.cookiejar import CookieJar
|
|
||||||
from typing import Optional, Union, Any
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
import click
|
|
||||||
import requests
|
|
||||||
from requests.adapters import HTTPAdapter, Retry
|
|
||||||
from rich.padding import Padding
|
|
||||||
from rich.rule import Rule
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Movies, Movie, Titles_T, Title_T
|
|
||||||
from envied.core.cacher import Cacher
|
|
||||||
from envied.core.config import config
|
|
||||||
from envied.core.console import console
|
|
||||||
from envied.core.constants import AnyTrack
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.tracks import Chapters, Tracks, Subtitle, Chapter
|
|
||||||
from envied.core.utilities import get_ip_info
|
|
||||||
from envied.core.manifests import HLS, DASH
|
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
import hashlib
|
|
||||||
import base64
|
|
||||||
import re
|
|
||||||
import json
|
|
||||||
|
|
||||||
class YTBE(Service):
|
|
||||||
"""
|
|
||||||
\b
|
|
||||||
YTBE = Service code for Youtube vod (https://youtube.com)
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.0
|
|
||||||
Author: sk8ord13
|
|
||||||
Authorization: Cookies
|
|
||||||
Robustness:
|
|
||||||
Widevine:
|
|
||||||
L3: 1080p
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
GEOFENCE = ('en',)
|
|
||||||
|
|
||||||
LICENSE_SERVER_URL: str = 'https://www.youtube.com/youtubei/v1/player/get_drm_license'
|
|
||||||
YOUTUBE_VIDEO_INFO_URL: str = 'https://www.youtube.com/youtubei/v1/player'
|
|
||||||
API_KEY: str = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="YTBE", short_help="https://youtube.com", help=__doc__)
|
|
||||||
@click.argument("title", type=str, required=False)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, **kwargs):
|
|
||||||
return YTBE(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx, title):
|
|
||||||
self.title = title
|
|
||||||
self.last_response_data: dict[str, Any] = {}
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
def get_titles(self) -> Titles_T:
|
|
||||||
youtube_url = f'https://www.youtube.com/watch?v={self.title}'
|
|
||||||
|
|
||||||
cookies_d: dict[str, str] = self.session.cookies.get_dict()
|
|
||||||
|
|
||||||
sapisid = cookies_d.get('__Secure-3PAPISID', '')
|
|
||||||
epoch = "1699161578"
|
|
||||||
origin = "https://www.youtube.com"
|
|
||||||
data = f"{epoch} {sapisid} {origin}"
|
|
||||||
sha1 = hashlib.sha1()
|
|
||||||
sha1.update(data.encode('utf-8'))
|
|
||||||
sapisidhash = f"SAPISIDHASH {epoch}_{sha1.hexdigest()}"
|
|
||||||
|
|
||||||
response = self.session.get(youtube_url)
|
|
||||||
soup = BeautifulSoup(response.text, 'html.parser')
|
|
||||||
|
|
||||||
yt_scripts = soup.find_all('script', string=re.compile('ytcfg.set'))
|
|
||||||
|
|
||||||
user_agent_extracted = "YouTube/15.49.4 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Mobile Safari/537.36 EdgA/46.0.0.1 GoogleTV/YouTube/16.12.34 (compatible; Widevine/1.4.8)"
|
|
||||||
client_name_extracted = "null"
|
|
||||||
client_version_extracted = "null"
|
|
||||||
id_token_extracted = "null"
|
|
||||||
vision_data_extracted = "null"
|
|
||||||
session_id_extracted = "null"
|
|
||||||
logged_yt_extracted = False
|
|
||||||
|
|
||||||
for yt_script in yt_scripts:
|
|
||||||
yt_script_content = yt_script.string
|
|
||||||
try:
|
|
||||||
match = re.search(r'ytcfg\.set\((.*?)\);', yt_script_content, re.DOTALL)
|
|
||||||
if match:
|
|
||||||
ytcfg_set_content = json.loads(match.group(1))
|
|
||||||
id_token_extracted = ytcfg_set_content.get("ID_TOKEN", id_token_extracted)
|
|
||||||
vision_data_extracted = ytcfg_set_content.get("INNERTUBE_CONTEXT", {}).get("client", {}).get("visitorData", vision_data_extracted)
|
|
||||||
session_id_extracted = ytcfg_set_content.get("SESSION_INDEX", session_id_extracted)
|
|
||||||
client_context = ytcfg_set_content.get('INNERTUBE_CONTEXT', {}).get('client', {})
|
|
||||||
user_agent_extracted = client_context.get('userAgent', user_agent_extracted)
|
|
||||||
client_version_extracted = client_context.get('clientVersion', client_version_extracted)
|
|
||||||
client_name_extracted = client_context.get('clientName', client_name_extracted)
|
|
||||||
logged_yt_extracted = ytcfg_set_content.get("LOGGED_IN", logged_yt_extracted)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'authorization': sapisidhash,
|
|
||||||
'origin': origin,
|
|
||||||
'user-agent': user_agent_extracted,
|
|
||||||
'X-YouTube-Client-Version': client_version_extracted,
|
|
||||||
'X-Youtube-Identity-Token': id_token_extracted,
|
|
||||||
'x-goog-authuser': session_id_extracted,
|
|
||||||
'x-goog-visitor-id': vision_data_extracted,
|
|
||||||
'x-youtube-bootstrap-logged-in': f"{logged_yt_extracted}",
|
|
||||||
'x-youtube-client-version': client_version_extracted
|
|
||||||
}
|
|
||||||
|
|
||||||
params_get_titles = {'key': self.API_KEY}
|
|
||||||
|
|
||||||
json_data_payload = {
|
|
||||||
'context': {
|
|
||||||
'client': {
|
|
||||||
'userAgent': user_agent_extracted,
|
|
||||||
'clientName': client_name_extracted,
|
|
||||||
'clientVersion': client_version_extracted,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'videoId': self.title
|
|
||||||
}
|
|
||||||
|
|
||||||
response_data = self.session.post(self.YOUTUBE_VIDEO_INFO_URL, params=params_get_titles, headers=headers, json=json_data_payload).json()
|
|
||||||
|
|
||||||
streaming_data = response_data.get('streamingData')
|
|
||||||
if not streaming_data:
|
|
||||||
return Movies([])
|
|
||||||
|
|
||||||
title_name = response_data['videoDetails']['title']
|
|
||||||
|
|
||||||
self.last_response_data = response_data
|
|
||||||
self.last_response_data['ytcfg_params'] = {
|
|
||||||
'user_agent': user_agent_extracted,
|
|
||||||
'client_name': client_name_extracted,
|
|
||||||
'client_version': client_version_extracted,
|
|
||||||
'id_token': id_token_extracted,
|
|
||||||
'vision_data': vision_data_extracted,
|
|
||||||
'session_id': session_id_extracted,
|
|
||||||
'logged_yt': logged_yt_extracted,
|
|
||||||
'video_id': self.title
|
|
||||||
}
|
|
||||||
self.last_response_data['request_headers'] = headers
|
|
||||||
|
|
||||||
return Movies([Movie(
|
|
||||||
id_=self.title,
|
|
||||||
service=self.__class__,
|
|
||||||
name=title_name,
|
|
||||||
year=None,
|
|
||||||
language=None
|
|
||||||
)])
|
|
||||||
|
|
||||||
def get_tracks(self, title_obj: Title_T) -> Tracks:
|
|
||||||
response_data = self.last_response_data
|
|
||||||
dashManifestUrl = response_data.get('streamingData', {}).get('dashManifestUrl')
|
|
||||||
|
|
||||||
return DASH.from_url(dashManifestUrl, session=self.session).to_tracks(language="en")
|
|
||||||
|
|
||||||
def get_chapters(self, title: Title_T) -> Chapters:
|
|
||||||
return []
|
|
||||||
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[str]:
|
|
||||||
response_data = self.last_response_data
|
|
||||||
drm_params = response_data.get("streamingData", {}).get("drmParams")
|
|
||||||
|
|
||||||
ytcfg_params = response_data.get('ytcfg_params', {})
|
|
||||||
user_agent = ytcfg_params.get('user_agent')
|
|
||||||
clientName = ytcfg_params.get('client_name')
|
|
||||||
clientVersion = ytcfg_params.get('client_version')
|
|
||||||
video_id = ytcfg_params.get('video_id')
|
|
||||||
session_id = ytcfg_params.get('session_id')
|
|
||||||
|
|
||||||
request_headers = response_data.get('request_headers', {})
|
|
||||||
|
|
||||||
lic_url = self.LICENSE_SERVER_URL
|
|
||||||
|
|
||||||
params_license = {'key': self.API_KEY}
|
|
||||||
|
|
||||||
json_data = {
|
|
||||||
'context': {
|
|
||||||
'client': {
|
|
||||||
'userAgent': user_agent,
|
|
||||||
'clientName': clientName,
|
|
||||||
'clientVersion': clientVersion,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'drmSystem': 'DRM_SYSTEM_WIDEVINE',
|
|
||||||
'videoId': video_id,
|
|
||||||
'cpn': 'MsQQaCE9gAkD9iLF',
|
|
||||||
'sessionId': session_id,
|
|
||||||
'drmParams': drm_params
|
|
||||||
}
|
|
||||||
|
|
||||||
json_data["licenseRequest"] = base64.b64encode(challenge).decode("utf-8")
|
|
||||||
|
|
||||||
get_res = requests.post(lic_url, params=params_license, headers=request_headers, json=json_data).json()
|
|
||||||
license_b64 = get_res.get("license")
|
|
||||||
if license_b64:
|
|
||||||
return license_b64.replace("-", "+").replace("_", "/")
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
cookie:
|
|
||||||
cookies\YTBE\default.txt
|
|
||||||
|
|
||||||
service:
|
|
||||||
services\YTBE\__init__.py
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
# Netscape HTTP Cookie File
|
|
||||||
|
|
||||||
.youtube.com TRUE / FALSE 1784370918 SID g.a000yAi9zzvdmi_M0Ze8c5X8qqXPAbM56-hFbHVgrA6DD4z5dISmTmFdTrrsfZMgIvZlmc_x-wACgYKAYISARQSFQHGX2MizhHhetLRoYNmuk2aEZCyQRoVAUF8yKqzF7bSpf29WTmggHnnz0gS0076
|
|
||||||
.youtube.com TRUE / TRUE 1781524392 __Secure-1PSIDTS sidts-CjIB5H03P2jhDmsG_y7f4uI_HqL0UW7y3a2CvxAErOwihIlb7QaTWfLXaCiztkUP9b106RAA
|
|
||||||
.youtube.com TRUE / TRUE 1781524392 __Secure-3PSIDTS sidts-CjIB5H03P2jhDmsG_y7f4uI_HqL0UW7y3a2CvxAErOwihIlb7QaTWfLXaCiztkUP9b106RAA
|
|
||||||
.youtube.com TRUE / TRUE 1784370918 __Secure-1PSID g.a000yAi9zzvdmi_M0Ze8c5X8qqXPAbM56-hFbHVgrA6DD4z5dISm0UWUFcaDv3bSngT-YtzptQACgYKAU8SARQSFQHGX2MiQQcOmo2GIkSr7_bS2DFxtRoVAUF8yKoqq73_7YxHCaAkcWc8Vv-40076
|
|
||||||
.youtube.com TRUE / TRUE 1784370918 __Secure-3PSID g.a000yAi9zzvdmi_M0Ze8c5X8qqXPAbM56-hFbHVgrA6DD4z5dISmXYyhva_jPKOPYEn31p_zkAACgYKAakSARQSFQHGX2MiQVPhEE5D6OvV9WhBJDo7sxoVAUF8yKo8bFy_jXoYyyKECLRrZYE-0076
|
|
||||||
.youtube.com TRUE / FALSE 1784370918 HSID ACBOZgbd6SYdWeivd
|
|
||||||
.youtube.com TRUE / TRUE 1784370918 SSID A30Hz0ZyxnNqA0j1K
|
|
||||||
.youtube.com TRUE / FALSE 1784370918 APISID s4r2XN1pV_ZwNlsa/A7jGociFTm8siA3sH
|
|
||||||
.youtube.com TRUE / TRUE 1784370918 SAPISID PyRmBXh4-UYF06Jb/AYUJ9V69VnwRk-Ekv
|
|
||||||
.youtube.com TRUE / TRUE 1784370918 __Secure-1PAPISID PyRmBXh4-UYF06Jb/AYUJ9V69VnwRk-Ekv
|
|
||||||
.youtube.com TRUE / TRUE 1784370918 __Secure-3PAPISID PyRmBXh4-UYF06Jb/AYUJ9V69VnwRk-Ekv
|
|
||||||
.youtube.com TRUE / TRUE 1771161048 LOGIN_INFO AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug:QUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR
|
|
||||||
.youtube.com TRUE / TRUE 1770729047 __Secure-YEC CgtDdkwxVTVxcUo1QSja24m8BjIKCgJHQhIEGgAgKQ%3D%3D
|
|
||||||
.youtube.com TRUE / TRUE 1765540387 VISITOR_PRIVACY_METADATA CgJHQhIEGgAgDA%3D%3D
|
|
||||||
.youtube.com TRUE / TRUE 1765529606 __Secure-ROLLOUT_TOKEN CMO51fHrwfLMswEQoJ-lhd_tigMYhZnIzIbzjQM%3D
|
|
||||||
.youtube.com TRUE / FALSE 1781524395 SIDCC AKEyXzUkr0sNN4Mi7pHTodkzRycIaC6OCTX_kwyieFis8refmRe8qs0SSYI0AAz-IGh9sWnlDw
|
|
||||||
.youtube.com TRUE / TRUE 1781524395 __Secure-1PSIDCC AKEyXzWAuSRYdboa3aV3HRLiIU9ww5i6lr6_gPOL9iFOP5pGlT8yjYRHLQfxj59Ywgy3IrRiQw
|
|
||||||
.youtube.com TRUE / TRUE 1781524395 __Secure-3PSIDCC AKEyXzXAHZUOMDCa_OYvSMTI80GRBaqv7nGhVZYfM-a6V7LTMjz-w-Nlgjz14hlhA-qDQAjNew
|
|
||||||
.youtube.com TRUE / TRUE 1784548388 PREF f4=4000000&tz=Europe.London&f5=30000&f7=100
|
|
||||||
.youtube.com TRUE / TRUE 1765540387 VISITOR_INFO1_LIVE V4hGlzDr7Ok
|
|
||||||
.youtube.com TRUE / FALSE 1749372950 ST-1dypcsd itct=CAEQ7fkGGBMiEwip_eynuOGNAxWWPHsEHRH-E52aAQUIMhD0JA%3D%3D&csn=F_16dU8Nut7TU4Rd&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAEQ7fkGGBMiEwip_eynuOGNAxWWPHsEHRH-E52aAQUIMhD0JA%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F9Y6eA6XQEoM%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%229Y6eA6XQEoM%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYL58CrEZkKs_NSalGcXeozGqBkNBT0FyQkZ2dkw2X05xbE5pdF9sclhIam1lc19BdmlWTEZZWlo1RjZPaWxXTnVwY1VnOXBCcGVQQXNvczg5SnlJWmpFkAcCuAeb_-6nuOGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F9Y6eA6XQEoM%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAIQsLUEIhMIqf3sp7jhjQMVljx7BB0R_hOd%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwib_-6nuOGNAxXHhfQHHT0vAjUqAEoPEg1wZXRlciBtaWxsYXJkogETCPrd8KCy4Y0DFczoSQcdU5UozA%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2272df6f9e-0000-2e11-bdef-7474463f47b5%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373013 ST-15dqge8 itct=CA8Q7fkGGAEiEwjoyJP0ueGNAxX7EnMJHW-5N-w%3D&csn=loqBZaESuFvP0ud0&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CA8Q7fkGGAEiEwjoyJP0ueGNAxX7EnMJHW-5N-w%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FWsOLOqUWAjo%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22WsOLOqUWAjo%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYHfEqXvM8KmTQaVMzc1ZGwKqBkNBT0FyQkZzQkdrR2VyOTdtR2F0OXlCeldUd2FzeHhocW1IeUs2dXpkR2g0YUhtRXpuU1FBWDNkMHFZLTdCWW5Qa2J3kAcCuAeqw5T0ueGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FWsOLOqUWAjo%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBAQsLUEIhMI6MiT9LnhjQMV-xJzCR1vuTfs%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwiqw5T0ueGNAxXeLAYAHQ0FG18qAEoPEg1wZXRlciBtaWxsYXJkogETCKn97Ke44Y0DFZY8ewQdEf4TnQ%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22731b6fe4-0000-296f-ae27-fc4116730059%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373051 ST-1abeef5 itct=CA0Q7fkGGAIiEwjoyJP0ueGNAxX7EnMJHW-5N-w%3D&csn=WcC327uIw2wtCk9M&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CA0Q7fkGGAIiEwjoyJP0ueGNAxX7EnMJHW-5N-w%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FHjBuHH8kM38%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22HjBuHH8kM38%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYFzvYzkXrWCp5Ee_22eVdTqqBkNBT0FyQkZzTHl0d3hBSWdHSjMyOVZIdWtKT2NWanNHQ2k5TVRwMVg2cVc5MVVOTVdwY2VnamY0cERwX0NJTWZ0bkRVkAcCuAeqw5T0ueGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FHjBuHH8kM38%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CA4QsLUEIhMI6MiT9LnhjQMV-xJzCR1vuTfs%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwiqw5T0ueGNAxXeLAYAHQ0FG18qAEoPEg1wZXRlciBtaWxsYXJkogETCKn97Ke44Y0DFZY8ewQdEf4TnQ%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22731b6fe5-0000-296f-ae27-fc4116730059%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373056 ST-yrkwx1 itct=CAoQ7fkGGAQiEwjoyJP0ueGNAxX7EnMJHW-5N-w%3D&csn=XkIusWQlGgB_0JNC&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAoQ7fkGGAQiEwjoyJP0ueGNAxX7EnMJHW-5N-w%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2Fte6NUWY5csE%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22te6NUWY5csE%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYHyFaQW2w0AAkaZJ5Mjwk5SqBkNBT0FyQkZ1Rmdjblp5eVJpbDg5bmg4YlN3ZFVRajhMVW1kZDNTVy1ncEdPZm4xRWpCMVFVREU1X1kzNFZQa1d3aC04kAcCuAeqw5T0ueGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2Fte6NUWY5csE%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A980%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAsQsLUEIhMI6MiT9LnhjQMV-xJzCR1vuTfs%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwiqw5T0ueGNAxXeLAYAHQ0FG18qAEoPEg1wZXRlciBtaWxsYXJkogETCKn97Ke44Y0DFZY8ewQdEf4TnQ%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22731b6fe7-0000-296f-ae27-fc4116730059%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373081 ST-1iu5721 itct=CAgQ7fkGGAUiEwjoyJP0ueGNAxX7EnMJHW-5N-w%3D&csn=aIGFV7KmJcwwNvIA&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAgQ7fkGGAUiEwjoyJP0ueGNAxX7EnMJHW-5N-w%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F9quGg5wlns4%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%229quGg5wlns4%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYJcV3GPqcy17JbBkGEDQgrGqBkNBT0FyQkZ0OXZoX1lpRUpwU1VRVGVIRWpFTDBfTzZZb19Hb05JZGtJUEpiZmNQMkg0X0RrTVFXckF0dzJGd3lmbDRZkAcCuAeqw5T0ueGNA_AHAg%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F9quGg5wlns4%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAkQsLUEIhMI6MiT9LnhjQMV-xJzCR1vuTfs%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwiqw5T0ueGNAxXeLAYAHQ0FG18qAEoPEg1wZXRlciBtaWxsYXJkogETCKn97Ke44Y0DFZY8ewQdEf4TnQ%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22731b6fe8-0000-296f-ae27-fc4116730059%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373089 ST-gxsb97 itct=CAYQ7fkGGAYiEwjoyJP0ueGNAxX7EnMJHW-5N-w%3D&csn=lNsCLotRj_XBHX9y&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAYQ7fkGGAYiEwjoyJP0ueGNAxX7EnMJHW-5N-w%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2Fy6gtcX3K42Y%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22y6gtcX3K42Y%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYKOUZyqdatTbBsFU3s4nuxuqBkNBT0FyQkZ0c2VteVdXMFFtc3VDQUJhZ3NncjE4OXF2X1NJSkFKZFlGYl94ck5oTnBTVUptcUxrVlFDR0RZdTV6RVJVkAcCuAeqw5T0ueGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2Fy6gtcX3K42Y%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAcQsLUEIhMI6MiT9LnhjQMV-xJzCR1vuTfs%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwiqw5T0ueGNAxXeLAYAHQ0FG18qAEoPEg1wZXRlciBtaWxsYXJkogETCKn97Ke44Y0DFZY8ewQdEf4TnQ%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22731b6fe9-0000-296f-ae27-fc4116730059%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373106 ST-3zatce itct=CAMQ7fkGGAgiEwjoyJP0ueGNAxX7EnMJHW-5N-w%3D&csn=nWeCpdo-xYPJ-gVl&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAMQ7fkGGAgiEwjoyJP0ueGNAxX7EnMJHW-5N-w%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F-JHrWrUhv3I%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22-JHrWrUhv3I%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYC9X8dKK2-xw_8WM5UXo18eqBkNBT0FyQkZ0cmhyanRFeWQ4VzdvRXh6WGF2ckZza0lVLUJJbkVPUDlsaDk4eXROa1dFakc2cTRhbFoyMS1ZR3A0SWxjkAcCuAeqw5T0ueGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F-JHrWrUhv3I%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAQQsLUEIhMI6MiT9LnhjQMV-xJzCR1vuTfs%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwiqw5T0ueGNAxXeLAYAHQ0FG18qAEoPEg1wZXRlciBtaWxsYXJkogETCKn97Ke44Y0DFZY8ewQdEf4TnQ%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22731b6feb-0000-296f-ae27-fc4116730059%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373144 ST-1sqvti0 itct=CAEQ7fkGGAkiEwjoyJP0ueGNAxX7EnMJHW-5N-w%3D&csn=oHnRYccDDOvcqiAg&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAEQ7fkGGAkiEwjoyJP0ueGNAxX7EnMJHW-5N-w%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F4e-UiG0PlUA%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%224e-UiG0PlUA%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYCzzJriZK87U1sO2tA1IyxCqBkNBT0FyQkZzTTJiSHN0VzVJZzBWYXJQQjFDWGt4UVNITGNMMU1kY0N3aFFPbVpSbnpQMW5ZTGJlVmFSLWFFN2doUXMwkAcCuAeqw5T0ueGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F4e-UiG0PlUA%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAIQsLUEIhMI6MiT9LnhjQMV-xJzCR1vuTfs%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwiqw5T0ueGNAxXeLAYAHQ0FG18qAEoPEg1wZXRlciBtaWxsYXJkogETCKn97Ke44Y0DFZY8ewQdEf4TnQ%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22731b6fec-0000-296f-ae27-fc4116730059%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373166 ST-4d25t8 itct=CB8Q7fkGGAIiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D&csn=OL7vzPUZ-xslnycr&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CB8Q7fkGGAIiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2Fpry3apQjhc4%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22pry3apQjhc4%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYDSav8QIFhxxVpF1nzs1tUGqBkNBT0FyQkZ1YWJKRGtEN0lkRlg4ZlJLSFJhZVE3eDZRUDZHNHZzQjZrUEVPOTZlWm5xa3QyTzk3dFV0UVZha0xMMzBBkAcCuAfziJrSuuGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2Fpry3apQjhc4%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CCAQsLUEIhMIuriY0rrhjQMVbAt7BB1ochO5%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwjziJrSuuGNAxUwJQYAHcGCBBgqAEoPEg1wZXRlciBtaWxsYXJkogETCOjIk_S54Y0DFfsScwkdb7k37A%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2271307f58-0000-2268-9da0-30fd3812b2b8%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373161 ST-1gjp20i itct=CCIQ7fkGGAAiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D&csn=cdz4We7g7ttoUKUC&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CCIQ7fkGGAAiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FdxtVikmnLAI%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22dxtVikmnLAI%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYOERiTYayhGEln6TiFPYVPOqBkNBT0FyQkZ1b0toZWt2U29qOXBXdjRJTUU3Z3ZSQ3J5NUhjRW5veWZJM2pCbXhObWhYV2NKOVdLVVVyT2RUb25HcmZNkAcCuAfziJrSuuGNA_AHAg%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FdxtVikmnLAI%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CCMQsLUEIhMIuriY0rrhjQMVbAt7BB1ochO5%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwjziJrSuuGNAxUwJQYAHcGCBBgqAEoPEg1wZXRlciBtaWxsYXJkogETCOjIk_S54Y0DFfsScwkdb7k37A%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2271307f53-0000-2268-9da0-30fd3812b2b8%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373187 ST-kpjmio itct=CB0Q7fkGGAMiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D&csn=bn_uKLozM2jrxmU4&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CB0Q7fkGGAMiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FmjkKlE-cm1Y%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22mjkKlE-cm1Y%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYPG3R0j38uLrEXgBNIC6FgqqBkNBT0FyQkZ2SWJzSVBQTndkS3hWT0xvVFQ2NmhVQlpFOFE1NFBCVXpTeVUzdU5jYUV5aVpZY0taclRCUktyRWJ0VGZrkAcCuAfziJrSuuGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FmjkKlE-cm1Y%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CB4QsLUEIhMIuriY0rrhjQMVbAt7BB1ochO5%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwjziJrSuuGNAxUwJQYAHcGCBBgqAEoPEg1wZXRlciBtaWxsYXJkogETCOjIk_S54Y0DFfsScwkdb7k37A%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2271307f59-0000-2268-9da0-30fd3812b2b8%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373195 ST-bgzyar itct=CBsQ7fkGGAQiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D&csn=AT3QoAD574FgN-2Y&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CBsQ7fkGGAQiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FXsfHCOoz-pc%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22XsfHCOoz-pc%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYM1Z1aBZpz_qGGW1CXj5_XuqBkNBT0FyQkZ1ZDNodHBPVnhZN2g5OHpVN2VTYzV5dGJ4YmVwQ21lWUFYZmVJQmFEOVcyVzQxRG1fQ0RvOHhmRU50V3VrkAcCuAfziJrSuuGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FXsfHCOoz-pc%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBwQsLUEIhMIuriY0rrhjQMVbAt7BB1ochO5%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwjziJrSuuGNAxUwJQYAHcGCBBgqAEoPEg1wZXRlciBtaWxsYXJkogETCOjIk_S54Y0DFfsScwkdb7k37A%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2271307f5a-0000-2268-9da0-30fd3812b2b8%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373223 ST-19p12fx itct=CBgQ7fkGGAYiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D&csn=-8N5a1NMLInV6ulR&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CBgQ7fkGGAYiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F6NCEio1aUhI%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%226NCEio1aUhI%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYFfIrJ8kfK1Mn3fszGATchOqBkNBT0FyQkZ2MTkwVHpvUjBXMmlMci1Oa1FaZnd2NThvUHdMOVc1X1VBa2NxbDdrNFRaRENaa0ZBWUVjR0tKbzJUa3pzkAcCuAfziJrSuuGNA_AHAg%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F6NCEio1aUhI%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBkQsLUEIhMIuriY0rrhjQMVbAt7BB1ochO5%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwjziJrSuuGNAxUwJQYAHcGCBBgqAEoPEg1wZXRlciBtaWxsYXJkogETCOjIk_S54Y0DFfsScwkdb7k37A%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2271307f5c-0000-2268-9da0-30fd3812b2b8%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373234 ST-6uhlkz itct=CBYQ7fkGGAciEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D&csn=5v_4cn1p493COCpW&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CBYQ7fkGGAciEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2Fep2ehH49V2k%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22ep2ehH49V2k%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYO8kqchnmil2_8onCt3YdoSqBkNBT0FyQkZ0OTBrZklKTzdZV3djaXZZSXJvbUN4YmZ3QU1YNDNmWVd6bm5VNEN0VGNELURiUHNYXzRJWjZXS2ltRTB3kAcCuAfziJrSuuGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2Fep2ehH49V2k%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBcQsLUEIhMIuriY0rrhjQMVbAt7BB1ochO5%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwjziJrSuuGNAxUwJQYAHcGCBBgqAEoPEg1wZXRlciBtaWxsYXJkogETCOjIk_S54Y0DFfsScwkdb7k37A%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2271307f5d-0000-2268-9da0-30fd3812b2b8%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373262 ST-1fj3sm7 itct=CBQQ7fkGGAgiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D&csn=Zu3v81wvas-1tvgg&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CBQQ7fkGGAgiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F3QKL1vygH5g%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%223QKL1vygH5g%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYHvIM9KIDSbVrV_soIBDAfWqBkNBT0FyQkZ1X3FPUEUzVGJvSXFtbWg0N19uT2Q1ZUZZbUk2eFRaMlVRT1Jrem4tRXJTQ2JvbFhBMzhlZkx1VWIyTHVZkAcCuAfziJrSuuGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F3QKL1vygH5g%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBUQsLUEIhMIuriY0rrhjQMVbAt7BB1ochO5%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwjziJrSuuGNAxUwJQYAHcGCBBgqAEoPEg1wZXRlciBtaWxsYXJkogETCOjIk_S54Y0DFfsScwkdb7k37A%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2271307f5e-0000-2268-9da0-30fd3812b2b8%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373276 ST-1vz2pmn itct=CBEQ7fkGGAoiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D&csn=siyDFp69HdzCnKZj&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CBEQ7fkGGAoiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FUCFDJXUUsRA%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22UCFDJXUUsRA%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYI0F8IsUPpFWJ4WTgGopsfyqBkNBT0FyQkZ2UWx0R2FkRGY4dTNqUDdqcUY1NmtVcGN1Rm5jaWtsc1VTZ3BtalREcFFlTHhkNE5mN2pKU2c3ZlZRa0N3kAcCuAfziJrSuuGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FUCFDJXUUsRA%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBIQsLUEIhMIuriY0rrhjQMVbAt7BB1ochO5%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwjziJrSuuGNAxUwJQYAHcGCBBgqAEoPEg1wZXRlciBtaWxsYXJkogETCOjIk_S54Y0DFfsScwkdb7k37A%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2271307f60-0000-2268-9da0-30fd3812b2b8%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373284 ST-1sdmqg4 itct=CA8Q7fkGGAsiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D&csn=-RR73HfmMDM7hWHB&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CA8Q7fkGGAsiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FkOudRAGvb7s%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22kOudRAGvb7s%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYDiqZeB4ow0dHqPb2Kh9m6yqBkNBT0FyQkZzRHA1OGJuVjFpdXY2QVg5eEdGa0NreUIyOUE4OXAyTUJPTHNERzktU1ZSRXg0akFlR3BEZm9Nc0NqZVdVkAcCuAfziJrSuuGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FkOudRAGvb7s%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBAQsLUEIhMIuriY0rrhjQMVbAt7BB1ochO5%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwjziJrSuuGNAxUwJQYAHcGCBBgqAEoPEg1wZXRlciBtaWxsYXJkogETCOjIk_S54Y0DFfsScwkdb7k37A%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2271307f61-0000-2268-9da0-30fd3812b2b8%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373300 ST-16oz5n itct=CA0Q7fkGGAwiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D&csn=24H69aeCGelg3ohM&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CA0Q7fkGGAwiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FgInnRXb5le8%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22gInnRXb5le8%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYKPn9dFPobbrjdnpyqOmRxyqBkNBT0FyQkZzOHZXb0tmMzhaSVdldFdjUWt2Vk1XQWloX2Y1RmxRaUloLXN3UkdITHpycWhpVlNxRVhrVHRPVVgxRTlVkAcCuAfziJrSuuGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FgInnRXb5le8%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CA4QsLUEIhMIuriY0rrhjQMVbAt7BB1ochO5%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwjziJrSuuGNAxUwJQYAHcGCBBgqAEoPEg1wZXRlciBtaWxsYXJkogETCOjIk_S54Y0DFfsScwkdb7k37A%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2271307f62-0000-2268-9da0-30fd3812b2b8%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373336 ST-16im15o itct=CAoQ7fkGGA4iEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D&csn=RmNYSgmN30NtFp8S&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAoQ7fkGGA4iEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F60aCWGJL1V8%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%2260aCWGJL1V8%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYJsYYHbEUAXW3yUbkcsWS5iqBkNBT0FyQkZ1SVU1TTBuNzktSm5zYU1ZbnNIWXEtSGtGV2tlUmNoVm5Xa1g0dGVHdmVHM3E5cV9WckhORjBxU1ZfTHNjkAcCuAfziJrSuuGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F60aCWGJL1V8%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAsQsLUEIhMIuriY0rrhjQMVbAt7BB1ochO5%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwjziJrSuuGNAxUwJQYAHcGCBBgqAEoPEg1wZXRlciBtaWxsYXJkogETCOjIk_S54Y0DFfsScwkdb7k37A%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2271307f64-0000-2268-9da0-30fd3812b2b8%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373397 ST-1bozjj0 itct=CAgQ7fkGGA8iEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D&csn=8B2T7xuwIPF5xBjR&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAgQ7fkGGA8iEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FNecV48Z3p5Q%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22NecV48Z3p5Q%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYG6DQWocacwEd_41C4UlVeeqBkNBT0FyQkZ1aF9Hcl9qQVBSa0YwZ3dpeXRDS3RSTkI5dDdDSDZVVWNEanFCdFRhOFpDOS1vUFVrZEZOb1NWZFpUb3VnkAcCuAfziJrSuuGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FNecV48Z3p5Q%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAkQsLUEIhMIuriY0rrhjQMVbAt7BB1ochO5%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwjziJrSuuGNAxUwJQYAHcGCBBgqAEoPEg1wZXRlciBtaWxsYXJkogETCOjIk_S54Y0DFfsScwkdb7k37A%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2271307f65-0000-2268-9da0-30fd3812b2b8%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373430 ST-glheo3 itct=CAYQ7fkGGBAiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D&csn=J0psGEA1upJ4xllC&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAYQ7fkGGBAiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FyI9HiiU_uKk%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22yI9HiiU_uKk%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYOzvAbPR-ZxFOITqs3cNH6-qBkNBT0FyQkZ0aXVpeXVKZm41RXVvZlpQVkRTc3BJdUkxa01QSW96NlJINnlfX3E0UHpNUG40U0xHX2JoM0JRRkFwcEJ3kAcCuAfziJrSuuGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FyI9HiiU_uKk%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAcQsLUEIhMIuriY0rrhjQMVbAt7BB1ochO5%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwjziJrSuuGNAxUwJQYAHcGCBBgqAEoPEg1wZXRlciBtaWxsYXJkogETCOjIk_S54Y0DFfsScwkdb7k37A%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2271307f66-0000-2268-9da0-30fd3812b2b8%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373437 ST-12bckge itct=CAMQ7fkGGBIiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D&csn=4vV_1lOegrMl-55q&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAMQ7fkGGBIiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FgawaiMsu0Zk%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22gawaiMsu0Zk%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYMwXMTtiEBfWUeebnMW5rQ6qBkNBT0FyQkZ1V1ZZWU04aDFrYnp0bkRTcUl4dTNOUVNSQjFfMXNGMHBZZTFVYnl0TjhHNFh6WnlRSkx2MUVseTlUcFZBkAcCuAfziJrSuuGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FgawaiMsu0Zk%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAQQsLUEIhMIuriY0rrhjQMVbAt7BB1ochO5%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwjziJrSuuGNAxUwJQYAHcGCBBgqAEoPEg1wZXRlciBtaWxsYXJkogETCOjIk_S54Y0DFfsScwkdb7k37A%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2271307f68-0000-2268-9da0-30fd3812b2b8%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373438 ST-1jkhrea endpoint=%7B%22clickTrackingParams%22%3A%22CAEQ7fkGGBMiEwi6uJjSuuGNAxVsC3sEHWhyE7k%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FSXbpN_n8m-I%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22SXbpN_n8m-I%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYKbWvnYJddMIsvmSdJ18XL-qBkNBT0FyQkZ1bmhkcGtpaEQ2Z0liTWdrOGtRSzlkS3hWZ2pzZmU1MTlPRWNrRDRkVFlvclVQT2Q1QzhMUmVVdWh6SGRRkAcCuAfziJrSuuGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FSXbpN_n8m-I%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAIQsLUEIhMIuriY0rrhjQMVbAt7BB1ochO5%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwjziJrSuuGNAxUwJQYAHcGCBBgqAEoPEg1wZXRlciBtaWxsYXJkogETCOjIk_S54Y0DFfsScwkdb7k37A%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2271307f69-0000-2268-9da0-30fd3812b2b8%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373443 ST-nb2m8m itct=CBAQ7fkGGAEiEwjqwqvwu-GNAxU9KAYAHVUCDCw%3D&csn=kqLHN0ZF2OGkLm3a&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CBAQ7fkGGAEiEwjqwqvwu-GNAxU9KAYAHVUCDCw%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FZgG6vism2Aw%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22ZgG6vism2Aw%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYAw1CRR3NCwwE3361CLOJvOqBkNBT0FyQkZ0WG5VU0JEOHhXZFprUl9QRjdxSkR3MnhpVkZySTlMYjZjZzJpci0tYk5Ud19RdExYTU9LVnJnb0p4QkhnkAcCuAek1qzwu-GNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FZgG6vism2Aw%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBEQsLUEIhMI6sKr8LvhjQMVPSgGAB1VAgws%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwik1qzwu-GNAxVW6UIFHRlwFLoqAEoPEg1wZXRlciBtaWxsYXJkogETCLq4mNK64Y0DFWwLewQdaHITuQ%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22734f7281-0000-2255-80fa-14223bc91526%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373447 ST-2t59nu itct=CA4Q7fkGGAIiEwjqwqvwu-GNAxU9KAYAHVUCDCw%3D&csn=Rnx9dSvF0eFKbLMW&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CA4Q7fkGGAIiEwjqwqvwu-GNAxU9KAYAHVUCDCw%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FlVGngRWM-Og%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22lVGngRWM-Og%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYM_IO1MOL0RZzIwxihRKeaCqBkNBT0FyQkZ0OUFTM0dGR08zd3BJa2ZGczF4cm1VdjMzQzZMalltbjhPbE9vOW5wOV9wcXZxc1RaeHBuYW0tMlFYTEVnkAcCuAek1qzwu-GNA_AHAg%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FlVGngRWM-Og%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CA8QsLUEIhMI6sKr8LvhjQMVPSgGAB1VAgws%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwik1qzwu-GNAxVW6UIFHRlwFLoqAEoPEg1wZXRlciBtaWxsYXJkogETCLq4mNK64Y0DFWwLewQdaHITuQ%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22734f7282-0000-2255-80fa-14223bc91526%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373448 ST-1c7p0mc itct=CAwQ7fkGGAMiEwjqwqvwu-GNAxU9KAYAHVUCDCw%3D&csn=Jtcm5CWB7058XCQc&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAwQ7fkGGAMiEwjqwqvwu-GNAxU9KAYAHVUCDCw%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F85v0dGiQVRk%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%2285v0dGiQVRk%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYMe_NTOrjkNPokqGmrWxK5KqBkNBT0FyQkZzb0FCZ3lhNmxYRE1fMFp3YnBKemc0cFEtbFRKXzMtYW9yZU52a1NkalFyS19sQ2gza3ZsTl95RzN1YXdnkAcCuAek1qzwu-GNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F85v0dGiQVRk%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CA0QsLUEIhMI6sKr8LvhjQMVPSgGAB1VAgws%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwik1qzwu-GNAxVW6UIFHRlwFLoqAEoPEg1wZXRlciBtaWxsYXJkogETCLq4mNK64Y0DFWwLewQdaHITuQ%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22734f7283-0000-2255-80fa-14223bc91526%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373450 ST-idmxf6 endpoint=%7B%22clickTrackingParams%22%3A%22CAkQ7fkGGAUiEwjqwqvwu-GNAxU9KAYAHVUCDCw%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FDxKQ9RCLnNw%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22DxKQ9RCLnNw%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYA2XG7uOnZ2wXzoZ-o5GiM-qBkNBT0FyQkZzRk1ma1dsNEdkQ0VKUVlnTDhnOEsxcWdoUGw0OEk5MVNDRk1YOU95aEgtN3VYSi1uVnpZZWR1NkZuVC1BkAcCuAek1qzwu-GNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FDxKQ9RCLnNw%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAoQsLUEIhMI6sKr8LvhjQMVPSgGAB1VAgws%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwik1qzwu-GNAxVW6UIFHRlwFLoqAEoPEg1wZXRlciBtaWxsYXJkogETCLq4mNK64Y0DFWwLewQdaHITuQ%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22734f7285-0000-2255-80fa-14223bc91526%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373514 ST-ievrv2 itct=CAcQ7fkGGAYiEwjqwqvwu-GNAxU9KAYAHVUCDCw%3D&csn=eVxFb0oLCsTQHTXC&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAcQ7fkGGAYiEwjqwqvwu-GNAxU9KAYAHVUCDCw%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FaMrmICtRStw%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22aMrmICtRStw%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYOrQSPsD7ZmFVPlotrfvsKWqBkNBT0FyQkZ0RHpyMDZuS0FUWnJTWjhqVll5UWR6ZjNxLWR2UW1vOFJNQW03eThCdkJEaEUyLWJReUNQdmx3Wm9qQ2d3kAcCuAek1qzwu-GNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FaMrmICtRStw%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAgQsLUEIhMI6sKr8LvhjQMVPSgGAB1VAgws%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwik1qzwu-GNAxVW6UIFHRlwFLoqAEoPEg1wZXRlciBtaWxsYXJkogETCLq4mNK64Y0DFWwLewQdaHITuQ%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22734f7286-0000-2255-80fa-14223bc91526%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373539 ST-1kt151p itct=CAMQ7fkGGAgiEwjqwqvwu-GNAxU9KAYAHVUCDCw%3D&csn=Bh-emeaygL4h4Ylh&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAMQ7fkGGAgiEwjqwqvwu-GNAxU9KAYAHVUCDCw%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FmakItjTLGVE%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22makItjTLGVE%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYK5oHhRpCX9MwjlYAVafvaSqBkNBT0FyQkZzbVpQeDZTaTUyZFZncW96bzhibXlJQ1FDNmlESnJVRjZyelpQa3NSVzc0RjBhR3cwd1hiWHpjejE3TmJNkAcCuAek1qzwu-GNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FmakItjTLGVE%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAQQsLUEIhMI6sKr8LvhjQMVPSgGAB1VAgws%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwik1qzwu-GNAxVW6UIFHRlwFLoqAEoPEg1wZXRlciBtaWxsYXJkogETCLq4mNK64Y0DFWwLewQdaHITuQ%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22734f7288-0000-2255-80fa-14223bc91526%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373540 ST-15u3huf itct=CAEQ7fkGGAkiEwjqwqvwu-GNAxU9KAYAHVUCDCw%3D&csn=zSia6ZtOcX5_olmt&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAEQ7fkGGAkiEwjqwqvwu-GNAxU9KAYAHVUCDCw%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2Fpky8w9Lq7kU%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22pky8w9Lq7kU%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYKD1zfFWbBYguAn2Mi_o6QGqBkNBT0FyQkZ0MFlrd2xhZTVyWGc3RkVadEhyNFhSQ1RJZ2wtNUdsVnlKRXdhTW9zaTluTjcwLWNnQkRPRUlWU1JyQ0RnkAcCuAek1qzwu-GNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2Fpky8w9Lq7kU%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAIQsLUEIhMI6sKr8LvhjQMVPSgGAB1VAgws%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwik1qzwu-GNAxVW6UIFHRlwFLoqAEoPEg1wZXRlciBtaWxsYXJkogETCLq4mNK64Y0DFWwLewQdaHITuQ%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22734f7289-0000-2255-80fa-14223bc91526%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373538 ST-9oozhm itct=CAUQ7fkGGAciEwjqwqvwu-GNAxU9KAYAHVUCDCw%3D&csn=uHpQs2KmLKT8LqC-&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAUQ7fkGGAciEwjqwqvwu-GNAxU9KAYAHVUCDCw%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F2vkbvqz_6Kc%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%222vkbvqz_6Kc%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYMdtgUmRRiZdMQvDBRthOSuqBkNBT0FyQkZ0a1YySGhFalFtdmpTYjhpLWNTR0JKYWVXV3NMN09pcEd0TnRpV1ZCam85dHd3cEV3Qkp4MkpKQkVDNlV3kAcCuAek1qzwu-GNA_AHAg%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F2vkbvqz_6Kc%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAYQsLUEIhMI6sKr8LvhjQMVPSgGAB1VAgws%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwik1qzwu-GNAxVW6UIFHRlwFLoqAEoPEg1wZXRlciBtaWxsYXJkogETCLq4mNK64Y0DFWwLewQdaHITuQ%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22734f7287-0000-2255-80fa-14223bc91526%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373575 ST-1b5cpyt itct=CA0Q7fkGGAAiEwjZh_aevOGNAxXgHHsEHeO0Ghg%3D&csn=OETzaPEwWfAPD9d-&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CA0Q7fkGGAAiEwjZh_aevOGNAxXgHHsEHeO0Ghg%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FwCNCpBOYqF0%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22wCNCpBOYqF0%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYO1abYPdOzt8zF6XDuWXm0SqBkNBT0FyQkZ1dlB0X2U1a2JEVi1ldUFIYW12dTlQdVZ4T1gzQ1NCT0lJb3BVa1ozcUxDN2RsSHNqY05KVTdJcGFZY21BkAcCuAeP8PaevOGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FwCNCpBOYqF0%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CA4QsLUEIhMI2Yf2nrzhjQMV4Bx7BB3jtBoY%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwiP8PaevOGNAxUpxkkHHTgXKCUqAEoPEg1wZXRlciBtaWxsYXJkogETCOrCq_C74Y0DFT0oBgAdVQIMLA%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2268eefe35-0000-24e3-ad2d-883d24fb65f4%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373590 ST-9du6nk itct=CAoQ7fkGGAIiEwjZh_aevOGNAxXgHHsEHeO0Ghg%3D&csn=hSfQmYbsiB9hYzVv&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAoQ7fkGGAIiEwjZh_aevOGNAxXgHHsEHeO0Ghg%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FP4ZUpacYRXE%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22P4ZUpacYRXE%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYLdp9ctwiV8yKkWXfYMhvcCqBkNBT0FyQkZ1VHhMNUNfQkVUUEJnY3lYa25UZGJoRm9NVkVhM0JsNUV2SlJuSjJnVE5TM3lyRlN6WmEta2FsU3lHc3djkAcCuAeP8PaevOGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FP4ZUpacYRXE%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAsQsLUEIhMI2Yf2nrzhjQMV4Bx7BB3jtBoY%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwiP8PaevOGNAxUpxkkHHTgXKCUqAEoPEg1wZXRlciBtaWxsYXJkogETCOrCq_C74Y0DFT0oBgAdVQIMLA%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2268eefe3a-0000-24e3-ad2d-883d24fb65f4%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373591 ST-1g7uji2 itct=CAgQ7fkGGAMiEwjZh_aevOGNAxXgHHsEHeO0Ghg%3D&csn=n7btK-BO-U7ao8U2&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAgQ7fkGGAMiEwjZh_aevOGNAxXgHHsEHeO0Ghg%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F30cBkEvGAlU%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%2230cBkEvGAlU%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYKqnutOpHgiy2PCvDcaJ9uqqBkNBT0FyQkZ1V0hHejVGWXNTVlJXcWlaeXVCQ2FvRkJwdE4yQzNfSWNDbEpWeDhMWGc2eGhyNFJ3Sl8zV2k0c01sVlFjkAcCuAeP8PaevOGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F30cBkEvGAlU%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAkQsLUEIhMI2Yf2nrzhjQMV4Bx7BB3jtBoY%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwiP8PaevOGNAxUpxkkHHTgXKCUqAEoPEg1wZXRlciBtaWxsYXJkogETCOrCq_C74Y0DFT0oBgAdVQIMLA%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2268eefe3b-0000-24e3-ad2d-883d24fb65f4%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373595 ST-ogb5ga itct=CAYQ7fkGGAQiEwjZh_aevOGNAxXgHHsEHeO0Ghg%3D&csn=Z6GbS9Y3ShW0ZvsO&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAYQ7fkGGAQiEwjZh_aevOGNAxXgHHsEHeO0Ghg%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2Fyk8lmLiD_L0%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22yk8lmLiD_L0%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYDTSa-SQ2JH_YnOPvkYCHPqqBkNBT0FyQkZ0MWVMTjhqajVNZUNiMHA3U1ZlLWQ3LXhrS1h6SFZCaDZlOUwzSnAxcEJlc2FZekV1MEhMLW5CZlZjNE44kAcCuAeP8PaevOGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2Fyk8lmLiD_L0%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAcQsLUEIhMI2Yf2nrzhjQMV4Bx7BB3jtBoY%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwiP8PaevOGNAxUpxkkHHTgXKCUqAEoPEg1wZXRlciBtaWxsYXJkogETCOrCq_C74Y0DFT0oBgAdVQIMLA%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2268eefe3c-0000-24e3-ad2d-883d24fb65f4%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373596 ST-7lzfni itct=CAMQ7fkGGAYiEwjZh_aevOGNAxXgHHsEHeO0Ghg%3D&csn=diXETs-9z8I26dkI&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAMQ7fkGGAYiEwjZh_aevOGNAxXgHHsEHeO0Ghg%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FbW9P3KeWYOs%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22bW9P3KeWYOs%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYISsSGT8aqHy75ssgHPn20qqBkNBT0FyQkZzQmRkd0E1RnNpVVRYSENCRFZLbXRLUXhJSHJuWThpcFVrMDBBcGRLanRVVTRkc2JBT0Fub0FJUUxjc21ZkAcCuAeP8PaevOGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FbW9P3KeWYOs%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAQQsLUEIhMI2Yf2nrzhjQMV4Bx7BB3jtBoY%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwiP8PaevOGNAxUpxkkHHTgXKCUqAEoPEg1wZXRlciBtaWxsYXJkogETCOrCq_C74Y0DFT0oBgAdVQIMLA%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2268eefe3e-0000-24e3-ad2d-883d24fb65f4%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373598 ST-1fhhjyz itct=CAEQ7fkGGAciEwjZh_aevOGNAxXgHHsEHeO0Ghg%3D&csn=ZeOzYF0_dwnthX-l&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAEQ7fkGGAciEwjZh_aevOGNAxXgHHsEHeO0Ghg%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FGgyiIwrIwOQ%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22GgyiIwrIwOQ%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYHPUrbdvGFgFkr5V0hwnUPiqBkNBT0FyQkZ0aG0zVmlHMDhJRHhYdmpyYW9USkF2LWFla2FGZGU1ZUtONDJIaFRVUVliN083cDduYWZSLVVRd3ZoUElzkAcCuAeP8PaevOGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FGgyiIwrIwOQ%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAIQsLUEIhMI2Yf2nrzhjQMV4Bx7BB3jtBoY%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwiP8PaevOGNAxUpxkkHHTgXKCUqAEoPEg1wZXRlciBtaWxsYXJkogETCOrCq_C74Y0DFT0oBgAdVQIMLA%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2268eefe3f-0000-24e3-ad2d-883d24fb65f4%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749373599 ST-gzpysm endpoint=%7B%22clickTrackingParams%22%3A%22CBMQ7fkGGAEiEwi854m8vOGNAxUgYkECHVAxM90%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FlhdIKVXeR3I%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22lhdIKVXeR3I%22%2C%22playerParams%22%3A%228AEByAMTwATMxYOk-8X50HOiBhUBdpLKYFllzccT0n6qbCbdmChw_OGqBkNBT0FyQkZ1cThDLUdwV2pUTXo4TGFLU3ptNmRma1ZkUVo4dUtQR1VaQXBfMmlkZm1VZU9EYVk4SURtdVppMTdOZDU4kAcCuAfk9oq8vOGNAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FlhdIKVXeR3I%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBQQsLUEIhMIvOeJvLzhjQMVIGJBAh1QMTPd%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CBQaEwjk9oq8vOGNAxV_6kIFHXkjGyUqAEoPEg1wZXRlciBtaWxsYXJkogETCNmH9p684Y0DFeAcewQd47QaGA%253D%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2273d92739-0000-2150-8c22-ac3eb1583cb8%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749642617 ST-kmqwf1 disableCache=false&itct=CCsQrIEJGAIiEwiur_HQpumNAxWjbHoFHaNKDHM%3D&csn=RsEFxW_TQzTMwiJ6&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CCsQrIEJGAIiEwiur_HQpumNAxWjbHoFHaNKDHM%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2F%40GBtrucklady%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_CHANNEL%22%2C%22rootVe%22%3A3611%2C%22apiUrl%22%3A%22%2Fyoutubei%2Fv1%2Fbrowse%22%7D%7D%2C%22browseEndpoint%22%3A%7B%22browseId%22%3A%22UCk579PPZcfzD0I0vyXQFttA%22%2C%22canonicalBaseUrl%22%3A%22%2F%40GBtrucklady%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749642622 ST-5gwr2i itct=CC8Q8JMBGAkiEwjG-azTpumNAxW7RnoFHZQVEJY%3D&csn=2YfAbU1Tb9MC5VkU&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CC8Q8JMBGAkiEwjG-azTpumNAxW7RnoFHZQVEJY%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2F%40GBtrucklady%2Fvideos%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_CHANNEL%22%2C%22rootVe%22%3A3611%2C%22apiUrl%22%3A%22%2Fyoutubei%2Fv1%2Fbrowse%22%7D%7D%2C%22browseEndpoint%22%3A%7B%22browseId%22%3A%22UCk579PPZcfzD0I0vyXQFttA%22%2C%22params%22%3A%22EgZ2aWRlb3PyBgQKAjoA%22%2C%22canonicalBaseUrl%22%3A%22%2F%40GBtrucklady%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749642721 ST-o4ysof itct=CIIGEJQ1GAAiEwjA8bD7pumNAxVIe3oFHRjhDppaGFVDQmNWUXItMDdNSC1wOWUya1JUZEIzQZoBBhDyOBijDA%3D%3D&csn=dHSGtY78500Bnk4g&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CIIGEJQ1GAAiEwjA8bD7pumNAxVIe3oFHRjhDppaGFVDQmNWUXItMDdNSC1wOWUya1JUZEIzQZoBBhDyOBijDA%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fwatch%3Fv%3Dme-5Ia0ZvJk%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_WATCH%22%2C%22rootVe%22%3A3832%7D%7D%2C%22watchEndpoint%22%3A%7B%22videoId%22%3A%22me-5Ia0ZvJk%22%2C%22watchEndpointSupportedOnesieConfig%22%3A%7B%22html5PlaybackOnesieConfig%22%3A%7B%22commonConfig%22%3A%7B%22url%22%3A%22https%3A%2F%2Frr5---sn-aigl6nzr.googlevideo.com%2Finitplayback%3Fsource%3Dyoutube%26oeis%3D1%26c%3DWEB%26oad%3D3200%26ovd%3D3200%26oaad%3D11000%26oavd%3D11000%26ocs%3D700%26oewis%3D1%26oputc%3D1%26ofpcc%3D1%26siu%3D1%26msp%3D1%26odepv%3D1%26onvi%3D1%26id%3D99efb921ad19bc99%26ip%3D2a04%253A201%253A1bff%253Af700%253A6a45%253A63ec%253A5b13%253Aa97e%26initcwndbps%3D5428750%26mt%3D1749642313%26oweuc%3D%22%7D%7D%7D%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749642941 ST-1lnl0gt session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR
|
|
||||||
.youtube.com TRUE / FALSE 1749643024 ST-bityqn itct=CCkQrIEJGAQiEwiur_HQpumNAxWjbHoFHaNKDHM%3D&csn=rQGe-lbYGCwVNebj&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CCkQrIEJGAQiEwiur_HQpumNAxWjbHoFHaNKDHM%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2F%40jutah%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_CHANNEL%22%2C%22rootVe%22%3A3611%2C%22apiUrl%22%3A%22%2Fyoutubei%2Fv1%2Fbrowse%22%7D%7D%2C%22browseEndpoint%22%3A%7B%22browseId%22%3A%22UCBcVQr-07MH-p9e2kRTdB3A%22%2C%22canonicalBaseUrl%22%3A%22%2F%40jutah%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643070 ST-3opvp5 session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR
|
|
||||||
.youtube.com TRUE / FALSE 1749643100 ST-94qwwt itct=CL8EEIf2BBgBIhMI_dDpl6jpjQMVX-hCBR0ePC-5Wg9GRXdoYXRfdG9fd2F0Y2iaAQUIJBCOHg%3D%3D&csn=wZba5OvQX8u_1rD7&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CL8EEIf2BBgBIhMI_dDpl6jpjQMVX-hCBR0ePC-5Wg9GRXdoYXRfdG9fd2F0Y2iaAQUIJBCOHg%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FrJgpecoXlcE%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22rJgpecoXlcE%22%2C%22playerParams%22%3A%228AEBoAMByAMkuAQFogYVAXaSymD1f3Eca0bjyPy2xXOYTegFkAcC%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FrJgpecoXlcE%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CMMEELC1BCITCP3Q6Zeo6Y0DFV_oQgUdHjwvuQ%3D%3D%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUwAg%253D%253D%22%2C%22sequenceProvider%22%3A%22REEL_WATCH_SEQUENCE_PROVIDER_RPC%22%2C%22sequenceParams%22%3A%22CgtySmdwZWNvWGxjRSoCGAVQGWgA%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22695cc4d5-0000-2c1e-8d1d-14223bc7f0f2%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643156 ST-7v3ayi itct=CB8Q7fkGGAIiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D&csn=8WhtGoEQ9SuwGd36&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CB8Q7fkGGAIiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FhKUUlCJPHJE%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22hKUUlCJPHJE%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymB9SlilVoJdVi76u3h3xy_QqgZDQU9BckJGdGJZLTkwdXNsTHlNeEF3NHliamFNNTRFYkY2VkQyc3pzU0RGdG4zVWpKSDVQT2VGRjNSdEdVTHFoSnBzZ5AHArgHqaC8uajpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FhKUUlCJPHJE%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CCAQsLUEIhMIwe66uajpjQMVmgF7BB158yrg%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwipoLy5qOmNAxWPhnwGHWxjC4AqAKIBEwj90OmXqOmNAxVf6EIFHR48L7k%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ac34af2-0000-2379-bc40-582429ae1b78%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643181 ST-fm5n6h itct=CB0Q7fkGGAMiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D&csn=FNmIqTU8lW9xUuem&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CB0Q7fkGGAMiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FJn1C8Kb3-rA%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22Jn1C8Kb3-rA%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymBY8sn73MKK2mXqn4rDTBGsqgZDQU9BckJGdFQycVU4Rnk0dUwyQmFjRWwtV0hkb2lzTDRvaTlCYnhHcUVWa2VrVnBqSVRwSFh3VGU0VDJYVEd0WVdvb5AHArgHqaC8uajpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FJn1C8Kb3-rA%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CB4QsLUEIhMIwe66uajpjQMVmgF7BB158yrg%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwipoLy5qOmNAxWPhnwGHWxjC4AqAKIBEwj90OmXqOmNAxVf6EIFHR48L7k%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ac34af3-0000-2379-bc40-582429ae1b78%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643205 ST-l0bcj8 itct=CBoQ7fkGGAUiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D&csn=9CRS-p-nVAkzYv70&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CBoQ7fkGGAUiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FR2UGQPy_Sjk%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22R2UGQPy_Sjk%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymALR9KAN4IGQ9AKf9H0Yt8nqgZDQU9BckJGc1gteDh6SS01SjY2RlcwZmtuMVpleEtwZTgzQWNvWTJWVTJlb3YxU3ZpV3hPMzZGeUlHMlI5R3duQTJkUZAHArgHqaC8uajpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FR2UGQPy_Sjk%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBsQsLUEIhMIwe66uajpjQMVmgF7BB158yrg%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwipoLy5qOmNAxWPhnwGHWxjC4AqAKIBEwj90OmXqOmNAxVf6EIFHR48L7k%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ac34af5-0000-2379-bc40-582429ae1b78%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643243 ST-bq3o81 itct=CBgQ7fkGGAYiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D&csn=LaTmVrivIVDhssjd&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CBgQ7fkGGAYiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FLRXox3FFEFc%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22LRXox3FFEFc%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymCSXOfUyvtkXceamXfYYT56qgZDQU9BckJGdEo3Zm11aU1ELUppOVRhYkVEQmM3dS1tOWd6N3MyZGdweVAwME1BaEpKSmx3b3ZUaWUxMzE3WDhYQTVub5AHArgHqaC8uajpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FLRXox3FFEFc%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBkQsLUEIhMIwe66uajpjQMVmgF7BB158yrg%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwipoLy5qOmNAxWPhnwGHWxjC4AqAKIBEwj90OmXqOmNAxVf6EIFHR48L7k%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ac34af6-0000-2379-bc40-582429ae1b78%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643252 ST-7f3guv itct=CBUQ7fkGGAgiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D&csn=_VBmoQKKE_G9RrW2&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CBUQ7fkGGAgiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2Fbx5NSOU9mCE%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22bx5NSOU9mCE%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymBJPe1k9ACWzZP0GeuYxO7qqgZDQU9BckJGc181dm1RbU9hV1M1U1hhaTR6endOMDNzaTJwMFZsVEM0Nk9vbzJzSWJFSGNzV0xUeVV4SmNReHYyMmFsVZAHArgHqaC8uajpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2Fbx5NSOU9mCE%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBYQsLUEIhMIwe66uajpjQMVmgF7BB158yrg%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwipoLy5qOmNAxWPhnwGHWxjC4AqAKIBEwj90OmXqOmNAxVf6EIFHR48L7k%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ac34af8-0000-2379-bc40-582429ae1b78%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643258 ST-18xyo7a itct=CBMQ7fkGGAkiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D&csn=BQcazyMkTiyTA6ZB&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CBMQ7fkGGAkiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FRXPApPzudtQ%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22RXPApPzudtQ%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymDo-ZdhNGm-3vW9iBPafUuGqgZDQU9BckJGdEh5ak54bjJaa1pFQTlPY1Y0ajJ4bjR5UTdPLWN6bDNtd2FpZ1cydUFlclluUjBseDlvSDZtNXJ3RHBSc5AHArgHqaC8uajpjQPwBwI%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FRXPApPzudtQ%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBQQsLUEIhMIwe66uajpjQMVmgF7BB158yrg%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwipoLy5qOmNAxWPhnwGHWxjC4AqAKIBEwj90OmXqOmNAxVf6EIFHR48L7k%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ac34af9-0000-2379-bc40-582429ae1b78%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643266 ST-so3w5u itct=CBAQ7fkGGAsiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D&csn=5wEAeTDKXW_Jcr72&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CBAQ7fkGGAsiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F2CkVpyJXyxM%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%222CkVpyJXyxM%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymDhzxh47G-t-hrq4B9dUo09qgZDQU9BckJGdVJ2TDNSZE5saW5lVVkxZ2w3elRxVGxPZDFmTlBxeVJtdHhsaFhiT1lzcFdPeXNsUDI4RTQwR1dYNFlPa5AHArgHqaC8uajpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F2CkVpyJXyxM%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBEQsLUEIhMIwe66uajpjQMVmgF7BB158yrg%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwipoLy5qOmNAxWPhnwGHWxjC4AqAKIBEwj90OmXqOmNAxVf6EIFHR48L7k%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ac34afb-0000-2379-bc40-582429ae1b78%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643330 ST-1l39arz itct=CAsQ7fkGGA4iEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D&csn=9c0Dl8W-BIvPR9S1&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAsQ7fkGGA4iEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2Fu7TvwWEcfd8%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22u7TvwWEcfd8%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymDCKdGvzniDDJZNHXKx0tohqgZDQU9BckJGdU9qQkxwa2RvRlBkZVVDMHQ1R3hINldGOXhjd09GVUl5UkdJNjBlSno3d3JuYXFaR2wzUGxQRWFkTVZqUZAHArgHqaC8uajpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2Fu7TvwWEcfd8%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAwQsLUEIhMIwe66uajpjQMVmgF7BB158yrg%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwipoLy5qOmNAxWPhnwGHWxjC4AqAKIBEwj90OmXqOmNAxVf6EIFHR48L7k%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ac34afe-0000-2379-bc40-582429ae1b78%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643329 ST-2h0mu itct=CA4Q7fkGGAwiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D&csn=Myfi5x3EooNcWBzX&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CA4Q7fkGGAwiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FlI7f5rNY1Eg%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22lI7f5rNY1Eg%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymCb1bfQavcgNN-9xWIYBG_CqgZDQU9BckJGdEswanlRYjlzSjhqcUdGMFhWVlZMSDc5QkZnbTFfTUFHb2ZCQWw5Yl9JbUFTQ0MtRWZEZ01SS3hCQndaY5AHArgHqaC8uajpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FlI7f5rNY1Eg%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CA8QsLUEIhMIwe66uajpjQMVmgF7BB158yrg%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwipoLy5qOmNAxWPhnwGHWxjC4AqAKIBEwj90OmXqOmNAxVf6EIFHR48L7k%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ac34afc-0000-2379-bc40-582429ae1b78%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643359 ST-stzcyz itct=CAkQ7fkGGA8iEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D&csn=EPRRH9k2qfoHKdto&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAkQ7fkGGA8iEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FbIDY1SNx_4Q%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22bIDY1SNx_4Q%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymDUkrwfXCOldGGSIldaH-TRqgZDQU9BckJGdnZ3ZngxMm5MZ2JwQnM2ZmhxdnhHbzJSOThSNXV0Y3V0Xy1XaGRMdVNCYUVabXJtYlgxQVhvNmlCNGlnd5AHArgHqaC8uajpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FbIDY1SNx_4Q%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAoQsLUEIhMIwe66uajpjQMVmgF7BB158yrg%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwipoLy5qOmNAxWPhnwGHWxjC4AqAKIBEwj90OmXqOmNAxVf6EIFHR48L7k%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ac34aff-0000-2379-bc40-582429ae1b78%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643392 ST-stlk8j itct=CAYQ7fkGGBEiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D&csn=yne214G9j81i2MW7&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAYQ7fkGGBEiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F0RuFVmaiK2U%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%220RuFVmaiK2U%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymCE5ZJt531nRd9ITWLxSh8yqgZDQU9BckJGdlR3M25TQlFRZ1BDdE1paWxmQkpaWHhVaDJGc2xYaFN4RFB6bXViRVFEQ05IWkFmRXZQS0NYM1MtTEYyQZAHArgHqaC8uajpjQPwBwI%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F0RuFVmaiK2U%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAcQsLUEIhMIwe66uajpjQMVmgF7BB158yrg%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwipoLy5qOmNAxWPhnwGHWxjC4AqAKIBEwj90OmXqOmNAxVf6EIFHR48L7k%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ac34b01-0000-2379-bc40-582429ae1b78%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643396 ST-arsigd itct=CAQQ7fkGGBIiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D&csn=BtXxTlKSPvAIQsL2&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAQQ7fkGGBIiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FUdaAx7Ozw_w%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22UdaAx7Ozw_w%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymBLEcdHcCCxnfi0XVjqt4q1qgZDQU9BckJGdEdZWFBscGhJYVJMODVsaE03bElDTnlidUxMS01WcmFvSGZJTWJDZlo1Wl95RVpyMzlEcWNRNUE4dUxWZ5AHArgHqaC8uajpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FUdaAx7Ozw_w%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAUQsLUEIhMIwe66uajpjQMVmgF7BB158yrg%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwipoLy5qOmNAxWPhnwGHWxjC4AqAKIBEwj90OmXqOmNAxVf6EIFHR48L7k%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ac34b02-0000-2379-bc40-582429ae1b78%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643397 ST-12qrboo itct=CAEQ7fkGGBQiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D&csn=vw9vbiYoBnUKp_ob&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAEQ7fkGGBQiEwjB7rq5qOmNAxWaAXsEHXnzKuCaAQUIJBCOHg%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FnEckGlknmbM%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22nEckGlknmbM%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymAgVij9RSpy8gydGyhaRpqoqgZDQU9BckJGdXgzVW5UNnN6LV9MSGZXdW41UUdaWE1DaDY5WG1mZjljTFo5RDFiVWhzcEp3Y0NlX3VpaHc3blJnNm5FOJAHArgHqaC8uajpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FnEckGlknmbM%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAIQsLUEIhMIwe66uajpjQMVmgF7BB158yrg%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwipoLy5qOmNAxWPhnwGHWxjC4AqAKIBEwj90OmXqOmNAxVf6EIFHR48L7k%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ac34b04-0000-2379-bc40-582429ae1b78%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643457 ST-1be7ywn itct=CBUQ7fkGGAMiEwjYi8PGqemNAxXcJHsEHQbtBX8%3D&csn=yMKqF_FRdvZe4EG0&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CBUQ7fkGGAMiEwjYi8PGqemNAxXcJHsEHQbtBX8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FcekZXEXSPbk%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22cekZXEXSPbk%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymBYbC4Y8Ocb3AzHnOHNY5vkqgZDQU9BckJGdEF3X1h3UmZyOW01UGU2aU5TVkVvaVFURjgzcGZxR095cEtHUG9nY2kzX2RRN3dFRXBmellnTFBiRzFac5AHArgHiOHDxqnpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FcekZXEXSPbk%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBYQsLUEIhMI2IvDxqnpjQMV3CR7BB0G7QV_%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwiI4cPGqemNAxXDYkECHWv_BnYqAKIBEwjB7rq5qOmNAxWaAXsEHXnzKuA%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a99af35-0000-2d06-b930-001a114621fe%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643461 ST-q1zjg3 itct=CBMQ7fkGGAQiEwjYi8PGqemNAxXcJHsEHQbtBX8%3D&csn=uyq8BG2zfVYUIlCN&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CBMQ7fkGGAQiEwjYi8PGqemNAxXcJHsEHQbtBX8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F1E7FmeDiPgs%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%221E7FmeDiPgs%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymBn2geo6al3jd4Qcqs5if79qgZDQU9BckJGdWYwOERwdGlaREt6TG1nZWRxcWVtZEJfS2ZqQ2pqYkp1LWhBU0E4REJaTWJfbzBjNTYxU2loWUZvUEFXMJAHArgHiOHDxqnpjQPwBwE%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F1E7FmeDiPgs%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBQQsLUEIhMI2IvDxqnpjQMV3CR7BB0G7QV_%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwiI4cPGqemNAxXDYkECHWv_BnYqAKIBEwjB7rq5qOmNAxWaAXsEHXnzKuA%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a99af36-0000-2d06-b930-001a114621fe%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643465 ST-q4wbwx itct=CBEQ7fkGGAUiEwjYi8PGqemNAxXcJHsEHQbtBX8%3D&csn=OOMSofJA-ARnu2g3&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CBEQ7fkGGAUiEwjYi8PGqemNAxXcJHsEHQbtBX8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FZ5lIuP3vY0g%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22Z5lIuP3vY0g%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymAvKHijYB7hDL8oRUULNDwgqgZDQU9BckJGc0FYU0h6N1JnY0xrdlBsektCY2V5SnBOQkRSckFraEVMU3Zlb29UZ01zUDhYX3BSdlFrY3U5al9hdlJmZ5AHArgHiOHDxqnpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FZ5lIuP3vY0g%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBIQsLUEIhMI2IvDxqnpjQMV3CR7BB0G7QV_%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwiI4cPGqemNAxXDYkECHWv_BnYqAKIBEwjB7rq5qOmNAxWaAXsEHXnzKuA%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a99af37-0000-2d06-b930-001a114621fe%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643467 ST-6bvyk1 itct=CA8Q7fkGGAYiEwjYi8PGqemNAxXcJHsEHQbtBX8%3D&csn=s0t7C3of00eT5G1O&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CA8Q7fkGGAYiEwjYi8PGqemNAxXcJHsEHQbtBX8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2Fxa_B9WUg2uY%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22xa_B9WUg2uY%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymCQe_aDjKjzGW_LqmSoPew-qgZDQU9BckJGczByeEluc3BFZ2VLU3BTNlZWZ1ZQX2M2X3ZFMlhYbl95cVFvbVhkZUNRV0Y2cml0azAtdG9yWW1TZEh2c5AHArgHiOHDxqnpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2Fxa_B9WUg2uY%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBAQsLUEIhMI2IvDxqnpjQMV3CR7BB0G7QV_%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwiI4cPGqemNAxXDYkECHWv_BnYqAKIBEwjB7rq5qOmNAxWaAXsEHXnzKuA%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a99af38-0000-2d06-b930-001a114621fe%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643468 ST-zaahpy itct=CA0Q7fkGGAciEwjYi8PGqemNAxXcJHsEHQbtBX8%3D&csn=El--Vg-_nIns05EK&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CA0Q7fkGGAciEwjYi8PGqemNAxXcJHsEHQbtBX8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FSc2pA4AzyaA%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22Sc2pA4AzyaA%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymA6huQQVdNm9E4VBmY3COIwqgZDQU9BckJGdVZLZmhmTE5rZGtaYk5hNlppTXdia0p1Vld0b3RBS1FTZDB5SGF2Qm1WU0pBYlpRSm13WEJIM0M4YUt6WZAHArgHiOHDxqnpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FSc2pA4AzyaA%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CA4QsLUEIhMI2IvDxqnpjQMV3CR7BB0G7QV_%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwiI4cPGqemNAxXDYkECHWv_BnYqAKIBEwjB7rq5qOmNAxWaAXsEHXnzKuA%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a99af39-0000-2d06-b930-001a114621fe%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643476 ST-1jj3qwu itct=CAoQ7fkGGAkiEwjYi8PGqemNAxXcJHsEHQbtBX8%3D&csn=lK_BMJtZAEhge5sv&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAoQ7fkGGAkiEwjYi8PGqemNAxXcJHsEHQbtBX8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FprCejZ2vsIU%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22prCejZ2vsIU%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymDF6PNxfG7YtY95aDb5LeKOqgZDQU9BckJGc3NGWktnTXlfSE9LaGVXQTBvTVJXRndSbThzNVNGT2lyMHg5dTJNaVVBTlN2a1dFSk1rSm83b2liYmphOJAHArgHiOHDxqnpjQPwBwE%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FprCejZ2vsIU%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAsQsLUEIhMI2IvDxqnpjQMV3CR7BB0G7QV_%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwiI4cPGqemNAxXDYkECHWv_BnYqAKIBEwjB7rq5qOmNAxWaAXsEHXnzKuA%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a99af3b-0000-2d06-b930-001a114621fe%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643480 ST-oi00uy itct=CAgQ7fkGGAoiEwjYi8PGqemNAxXcJHsEHQbtBX8%3D&csn=HWAy8QT8dC04i9cs&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAgQ7fkGGAoiEwjYi8PGqemNAxXcJHsEHQbtBX8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F87CnUkYOy7w%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%2287CnUkYOy7w%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymDrneeUsXGQkClNg3dtLtb2qgZDQU9BckJGc1FHNjBSWUpfSGtSdnl1QVE3WjFaMmxiMURMSHBpY3JCVmNKMU90OXhHSmNPbU5MMkE2MHl2cy1CbkFnNJAHArgHiOHDxqnpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F87CnUkYOy7w%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAkQsLUEIhMI2IvDxqnpjQMV3CR7BB0G7QV_%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwiI4cPGqemNAxXDYkECHWv_BnYqAKIBEwjB7rq5qOmNAxWaAXsEHXnzKuA%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a99af3c-0000-2d06-b930-001a114621fe%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643509 ST-1q3hyeb itct=CAUQ7fkGGAwiEwjYi8PGqemNAxXcJHsEHQbtBX8%3D&csn=yCt2ZZHUUomBXLQn&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAUQ7fkGGAwiEwjYi8PGqemNAxXcJHsEHQbtBX8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FYK0V4NvgnHI%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22YK0V4NvgnHI%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymAlYVROgcWA2e8dH6-_PJYrqgZDQU9BckJGdkNvR2tnX3FlZy1neE9KT1VhLW9XZG9uRDNMUEJMQzd3UF9LaDRKXzVXWVpvX0xtSm9ocUo2bVMxanNhY5AHArgHiOHDxqnpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FYK0V4NvgnHI%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAYQsLUEIhMI2IvDxqnpjQMV3CR7BB0G7QV_%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwiI4cPGqemNAxXDYkECHWv_BnYqAKIBEwjB7rq5qOmNAxWaAXsEHXnzKuA%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a99af40-0000-2d06-b930-001a114621fe%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643550 ST-1393kri itct=CAMQ7fkGGA0iEwjYi8PGqemNAxXcJHsEHQbtBX8%3D&csn=ekF4m2L_Gb8k0Z9w&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAMQ7fkGGA0iEwjYi8PGqemNAxXcJHsEHQbtBX8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FfiwHGaiaQWs%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22fiwHGaiaQWs%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymBQWshBKQflCmTkuF0PevyfqgZDQU9BckJGdWJGU3M3RWpwcndjQjJrdHRxaktBNFV1MXBjdFEtSm5oMzFrbnVBOWR5RUc0eGd6RklTeWxoMC1WcG5SMJAHArgHiOHDxqnpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FfiwHGaiaQWs%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAQQsLUEIhMI2IvDxqnpjQMV3CR7BB0G7QV_%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwiI4cPGqemNAxXDYkECHWv_BnYqAKIBEwjB7rq5qOmNAxWaAXsEHXnzKuA%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a99af41-0000-2d06-b930-001a114621fe%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643589 ST-13ym121 itct=CAEQ7fkGGA4iEwjYi8PGqemNAxXcJHsEHQbtBX8%3D&csn=cbDUT3tUITHhiWu8&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAEQ7fkGGA4iEwjYi8PGqemNAxXcJHsEHQbtBX8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F6tX9Wzcwcvk%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%226tX9Wzcwcvk%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymACVYPeBVcJDUFne7kYKNjSqgZDQU9BckJGdnI3R3R4RVAtbUZ5N3hSOUxrU0lmd2lPRmNiUDJGaXQ1UHdlOGxWX2xCU0Q5NDdHR2hVRVNDNlpXTHhGUZAHArgHiOHDxqnpjQPwBwE%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F6tX9Wzcwcvk%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAIQsLUEIhMI2IvDxqnpjQMV3CR7BB0G7QV_%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwiI4cPGqemNAxXDYkECHWv_BnYqAKIBEwjB7rq5qOmNAxWaAXsEHXnzKuA%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a99af42-0000-2d06-b930-001a114621fe%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643617 ST-i9q9wq itct=CBcQ7fkGGAQiEwiJkPSPqumNAxW7AXsEHZ3dKqM%3D&csn=EEcF5Nfv3gmhutFX&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CBcQ7fkGGAQiEwiJkPSPqumNAxW7AXsEHZ3dKqM%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2Fwn6djp_W3u8%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22wn6djp_W3u8%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymCavSRIhsfHwsVGdLa4k_iZqgZDQU9BckJGdVlTeGsyQ2JvM1dXZkV6UXR3R1FMMXFPQjhHRVNWMHBhR2pGMWNrUC0yTmxST1VET3kwRGdyNWdMRk9CY5AHArgH1uv1j6rpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2Fwn6djp_W3u8%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBgQsLUEIhMIiZD0j6rpjQMVuwF7BB2d3Sqj%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwjW6_WPqumNAxU-yk8IHVAnL40qAKIBEwjYi8PGqemNAxXcJHsEHQbtBX8%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a0b6406-0000-2d9d-b4cb-582429a9b058%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643618 ST-zuvb3i itct=CBUQ7fkGGAUiEwiJkPSPqumNAxW7AXsEHZ3dKqM%3D&csn=di6kbX9-JZtBfWbe&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CBUQ7fkGGAUiEwiJkPSPqumNAxW7AXsEHZ3dKqM%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FDFyExjgNZ5Y%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22DFyExjgNZ5Y%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymCtMBAbgmwJ4fPCYLvQGeeGqgZDQU9BckJGczlpaVhlMUZJaHdXRTdnMkQwZThQRTlaWGRmbUJpQXR4Vll4M0ZoaV9NaEVIaU82MXRsS2htSFJPSXotWZAHArgH1uv1j6rpjQPwBwE%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FDFyExjgNZ5Y%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBYQsLUEIhMIiZD0j6rpjQMVuwF7BB2d3Sqj%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwjW6_WPqumNAxU-yk8IHVAnL40qAKIBEwjYi8PGqemNAxXcJHsEHQbtBX8%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a0b6407-0000-2d9d-b4cb-582429a9b058%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749643620 ST-vmitts endpoint=%7B%22clickTrackingParams%22%3A%22CBMQ7fkGGAYiEwiJkPSPqumNAxW7AXsEHZ3dKqM%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F908C_cnsF5c%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22908C_cnsF5c%22%2C%22playerParams%22%3A%228AEByAMTwAS09bP_5aPMrLoBogYVAXaSymBZixJ9Kd6UmilX13q5iPq5qgZDQU9BckJGdnowNDVVSngtcnBjMGwwUmtrU3czOGpya3hDY1VDVG9JQ3ZXTDhWZ0Y5UUlDUnZCMVVad29lMzRtZVBDc5AHArgH1uv1j6rpjQM%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F908C_cnsF5c%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBQQsLUEIhMIiZD0j6rpjQMVuwF7BB2d3Sqj%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUaEwjW6_WPqumNAxU-yk8IHVAnL40qAKIBEwjYi8PGqemNAxXcJHsEHQbtBX8%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a0b6408-0000-2d9d-b4cb-582429a9b058%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749810929 ST-m8gbol disableCache=false&itct=CCwQrIEJGAEiEwiy6M3Sme6NAxWtHwYAHeRGHvw%3D&csn=MWPLc8bGUj2ZB8_6&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CCwQrIEJGAEiEwiy6M3Sme6NAxWtHwYAHeRGHvw%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2F%402vintage%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_CHANNEL%22%2C%22rootVe%22%3A3611%2C%22apiUrl%22%3A%22%2Fyoutubei%2Fv1%2Fbrowse%22%7D%7D%2C%22browseEndpoint%22%3A%7B%22browseId%22%3A%22UCnoEKvDdh9AeRBlG1Pf3v8Q%22%2C%22canonicalBaseUrl%22%3A%22%2F%402vintage%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749810950 ST-19re4kl itct=CIsDEJQ1GAAiEwiLj97Ume6NAxXAYXoFHRZqGtZaGFVDbm9FS3ZEZGg5QWVSQmxHMVBmM3Y4UZoBBhDyOBijDA%3D%3D&csn=AY3kJ9vNyMzGjSq9&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CIsDEJQ1GAAiEwiLj97Ume6NAxXAYXoFHRZqGtZaGFVDbm9FS3ZEZGg5QWVSQmxHMVBmM3Y4UZoBBhDyOBijDA%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fwatch%3Fv%3DqfBLM2unCDI%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_WATCH%22%2C%22rootVe%22%3A3832%7D%7D%2C%22watchEndpoint%22%3A%7B%22videoId%22%3A%22qfBLM2unCDI%22%2C%22watchEndpointSupportedOnesieConfig%22%3A%7B%22html5PlaybackOnesieConfig%22%3A%7B%22commonConfig%22%3A%7B%22url%22%3A%22https%3A%2F%2Frr3---sn-aigl6ns6.googlevideo.com%2Finitplayback%3Fsource%3Dyoutube%26oeis%3D1%26c%3DWEB%26oad%3D3200%26ovd%3D3200%26oaad%3D11000%26oavd%3D11000%26ocs%3D700%26oewis%3D1%26oputc%3D1%26ofpcc%3D1%26siu%3D1%26msp%3D1%26odepv%3D1%26onvi%3D1%26id%3Da9f04b336ba70832%26ip%3D2a04%253A201%253A1bff%253Af700%253A6a45%253A63ec%253A5b13%253Aa97e%26initcwndbps%3D4513750%26mt%3D1749810552%26oweuc%3D%22%7D%7D%7D%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811030 ST-p38c68 itct=CNADEIf2BBgBIhMIu6Ps3pnujQMVi0pBAh1NyBpFmgEFCCUQ-B0%3D&csn=DImSmkLIXPgH1cMv&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CNADEIf2BBgBIhMIu6Ps3pnujQMVi0pBAh1NyBpFmgEFCCUQ-B0%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F6ixKH6pmbqI%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%226ixKH6pmbqI%22%2C%22playerParams%22%3A%228AEBoAMCyAMluAQGogYVAXaSymBU4EPXFki7bL1rZb-Qlq7akAcC%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F6ixKH6pmbqI%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CNUDELC1BCITCLuj7N6Z7o0DFYtKQQIdTcgaRQ%3D%3D%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYwAg%253D%253D%22%2C%22sequenceProvider%22%3A%22REEL_WATCH_SEQUENCE_PROVIDER_RPC%22%2C%22sequenceParams%22%3A%22Cgs2aXhLSDZwbWJxSSoCGAZQGWgA%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226b4ffd40-0000-284d-b2a6-3c286d454ed2%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811090 ST-1d4z8nq itct=CAsQ7fkGGAIiEwjAqdaEmu6NAxVqJAYAHTAdNm2aAQUIJRD4HQ%3D%3D&csn=C_dO14zUPvMRHNVB&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAsQ7fkGGAIiEwjAqdaEmu6NAxVqJAYAHTAdNm2aAQUIJRD4HQ%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FGd4YwX0wpvo%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22Gd4YwX0wpvo%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYPyV_tCaK_pLnGnTnmM6YUGqBkNBT0FyQkZzZXJDdWJTM0FPb2tvaHdsc3BEclNTUnBLajl5OHhaTzZIc0EwVVp5dHRoRUs1RWtIbVFzRDExTGRKbTNRkAcCuAfL99aEmu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FGd4YwX0wpvo%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAwQsLUEIhMIwKnWhJrujQMVaiQGAB0wHTZt%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwjL99aEmu6NAxV2JwYAHYfvNfEqAKIBEwi7o-zeme6NAxWLSkECHU3IGkU%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a502cc8-0000-2d30-8637-14223bc661be%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811137 ST-j6mrhr itct=CAkQ7fkGGAMiEwjAqdaEmu6NAxVqJAYAHTAdNm2aAQUIJRD4HQ%3D%3D&csn=-ry0cs3O-OJnLFoU&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAkQ7fkGGAMiEwjAqdaEmu6NAxVqJAYAHTAdNm2aAQUIJRD4HQ%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FyTTmVMZ_leA%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22yTTmVMZ_leA%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYBlMrg3iFb2kD-n0_CG4NlSqBkNBT0FyQkZ1Nm9mcDl3WTdQOUZ5SlpMQnRTd0JROW5nNVRaMThFY3RYMUxNdjBodzNVelEyMlpoTEtETWVVekN0WEZJkAcCuAfL99aEmu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FyTTmVMZ_leA%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAoQsLUEIhMIwKnWhJrujQMVaiQGAB0wHTZt%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwjL99aEmu6NAxV2JwYAHYfvNfEqAKIBEwi7o-zeme6NAxWLSkECHU3IGkU%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a502cc9-0000-2d30-8637-14223bc661be%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811253 ST-1simi12 itct=CAYQ7fkGGAUiEwjAqdaEmu6NAxVqJAYAHTAdNm2aAQUIJRD4HQ%3D%3D&csn=WxMKcBf9dQhQqZco&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAYQ7fkGGAUiEwjAqdaEmu6NAxVqJAYAHTAdNm2aAQUIJRD4HQ%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FCGWsfHtY3NQ%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22CGWsfHtY3NQ%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYO9bYD7aPqwWe9ho8ZonzmCqBkNBT0FyQkZ2U21oZ1ZMLWt1emxvS1Rmblo1Vl9UQmFIemxYU3g5R3hGUVJJU2dpbEtucWNfVW5rZzd6ME9pWDJBcmZrkAcCuAfL99aEmu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FCGWsfHtY3NQ%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAcQsLUEIhMIwKnWhJrujQMVaiQGAB0wHTZt%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwjL99aEmu6NAxV2JwYAHYfvNfEqAKIBEwi7o-zeme6NAxWLSkECHU3IGkU%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a502ccb-0000-2d30-8637-14223bc661be%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811276 ST-vevd3y itct=CAQQ7fkGGAYiEwjAqdaEmu6NAxVqJAYAHTAdNm2aAQUIJRD4HQ%3D%3D&csn=X1YRczarASGMtZOa&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAQQ7fkGGAYiEwjAqdaEmu6NAxVqJAYAHTAdNm2aAQUIJRD4HQ%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2Fuswse_5E8BU%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22uswse_5E8BU%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYEGOg0aMSZBeY1tYVJBw3tyqBkNBT0FyQkZzeE5zd2N3MWNzOGNJWVlIWGFnN1FGNllXaHlad1FBTGpxMjkzM2V6M3hueDk2TUhwZjJpSFRSVlpvd0JBkAcCuAfL99aEmu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2Fuswse_5E8BU%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAUQsLUEIhMIwKnWhJrujQMVaiQGAB0wHTZt%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwjL99aEmu6NAxV2JwYAHYfvNfEqAKIBEwi7o-zeme6NAxWLSkECHU3IGkU%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a502ccc-0000-2d30-8637-14223bc661be%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811402 ST-mbeo3q itct=CAEQ7fkGGAgiEwjAqdaEmu6NAxVqJAYAHTAdNm2aAQUIJRD4HQ%3D%3D&csn=dJTxLc6nZxsRFv6i&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAEQ7fkGGAgiEwjAqdaEmu6NAxVqJAYAHTAdNm2aAQUIJRD4HQ%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FiuLMA30NDks%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22iuLMA30NDks%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYLdC1SM6kZWlcjomHJ6PYheqBkNBT0FyQkZ2RzhQQnJZS2hLS2E0c3BidFh0Z295UnVxQ1Y0c21QamNpREtreVNmaW0zNDd2THppMkVBNkphZjV3MkpvkAcCuAfL99aEmu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FiuLMA30NDks%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAIQsLUEIhMIwKnWhJrujQMVaiQGAB0wHTZt%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwjL99aEmu6NAxV2JwYAHYfvNfEqAKIBEwi7o-zeme6NAxWLSkECHU3IGkU%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a502cce-0000-2d30-8637-14223bc661be%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811428 ST-ccn68f itct=CAgQ7fkGGAMiEwi4wo_6mu6NAxUJfUECHTF8FLY%3D&csn=hbjjXGJ57SKpPR6F&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAgQ7fkGGAMiEwi4wo_6mu6NAxUJfUECHTF8FLY%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FJsj3fbsb5gg%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22Jsj3fbsb5gg%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYLIqGufTtFd_oZvGcfYQ4ZuqBkNBT0FyQkZ1OHduSTBYdzJqN2FzVXBBbjFTaDFHOXUxUDlMU2NRdEJXU2hFUzA5c1ltV2F4UEJLRDhTYW9OeUJUeUpnkAcCuAezk5D6mu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FJsj3fbsb5gg%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAkQsLUEIhMIuMKP-prujQMVCX1BAh0xfBS2%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwizk5D6mu6NAxVgJQYAHbHcJuMqAKIBEwjAqdaEmu6NAxVqJAYAHTAdNm0%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2268b43945-0000-2c31-9d1a-582429ced358%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811431 ST-gptv5w itct=CAYQ7fkGGAQiEwi4wo_6mu6NAxUJfUECHTF8FLY%3D&csn=VnpPBCi4swIdGhta&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAYQ7fkGGAQiEwi4wo_6mu6NAxUJfUECHTF8FLY%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F2vyS3gG1g5w%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%222vyS3gG1g5w%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYIGFi6A6skiYOZRk7bGvA8SqBkNBT0FyQkZzYUhvdVFPMk5uTGpha283dHRZUGkwRUZ5eXFObFlBa0JRcEtMLWtOUklRVGwzNDNzOXFqSW1oeEszcWdFkAcCuAezk5D6mu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F2vyS3gG1g5w%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAcQsLUEIhMIuMKP-prujQMVCX1BAh0xfBS2%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwizk5D6mu6NAxVgJQYAHbHcJuMqAKIBEwjAqdaEmu6NAxVqJAYAHTAdNm0%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2268b43946-0000-2c31-9d1a-582429ced358%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811491 ST-1nvblsu itct=CAMQ7fkGGAYiEwi4wo_6mu6NAxUJfUECHTF8FLY%3D&csn=uIkV2cLnq4KB6qk1&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAMQ7fkGGAYiEwi4wo_6mu6NAxUJfUECHTF8FLY%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FaHLPq10R7jg%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22aHLPq10R7jg%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYCP1GSP_MpxbAzoU64_fLjKqBkNBT0FyQkZzVzQ3OHBPLV9KZ1RmbnBSSXV6MUFtLW9CcnJUTDloNUZiaURIMnA2SVZEbV80bkxiZzFEXzVUSHJYelhnkAcCuAezk5D6mu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FaHLPq10R7jg%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAQQsLUEIhMIuMKP-prujQMVCX1BAh0xfBS2%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwizk5D6mu6NAxVgJQYAHbHcJuMqAKIBEwjAqdaEmu6NAxVqJAYAHTAdNm0%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2268b43948-0000-2c31-9d1a-582429ced358%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811608 ST-1m26xie itct=CAEQ7fkGGAciEwi4wo_6mu6NAxUJfUECHTF8FLY%3D&csn=PI-6ELSu4TRgH7fT&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAEQ7fkGGAciEwi4wo_6mu6NAxUJfUECHTF8FLY%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FfaRW9umlURg%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22faRW9umlURg%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYBZB-_tX43IoCntQL5rsTVaqBkNBT0FyQkZ0Rk9rT2treVJVdG5aMDc4Um83ekdUNkx1Q3dvX0V5WlVZb25TaFpPWGdBZi1XZ2podmlmTkwwOTdHbThjkAcCuAezk5D6mu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FfaRW9umlURg%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAIQsLUEIhMIuMKP-prujQMVCX1BAh0xfBS2%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwizk5D6mu6NAxVgJQYAHbHcJuMqAKIBEwjAqdaEmu6NAxVqJAYAHTAdNm0%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2268b43949-0000-2c31-9d1a-582429ced358%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811668 ST-19p5wsm itct=CAgQ7fkGGAQiEwi_mJrgm-6NAxU3B3sEHVMzKpI%3D&csn=2JfzciTI6BftVCPJ&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAgQ7fkGGAQiEwi_mJrgm-6NAxU3B3sEHVMzKpI%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FWCqgBcCzyaQ%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22WCqgBcCzyaQ%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYA6n12EhDYpL0hDuFsaj1eaqBkNBT0FyQkZ1d3A0QVZkVFJSM1J0WUlVbVhWS3dCbUdOTzRtZm5lUlV0ajVNNEdSeGlZdWl2dEhPaHJfMnFDQ2lKSG4wkAcCuAfujpvgm-6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FWCqgBcCzyaQ%2Fframe0.jpg%22%2C%22width%22%3A576%2C%22height%22%3A1024%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAkQsLUEIhMIv5ia4JvujQMVNwd7BB1TMyqS%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwjujpvgm-6NAxUVRXoFHXYzDH8qAKIBEwi4wo_6mu6NAxUJfUECHTF8FLY%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2269b2b5b7-0000-2353-8cba-001a114ce73a%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811670 ST-nm13n6 itct=CAYQ7fkGGAUiEwi_mJrgm-6NAxU3B3sEHVMzKpI%3D&csn=Bv4eRdGs21mGnMWH&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAYQ7fkGGAUiEwi_mJrgm-6NAxU3B3sEHVMzKpI%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FBm2qz41qn2c%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22Bm2qz41qn2c%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYCJyi6X9meevGmAvJ8n61kaqBkNBT0FyQkZ2Tk9RSnhCUG1RaHNLbEN1Nmk5dWN1czlmSTZvaHh6M3VEeFM2VEFWZk9uVU96RzFpeDJyS3JTUHB3NHhvkAcCuAfujpvgm-6NA_AHAg%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FBm2qz41qn2c%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAcQsLUEIhMIv5ia4JvujQMVNwd7BB1TMyqS%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwjujpvgm-6NAxUVRXoFHXYzDH8qAKIBEwi4wo_6mu6NAxUJfUECHTF8FLY%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2269b2b5b8-0000-2353-8cba-001a114ce73a%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811671 ST-dchifo itct=CAMQ7fkGGAciEwi_mJrgm-6NAxU3B3sEHVMzKpI%3D&csn=0qsvS3sYwYLkJy2T&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAMQ7fkGGAciEwi_mJrgm-6NAxU3B3sEHVMzKpI%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FGQWYQuusAmw%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22GQWYQuusAmw%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYP9BWL3mt4aq1s7eu1xPlMSqBkNBT0FyQkZ1OEpXRkdEYm55NmJXRU53dFBsb2YtSGdnZ1VuZ2VseFl2WEpFdkxPN2JYSVFWbjN1WWZKWVN4MDhyMmN3kAcCuAfujpvgm-6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FGQWYQuusAmw%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAQQsLUEIhMIv5ia4JvujQMVNwd7BB1TMyqS%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwjujpvgm-6NAxUVRXoFHXYzDH8qAKIBEwi4wo_6mu6NAxUJfUECHTF8FLY%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2269b2b5ba-0000-2353-8cba-001a114ce73a%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811674 ST-hilr9m itct=CAEQ7fkGGAgiEwi_mJrgm-6NAxU3B3sEHVMzKpI%3D&csn=yvn3wZtCZz6qQzV4&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAEQ7fkGGAgiEwi_mJrgm-6NAxU3B3sEHVMzKpI%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FMGC1BzQwC-c%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22MGC1BzQwC-c%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYHbBYqdFop4kLM3Tkpzm6C-qBkNBT0FyQkZ1MFlVVS1vSzIxWUlqVkxZZG9iV2NZX2ZrWnhYWm1KMnZ3cXItRWlwMnV0LS1QS1pwTnk1bFc4b1dxbUJFkAcCuAfujpvgm-6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FMGC1BzQwC-c%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAIQsLUEIhMIv5ia4JvujQMVNwd7BB1TMyqS%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwjujpvgm-6NAxUVRXoFHXYzDH8qAKIBEwi4wo_6mu6NAxUJfUECHTF8FLY%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2269b2b5bb-0000-2353-8cba-001a114ce73a%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811676 ST-14vcnwh itct=CAsQ7fkGGAQiEwjU5dTgm-6NAxU_ZXoFHVPbO1w%3D&csn=gpss2BBIN2-eVhWQ&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAsQ7fkGGAQiEwjU5dTgm-6NAxU_ZXoFHVPbO1w%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F840KNF8D9co%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22840KNF8D9co%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYKxDY1Q1wvoG7ILK28yIScWqBkNBT0FyQkZ1YTQtdUFRWTFSUWh4NDNiVGxveDRCbHJ5RGRIMlh4Q05uRnBYdGFnblp6RXB0QXh3RlJNRVBEWDdjaktBkAcCuAe0u9Xgm-6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F840KNF8D9co%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAwQsLUEIhMI1OXU4JvujQMVP2V6BR1T2ztc%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi0u9Xgm-6NAxX00UIFHbzJEuoqAKIBEwi4wo_6mu6NAxUJfUECHTF8FLY%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226b2fa594-0000-2b53-b60f-2405887ef404%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811677 ST-1b2beav itct=CAkQ7fkGGAUiEwjU5dTgm-6NAxU_ZXoFHVPbO1w%3D&csn=CUMHMJv4hc8wYshF&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAkQ7fkGGAUiEwjU5dTgm-6NAxU_ZXoFHVPbO1w%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2Fu2NGib4dHFM%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22u2NGib4dHFM%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYHnlo_htyY0xq8XohPltqJSqBkNBT0FyQkZ2bkR4TkxJLXdsZk9KSUJXNGFGaXNPOEVSTFhIb2pIUVV2a3dXaF9BVXN6TWNDY1FxeUVEX0tfQm5yVUQ0kAcCuAe0u9Xgm-6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2Fu2NGib4dHFM%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAoQsLUEIhMI1OXU4JvujQMVP2V6BR1T2ztc%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi0u9Xgm-6NAxX00UIFHbzJEuoqAKIBEwi4wo_6mu6NAxUJfUECHTF8FLY%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226b2fa595-0000-2b53-b60f-2405887ef404%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811687 ST-z22di1 itct=CAYQ7fkGGAciEwjU5dTgm-6NAxU_ZXoFHVPbO1w%3D&csn=dne15L9VARUJ8KoQ&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAYQ7fkGGAciEwjU5dTgm-6NAxU_ZXoFHVPbO1w%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FnW8iIy1y1nQ%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22nW8iIy1y1nQ%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYMpMFAb95TgTMHgQeaGrHouqBkNBT0FyQkZ2Y21PelpabWphQ2g0MEtreUh4WFJiZUphc19aOWo0U01XcVBXako3MUJ5R2RTa0VlZk5yTklzR3BvVnQwkAcCuAe0u9Xgm-6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FnW8iIy1y1nQ%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAcQsLUEIhMI1OXU4JvujQMVP2V6BR1T2ztc%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi0u9Xgm-6NAxX00UIFHbzJEuoqAKIBEwi4wo_6mu6NAxUJfUECHTF8FLY%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226b2fa59c-0000-2b53-b60f-2405887ef404%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811725 ST-hwdyct itct=CAQQ7fkGGAgiEwjU5dTgm-6NAxU_ZXoFHVPbO1w%3D&csn=PFHBHq6KjLO5pjrh&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAQQ7fkGGAgiEwjU5dTgm-6NAxU_ZXoFHVPbO1w%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2Fu_yXmdOwX1w%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22u_yXmdOwX1w%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYDt5RSUsN7JqZuhJ8NmZeuOqBkNBT0FyQkZ1RDl2RlAxQ216anNuR1pydHlVTTY2YWtzQXZEdXlzOHY5UHAzbW5OMldFQUhsVGRhMjctSXVsSzBST3VZkAcCuAe0u9Xgm-6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2Fu_yXmdOwX1w%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAUQsLUEIhMI1OXU4JvujQMVP2V6BR1T2ztc%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi0u9Xgm-6NAxX00UIFHbzJEuoqAKIBEwi4wo_6mu6NAxUJfUECHTF8FLY%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226b2fa59f-0000-2b53-b60f-2405887ef404%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811742 ST-1cylpzt itct=CAEQ7fkGGAoiEwjU5dTgm-6NAxU_ZXoFHVPbO1w%3D&csn=5m5IC_O_nn6pq_W5&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAEQ7fkGGAoiEwjU5dTgm-6NAxU_ZXoFHVPbO1w%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2Fwait0Pxf-yQ%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22wait0Pxf-yQ%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYGrbH77mSgFjmOxiDCE0oXCqBkNBT0FyQkZzVFhCeHZlNUNDcy1PQXV1bkF6RjVlQ0FjeXgwdFkzVmNEQVdqTkNJMGtUdko2UmF3V2hxQWV5d3RUVnZvkAcCuAe0u9Xgm-6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2Fwait0Pxf-yQ%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAIQsLUEIhMI1OXU4JvujQMVP2V6BR1T2ztc%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi0u9Xgm-6NAxX00UIFHbzJEuoqAKIBEwi4wo_6mu6NAxUJfUECHTF8FLY%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226b2fa5a6-0000-2b53-b60f-2405887ef404%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811972 ST-odg2gx itct=CBAQ7fkGGAMiEwi2hp7QnO6NAxWcO3sEHaTjHy8%3D&csn=8-eTqG0wgC-RZczn&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CBAQ7fkGGAMiEwi2hp7QnO6NAxWcO3sEHaTjHy8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FjTs_OILkxDE%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22jTs_OILkxDE%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYPB8yUPCW2LputD1p0dk0f6qBkNBT0FyQkZ1V2VraUlSeGdVTnRsZVRpcDRabVFuUjZsMjFuWmdnYXdHVVVKRjMxZGdla0FqTi1jdGUyVHlZZzdNUHBzkAcCuAe1_5_QnO6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FjTs_OILkxDE%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBEQsLUEIhMItoae0JzujQMVnDt7BB2k4x8v%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi1_5_QnO6NAxUS3E8IHSlFBa4qAKIBEwjU5dTgm-6NAxU_ZXoFHVPbO1w%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2269d90775-0000-23a4-b9d1-d4f547f31ef4%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749811997 ST-pytcd3 itct=CA4Q7fkGGAQiEwi2hp7QnO6NAxWcO3sEHaTjHy8%3D&csn=y-CXHtW2j-CglqxE&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CA4Q7fkGGAQiEwi2hp7QnO6NAxWcO3sEHaTjHy8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FGqdALdm2Obw%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22GqdALdm2Obw%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYLOB9qmUyiCqj3FzcGLCf3aqBkNBT0FyQkZ0NUs1bUFZcTcwWjY1VGVPaWQxUlJKWE42bFZ6RTZ5LVpuSEdhN0VoOU1Pd0RVbzZ4YjFVRFlyXzRBbUtvkAcCuAe1_5_QnO6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FGqdALdm2Obw%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CA8QsLUEIhMItoae0JzujQMVnDt7BB2k4x8v%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi1_5_QnO6NAxUS3E8IHSlFBa4qAKIBEwjU5dTgm-6NAxU_ZXoFHVPbO1w%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2269d90776-0000-23a4-b9d1-d4f547f31ef4%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812018 ST-9dpn0y itct=CAwQ7fkGGAUiEwi2hp7QnO6NAxWcO3sEHaTjHy8%3D&csn=FA8Q5XzGjHaqErvS&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAwQ7fkGGAUiEwi2hp7QnO6NAxWcO3sEHaTjHy8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FKWkbC2dvVHQ%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22KWkbC2dvVHQ%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYFwexsxYcUaCthHN0jffgUSqBkNBT0FyQkZ2M1hIaUp2aGYtLXdKRjIxU0drMmw0YVhSSkJ4Z19veUh3aEUwTkJNclR5VEZINzM2UWRlaGtob0pKdDVVkAcCuAe1_5_QnO6NA_AHAg%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FKWkbC2dvVHQ%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CA0QsLUEIhMItoae0JzujQMVnDt7BB2k4x8v%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi1_5_QnO6NAxUS3E8IHSlFBa4qAKIBEwjU5dTgm-6NAxU_ZXoFHVPbO1w%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2269d90777-0000-23a4-b9d1-d4f547f31ef4%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812042 ST-10nj9cd itct=CAoQ7fkGGAYiEwi2hp7QnO6NAxWcO3sEHaTjHy8%3D&csn=GJTC2x03F0tsfN1z&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAoQ7fkGGAYiEwi2hp7QnO6NAxWcO3sEHaTjHy8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FWvG-wsjAoQ0%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22WvG-wsjAoQ0%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYOj5pHAnspqKH7OAR3C-VZeqBkNBT0FyQkZ2U2d4eFkxeDFsUk5IUlJEYVkxM3ZpN2tld1pjejVIRXJIRkZCYXpuYlU5MW80VUhfUkJfY0NFOEVBRjZVkAcCuAe1_5_QnO6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FWvG-wsjAoQ0%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAsQsLUEIhMItoae0JzujQMVnDt7BB2k4x8v%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi1_5_QnO6NAxUS3E8IHSlFBa4qAKIBEwjU5dTgm-6NAxU_ZXoFHVPbO1w%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2269d90778-0000-23a4-b9d1-d4f547f31ef4%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812048 ST-1gsuta8 itct=CAcQ7fkGGAgiEwi2hp7QnO6NAxWcO3sEHaTjHy8%3D&csn=rK9SbwY9U2Xb_8-_&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAcQ7fkGGAgiEwi2hp7QnO6NAxWcO3sEHaTjHy8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F3LEa6LIRGWM%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%223LEa6LIRGWM%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYFqMMELfnw5euykX66v19xGqBkNBT0FyQkZ0TEVxcnR0d29UWDhfdFo5Sk5CWkVQT1lPNWtTaGd4Zmg4NHhDUERZQ2dwSlJOOGNha2NQTDZQN0pweTRnkAcCuAe1_5_QnO6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F3LEa6LIRGWM%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAgQsLUEIhMItoae0JzujQMVnDt7BB2k4x8v%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi1_5_QnO6NAxUS3E8IHSlFBa4qAKIBEwjU5dTgm-6NAxU_ZXoFHVPbO1w%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2269d9077a-0000-23a4-b9d1-d4f547f31ef4%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812111 ST-1uc374s itct=CAUQ7fkGGAkiEwi2hp7QnO6NAxWcO3sEHaTjHy8%3D&csn=YiAFOyWdVog8F4IE&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAUQ7fkGGAkiEwi2hp7QnO6NAxWcO3sEHaTjHy8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F3NJmZW_7x9Y%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%223NJmZW_7x9Y%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYHyMnFNuhYyT231RFxK9o12qBkNBT0FyQkZ1LXVvZEdTUmg0QVZtT04xWE02eTVuVWJ4VXhmU05Rcm1zaHdZcmthVWtjOVR4M1diYm80b3loZXotN0VzkAcCuAe1_5_QnO6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F3NJmZW_7x9Y%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAYQsLUEIhMItoae0JzujQMVnDt7BB2k4x8v%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi1_5_QnO6NAxUS3E8IHSlFBa4qAKIBEwjU5dTgm-6NAxU_ZXoFHVPbO1w%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2269d9077b-0000-23a4-b9d1-d4f547f31ef4%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812159 ST-rdnhu5 itct=CAMQ7fkGGAoiEwi2hp7QnO6NAxWcO3sEHaTjHy8%3D&csn=IDAqw1S7vfHZjQ_3&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAMQ7fkGGAoiEwi2hp7QnO6NAxWcO3sEHaTjHy8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FZ54Yg8iZJiw%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22Z54Yg8iZJiw%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYFw6hdA1-2SWEMNQQCT56nmQBwK4B7X_n9Cc7o0D8AcC%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FZ54Yg8iZJiw%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAQQsLUEIhMItoae0JzujQMVnDt7BB2k4x8v%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi1_5_QnO6NAxUS3E8IHSlFBa4qAKIBEwjU5dTgm-6NAxU_ZXoFHVPbO1w%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2269d9077c-0000-23a4-b9d1-d4f547f31ef4%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812162 ST-p88mi3 itct=CAEQ7fkGGAsiEwi2hp7QnO6NAxWcO3sEHaTjHy8%3D&csn=ikdS1kHFqFqz4COF&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAEQ7fkGGAsiEwi2hp7QnO6NAxWcO3sEHaTjHy8%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FEg_2EwaSZVI%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22Eg_2EwaSZVI%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYCWbn0WBSKSO69E4fRYOtiKqBkNBT0FyQkZ0d2ZwQ0NwM0FLTzhNUnUtUk02N0dBTldiODh1eE5zc3VCMmxKNmNRNEdyekNQelFmLTdRU094S1RXSXljkAcCuAe1_5_QnO6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FEg_2EwaSZVI%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAIQsLUEIhMItoae0JzujQMVnDt7BB2k4x8v%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi1_5_QnO6NAxUS3E8IHSlFBa4qAKIBEwjU5dTgm-6NAxU_ZXoFHVPbO1w%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%2269d9077d-0000-23a4-b9d1-d4f547f31ef4%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812183 ST-wqezwn itct=CAwQ7fkGGAMiEwjpqYqfnu6NAxWae3oFHdlRDxg%3D&csn=LbzaeivYbrvcHj44&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAwQ7fkGGAMiEwjpqYqfnu6NAxWae3oFHdlRDxg%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F14U3F9SXjq0%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%2214U3F9SXjq0%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYN0LYw1SrXmcDP-vlLY0fquQBwK4B7eWi5-e7o0D8AcC%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F14U3F9SXjq0%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CA0QsLUEIhMI6amKn57ujQMVmnt6BR3ZUQ8Y%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi3loufnu6NAxXmWU8EHZEJE3QqAKIBEwi2hp7QnO6NAxWcO3sEHaTjHy8%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ad36d9a-0000-21d9-9667-34c7e9139b9b%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812186 ST-kwtrkl itct=CAoQ7fkGGAQiEwjpqYqfnu6NAxWae3oFHdlRDxg%3D&csn=2nTd7s2PnZSwmF81&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAoQ7fkGGAQiEwjpqYqfnu6NAxWae3oFHdlRDxg%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FivhUt5dEhAs%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22ivhUt5dEhAs%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYC340w_b86MO-mGPNiA7giiqBkNBT0FyQkZzV002d0NtRE9IQVNoSmJ6THpJejI3Y2tsVVBuZm1uTFlzbl92UU56SHYydEFFS0hGRGt3T3h1R1Y4b3RrkAcCuAe3loufnu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FivhUt5dEhAs%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAsQsLUEIhMI6amKn57ujQMVmnt6BR3ZUQ8Y%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi3loufnu6NAxXmWU8EHZEJE3QqAKIBEwi2hp7QnO6NAxWcO3sEHaTjHy8%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ad36d9b-0000-21d9-9667-34c7e9139b9b%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812354 ST-ighdnt itct=CAcQ7fkGGAYiEwjpqYqfnu6NAxWae3oFHdlRDxg%3D&csn=IMv9V00pWfyWNrwx&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAcQ7fkGGAYiEwjpqYqfnu6NAxWae3oFHdlRDxg%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FH-0PHnE-i-I%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22H-0PHnE-i-I%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYMGqfJ0dMVdNW90CGfRPHDeqBkNBT0FyQkZ0aGVBX0FaVzVudEM3ZnlhVWtvTVBwLXhkcnpoMjdZZkw5cGRDM3ZzelN6bzAxTEdNc3lEY1ZCdXBsXzZFkAcCuAe3loufnu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FH-0PHnE-i-I%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAgQsLUEIhMI6amKn57ujQMVmnt6BR3ZUQ8Y%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi3loufnu6NAxXmWU8EHZEJE3QqAKIBEwi2hp7QnO6NAxWcO3sEHaTjHy8%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ad36d9d-0000-21d9-9667-34c7e9139b9b%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812357 ST-frohdv itct=CAUQ7fkGGAciEwjpqYqfnu6NAxWae3oFHdlRDxg%3D&csn=SCXnC5t5Om8yUoU4&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAUQ7fkGGAciEwjpqYqfnu6NAxWae3oFHdlRDxg%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FKPHG7zTWetE%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22KPHG7zTWetE%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYO2RsD5N2joDMr16evLAp6mqBkNBT0FyQkZzMmRwdnJ6MkhaR0h3LVVCb1ltcmg2TjBZdGZ4RllWVlJpa09kcVlyeExPRUloc040MlRoTDd5aFNNeVZBkAcCuAe3loufnu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FKPHG7zTWetE%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAYQsLUEIhMI6amKn57ujQMVmnt6BR3ZUQ8Y%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi3loufnu6NAxXmWU8EHZEJE3QqAKIBEwi2hp7QnO6NAxWcO3sEHaTjHy8%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ad36d9e-0000-21d9-9667-34c7e9139b9b%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812359 ST-c8wzqn itct=CAMQ7fkGGAgiEwjpqYqfnu6NAxWae3oFHdlRDxg%3D&csn=gVETebLCatZOZ7Tq&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAMQ7fkGGAgiEwjpqYqfnu6NAxWae3oFHdlRDxg%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F3P-IYOBWApk%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%223P-IYOBWApk%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYBvnn6vVjha0KMivpaVXgdGQBwK4B7eWi5-e7o0D8AcC%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F3P-IYOBWApk%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAQQsLUEIhMI6amKn57ujQMVmnt6BR3ZUQ8Y%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi3loufnu6NAxXmWU8EHZEJE3QqAKIBEwi2hp7QnO6NAxWcO3sEHaTjHy8%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ad36d9f-0000-21d9-9667-34c7e9139b9b%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812360 ST-13rr2js itct=CAEQ7fkGGAkiEwjpqYqfnu6NAxWae3oFHdlRDxg%3D&csn=mKJ0oWLj87BOFbQl&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAEQ7fkGGAkiEwjpqYqfnu6NAxWae3oFHdlRDxg%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FXG8HMIsStwo%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22XG8HMIsStwo%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYOr6rGQB41EeZrn5ZVdgPRGqBkNBT0FyQkZ2YkhlaEpiNlhmbmgyX3A4R0hSbUE5R0JfWDFSRGZ0N1N5LV9MVmVSQUkyMzdua1F1NmdXakJBU1BNRWVjkAcCuAe3loufnu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FXG8HMIsStwo%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAIQsLUEIhMI6amKn57ujQMVmnt6BR3ZUQ8Y%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwi3loufnu6NAxXmWU8EHZEJE3QqAKIBEwi2hp7QnO6NAxWcO3sEHaTjHy8%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226ad36da0-0000-21d9-9667-34c7e9139b9b%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812380 ST-okba0e itct=CA0Q7fkGGAMiEwivitX-nu6NAxVhIHMJHRV2Heo%3D&csn=rCLxy7KiO3gGyGPW&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CA0Q7fkGGAMiEwivitX-nu6NAxVhIHMJHRV2Heo%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FLF02RoV5lq4%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22LF02RoV5lq4%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYD7ysxSDSk7qeELMr2hHVDqQBwK4B4ff1v6e7o0D8AcC%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FLF02RoV5lq4%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CA4QsLUEIhMIr4rV_p7ujQMVYSBzCR0Vdh3q%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwiH39b-nu6NAxXTofQHHc0VHBEqAKIBEwjpqYqfnu6NAxWae3oFHdlRDxg%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226990399e-0000-2615-9d26-9898fb8699f5%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812384 ST-1khw8dj itct=CAsQ7fkGGAQiEwivitX-nu6NAxVhIHMJHRV2Heo%3D&csn=BzGerOYkjCBQ9Dki&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAsQ7fkGGAQiEwivitX-nu6NAxVhIHMJHRV2Heo%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FIcvlFdGsOjE%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22IcvlFdGsOjE%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYI7Zbo1T1sxnn5jD69__YcSqBkNBT0FyQkZ1ZHVWXzAtQVQ0b0xwcEtobzlCbVB2aXEydWh2WUZvWlh1R1J2OE9ldnBwbV9Gb1RKdXRaLVpab0hpY2ZnkAcCuAeH39b-nu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FIcvlFdGsOjE%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAwQsLUEIhMIr4rV_p7ujQMVYSBzCR0Vdh3q%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwiH39b-nu6NAxXTofQHHc0VHBEqAKIBEwjpqYqfnu6NAxWae3oFHdlRDxg%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226990399f-0000-2615-9d26-9898fb8699f5%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812385 ST-oh1x68 endpoint=%7B%22clickTrackingParams%22%3A%22CAgQ7fkGGAYiEwivitX-nu6NAxVhIHMJHRV2Heo%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FHIhkNEpwYsU%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22HIhkNEpwYsU%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYAG7Oyx2K7SvO8-tPwI-ygKqBkNBT0FyQkZ2ejlNLXJhclJvcHIySG5UTFpsQ0lwN1l1aXA3SmhSTzE0QUxwbjBneTJmaWZSRzZka0ZhM2MxZjhlTDA0kAcCuAeH39b-nu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FHIhkNEpwYsU%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAkQsLUEIhMIr4rV_p7ujQMVYSBzCR0Vdh3q%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwiH39b-nu6NAxXTofQHHc0VHBEqAKIBEwjpqYqfnu6NAxWae3oFHdlRDxg%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22699039a7-0000-2615-9d26-9898fb8699f5%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812426 ST-31bk8w itct=CAYQ7fkGGAciEwivitX-nu6NAxVhIHMJHRV2Heo%3D&csn=n3LphWj5wlUkWS_1&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAYQ7fkGGAciEwivitX-nu6NAxVhIHMJHRV2Heo%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FzVze7BY2mQ8%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22zVze7BY2mQ8%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYKV67H0YDfdQXFwEYmyu5IuqBkNBT0FyQkZ2TUdYemVtamZQbloxZ185d2dRbjdheWhDMHFiVHgyNXVEelhXU0hrMS1rdnFYOGZNcjBTYXBoZ1p1VU5vkAcCuAeH39b-nu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FzVze7BY2mQ8%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAcQsLUEIhMIr4rV_p7ujQMVYSBzCR0Vdh3q%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwiH39b-nu6NAxXTofQHHc0VHBEqAKIBEwjpqYqfnu6NAxWae3oFHdlRDxg%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22699039a8-0000-2615-9d26-9898fb8699f5%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812435 ST-lgxx6f itct=CAMQ7fkGGAkiEwivitX-nu6NAxVhIHMJHRV2Heo%3D&csn=ojJrIIjUPgFkTlAY&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAMQ7fkGGAkiEwivitX-nu6NAxVhIHMJHRV2Heo%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2Fcl6HNHa0P9M%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22cl6HNHa0P9M%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYOKe8nt9ZJr8PWtKGAsrBzyqBkNBT0FyQkZzYjJBTkhLYVNWbDluZDRCcHpYbXVZSXBqNHpYWkxub1dSN1Zmai1KNUtOQmhkeVYzWkxxLVVwU2RVVXQ4kAcCuAeH39b-nu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2Fcl6HNHa0P9M%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAQQsLUEIhMIr4rV_p7ujQMVYSBzCR0Vdh3q%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwiH39b-nu6NAxXTofQHHc0VHBEqAKIBEwjpqYqfnu6NAxWae3oFHdlRDxg%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22699039aa-0000-2615-9d26-9898fb8699f5%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812438 ST-s3b9kb itct=CAEQ7fkGGAoiEwivitX-nu6NAxVhIHMJHRV2Heo%3D&csn=6aRIyIIchuDMyjaC&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAEQ7fkGGAoiEwivitX-nu6NAxVhIHMJHRV2Heo%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F0ghIWvO7xfA%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%220ghIWvO7xfA%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYEPJVWMvLGygcuOsfkbDa76qBkNBT0FyQkZzbi1mdm5MSXJQbnlvQmowaDYwc0VlQ3pXcmc3eHhoN25KaUhqc00takNIVlcwVHdXNTdUNkhpbUtMZkR3kAcCuAeH39b-nu6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F0ghIWvO7xfA%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAIQsLUEIhMIr4rV_p7ujQMVYSBzCR0Vdh3q%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwiH39b-nu6NAxXTofQHHc0VHBEqAKIBEwjpqYqfnu6NAxWae3oFHdlRDxg%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%22699039ab-0000-2615-9d26-9898fb8699f5%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812440 ST-nppm2n itct=CAcQ7fkGGAMiEwjwjd2in-6NAxXqJQYAHRoxAiE%3D&csn=y93td2HCkLkaZS1-&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAcQ7fkGGAMiEwjwjd2in-6NAxXqJQYAHRoxAiE%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FbobxdlqFFwY%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22bobxdlqFFwY%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYOAjZz12ucbIkCNUIylJzJeqBkNBT0FyQkZ2Tng2UFAzdVpVejNhekdic2F5ZTBoUVZWMDMxbWVYcm9pbTRfRERTVS1HajhTZjM4Q3BTaG9PYkhaMHpFkAcCuAfj392in-6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FbobxdlqFFwY%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAgQsLUEIhMI8I3dop_ujQMV6iUGAB0aMQIh%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwjj392in-6NAxU5EfEFHcz3E04qAKIBEwivitX-nu6NAxVhIHMJHRV2Heo%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226aa68d63-0000-211a-8e82-14223bc7ae2a%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812444 ST-b3uteg itct=CAUQ7fkGGAQiEwjwjd2in-6NAxXqJQYAHRoxAiE%3D&csn=5Nhhr2TJoHissjRR&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAUQ7fkGGAQiEwjwjd2in-6NAxXqJQYAHRoxAiE%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FY1sHKoexjpk%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22Y1sHKoexjpk%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYBFCz-WTNYPn_29Hy3xCyoyqBkNBT0FyQkZzU0tnNjVMQXVHUm1KSC0wbTUtWXBtTE01OWxhMGZuNnQtMGQ1Y0dLNDlBZlJCbDM3czBwQlREVklleW53kAcCuAfj392in-6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FY1sHKoexjpk%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAYQsLUEIhMI8I3dop_ujQMV6iUGAB0aMQIh%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwjj392in-6NAxU5EfEFHcz3E04qAKIBEwivitX-nu6NAxVhIHMJHRV2Heo%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226aa68d64-0000-211a-8e82-14223bc7ae2a%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812584 ST-1rns7zn itct=CAMQ7fkGGAUiEwjwjd2in-6NAxXqJQYAHRoxAiE%3D&csn=7kJ9IiDBXZQjaCCi&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAMQ7fkGGAUiEwjwjd2in-6NAxXqJQYAHRoxAiE%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F8OjyXgowEMA%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%228OjyXgowEMA%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYMPbANe10wIYfk_86hidQBmqBkNBT0FyQkZ1M2F6OHdmQ3ZXNkx6UVVLYXBZMXIyXzd2WDNtd3h5eHE0YVF6Vld2bnJMMGtiVHZvT3VnVkdlTXFzVXdzkAcCuAfj392in-6NA_AHAQ%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F8OjyXgowEMA%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAQQsLUEIhMI8I3dop_ujQMV6iUGAB0aMQIh%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwjj392in-6NAxU5EfEFHcz3E04qAKIBEwivitX-nu6NAxVhIHMJHRV2Heo%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226aa68d65-0000-211a-8e82-14223bc7ae2a%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812593 ST-11y9w0r itct=CAEQ7fkGGAYiEwjwjd2in-6NAxXqJQYAHRoxAiE%3D&csn=SqaZdfnqtTKNdruN&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAEQ7fkGGAYiEwjwjd2in-6NAxXqJQYAHRoxAiE%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FLz4hL0P58BY%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22Lz4hL0P58BY%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYMQ3aZKf4sS2EalkO33HJbqqBkNBT0FyQkZzMXlsM3M4cHM1WXRrNmtidHhDUGx0QlAxME5yT2JRVlkzakRaRGNPSEg1ODlNWElrOThmRXRuenliZWJBkAcCuAfj392in-6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FLz4hL0P58BY%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAIQsLUEIhMI8I3dop_ujQMV6iUGAB0aMQIh%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwjj392in-6NAxU5EfEFHcz3E04qAKIBEwivitX-nu6NAxVhIHMJHRV2Heo%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226aa68d66-0000-211a-8e82-14223bc7ae2a%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812662 ST-13vnh0q itct=CAsQ7fkGGAQiEwip1ILqn-6NAxURYkECHeNeEsc%3D&csn=UPbvDx1b_HFOvQpV&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAsQ7fkGGAQiEwip1ILqn-6NAxURYkECHeNeEsc%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F2Bo2y3zW_V4%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%222Bo2y3zW_V4%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYEWKwDwqE_GJJupzFk0ucfmqBkNBT0FyQkZ2bUFidmZvb1VEanloRy10ejV1ZUdQN0xMQWgxNmVnM0RfMTZtY3JrNHlSSnM5cTJ6UUExMTgxTUVKNUM4kAcCuAeAs4Pqn-6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F2Bo2y3zW_V4%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAwQsLUEIhMIqdSC6p_ujQMVEWJBAh3jXhLH%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwiAs4Pqn-6NAxVIx0kHHXQ3DKUqAKIBEwjwjd2in-6NAxXqJQYAHRoxAiE%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a63f65a-0000-2ee3-9479-c82add7fd040%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812667 ST-1w5ehet itct=CAkQ7fkGGAUiEwip1ILqn-6NAxURYkECHeNeEsc%3D&csn=dUQPb9pxprtt_0bF&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAkQ7fkGGAUiEwip1ILqn-6NAxURYkECHeNeEsc%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2Fb3F-kyeF-AI%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22b3F-kyeF-AI%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYLDQUhp59Lg7hdEpRWlrTlKqBkNBT0FyQkZzaDJ3eW5HbHNReFFNLWtqOUV0RW1hVVE1OGJsd2RNV2dWM3ZoZGRtOVJ3bUdYajFGSDBDWktYN2F5ZWVFkAcCuAeAs4Pqn-6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2Fb3F-kyeF-AI%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAoQsLUEIhMIqdSC6p_ujQMVEWJBAh3jXhLH%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwiAs4Pqn-6NAxVIx0kHHXQ3DKUqAKIBEwjwjd2in-6NAxXqJQYAHRoxAiE%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a63f65b-0000-2ee3-9479-c82add7fd040%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812691 ST-1wmw2ob itct=CAYQ7fkGGAciEwip1ILqn-6NAxURYkECHeNeEsc%3D&csn=GnLJGIxrBqNjdV3K&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAYQ7fkGGAciEwip1ILqn-6NAxURYkECHeNeEsc%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FLmtv2v9i24M%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22Lmtv2v9i24M%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYHTgYKDFC03X1SjMJe34SheqBkNBT0FyQkZzWkxZSWpKY1hFNjNtLWU3dTMtNzQwckdfeVQ5d1ZWQjJha2RaM0w3eEZOS3JkRmhfZnAwTHI2bWpWX3FnkAcCuAeAs4Pqn-6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FLmtv2v9i24M%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAcQsLUEIhMIqdSC6p_ujQMVEWJBAh3jXhLH%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwiAs4Pqn-6NAxVIx0kHHXQ3DKUqAKIBEwjwjd2in-6NAxXqJQYAHRoxAiE%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a63f65d-0000-2ee3-9479-c82add7fd040%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812753 ST-1bhstkx itct=CAQQ7fkGGAgiEwip1ILqn-6NAxURYkECHeNeEsc%3D&csn=zbatCaBOBPXj-IW_&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAQQ7fkGGAgiEwip1ILqn-6NAxURYkECHeNeEsc%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F-01P9KS_H5w%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22-01P9KS_H5w%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYK3mSbxHxfZF4IZ4vlev6QWqBkNBT0FyQkZ2aERBYTd5Q3l4YTc2bnFia1RSaXV4UWkxMXJ5XzNmanUzTGh6cF9tSlRvZUJXVjduYzU5ZU9qb2ZJRk9BkAcCuAeAs4Pqn-6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F-01P9KS_H5w%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAUQsLUEIhMIqdSC6p_ujQMVEWJBAh3jXhLH%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwiAs4Pqn-6NAxVIx0kHHXQ3DKUqAKIBEwjwjd2in-6NAxXqJQYAHRoxAiE%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a63f65e-0000-2ee3-9479-c82add7fd040%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812753 ST-1tedtqx session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR
|
|
||||||
.youtube.com TRUE / FALSE 1749812801 ST-1m1x9se itct=CAEQ7fkGGAoiEwip1ILqn-6NAxURYkECHeNeEsc%3D&csn=mrUmQCePbi3AhFBv&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAEQ7fkGGAoiEwip1ILqn-6NAxURYkECHeNeEsc%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FjhSeZj5cg9w%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22jhSeZj5cg9w%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYGvCEJl-_eumngS8FgbBZS-qBkNBT0FyQkZzd0ZfTU1WWUZBN2U2NTdXSzRXdHhfbjM0RW96a0hFX05EVUg4YXFwbGxzbWlVNlNmVS1PZE9sR2o3Z3BJkAcCuAeAs4Pqn-6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FjhSeZj5cg9w%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAIQsLUEIhMIqdSC6p_ujQMVEWJBAh3jXhLH%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwiAs4Pqn-6NAxVIx0kHHXQ3DKUqAKIBEwjwjd2in-6NAxXqJQYAHRoxAiE%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a63f660-0000-2ee3-9479-c82add7fd040%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812879 ST-f78vs5 itct=CA8Q7fkGGAMiEwiHkJ66oO6NAxW_XkECHTKqMoo%3D&csn=6HZUwriWNYlpPDbT&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CA8Q7fkGGAMiEwiHkJ66oO6NAxW_XkECHTKqMoo%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FXjlZ3huoi1E%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22XjlZ3huoi1E%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYHLnqkBOEX5OLHU5fttO4q6qBkNBT0FyQkZ0cXBSUWVEZFp6bllqUnJEYmpqX3ItVldCX2JiVFF5c2hSVGh6b0s0RnRNMnBVRzRaRnU1aGUwLVhGTG9vkAcCuAfMt6C6oO6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FXjlZ3huoi1E%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CBAQsLUEIhMIh5CeuqDujQMVv15BAh0yqjKK%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwjMt6C6oO6NAxWg308IHVhnJZsqAKIBEwip1ILqn-6NAxURYkECHeNeEsc%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a70c0a6-0000-2a32-aa2b-3c286d4e42ea%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812916 ST-y2gipp itct=CAwQ7fkGGAUiEwiHkJ66oO6NAxW_XkECHTKqMoo%3D&csn=tGTkuV_jLWmRf6So&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CAwQ7fkGGAUiEwiHkJ66oO6NAxW_XkECHTKqMoo%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2F-ERBpFz_YYI%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22-ERBpFz_YYI%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYMkGeJM6S7R0A8d7SkN9h0CqBkNBT0FyQkZ1aHNuWHNqWjh2WVhfTnlZUEdIZFFrUFlRQ2ZrLVN5eENHUlNzRkNBMXVidWVfbUFPU3RQdHpRaXZwTmdvkAcCuAfMt6C6oO6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2F-ERBpFz_YYI%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CA0QsLUEIhMIh5CeuqDujQMVv15BAh0yqjKK%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwjMt6C6oO6NAxWg308IHVhnJZsqAKIBEwip1ILqn-6NAxURYkECHeNeEsc%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a70c0a8-0000-2a32-aa2b-3c286d4e42ea%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749812917 ST-1yus4er endpoint=%7B%22clickTrackingParams%22%3A%22CAoQ7fkGGAYiEwiHkJ66oO6NAxW_XkECHTKqMoo%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FS_5e1i47U5s%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22S_5e1i47U5s%22%2C%22playerParams%22%3A%228AEByAMTwATyz9eEiPjN_m6iBhUBdpLKYERkNWSaygkY3Z2tsdoERjqqBkNBT0FyQkZ0UnFtSTZIbXlDanRfY0JYSEczWURLdmF6Q3hnRDR4SXl6WjVnaS1MZVc0SUhQNGpEcW9yTWw3eG44V1ZFkAcCuAfMt6C6oO6NAw%253D%253D%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FS_5e1i47U5s%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CAsQsLUEIhMIh5CeuqDujQMVv15BAh0yqjKK%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAYaEwjMt6C6oO6NAxWg308IHVhnJZsqAKIBEwip1ILqn-6NAxURYkECHeNeEsc%253D%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%2C%22identifier%22%3A%226a70c0a9-0000-2a32-aa2b-3c286d4e42ea%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749815887 ST-eiyx3r disableCache=false&itct=CCwQrIEJGAEiEwiaivCOrO6NAxW00UkHHSm-Dlg%3D&csn=fHiSvRSPZ9iLCHFr&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CCwQrIEJGAEiEwiaivCOrO6NAxW00UkHHSm-Dlg%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2F%40CuttingEdgeEngineering%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_CHANNEL%22%2C%22rootVe%22%3A3611%2C%22apiUrl%22%3A%22%2Fyoutubei%2Fv1%2Fbrowse%22%7D%7D%2C%22browseEndpoint%22%3A%7B%22browseId%22%3A%22UC2wdo5vU7bPBNzyC2nnwmNQ%22%2C%22canonicalBaseUrl%22%3A%22%2F%40CuttingEdgeEngineering%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749815932 ST-1i5z18h itct=CDUQ8JMBGAkiEwiDouuirO6NAxXuIQYAHUpLAyE%3D&csn=fPLvHAw8SvKtBpBv&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CDUQ8JMBGAkiEwiDouuirO6NAxXuIQYAHUpLAyE%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2F%40PlanesTrainsEverything%2Fvideos%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_CHANNEL%22%2C%22rootVe%22%3A3611%2C%22apiUrl%22%3A%22%2Fyoutubei%2Fv1%2Fbrowse%22%7D%7D%2C%22browseEndpoint%22%3A%7B%22browseId%22%3A%22UCcKw8Eg0FfRvhIAnC0cPGAA%22%2C%22params%22%3A%22EgZ2aWRlb3PyBgQKAjoA%22%2C%22canonicalBaseUrl%22%3A%22%2F%40PlanesTrainsEverything%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749820265 ST-aaui2p disableCache=false&itct=CCwQrIEJGAEiEwif-9KJvO6NAxXz70kHHaBWFPs%3D&csn=cDmMyD_nPlbFOpGv&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CCwQrIEJGAEiEwif-9KJvO6NAxXz70kHHaBWFPs%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2F%40KrisHarbour%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_CHANNEL%22%2C%22rootVe%22%3A3611%2C%22apiUrl%22%3A%22%2Fyoutubei%2Fv1%2Fbrowse%22%7D%7D%2C%22browseEndpoint%22%3A%7B%22browseId%22%3A%22UC5rT7F0PGNuD54rJ9kzgWzw%22%2C%22canonicalBaseUrl%22%3A%22%2F%40KrisHarbour%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749820274 ST-rq7u3z itct=CNkCEJQ1GAAiEwif1KK4vO6NAxX-50kHHetoHHRaGFVDNXJUN0YwUEdOdUQ1NHJKOWt6Z1d6d5oBBhDyOBijDA%3D%3D&csn=E6brKvIyJ6s0IWLD&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CNkCEJQ1GAAiEwif1KK4vO6NAxX-50kHHetoHHRaGFVDNXJUN0YwUEdOdUQ1NHJKOWt6Z1d6d5oBBhDyOBijDA%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fwatch%3Fv%3DZ5ZwlFY9rNc%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_WATCH%22%2C%22rootVe%22%3A3832%7D%7D%2C%22watchEndpoint%22%3A%7B%22videoId%22%3A%22Z5ZwlFY9rNc%22%2C%22watchEndpointSupportedOnesieConfig%22%3A%7B%22html5PlaybackOnesieConfig%22%3A%7B%22commonConfig%22%3A%7B%22url%22%3A%22https%3A%2F%2Frr1---sn-aigl6nek.googlevideo.com%2Finitplayback%3Fsource%3Dyoutube%26oeis%3D1%26c%3DWEB%26oad%3D3200%26ovd%3D3200%26oaad%3D11000%26oavd%3D11000%26ocs%3D700%26oewis%3D1%26oputc%3D1%26ofpcc%3D1%26siu%3D1%26msp%3D1%26odepv%3D1%26onvi%3D1%26id%3D67967094563dacd7%26ip%3D2a04%253A201%253A1bff%253Af700%253A6a45%253A63ec%253A5b13%253Aa97e%26initcwndbps%3D5106250%26mt%3D1749819918%26oweuc%3D%22%7D%7D%7D%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749888887 ST-10zfwmu disableCache=false&itct=CC0QrIEJGAEiEwjC1sCHvPCNAxUzBXMJHbniB3w%3D&csn=0pAOn4GlEyLJOaUr&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CC0QrIEJGAEiEwjC1sCHvPCNAxUzBXMJHbniB3w%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2F%40PrimeTales5%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_CHANNEL%22%2C%22rootVe%22%3A3611%2C%22apiUrl%22%3A%22%2Fyoutubei%2Fv1%2Fbrowse%22%7D%7D%2C%22browseEndpoint%22%3A%7B%22browseId%22%3A%22UCh7qCwKMxqTSZGPCH8CzZjA%22%2C%22canonicalBaseUrl%22%3A%22%2F%40PrimeTales5%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749888897 ST-163o2bb session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR
|
|
||||||
.youtube.com TRUE / TRUE 1749889493 CONSISTENCY AKreu9uxIFFJ0UuiSJW4xXwEuOZru4OE_oc0aPGeMWTVwT3U1Tr3B5dHDiFxZ5kF8G-p-y04n4T83l-NKw1qANGgkDDThur-PIyKQHPoybmHzUq-XwpzT5QrBKg
|
|
||||||
.youtube.com TRUE / FALSE 1749888916 ST-ld9q6 disableCache=false&itct=CCwQrIEJGAIiEwjC1sCHvPCNAxUzBXMJHbniB3w%3D&csn=8N1ed-CMMLlmrCSz&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CCwQrIEJGAIiEwjC1sCHvPCNAxUzBXMJHbniB3w%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2F%40electrictrucker%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_CHANNEL%22%2C%22rootVe%22%3A3611%2C%22apiUrl%22%3A%22%2Fyoutubei%2Fv1%2Fbrowse%22%7D%7D%2C%22browseEndpoint%22%3A%7B%22browseId%22%3A%22UCHHto2oaQ9OqZke5c25xtjQ%22%2C%22canonicalBaseUrl%22%3A%22%2F%40electrictrucker%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749888943 ST-2lpuvf itct=CJgCENwwIhMIlo3WmrzwjQMVFgZzCR33HBgiWhhVQ0hIdG8yb2FROU9xWmtlNWMyNXh0alGaAQMQ8jg%3D&csn=0oKNCLdxP3q3St-V&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CJgCENwwIhMIlo3WmrzwjQMVFgZzCR33HBgiWhhVQ0hIdG8yb2FROU9xWmtlNWMyNXh0alGaAQMQ8jg%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fwatch%3Fv%3DL3luSlI9-2o%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_WATCH%22%2C%22rootVe%22%3A3832%7D%7D%2C%22watchEndpoint%22%3A%7B%22videoId%22%3A%22L3luSlI9-2o%22%2C%22watchEndpointSupportedOnesieConfig%22%3A%7B%22html5PlaybackOnesieConfig%22%3A%7B%22commonConfig%22%3A%7B%22url%22%3A%22https%3A%2F%2Frr1---sn-aigl6nzr.googlevideo.com%2Finitplayback%3Fsource%3Dyoutube%26oeis%3D1%26c%3DWEB%26oad%3D3200%26ovd%3D3200%26oaad%3D11000%26oavd%3D11000%26ocs%3D700%26oewis%3D1%26oputc%3D1%26ofpcc%3D1%26siu%3D1%26msp%3D1%26odepv%3D1%26onvi%3D1%26id%3D2f796e4a523dfb6a%26ip%3D2a04%253A201%253A1bff%253Af700%253A6a45%253A63ec%253A5b13%253Aa97e%26initcwndbps%3D4961250%26mt%3D1749888543%26oweuc%3D%22%7D%7D%7D%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749888944 ST-jqw917 session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR
|
|
||||||
.youtube.com TRUE / FALSE 1749888958 ST-21r978 itct=CKACENwwIhMIlo3WmrzwjQMVFgZzCR33HBgiWhhVQ0hIdG8yb2FROU9xWmtlNWMyNXh0alGaAQMQ8jg%3D&csn=9Bu2QOV0IVlq8HF-&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CKACENwwIhMIlo3WmrzwjQMVFgZzCR33HBgiWhhVQ0hIdG8yb2FROU9xWmtlNWMyNXh0alGaAQMQ8jg%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fwatch%3Fv%3DztCycoq2jhU%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_WATCH%22%2C%22rootVe%22%3A3832%7D%7D%2C%22watchEndpoint%22%3A%7B%22videoId%22%3A%22ztCycoq2jhU%22%2C%22watchEndpointSupportedOnesieConfig%22%3A%7B%22html5PlaybackOnesieConfig%22%3A%7B%22commonConfig%22%3A%7B%22url%22%3A%22https%3A%2F%2Frr4---sn-aigl6nze.googlevideo.com%2Finitplayback%3Fsource%3Dyoutube%26oeis%3D1%26c%3DWEB%26oad%3D3200%26ovd%3D3200%26oaad%3D11000%26oavd%3D11000%26ocs%3D700%26oewis%3D1%26oputc%3D1%26ofpcc%3D1%26siu%3D1%26msp%3D1%26odepv%3D1%26onvi%3D1%26id%3Dced0b2728ab68e15%26ip%3D2a04%253A201%253A1bff%253Af700%253A6a45%253A63ec%253A5b13%253Aa97e%26initcwndbps%3D4961250%26mt%3D1749888543%26oweuc%3D%22%7D%7D%7D%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749890200 ST-tql1xl itct=CC8Q8JMBGAkiEwj6lOiXvPCNAxVl1UkHHafAOsQ%3D&csn=r9Od8m5FZiQ6KvNY&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CC8Q8JMBGAkiEwj6lOiXvPCNAxVl1UkHHafAOsQ%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2F%40electrictrucker%2Fvideos%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_CHANNEL%22%2C%22rootVe%22%3A3611%2C%22apiUrl%22%3A%22%2Fyoutubei%2Fv1%2Fbrowse%22%7D%7D%2C%22browseEndpoint%22%3A%7B%22browseId%22%3A%22UCHHto2oaQ9OqZke5c25xtjQ%22%2C%22params%22%3A%22EgZ2aWRlb3PyBgQKAjoA%22%2C%22canonicalBaseUrl%22%3A%22%2F%40electrictrucker%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749890203 ST-gl93c8 disableCache=false&itct=CCsQrIEJGAMiEwjC1sCHvPCNAxUzBXMJHbniB3w%3D&csn=NiFgWTOni5v5NNPz&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CCsQrIEJGAMiEwjC1sCHvPCNAxUzBXMJHbniB3w%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2F%40FlywithHayley%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_CHANNEL%22%2C%22rootVe%22%3A3611%2C%22apiUrl%22%3A%22%2Fyoutubei%2Fv1%2Fbrowse%22%7D%7D%2C%22browseEndpoint%22%3A%7B%22browseId%22%3A%22UC_Sq9GhLG8GzDYZBNytqkEQ%22%2C%22canonicalBaseUrl%22%3A%22%2F%40FlywithHayley%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749902427 ST-1kicthe itct=CPUCEJQ1GAAiEwjrh-i87vCNAxVQEnMJHTvQLghaGFVDY0t3OEVnMEZmUnZoSUFuQzBjUEdBQZoBBhDyOBijDA%3D%3D&csn=FeGoqi05t7FLcnlQ&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CPUCEJQ1GAAiEwjrh-i87vCNAxVQEnMJHTvQLghaGFVDY0t3OEVnMEZmUnZoSUFuQzBjUEdBQZoBBhDyOBijDA%3D%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fwatch%3Fv%3DPwFpf7oCDSM%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_WATCH%22%2C%22rootVe%22%3A3832%7D%7D%2C%22watchEndpoint%22%3A%7B%22videoId%22%3A%22PwFpf7oCDSM%22%2C%22watchEndpointSupportedOnesieConfig%22%3A%7B%22html5PlaybackOnesieConfig%22%3A%7B%22commonConfig%22%3A%7B%22url%22%3A%22https%3A%2F%2Frr3---sn-aigl6nzr.googlevideo.com%2Finitplayback%3Fsource%3Dyoutube%26oeis%3D1%26c%3DWEB%26oad%3D3200%26ovd%3D3200%26oaad%3D11000%26oavd%3D11000%26ocs%3D700%26oewis%3D1%26oputc%3D1%26ofpcc%3D1%26siu%3D1%26msp%3D1%26odepv%3D1%26onvi%3D1%26id%3D3f01697fba020d23%26ip%3D2a04%253A201%253A1bff%253Af700%253A6a45%253A63ec%253A5b13%253Aa97e%26initcwndbps%3D5247500%26mt%3D1749902214%26oweuc%3D%22%7D%7D%7D%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749904196 ST-k4wyo6 itct=CCAQrYEJGA0iEwi7uJWA7vCNAxXc6EkHHY1FOO4%3D&csn=RjUfyiq8LcnFLDK_&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CCAQrYEJGA0iEwi7uJWA7vCNAxXc6EkHHY1FOO4%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2F%40PlanesTrainsEverything%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_CHANNEL%22%2C%22rootVe%22%3A3611%2C%22apiUrl%22%3A%22%2Fyoutubei%2Fv1%2Fbrowse%22%7D%7D%2C%22browseEndpoint%22%3A%7B%22browseId%22%3A%22UCcKw8Eg0FfRvhIAnC0cPGAA%22%2C%22canonicalBaseUrl%22%3A%22%2F%40PlanesTrainsEverything%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749977615 ST-18nws10 disableCache=false&itct=CCwQrIEJGAEiEwiMtrDNhvONAxXc1kkHHYI2Gio%3D&csn=2lN8qiY5cmeYPB4v&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CCwQrIEJGAEiEwiMtrDNhvONAxXc1kkHHYI2Gio%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2F%40rctestflight%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_CHANNEL%22%2C%22rootVe%22%3A3611%2C%22apiUrl%22%3A%22%2Fyoutubei%2Fv1%2Fbrowse%22%7D%7D%2C%22browseEndpoint%22%3A%7B%22browseId%22%3A%22UCq2rNse2XX4Rjzmldv9GqrQ%22%2C%22canonicalBaseUrl%22%3A%22%2F%40rctestflight%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749977623 ST-12dty3n itct=CDUQ8JMBGAsiEwin5fPOhvONAxWRDnMJHRWVFDs%3D&csn=ZbQNAqnstXaJdp2F&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CDUQ8JMBGAsiEwin5fPOhvONAxWRDnMJHRWVFDs%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2F%40rctestflight%2Fvideos%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_CHANNEL%22%2C%22rootVe%22%3A3611%2C%22apiUrl%22%3A%22%2Fyoutubei%2Fv1%2Fbrowse%22%7D%7D%2C%22browseEndpoint%22%3A%7B%22browseId%22%3A%22UCq2rNse2XX4Rjzmldv9GqrQ%22%2C%22params%22%3A%22EgZ2aWRlb3PyBgQKAjoA%22%2C%22canonicalBaseUrl%22%3A%22%2F%40rctestflight%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749977632 ST-1uz7ucq itct=CKYCENwwIhMIpIK30obzjQMVuzzxBR0GsyPdWhhVQ3Eyck5zZTJYWDRSanptbGR2OUdxclGaAQMQ8jg%3D&csn=vTzHi2M1qrClZPHQ&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CKYCENwwIhMIpIK30obzjQMVuzzxBR0GsyPdWhhVQ3Eyck5zZTJYWDRSanptbGR2OUdxclGaAQMQ8jg%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fwatch%3Fv%3DR5_rlyRI9C4%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_WATCH%22%2C%22rootVe%22%3A3832%7D%7D%2C%22watchEndpoint%22%3A%7B%22videoId%22%3A%22R5_rlyRI9C4%22%2C%22watchEndpointSupportedOnesieConfig%22%3A%7B%22html5PlaybackOnesieConfig%22%3A%7B%22commonConfig%22%3A%7B%22url%22%3A%22https%3A%2F%2Frr3---sn-aigl6nsr.googlevideo.com%2Finitplayback%3Fsource%3Dyoutube%26oeis%3D1%26c%3DWEB%26oad%3D3200%26ovd%3D3200%26oaad%3D11000%26oavd%3D11000%26ocs%3D700%26oewis%3D1%26oputc%3D1%26ofpcc%3D1%26siu%3D1%26msp%3D1%26odepv%3D1%26onvi%3D1%26id%3D479feb972448f42e%26ip%3D2a04%253A201%253A1bff%253Af700%253A6a45%253A63ec%253A5b13%253Aa97e%26initcwndbps%3D4783750%26mt%3D1749977096%26oweuc%3D%26pxtags%3DCg4KAnR4Egg1MTUwNzc2Ng%26rxtags%3DCg4KAnR4Egg1MTUwNzc2Ng%252CCg4KAnR4Egg1MTUwNzc2Nw%252CCg4KAnR4Egg1MTUwNzc2OA%252CCg4KAnR4Egg1MTUwNzc2OQ%252CCg4KAnR4Egg1MTUwNzc3MA%22%7D%7D%7D%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749978426 ST-amrb2j session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR
|
|
||||||
.youtube.com TRUE / FALSE 1749978780 ST-pd28k9 session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR
|
|
||||||
.youtube.com TRUE / FALSE 1749978836 ST-183jmdn session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR
|
|
||||||
.youtube.com TRUE / TRUE 0 YSC scaCd70S9y8
|
|
||||||
.youtube.com TRUE / FALSE 1749988393 ST-tladcw session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR
|
|
||||||
.youtube.com TRUE / FALSE 1749988395 ST-xuwub9 session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR
|
|
||||||
.youtube.com TRUE / FALSE 1749988399 ST-1b disableCache=false&itct=CDoQ8KgHGAAiEwislMDirvONAxUZ9kIFHX1VL2Y%3D&csn=ukPuXrtOE2ZwzzUb&session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR&endpoint=%7B%22clickTrackingParams%22%3A%22CDoQ8KgHGAAiEwislMDirvONAxUZ9kIFHX1VL2Y%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2F%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_BROWSE%22%2C%22rootVe%22%3A3854%2C%22apiUrl%22%3A%22%2Fyoutubei%2Fv1%2Fbrowse%22%7D%7D%2C%22browseEndpoint%22%3A%7B%22browseId%22%3A%22FEwhat_to_watch%22%7D%7D
|
|
||||||
.youtube.com TRUE / FALSE 1749988399 ST-yve142 session_logininfo=AFmmF2swRQIgcrCr18aSuVOd9dRA3fuxiw6Mw1hFkNzuSxh_LINRipQCIQCkpknYouKiTmTYocqQq9mqStav0NEftzQurx2RFmX8Ug%3AQUQ3MjNmeUxPUzFVUkFUdjNmRS16TE9McG1YaWMtT1A2aWY4WkZEZ0RUajFkcjVlVjIxMDE0MWo4VVBMQXZuV2kzZENyMkN4aHFzeFJLVklua1FETjV5N1pnNzgxTUhkWFZaQ0dPMWYyVDNqMzE1WW13S3h4SnVCaDVGZnlpdEFMZkJFWGQwRGQwd1VqT285ajNzdjNYcmlzRmE0RlJNWnZR
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from http.cookiejar import MozillaCookieJar
|
|
||||||
from typing import Any, Optional, Union
|
|
||||||
import sys
|
|
||||||
import re
|
|
||||||
|
|
||||||
import click
|
|
||||||
from click import Context
|
|
||||||
|
|
||||||
from envied.core.credential import Credential
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Movie, Movies, Episode, Series
|
|
||||||
from envied.core.tracks import Track, Chapter, Tracks, Video, Subtitle
|
|
||||||
|
|
||||||
|
|
||||||
class ZDF(Service):
|
|
||||||
"""
|
|
||||||
Service code for ZDF.de (https://www.zdf.de)
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.0
|
|
||||||
Author: lambda
|
|
||||||
Authorization: None
|
|
||||||
Robustness:
|
|
||||||
Unencrypted: 2160p HLG, AAC2.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
GEOFENCE = ("de",)
|
|
||||||
VIDEO_RE = r"^https://www\.zdf\.de/(play|video)/(?P<content_type>.+)/(?P<series_slug>.+)/(?P<item_slug>[^\?]+)(\?.+)?$"
|
|
||||||
SERIES_RE = r"^https://www.zdf.de/serien/(?P<slug>[^\?]+)(\?.+)?$"
|
|
||||||
VIDEO_CODEC_MAP = {
|
|
||||||
"video/mp4": Video.Codec.AVC,
|
|
||||||
"video/webm": Video.Codec.VP9
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="ZDF", short_help="https://www.zdf.de", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> ZDF:
|
|
||||||
return ZDF(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
||||||
# This seems to be more or less static, but it's easy enough to fetch every time
|
|
||||||
r = self.session.get("http://hbbtv.zdf.de/zdfm3/index.php")
|
|
||||||
match = re.match(r'.+GLOBALS\.apikey += +"(?P<header>[^"\n]+).+";', r.text, re.DOTALL)
|
|
||||||
self.session.headers.update({"Api-Auth": match.group('header')})
|
|
||||||
|
|
||||||
def get_titles(self) -> Union[Movies, Series]:
|
|
||||||
if match := re.match(self.SERIES_RE, self.title):
|
|
||||||
return self.handle_series_page(match.group('slug'))
|
|
||||||
|
|
||||||
if match := re.match(self.VIDEO_RE, self.title):
|
|
||||||
r = self.session.post(self.config["endpoints"]["graphql"], json={
|
|
||||||
"operationName": "VideoByCanonical",
|
|
||||||
"query": self.config["queries"]["VideoByCanonical"],
|
|
||||||
"variables": {"canonical": match.group('item_slug'), "first": 1},
|
|
||||||
}, headers={"content-type": "application/json"})
|
|
||||||
|
|
||||||
video = r.json()["data"]["videoByCanonical"]
|
|
||||||
return self.parse_video_data(video)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Union[Episode, Movie]) -> Tracks:
|
|
||||||
tracks = Tracks()
|
|
||||||
for node in title.data["nodes"]:
|
|
||||||
if node["vodMediaType"] != "DEFAULT":
|
|
||||||
continue
|
|
||||||
|
|
||||||
for player_type in self.config["meta"]["player_types"]:
|
|
||||||
ptmd_url = (self.config["endpoints"]["ptmd_base"] +
|
|
||||||
node["ptmdTemplate"].format(playerId=player_type))
|
|
||||||
|
|
||||||
r = self.session.get(ptmd_url)
|
|
||||||
ptmd = r.json()
|
|
||||||
|
|
||||||
for pl in ptmd["priorityList"]:
|
|
||||||
for media_format in pl["formitaeten"]:
|
|
||||||
if "restriction_useragent" in media_format["facets"] or media_format["mimeType"] not in self.VIDEO_CODEC_MAP.keys():
|
|
||||||
continue
|
|
||||||
|
|
||||||
if 'hdr_hlg' in media_format["facets"]:
|
|
||||||
video_range = Video.Range.HLG
|
|
||||||
video_codec = Video.Codec.HEVC
|
|
||||||
else:
|
|
||||||
video_range = Video.Range.SDR
|
|
||||||
video_codec = self.VIDEO_CODEC_MAP[media_format["mimeType"]]
|
|
||||||
|
|
||||||
for quality in media_format["qualities"]:
|
|
||||||
for track in quality["audio"]["tracks"]:
|
|
||||||
if track["class"] not in ("main", "ot"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
track_id = f'{video_codec}-{track["language"]}-{quality["highestVerticalResolution"]}'
|
|
||||||
if tracks.exists(by_id=track_id):
|
|
||||||
continue
|
|
||||||
|
|
||||||
tracks.add(Video(
|
|
||||||
id_=track_id,
|
|
||||||
codec=video_codec,
|
|
||||||
range_=video_range,
|
|
||||||
width=quality["highestVerticalResolution"] // 9 * 16,
|
|
||||||
height=quality["highestVerticalResolution"],
|
|
||||||
url=track["uri"],
|
|
||||||
language=track["language"],
|
|
||||||
fps=50,
|
|
||||||
))
|
|
||||||
|
|
||||||
for subs in ptmd["captions"]:
|
|
||||||
if subs["format"] == "ebu-tt-d-basic-de":
|
|
||||||
track_id = f'subs-{subs["language"]}-{subs["class"]}'
|
|
||||||
if tracks.exists(by_id=track_id):
|
|
||||||
continue
|
|
||||||
|
|
||||||
tracks.add(Subtitle(
|
|
||||||
id_=track_id,
|
|
||||||
codec=Subtitle.Codec.TimedTextMarkupLang,
|
|
||||||
language=subs["language"],
|
|
||||||
sdh=subs["class"] == "hoh",
|
|
||||||
url=subs["uri"]
|
|
||||||
))
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Union[Episode, Movie]) -> list[Chapter]:
|
|
||||||
for node in title.data["nodes"]:
|
|
||||||
si = node.get("skipIntro")
|
|
||||||
|
|
||||||
if si and node["vodMediaType"] == "DEFAULT":
|
|
||||||
if si["startIntroTimeOffset"] and si["stopIntroTimeOffset"]:
|
|
||||||
intro_start = float(si["startIntroTimeOffset"])
|
|
||||||
intro_stop = float(si["stopIntroTimeOffset"])
|
|
||||||
chapters = []
|
|
||||||
|
|
||||||
if intro_start != 0:
|
|
||||||
chapters.append(Chapter(timestamp=0))
|
|
||||||
|
|
||||||
return chapters + [
|
|
||||||
Chapter(timestamp=intro_start),
|
|
||||||
Chapter(timestamp=intro_stop),
|
|
||||||
]
|
|
||||||
break
|
|
||||||
return []
|
|
||||||
|
|
||||||
def parse_video_data(self, video):
|
|
||||||
common_data = {
|
|
||||||
"id_": video["id"],
|
|
||||||
"service": self.__class__,
|
|
||||||
"year": video["editorialDate"][0:4],
|
|
||||||
"data": video["currentMedia"],
|
|
||||||
}
|
|
||||||
|
|
||||||
meta = video["structuralMetadata"]
|
|
||||||
if "publicationFormInfo" in meta and meta["publicationFormInfo"]["original"] == "Film":
|
|
||||||
return Movies([Movie(
|
|
||||||
name=video["title"],
|
|
||||||
**common_data
|
|
||||||
)])
|
|
||||||
else:
|
|
||||||
name = video["title"]
|
|
||||||
series_title = video["smartCollection"].get("title", "DUMMY")
|
|
||||||
|
|
||||||
# Ignore fake episode names like "Episode 123" or "Series Name (1/8)"
|
|
||||||
if re.match(fr"^(Folge \d+|{series_title} \(\d+/\d+\))$", name):
|
|
||||||
name = None
|
|
||||||
|
|
||||||
return Series([Episode(
|
|
||||||
**common_data,
|
|
||||||
name=name,
|
|
||||||
title=series_title,
|
|
||||||
season=video["episodeInfo"]["seasonNumber"],
|
|
||||||
number=video["episodeInfo"]["episodeNumber"],
|
|
||||||
)])
|
|
||||||
|
|
||||||
def handle_series_page(self, slug):
|
|
||||||
extensions = {
|
|
||||||
"persistedQuery": {
|
|
||||||
"version": 1,
|
|
||||||
"sha256Hash": "9412a0f4ac55dc37d46975d461ec64bfd14380d815df843a1492348f77b5c99a"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
variables = {
|
|
||||||
"seasonIndex": 0,
|
|
||||||
"episodesPageSize": 24,
|
|
||||||
"canonical": slug,
|
|
||||||
"sortBy": [
|
|
||||||
{
|
|
||||||
"field": "EDITORIAL_DATE",
|
|
||||||
"direction": "ASC"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
r = self.session.get(self.config["endpoints"]["graphql"], params={
|
|
||||||
"extensions": json.dumps(extensions),
|
|
||||||
"variables": json.dumps(variables)
|
|
||||||
}, headers={"content-type": "application/json"})
|
|
||||||
|
|
||||||
data = r.json()["data"]["smartCollectionByCanonical"]
|
|
||||||
if not data:
|
|
||||||
return
|
|
||||||
|
|
||||||
series = Series()
|
|
||||||
for season in data["seasons"]["nodes"]:
|
|
||||||
for video in season["episodes"]["nodes"]:
|
|
||||||
series += self.parse_video_data(video)
|
|
||||||
|
|
||||||
return series
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
headers:
|
|
||||||
Accept-Language: de-DE,de;q=0.8
|
|
||||||
User-Agent: Mozilla/5.0 (Web0S; Linux/SmartTV) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.79 Safari/537.36 DMOST/2.0.0 (; LGE; webOSTV; WEBOS6.3.2 03.34.95; W6_lm21a;)
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
graphql: https://api.zdf.de/graphql
|
|
||||||
ptmd_base: https://api.zdf.de
|
|
||||||
|
|
||||||
meta:
|
|
||||||
# Known options:
|
|
||||||
# ngplayer_2_5 (Web player - H.264 1080p + VP9 1080p))
|
|
||||||
# smarttv_6 (HBBTV - H.264 1080p + H.265 HLG 2160p)
|
|
||||||
# smarttv_7 (Unknown - same formats as smarttv_6)
|
|
||||||
# android_native_5 (Android - H.264 1080p + VP9 1080p + H.265 HLG 2160p)
|
|
||||||
player_types:
|
|
||||||
- android_native_5
|
|
||||||
|
|
||||||
queries:
|
|
||||||
VideoByCanonical: |
|
|
||||||
query VideoByCanonical($canonical: String!, $first: Int) {
|
|
||||||
videoByCanonical(canonical: $canonical) {
|
|
||||||
id
|
|
||||||
canonical
|
|
||||||
contentType
|
|
||||||
title
|
|
||||||
editorialDate
|
|
||||||
streamingOptions {
|
|
||||||
ad
|
|
||||||
ut
|
|
||||||
dgs
|
|
||||||
ov
|
|
||||||
ks
|
|
||||||
fsk
|
|
||||||
}
|
|
||||||
episodeInfo {
|
|
||||||
episodeNumber
|
|
||||||
seasonNumber
|
|
||||||
}
|
|
||||||
structuralMetadata {
|
|
||||||
isChildrenContent
|
|
||||||
publicationFormInfo {
|
|
||||||
original
|
|
||||||
transformed
|
|
||||||
}
|
|
||||||
visualDimension {
|
|
||||||
moods(first: $first) {
|
|
||||||
nodes {
|
|
||||||
mood
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
smartCollection {
|
|
||||||
id
|
|
||||||
canonical
|
|
||||||
title
|
|
||||||
collectionType
|
|
||||||
structuralMetadata {
|
|
||||||
contentFamily
|
|
||||||
publicationFormInfo {
|
|
||||||
original
|
|
||||||
transformed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seo {
|
|
||||||
title
|
|
||||||
}
|
|
||||||
availability {
|
|
||||||
fskBlocked
|
|
||||||
}
|
|
||||||
currentMediaType
|
|
||||||
subtitle
|
|
||||||
webUrl
|
|
||||||
publicationDate
|
|
||||||
currentMedia {
|
|
||||||
nodes {
|
|
||||||
ptmdTemplate
|
|
||||||
... on VodMedia {
|
|
||||||
duration
|
|
||||||
aspectRatio
|
|
||||||
visible
|
|
||||||
geoLocation
|
|
||||||
highestVerticalResolution
|
|
||||||
streamAnchorTags {
|
|
||||||
nodes {
|
|
||||||
anchorOffset
|
|
||||||
anchorLabel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
skipIntro {
|
|
||||||
startIntroTimeOffset
|
|
||||||
stopIntroTimeOffset
|
|
||||||
skipButtonDisplayTime
|
|
||||||
skipButtonLabel
|
|
||||||
}
|
|
||||||
vodMediaType
|
|
||||||
label
|
|
||||||
contentType
|
|
||||||
}
|
|
||||||
... on LiveMedia {
|
|
||||||
geoLocation
|
|
||||||
tvService
|
|
||||||
title
|
|
||||||
start
|
|
||||||
stop
|
|
||||||
editorialStart
|
|
||||||
editorialStop
|
|
||||||
encryption
|
|
||||||
liveMediaType
|
|
||||||
label
|
|
||||||
}
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import tempfile
|
|
||||||
import warnings
|
|
||||||
from collections.abc import Generator
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Union
|
|
||||||
|
|
||||||
import click
|
|
||||||
from bs4 import XMLParsedAsHTMLWarning
|
|
||||||
from click import Context
|
|
||||||
from envied.core.manifests import DASH, HLS
|
|
||||||
from envied.core.search_result import SearchResult
|
|
||||||
from envied.core.service import Service
|
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series
|
|
||||||
from envied.core.tracks import Audio, Chapters, Subtitle, Tracks, Video
|
|
||||||
from envied.core.utils.collections import as_list
|
|
||||||
from envied.core.utils.sslciphers import SSLCiphers
|
|
||||||
|
|
||||||
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
|
|
||||||
|
|
||||||
|
|
||||||
class iP(Service):
|
|
||||||
"""
|
|
||||||
\b
|
|
||||||
Service code for the BBC iPlayer streaming service (https://www.bbc.co.uk/iplayer).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Version: 1.0.2
|
|
||||||
Author: stabbedbybrick
|
|
||||||
Authorization: None
|
|
||||||
Security: None
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips:
|
|
||||||
- Use full title URL as input for best results.
|
|
||||||
- Use --list-titles before anything, iPlayer's listings are often messed up.
|
|
||||||
\b
|
|
||||||
- Use --range HLG to request H.265 UHD tracks
|
|
||||||
- See which titles are available in UHD:
|
|
||||||
https://www.bbc.co.uk/iplayer/help/questions/programme-availability/uhd-content
|
|
||||||
"""
|
|
||||||
|
|
||||||
ALIASES = ("bbciplayer", "bbc", "iplayer")
|
|
||||||
GEOFENCE = ("gb",)
|
|
||||||
TITLE_RE = r"^(?:https?://(?:www\.)?bbc\.co\.uk/(?:iplayer/(?P<kind>episode|episodes)/|programmes/))?(?P<id>[a-z0-9]+)(?:/.*)?$"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="iP", short_help="https://www.bbc.co.uk/iplayer", help=__doc__)
|
|
||||||
@click.argument("title", type=str)
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx: Context, **kwargs: Any) -> "iP":
|
|
||||||
return iP(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
|
||||||
super().__init__(ctx)
|
|
||||||
self.title = title
|
|
||||||
self.vcodec = ctx.parent.params.get("vcodec")
|
|
||||||
self.range = ctx.parent.params.get("range_")
|
|
||||||
|
|
||||||
self.session.headers.update({"user-agent": "BBCiPlayer/5.17.2.32046"})
|
|
||||||
|
|
||||||
if self.range and self.range[0].name == "HLG":
|
|
||||||
if not self.config.get("certificate"):
|
|
||||||
raise CertificateMissingError("HLG/H.265 tracks cannot be requested without a TLS certificate.")
|
|
||||||
|
|
||||||
self.session.headers.update({"user-agent": self.config["user_agent"]})
|
|
||||||
self.vcodec = "H.265"
|
|
||||||
|
|
||||||
def search(self) -> Generator[SearchResult, None, None]:
|
|
||||||
r = self.session.get(self.config["endpoints"]["search"], params={"q": self.title})
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
results = r.json().get("new_search", {}).get("results", [])
|
|
||||||
for result in results:
|
|
||||||
programme_type = result.get("type", "unknown")
|
|
||||||
category = result.get("labels", {}).get("category", "")
|
|
||||||
path = "episode" if programme_type == "episode" else "episodes"
|
|
||||||
yield SearchResult(
|
|
||||||
id_=result.get("id"),
|
|
||||||
title=result.get("title"),
|
|
||||||
description=result.get("synopses", {}).get("small"),
|
|
||||||
label=f"{programme_type} - {category}",
|
|
||||||
url=f"https://www.bbc.co.uk/iplayer/{path}/{result.get('id')}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_titles(self) -> Union[Movies, Series]:
|
|
||||||
match = re.match(self.TITLE_RE, self.title)
|
|
||||||
if not match:
|
|
||||||
raise ValueError("Could not parse ID from title - is the URL/ID format correct?")
|
|
||||||
|
|
||||||
groups = match.groupdict()
|
|
||||||
pid = groups.get("id")
|
|
||||||
kind = groups.get("kind")
|
|
||||||
|
|
||||||
# Attempt to get brand/series data first
|
|
||||||
data = self.get_data(pid, slice_id=None)
|
|
||||||
|
|
||||||
# Handle case where the input is a direct episode URL and get_data fails
|
|
||||||
if data is None and kind == "episode":
|
|
||||||
return Series([self.fetch_episode(pid)])
|
|
||||||
|
|
||||||
if data is None:
|
|
||||||
raise MetadataError(f"Metadata not found for '{pid}'. If it's an episode, use the full URL.")
|
|
||||||
|
|
||||||
# If it's a "series" with only one item, it might be a movie.
|
|
||||||
if data.get("count", 0) < 2:
|
|
||||||
r = self.session.get(self.config["endpoints"]["episodes"].format(pid=pid))
|
|
||||||
r.raise_for_status()
|
|
||||||
episodes_data = r.json()
|
|
||||||
if not episodes_data.get("episodes"):
|
|
||||||
raise MetadataError(f"Episode metadata not found for '{pid}'.")
|
|
||||||
|
|
||||||
movie_data = episodes_data["episodes"][0]
|
|
||||||
return Movies(
|
|
||||||
[
|
|
||||||
Movie(
|
|
||||||
id_=movie_data.get("id"),
|
|
||||||
name=movie_data.get("title"),
|
|
||||||
year=(movie_data.get("release_date_time", "") or "").split("-")[0],
|
|
||||||
service=self.__class__,
|
|
||||||
language="en",
|
|
||||||
data=data,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# It's a full series
|
|
||||||
seasons = [self.get_data(pid, x["id"]) for x in data.get("slices") or [{"id": None}]]
|
|
||||||
episode_ids = [
|
|
||||||
episode.get("episode", {}).get("id")
|
|
||||||
for season in seasons
|
|
||||||
for episode in season.get("entities", {}).get("results", [])
|
|
||||||
if not episode.get("episode", {}).get("live")
|
|
||||||
and episode.get("episode", {}).get("id")
|
|
||||||
]
|
|
||||||
|
|
||||||
episodes = self.get_episodes(episode_ids)
|
|
||||||
return Series(episodes)
|
|
||||||
|
|
||||||
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
|
||||||
versions = self._get_available_versions(title.id)
|
|
||||||
if not versions:
|
|
||||||
raise NoStreamsAvailableError("No available versions for this title were found.")
|
|
||||||
|
|
||||||
connections = [self.check_all_versions(version["pid"]) for version in versions]
|
|
||||||
connections = [c for c in connections if c]
|
|
||||||
if not connections:
|
|
||||||
if self.vcodec == "H.265":
|
|
||||||
raise NoStreamsAvailableError("Selection unavailable in UHD.")
|
|
||||||
raise NoStreamsAvailableError("Selection unavailable. Title may be missing or geo-blocked.")
|
|
||||||
|
|
||||||
media = self._select_best_media(connections)
|
|
||||||
if not media:
|
|
||||||
raise NoStreamsAvailableError("Could not find a suitable media stream.")
|
|
||||||
|
|
||||||
tracks = self._select_tracks(media, title.language)
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
|
|
||||||
return Chapters()
|
|
||||||
|
|
||||||
def _get_available_versions(self, pid: str) -> list[dict]:
|
|
||||||
"""Fetch all available versions for a programme ID."""
|
|
||||||
r = self.session.get(url=self.config["endpoints"]["playlist"].format(pid=pid))
|
|
||||||
r.raise_for_status()
|
|
||||||
playlist = r.json()
|
|
||||||
|
|
||||||
versions = playlist.get("allAvailableVersions")
|
|
||||||
if versions:
|
|
||||||
return versions
|
|
||||||
|
|
||||||
# Fallback to scraping webpage if API returns no versions
|
|
||||||
self.log.debug("No versions in playlist API, falling back to webpage scrape.")
|
|
||||||
r = self.session.get(self.config["base_url"].format(type="episode", pid=pid))
|
|
||||||
r.raise_for_status()
|
|
||||||
match = re.search(r"window\.__IPLAYER_REDUX_STATE__\s*=\s*(.*?);\s*</script>", r.text)
|
|
||||||
if match:
|
|
||||||
redux_data = json.loads(match.group(1))
|
|
||||||
redux_versions = redux_data.get("versions")
|
|
||||||
versions = redux_versions.values() if isinstance(redux_versions, dict) else redux_versions
|
|
||||||
# Filter out audio-described versions
|
|
||||||
return [
|
|
||||||
{"pid": v.get("id")}
|
|
||||||
for v in versions
|
|
||||||
if v.get("kind") != "audio-described" and v.get("id")
|
|
||||||
]
|
|
||||||
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _select_best_media(self, connections: list[list[dict]]) -> list[dict]:
|
|
||||||
"""Selects the media group corresponding to the highest available video quality."""
|
|
||||||
heights = sorted(
|
|
||||||
{
|
|
||||||
int(c["height"])
|
|
||||||
for media_list in connections
|
|
||||||
for c in media_list
|
|
||||||
if c.get("height", "").isdigit()
|
|
||||||
},
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not heights:
|
|
||||||
self.log.warning("No video streams with height information were found.")
|
|
||||||
# Fallback: return the first available media group if any exist.
|
|
||||||
return connections[0] if connections else None
|
|
||||||
|
|
||||||
highest_height = heights[0]
|
|
||||||
self.log.debug(f"Available resolutions (p): {heights}. Selecting highest: {highest_height}p.")
|
|
||||||
|
|
||||||
best_media_list = next(
|
|
||||||
(
|
|
||||||
media_list
|
|
||||||
for media_list in connections
|
|
||||||
if any(conn.get("height") == str(highest_height) for conn in media_list)
|
|
||||||
),
|
|
||||||
None, # Default to None if no matching group is found (should be impossible if heights is not empty)
|
|
||||||
)
|
|
||||||
|
|
||||||
return best_media_list
|
|
||||||
|
|
||||||
def _select_tracks(self, media: list[dict], lang: str):
|
|
||||||
for video_stream_info in (m for m in media if m.get("kind") == "video"):
|
|
||||||
connections = sorted(video_stream_info["connection"], key=lambda x: x.get("priority", 99))
|
|
||||||
|
|
||||||
if self.vcodec == "H.265":
|
|
||||||
connection = connections[0]
|
|
||||||
else:
|
|
||||||
connection = next((c for c in connections if c["supplier"] == "mf_akamai" and c["transferFormat"] == "dash"), None)
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
if not self.vcodec == "H.265":
|
|
||||||
if connection["transferFormat"] == "dash":
|
|
||||||
connection["href"] = "/".join(
|
|
||||||
connection["href"]
|
|
||||||
.replace("dash", "hls")
|
|
||||||
.split("?")[0]
|
|
||||||
.split("/")[0:-1]
|
|
||||||
+ ["hls", "master.m3u8"]
|
|
||||||
)
|
|
||||||
connection["transferFormat"] = "hls"
|
|
||||||
elif connection["transferFormat"] == "hls":
|
|
||||||
connection["href"] = "/".join(
|
|
||||||
connection["href"]
|
|
||||||
.replace(".hlsv2.ism", "")
|
|
||||||
.split("?")[0]
|
|
||||||
.split("/")[0:-1]
|
|
||||||
+ ["hls", "master.m3u8"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if connection["transferFormat"] == "dash":
|
|
||||||
tracks = DASH.from_url(url=connection["href"], session=self.session).to_tracks(language=lang)
|
|
||||||
elif connection["transferFormat"] == "hls":
|
|
||||||
tracks = HLS.from_url(url=connection["href"], session=self.session).to_tracks(language=lang)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unsupported transfer format: {connection['transferFormat']}")
|
|
||||||
|
|
||||||
for video in tracks.videos:
|
|
||||||
# UHD DASH manifest has no range information, so we add it manually
|
|
||||||
if video.codec == Video.Codec.HEVC:
|
|
||||||
video.range = Video.Range.HLG
|
|
||||||
|
|
||||||
if any(re.search(r"-audio_\w+=\d+", x) for x in as_list(video.url)):
|
|
||||||
# create audio stream from the video stream
|
|
||||||
audio_url = re.sub(r"-video=\d+", "", as_list(video.url)[0])
|
|
||||||
audio = Audio(
|
|
||||||
# use audio_url not video url, as to ignore video bitrate in ID
|
|
||||||
id_=hashlib.md5(audio_url.encode()).hexdigest()[0:7],
|
|
||||||
url=audio_url,
|
|
||||||
codec=Audio.Codec.from_codecs(video.data["hls"]["playlist"].stream_info.codecs),
|
|
||||||
language=video.data["hls"]["playlist"].media[0].language,
|
|
||||||
bitrate=int(self.find(r"-audio_\w+=(\d+)", as_list(video.url)[0]) or 0),
|
|
||||||
channels=video.data["hls"]["playlist"].media[0].channels,
|
|
||||||
descriptive=False, # Not available
|
|
||||||
descriptor=Audio.Descriptor.HLS,
|
|
||||||
drm=video.drm,
|
|
||||||
data=video.data,
|
|
||||||
)
|
|
||||||
if not tracks.exists(by_id=audio.id):
|
|
||||||
# some video streams use the same audio, so natural dupes exist
|
|
||||||
tracks.add(audio)
|
|
||||||
# remove audio from the video stream
|
|
||||||
video.url = [re.sub(r"-audio_\w+=\d+", "", x) for x in as_list(video.url)][0]
|
|
||||||
video.codec = Video.Codec.from_codecs(video.data["hls"]["playlist"].stream_info.codecs)
|
|
||||||
video.bitrate = int(self.find(r"-video=(\d+)", as_list(video.url)[0]) or 0)
|
|
||||||
|
|
||||||
for caption in [x for x in media if x["kind"] == "captions"]:
|
|
||||||
connection = sorted(caption["connection"], key=lambda x: x["priority"])[0]
|
|
||||||
tracks.add(
|
|
||||||
Subtitle(
|
|
||||||
id_=hashlib.md5(connection["href"].encode()).hexdigest()[0:6],
|
|
||||||
url=connection["href"],
|
|
||||||
codec=Subtitle.Codec.from_codecs("ttml"),
|
|
||||||
language=lang,
|
|
||||||
is_original_lang=True,
|
|
||||||
forced=False,
|
|
||||||
sdh=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_data(self, pid: str, slice_id: str) -> dict:
|
|
||||||
"""Fetches programme metadata from the GraphQL-like endpoint."""
|
|
||||||
json_data = {
|
|
||||||
"id": "9fd1636abe711717c2baf00cebb668de",
|
|
||||||
"variables": {"id": pid, "perPage": 200, "page": 1, "sliceId": slice_id},
|
|
||||||
}
|
|
||||||
r = self.session.post(self.config["endpoints"]["metadata"], json=json_data)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json().get("data", {}).get("programme")
|
|
||||||
|
|
||||||
def check_all_versions(self, vpid: str) -> list:
|
|
||||||
"""Checks media availability for a given version PID, trying multiple mediators."""
|
|
||||||
session = self.session
|
|
||||||
cert_path = None
|
|
||||||
params = {}
|
|
||||||
|
|
||||||
if self.vcodec == "H.265":
|
|
||||||
if not self.config.get("certificate"):
|
|
||||||
raise CertificateMissingError("TLS certificate not configured.")
|
|
||||||
|
|
||||||
session.mount("https://", SSLCiphers())
|
|
||||||
endpoint_template = self.config["endpoints"]["secure"]
|
|
||||||
mediators = ["securegate.iplayer.bbc.co.uk", "ipsecure.stage.bbc.co.uk"]
|
|
||||||
mediaset = "iptv-uhd"
|
|
||||||
|
|
||||||
cert_binary = base64.b64decode(self.config["certificate"])
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w+b", delete=False, suffix=".pem") as cert_file:
|
|
||||||
cert_file.write(cert_binary)
|
|
||||||
cert_path = cert_file.name
|
|
||||||
|
|
||||||
params["cert"] = cert_path
|
|
||||||
else:
|
|
||||||
endpoint_template = self.config["endpoints"]["open"]
|
|
||||||
mediators = ["open.live.bbc.co.uk", "open.stage.bbc.co.uk"]
|
|
||||||
mediaset = "iptv-all"
|
|
||||||
|
|
||||||
for mediator in mediators:
|
|
||||||
if self.vcodec == "H.265":
|
|
||||||
url = endpoint_template.format(mediator, vpid, mediaset)
|
|
||||||
else:
|
|
||||||
url = endpoint_template.format(mediator, mediaset, vpid)
|
|
||||||
|
|
||||||
try:
|
|
||||||
r = session.get(url, **params)
|
|
||||||
r.raise_for_status()
|
|
||||||
availability = r.json()
|
|
||||||
|
|
||||||
if availability.get("media"):
|
|
||||||
return availability["media"]
|
|
||||||
if availability.get("result"):
|
|
||||||
self.log.warning(
|
|
||||||
f"Mediator '{mediator}' reported an error: {availability['result']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log.debug(f"Failed to check mediator '{mediator}': {e}")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if cert_path is not None:
|
|
||||||
Path(cert_path).unlink(missing_ok=True)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def fetch_episode(self, pid: str) -> Episode:
|
|
||||||
"""Fetches and parses data for a single episode."""
|
|
||||||
r = self.session.get(self.config["endpoints"]["episodes"].format(pid=pid))
|
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
|
|
||||||
if not data.get("episodes"):
|
|
||||||
return None
|
|
||||||
|
|
||||||
episode_data = data["episodes"][0]
|
|
||||||
subtitle = episode_data.get("subtitle", "")
|
|
||||||
year = (episode_data.get("release_date_time", "") or "").split("-")[0]
|
|
||||||
|
|
||||||
series_match = next(re.finditer(r"Series (\d+).*?:|Season (\d+).*?:|(\d{4}/\d{2}): Episode \d+", subtitle), None)
|
|
||||||
season_num = 0
|
|
||||||
if series_match:
|
|
||||||
season_str = next(g for g in series_match.groups() if g is not None)
|
|
||||||
season_num = int(season_str.replace("/", ""))
|
|
||||||
elif not data.get("slices"): # Fallback for single-season shows
|
|
||||||
season_num = 1
|
|
||||||
|
|
||||||
num_match = next(re.finditer(r"(\d+)\.|Episode (\d+)", subtitle), None)
|
|
||||||
number = 0
|
|
||||||
if num_match:
|
|
||||||
number = int(next(g for g in num_match.groups() if g is not None))
|
|
||||||
else:
|
|
||||||
number = episode_data.get("numeric_tleo_position", 0)
|
|
||||||
|
|
||||||
name_match = re.search(r"\d+\. (.+)", subtitle)
|
|
||||||
name = ""
|
|
||||||
if name_match:
|
|
||||||
name = name_match.group(1)
|
|
||||||
elif not re.search(r"Series \d+: Episode \d+", subtitle):
|
|
||||||
name = subtitle
|
|
||||||
|
|
||||||
return Episode(
|
|
||||||
id_=episode_data.get("id"),
|
|
||||||
service=self.__class__,
|
|
||||||
title=episode_data.get("title"),
|
|
||||||
season=season_num,
|
|
||||||
number=number,
|
|
||||||
name=name,
|
|
||||||
language="en",
|
|
||||||
year=year,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_episodes(self, episode_ids: list) -> list[Episode]:
|
|
||||||
"""Fetches multiple episodes concurrently."""
|
|
||||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
||||||
tasks = executor.map(self.fetch_episode, episode_ids)
|
|
||||||
return [task for task in tasks if task is not None]
|
|
||||||
|
|
||||||
def find(self, pattern, string, group=None):
|
|
||||||
if group:
|
|
||||||
m = re.search(pattern, string)
|
|
||||||
if m:
|
|
||||||
return m.group(group)
|
|
||||||
else:
|
|
||||||
return next(iter(re.findall(pattern, string)), None)
|
|
||||||
|
|
||||||
|
|
||||||
class iPlayerError(Exception):
|
|
||||||
"""Base exception for this service."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class CertificateMissingError(iPlayerError):
|
|
||||||
"""Raised when an TLS certificate is required but not provided."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class NoStreamsAvailableError(iPlayerError):
|
|
||||||
"""Raised when no playable streams are found for a title."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class MetadataError(iPlayerError):
|
|
||||||
"""Raised when metadata for a title cannot be found."""
|
|
||||||
pass
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
base_url: https://www.bbc.co.uk/iplayer/{type}/{pid}
|
|
||||||
user_agent: smarttv_AFTMM_Build_0003255372676_Chromium_41.0.2250.2
|
|
||||||
api_key: D2FgtcTxGqqIgLsfBWTJdrQh2tVdeaAp
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
episodes: https://ibl.api.bbci.co.uk/ibl/v1/episodes/{pid}?rights=mobile&availability=available
|
|
||||||
metadata: https://graph.ibl.api.bbc.co.uk/
|
|
||||||
playlist: https://www.bbc.co.uk/programmes/{pid}/playlist.json
|
|
||||||
open: https://{}/mediaselector/6/select/version/2.0/mediaset/{}/vpid/{}/
|
|
||||||
secure: https://{}/mediaselector/6/select/version/2.0/vpid/{}/format/json/mediaset/{}/proto/https
|
|
||||||
search: https://ibl.api.bbc.co.uk/ibl/v1/new-search
|
|
||||||
|
|
||||||
certificate: |
|
|
||||||
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tDQpNSUlFT3pDQ0F5T2dBd0lCQWdJQkFUQU5CZ2txaGtpRzl3MEJBUVVGQURDQm96RU
|
|
||||||
xNQWtHQTFVRUJoTUNWVk14DQpFekFSQmdOVkJBZ1RDa05oYkdsbWIzSnVhV0V4RWpBUUJnTlZCQWNUQ1VOMWNHVnlkR2x1YnpFZU1C
|
|
||||||
d0dBMVVFDQpDeE1WVUhKdlpDQlNiMjkwSUVObGNuUnBabWxqWVhSbE1Sa3dGd1lEVlFRTEV4QkVhV2RwZEdGc0lGQnliMlIxDQpZM1
|
|
||||||
J6TVE4d0RRWURWUVFLRXdaQmJXRjZiMjR4SHpBZEJnTlZCQU1URmtGdFlYcHZiaUJHYVhKbFZGWWdVbTl2DQpkRU5CTURFd0hoY05N
|
|
||||||
VFF4TURFMU1EQTFPREkyV2hjTk16UXhNREV3TURBMU9ESTJXakNCbVRFTE1Ba0dBMVVFDQpCaE1DVlZNeEV6QVJCZ05WQkFnVENrTm
|
|
||||||
hiR2xtYjNKdWFXRXhFakFRQmdOVkJBY1RDVU4xY0dWeWRHbHViekVkDQpNQnNHQTFVRUN4TVVSR1YySUZKdmIzUWdRMlZ5ZEdsbWFX
|
|
||||||
TmhkR1V4R1RBWEJnTlZCQXNURUVScFoybDBZV3dnDQpVSEp2WkhWamRITXhEekFOQmdOVkJBb1RCa0Z0WVhwdmJqRVdNQlFHQTFVRU
|
|
||||||
F4TU5SbWx5WlZSV1VISnZaREF3DQpNVENDQVNBd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dFTkFEQ0NBUWdDZ2dFQkFNRFZTNUwwVUR4
|
|
||||||
WnMwNkpGMld2DQpuZE1KajdIVGRlSlg5b0ltWWg3aytNY0VENXZ5OTA2M0p5c3FkS0tsbzVJZERvY2tuczg0VEhWNlNCVkFBaTBEDQ
|
|
||||||
p6cEI4dHRJNUFBM1l3djFZUDJiOThpQ3F2OWhQalZndE9nNHFvMXZkK0oxdFdISUh5ZkV6cWlPRXVXNTlVd2xoDQpVTmFvY3JtZGNx
|
|
||||||
bGcyWmIyZ1VybTZ2dlZqUThZcjQzY29MNnBBMk5ESXNyT0Z4c0ZZaXdaVk12cDZqMlk4dnFrDQpFOHJ2Tm04c3JkY0FhZjRXdHBuYW
|
|
||||||
gyZ3RBY3IrdTVYNExZdmEwTzZrNGhENEdnNHZQQ2xQZ0JXbDZFSHRBdnFDDQpGWm9KbDhMNTN2VVY1QWhMQjdKQk0wUTFXVERINWs4
|
|
||||||
NWNYT2tFd042NDhuZ09hZUtPMGxqYndZVG52NHhDV2NlDQo2RXNDQVFPamdZTXdnWUF3SHdZRFZSMGpCQmd3Rm9BVVo2RFJJSlNLK2
|
|
||||||
hmWCtHVnBycWlubGMraTVmZ3dIUVlEDQpWUjBPQkJZRUZOeUNPZkhja3Vpclp2QXF6TzBXbjZLTmtlR1BNQWtHQTFVZEV3UUNNQUF3
|
|
||||||
RXdZRFZSMGxCQXd3DQpDZ1lJS3dZQkJRVUhBd0l3RVFZSllJWklBWWI0UWdFQkJBUURBZ2VBTUFzR0ExVWREd1FFQXdJSGdEQU5CZ2
|
|
||||||
txDQpoa2lHOXcwQkFRVUZBQU9DQVFFQXZXUHd4b1VhV3IwV0tXRXhHdHpQOElGVUUrZis5SUZjSzNoWXl2QmxLOUxODQo3Ym9WZHhx
|
|
||||||
dWJGeEgzMFNmOC90VnNYMUpBOUM3bnMzZ09jV2Z0dTEzeUtzK0RnZGhqdG5GVkgraW4zNkVpZEZBDQpRRzM1UE1PU0ltNGNaVXkwME
|
|
||||||
4xRXRwVGpGY2VBbmF1ZjVJTTZNZmRBWlQ0RXNsL09OUHp5VGJYdHRCVlpBQmsxDQpXV2VHMEcwNDdUVlV6M2Ira0dOVTNzZEs5Ri9o
|
|
||||||
NmRiS3c0azdlZWJMZi9KNjZKSnlkQUhybFhJdVd6R2tDbjFqDQozNWdHRHlQajd5MDZWNXV6MlUzYjlMZTdZWENnNkJCanBRN0wrRW
|
|
||||||
d3OVVsSmpoN1pRMXU2R2RCNUEwcGFWM0VQDQpQTk1KN2J6Rkl1cHozdklPdk5nUVV4ZWs1SUVIczZKeXdjNXByck5MS3c9PQ0KLS0t
|
|
||||||
LS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ0KLS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tDQpNSUlFdlFJQkFEQU5CZ2txaGtpRzl3ME
|
|
||||||
JBUUVGQUFTQ0JLY3dnZ1NqQWdFQUFvSUJBUURBMVV1UzlGQThXYk5PDQppUmRscjUzVENZK3gwM1hpVi9hQ0ptSWU1UGpIQkErYjh2
|
|
||||||
ZE90eWNyS25TaXBhT1NIUTZISko3UE9FeDFla2dWDQpRQUl0QTg2UWZMYlNPUUFOMk1MOVdEOW0vZklncXIvWVQ0MVlMVG9PS3FOYj
|
|
||||||
NmaWRiVmh5QjhueE02b2poTGx1DQpmVk1KWVZEV3FISzVuWEtwWU5tVzlvRks1dXI3MVkwUEdLK04zS0MrcVFOalF5TEt6aGNiQldJ
|
|
||||||
c0dWVEw2ZW85DQptUEw2cEJQSzd6WnZMSzNYQUduK0ZyYVoyb2RvTFFISy9ydVYrQzJMMnREdXBPSVErQm9PTHp3cFQ0QVZwZWhCDQ
|
|
||||||
o3UUw2Z2hXYUNaZkMrZDcxRmVRSVN3ZXlRVE5FTlZrd3grWlBPWEZ6cEJNRGV1UEo0RG1uaWp0SlkyOEdFNTcrDQpNUWxuSHVoTEFn
|
|
||||||
RURBb0lCQVFDQWpqSmgrRFY5a1NJMFcyVHVkUlBpQmwvTDRrNlc1VThCYnV3VW1LWGFBclVTDQpvZm8wZWhvY3h2aHNibTBNRTE4RX
|
|
||||||
d4U0tKWWhPVVlWamdBRnpWOThLL2M4MjBLcXo1ZGRUa0NwRXFVd1Z4eXFRDQpOUWpsYzN3SmNjSTlQcVcrU09XaFdvYWd6UndYcmRE
|
|
||||||
MFU0eXc2NHM1eGFIUkU2SEdRSkVQVHdEY21mSDlOK0JXDQovdVU4YVc1QWZOcHhqRzduSGF0cmhJQjU1cDZuNHNFNUVoTjBnSk9WMD
|
|
||||||
lmMEdOb1pQUVhiT1VVcEJWOU1jQ2FsDQpsK1VTalpBRmRIbUlqWFBwR1FEelJJWTViY1hVQzBZYlRwaytRSmhrZ1RjSW1LRFJmd0FC
|
|
||||||
YXRIdnlMeDlpaVY1DQp0ZWZoV1hhaDE4STdkbUF3TmRTN0U4QlpoL3d5MlIwNXQ0RHppYjlyQW9HQkFPU25yZXAybk1VRVAyNXdSQW
|
|
||||||
RBDQozWDUxenYwOFNLWkh6b0VuNExRS1krLzg5VFRGOHZWS2wwQjZLWWlaYW14aWJqU1RtaDRCWHI4ZndRaytiazFCDQpReEZ3ZHVG
|
|
||||||
eTd1MU43d0hSNU45WEFpNEtuamgxQStHcW9SYjg4bk43b1htekM3cTZzdFZRUk9peDJlRVFJWTVvDQpiREZUellaRnloNGlMdkU0bj
|
|
||||||
V1WnVHL1JBb0dCQU5mazdHMDhvYlpacmsxSXJIVXZSQmVENzZRNDlzQ0lSMGRBDQpIU0hCZjBadFBEMjdGSEZtamFDN0YwWkM2QXdU
|
|
||||||
RnBNL0FNWDR4UlpqNnhGalltYnlENGN3MFpGZ08rb0pwZjFIDQpFajNHSHdMNHFZekJFUXdRTmswSk9GbE84cDdVMm1ZL2hEVXM3bG
|
|
||||||
JQQm82YUo4VVpJMGs3SHhSOVRWYVhud0h1DQovaXhnRjlsYkFvR0JBSmh2eVViNXZkaXRmNTcxZ3ErQWs2bWozMU45aGNRdjN3REZR
|
|
||||||
SGdHN1Vxb28zaUQ5MDR4DQp1aXI4RzdCbVJ2THNTWGhpWnI2cmxIOXFnTERVU1lqV0xMWksrZXVoOUo0ejlLdmhReitQVnNsY2FYcj
|
|
||||||
RyVUVjDQphMlNvb2FKU2E2WjNYU2NuSWVPSzJKc2hPK3RnRmw3d1NDRGlpUVF1aHI3QmRLRFFhbWU3MEVxTEFvR0JBSS90DQo4dk45
|
|
||||||
d1NRN3lZamJIYU4wMkErdFNtMTdUeXNGaE5vcXZoYUEvNFJJMHRQU0RhRHZDUlhTRDRRc21ySzNaR0lxDQpBSVA3TGc3dFIyRHM3RV
|
|
||||||
NoWDY5MTRRdVZmVWF4R1ZPRXR0UFphZ0g3RzdNcllMSzFlWWl3MER1Sjl4U041dTdWDQpBczRkOURuZldiUm14UzRRd2pEU0ZMaFRp
|
|
||||||
T1JsRkt2MHFYTHF1cERuQW9HQWVFa3J4SjhJaXdhVEhnWXltM21TDQprU2h5anNWK01tVkJsVHNRK0ZabjFTM3k0YVdxbERhNUtMZF
|
|
||||||
QvWDEwQXg4NHNQTmVtQVFVMGV4YTN0OHM5bHdIDQorT3NEaktLb3hqQ1Q3S2wzckdQeUFISnJmVlZ5U2VFZVgrOERLZFZKcjByU1Bk
|
|
||||||
Qkk4Y2tFQ3kzQXpsVmphK3d3DQpST0N0emMxVHVyeG5OQTVxV0QzbjNmND0NCi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0NCg==
|
|
||||||
Reference in New Issue
Block a user