TVNZ update

This commit is contained in:
VineFeeder
2026-06-20 12:09:43 +01:00
parent 1beabfdf73
commit 40a42bfc57
3 changed files with 430 additions and 384 deletions
@@ -24,7 +24,7 @@ def get_widevine_license_url(manifest_str):
try: try:
data = json.loads(manifest_str) data = json.loads(manifest_str)
for source in data.get("sources", []): for source in data.get("sources", []):
widevine = source.get("key_systems", {}).get("com.wienvied.alpha") widevine = source.get("key_systems", {}).get("com.widevine.alpha")
if widevine and "license_url" in widevine: if widevine and "license_url" in widevine:
return widevine["license_url"] return widevine["license_url"]
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -48,7 +48,7 @@ class TPTV(Service):
Author: A_n_g_e_l_a Author: A_n_g_e_l_a
Authorization: email/password for service in envied.yaml Authorization: email/password for service in envied.yaml
Robustness: Robustness:
DRM free... with rare exceptions DRM free... with rare exceptions L3
\b \b
Note: Note:
@@ -1,436 +1,482 @@
# TVNZ __init__.py from __future__ import annotations
import copy
from importlib.resources import files
from types import SimpleNamespace
from typing import Any
from urllib.parse import urlparse
import click
from unshackle.core import service
import yaml
from beaupy import select_multiple
from rich.console import Console
from vinefeeder.base_loader import BaseLoader from vinefeeder.base_loader import BaseLoader
from vinefeeder.parsing_utils import split_options, list_prettify from vinefeeder.parsing_utils import split_options, list_prettify
from rich.console import Console
from beaupy import select_multiple
import json
console = Console()
# TV Shows https://www.tvnz.co.nz/shows/boiling-point/episodes/s1-e1 or from envied.core import binaries
# Movies https://www.tvnz.co.nz/shows/legally-blonde/movie/s1-e1 or from envied.core.config import config as envied_config
# Sport https://www.tvnz.co.nz/sport/football/uefa-euro/spain-v-france-semi-finals-highlights from envied.core.proxies import Basic, Gluetun, Hola, NordVPN, SurfsharkVPN, WindscribeVPN
from envied.core.titles import Movies, Series
from envied.services.TVNZ import TVNZ as EnviedTVNZ
console = Console()
class TvnzLoader(BaseLoader): class TvnzLoader(BaseLoader):
# global options """
Vinefeeder adapter for TVNZ using some code from StabbedByBrick's service.
This service was written by AI.
Keep TVNZ API/login/catalogue logic in envied.services.TVNZ.
Vinefeeder's job here is only:
1. search and display programme-level results;
2. expand a chosen TV series into individual envied Episode titles;
3. let the user choose individual episodes with beaupy;
4. call envied once per selected single title.
"""
options = "" options = ""
def __init__(self): def __init__(self):
"""
Initialize the All4Loader class with the provided headers.
Parameters:
None
Attributes:
options (str): Global options; later taken from service config.yaml
headers (dict): Global headers; may be overridden
"""
headers = { headers = {
"Accept": "*/*", "Accept": "*/*",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64)", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64)",
#'Origin': 'https://www.tvnz.co.nz',
#'Referer': 'https://www.tvnz.co.nz',
} }
super().__init__(headers) super().__init__(headers)
self.showType = None self.options_list: list[str] = []
self.category = None
self._tvnz_service: EnviedTVNZ | None = None
# ---------------------------------------------------------------------
# Vinefeeder entry points
# ---------------------------------------------------------------------
# entry point from Vinefeeder
def receive( def receive(
self, inx: None, search_term: None, category=None, hlg_status=False, opts=None self,
inx: int | None,
search_term: str | None,
category=None,
hlg_status=False,
opts=None,
): ):
""" if opts is not None:
First fetch for series titles matching all or part of search_term.
Uses inx, an int variable to switch:-
0 for greedy search using url
1 for direct url download
2 for category browse
3 for search with keyword
If search_url_entry is left blank vinefeeder generates a
a menu with 3 options:
- Greedy Search by URL
- Browse by Category
- Search by Keyword
results are forwarded to receive().
If inx == 1, do direct download using url.
If inx == 3, do keyword search.
If inx == 0, fetch videos using a greedy search or browse-category.
If inx == 2, fetch videos from a category url.
If an unknown error occurs, exit with code 0.
"""
# re-entry here for second time loses options settings
# so reset
if opts:
TvnzLoader.options = opts TvnzLoader.options = opts
self.options_list = split_options(TvnzLoader.options)
# direct download
if "http" in search_term and inx == 1: self.options_list = self._normalised_options()
try: search_term = (search_term or "").strip()
if self.options_list[0] == "":
command = ['uv', 'run', 'envied', "dl", "TVNZ", search_term]
else:
command = ['uv', 'run', 'envied', "dl", *self.options_list, "TVNZ", search_term]
self.runsubprocess(command)
return
except Exception as e:
print(
"Error downloading video:",
e,
"Is self.DOWNLOAD_ORCHESTRATOR installed correctly via 'pip install self.DOWNLOAD_ORCHESTRATOR?",
)
return
# keyword search if not search_term:
elif inx == 3: print("No search term or URL supplied.")
print(f"Searching for {search_term}") return None
# Direct download from the GUI URL field.
if inx == 1 and self._looks_like_url(search_term):
return self._download_url(search_term)
# Keyword search.
if inx == 3:
print(f"Searching TVNZ for {search_term}")
return self.fetch_videos(search_term) return self.fetch_videos(search_term)
# POP-UP MENU alternates: # Greedy mode: URL means expand it; plain text means search it.
elif inx == 0: if inx == 0:
# from greedy-search OR selecting Browse-category if self._looks_like_url(search_term):
# need a search keyword(s) from url return self.second_fetch(self._normalise_tvnz_url(search_term))
# split and select series name return self.fetch_videos(search_term)
if not 'https' in search_term:
return self.fetch_videos(search_term)
# need a search keyword(s) from category url
# split and select series name
else:
search_term = search_term.split("/")[4]
return self.fetch_videos(search_term) # Category browse is now only a light wrapper. TVNZ's old category API
# was part of the code we are intentionally not duplicating.
elif "http" in search_term and inx == 2: if inx == 2 and self._looks_like_url(search_term):
self.category = category self.category = category
self.fetch_videos_by_category( return self.fetch_videos_by_category(search_term)
search_term
) # search_term here holds a URL!!!
else: print(f"Unknown TVNZ request: inx={inx!r}, search_term={search_term!r}")
print(f"Unknown error when searching for {search_term}")
return
def fetch_videos(self, search_term):
"""Fetch videos from TVNZ using a search term.
Here the first search for series titles matches all or part of search_term.
The function will prepare the series data, matching the search term for display.
"""
# returns json as type String
#url = f"https://apis-public-prod.tech.tvnz.co.nz/api/v1/web/play/search?q={search_term}"
url = f"https://search-cdn.cms-api.tvnz.co.nz/content/search?mode=detail&st=published&term={search_term}&pageNumber=1&pageSize=50&reg=nz&dt=web&client=tvnz-tvnz-web&pf=regular&allowpg=false"
try:
html = self.get_data(url)
if "No Matches" in html:
print("Nothing found for that search; try again.")
return
else:
parsed_data = self.parse_data(html) # to json
except Exception:
print(f"No valid data returned for {url}")
return
'''# console.print_json(data=parsed_data)
f = open('tvnz.json', 'w')
f.write(json.dumps(parsed_data)) # parsed_data)
f.close()'''
if parsed_data and "Success" in parsed_data["header"]["message"]:
for item in parsed_data["data"]:
title = item.get("lon", [{"n": ""}])[0].get("n")
content_type = item.get("cty")
content_id = item.get("nu")
synopsis = item.get("losd", [{"n": ""}])[0].get("n")
url=f"https://tvnz.co.nz/{content_type}/{content_id}"
episode = {
"type": content_type,
"title": title,
"url": url,
"synopsis": synopsis,
}
self.add_episode(title, episode)
else:
print(f"No valid data returned for {url}")
return None
selected_series = self.display_series_list()
if selected_series:
return self.second_fetch(
selected_series
) # Return URLs for selected episodes
return None return None
def second_fetch(self, selected): def fetch_videos(self, search_term: str):
# TVNZ video type: sportVideo, showVideo or Movie
# makes this extractor unusual and should not be used
# to model other services
""" """
Given a selected series name, fetch its HTML and extract its episodes. First pass: use envied TVNZ search(), then let Vinefeeder choose a title.
Or if given a direct url, fetch it and process for greedy download.
The function will prepare the series data for episode selection and display the final episode list. The data stored in BaseLoader.series_data is deliberately small:
It will return the URLs for the selected episodes. just enough for Vinefeeder's programme picker and second_fetch().
""" """
if "https" in selected: # direct url provided skip url preparation self.clear_series_data()
url = selected
else:
# initial pass to find type
series_name = (
selected.lower().replace(" ", "-").replace(":", "").replace(",", "")
)
episodes = self.get_series_data()
episode_test = episodes[selected][0]
type = episode_test.get("type")
beaupylist = []
# if sport video / news video - assume no episodes
# use data from first fetch directly
if type == "tvseries":
for item in episodes[selected]: # existing data
url = "https://www.tvnz.co.nz/" + item.get("url").split("/page/")[1]
beaupylist.append(
[
item.get("title"),
url,
item.get("synopsis", "No synopsis available."),
]
)
selected = select_multiple(
beaupylist,
preprocessor=lambda val: list_prettify(val),
minimal_count=1,
cursor_style="pink1",
pagination=True,
page_size=8,
)
for item in selected:
url = item[1]
if self.options_list[0] == "":
command = ['uv', 'run', 'envied', "dl", "TVNZ", url]
else:
command = ['uv', 'run', 'envied', "dl", *self.options_list, "TVNZ", url]
self.runsubprocess(command)
return None
elif type == "movie" or type == "tvseries":
url = f"https://apis-public-prod.tech.tvnz.co.nz/api/v1/web/play/page/shows/{series_name}/episodes"
try:
html = self.get_data(url)
parsed_data = self.parse_data(html)
except Exception:
try:
# direct download as seems only one episode
# https://www.tvnz.co.nz/shows/circle-of-friends/movie/s1-e1
url = f"https://www.tvnz.co.nz/{type}/{series_name}/s1-e1"
if self.options_list[0] == "":
command = ['uv', 'run', 'envied', "dl", "TVNZ", url]
else:
command = ['uv', 'run', 'envied', "dl", *self.options_list, "TVNZ", url]
self.runsubprocess(command)
return
except Exception as e:
print(
"Error downloading video:",
e,
"Is self.DOWNLOAD_ORCHESTRATOR installed correctly via 'pip install self.DOWNLOAD_ORCHESTRATOR?",
)
return
try:
href_list = []
# iterate over all seasons and capture url for each
for item in parsed_data["layout"]["slots"]["main"]["modules"][0][
"lists"
]:
href_list.append(item["href"])
except Exception:
# object►layout►slots►main►modules►0►mobiledoc►sections►0►2►0►3
try:
message = parsed_data["layout"]["slots"]["main"]["modules"][0][
"mobiledoc"
]["sections"][0][2][0][3]
print(
"There is no content for this series. TVNZ says ..."
+ message
)
except Exception:
print(f"No valid data returned for {url}")
return
# with season url, iterate over each season and capture episodes
try: try:
for url in href_list: # for all seasons service = self._envied_tvnz(search_term, authenticate=True)
url = "https://apis-edge-prod.tech.tvnz.co.nz" + url self._tvnz_service = service
myhtml = self.get_data(url=url) results = list(service.search())
parsed_data = self.parse_data(myhtml) except SystemExit:
if parsed_data and "_embedded" in parsed_data: return None
try: except Exception as e:
for item_key, item in parsed_data["_embedded"].items(): print(f"TVNZ search failed: {e}")
series_no = item.get("seasonNumber", "100") return None
myurl = "https://www.tvnz.co.nz" + item.get("page", {}).get(
"url", ""
)
episode_no = item.get("episodeNumber", None)
synopsis = item.get("synopsis", "No synopsis available")
episode = {
"series_no": series_no,
"title": episode_no,
"url": myurl,
"synopsis": synopsis,
}
self.add_episode(series_name, episode)
except Exception:
pass # disregard any episode that does not fit the scheme
else: if not results:
print(f"No valid data at {url} found.\n Exiting") print(f"No TVNZ matches found for {search_term!r}.")
return return None
except Exception:
print(f"No valid data returned for {url}")
return
self.options_list = split_options(self.options) for result in results:
# direct download single items title = result.title or "Unknown Title"
if self.get_number_of_episodes(series_name) == 1: self.add_episode(
item = self.get_series(series_name)[0] title,
# check if has https://www.tvnz.co.nz {
"type": result.label,
"title": result.title,
"url": self._normalise_tvnz_url(result.url or str(result.id)),
"synopsis": result.description or "No synopsis available.",
},
)
if "https://www.tvnz.co.nz" in item["url"]: selected_series = self.display_series_list()
url = item["url"] if selected_series:
return self.second_fetch(selected_series)
return None
def second_fetch(self, selected: str):
"""
Expand the selected TVNZ result.
* tvseries with 2+ episodes: show a beaupy multi-select list.
* tvseries with 1 episode: call envied for that one episode.
* movie/event/highlight/news clip/sport clip: call envied directly.
"""
self.options_list = self._normalised_options()
selected_url = self._selected_to_url(selected)
if not selected_url:
print(f"No TVNZ URL found for {selected!r}.")
return None
selected_url = self._normalise_tvnz_url(selected_url)
try:
service = self._tvnz_service
if service is None:
# Direct URL / greedy path: second_fetch may be entered without fetch_videos()
service = self._envied_tvnz(selected_url, authenticate=True)
else: else:
url = f'https://www.tvnz.co.nz{item["url"]}' # Reuse the authenticated envied TVNZ instance,
try: # but point it at the selected TVNZ URL before get_titles_cached().
if self.options_list[0] == "": service.title = selected_url
command = ['uv', 'run', 'envied', "dl", "TVNZ", url]
else: titles = service.get_titles_cached()
command = ['uv', 'run', 'envied', "dl", *self.options_list, "TVNZ", url]
self.runsubprocess(command) except SystemExit:
return None
except Exception as e:
print(f"TVNZ title lookup failed for {selected_url}: {e}")
return None
# Movies also covers TVNZ sport events, highlights and clips in the
# envied TVNZ implementation.
if isinstance(titles, Movies):
return self._download_url(selected_url)
if not isinstance(titles, Series):
# Defensive fallback for any future envied title container.
return self._download_url(selected_url)
episodes = list(titles)
# Single tvepisode, or a "series" container that only has one playable
# item: no list required.
if len(episodes) <= 1:
if not episodes:
print(f"No playable TVNZ episodes found for {selected_url}.")
return None return None
except Exception as e: return self._download_url(self._title_to_url(episodes[0], fallback=selected_url))
print(
"Error downloading video:", beaupylist = []
e, for episode in episodes:
"Is self.DOWNLOAD_ORCHESTRATOR installed correctly via 'pip install self.DOWNLOAD_ORCHESTRATOR?", url = self._title_to_url(episode, fallback=selected_url)
) beaupylist.append(
return [
# else present list of series and display for multiple selection self._season_episode_label(episode),
self.prepare_series_for_episode_selection( episode.name or episode.title,
series_name url,
) # creates list of series; allows user selection of wanted series prepares an episode list over chosen series self._synopsis(episode.data),
self.final_episode_data = self.sort_episodes(self.get_final_episode_list()) ]
selected_final_episodes = self.display_final_episode_list( )
self.final_episode_data
selected_episodes = select_multiple(
beaupylist,
preprocessor=lambda val: list_prettify(val),
minimal_count=1,
cursor_style="pink1",
pagination=True,
page_size=8,
) )
for item in selected_final_episodes: for item in selected_episodes:
url = item.split(",")[2].lstrip() url = item[2]
if not url:
if url == "None": print(f"No valid TVNZ URL for {item[0]} {item[1]}")
print(f"No valid URL for {item.split(',')[1]}")
continue continue
self._download_url(url)
if self.options_list[0] == "": return None
command = ['uv', 'run', 'envied', "dl", "TVNZ", url]
else:
command = ['uv', 'run', 'envied', "dl", *self.options_list, "TVNZ", url]
self.runsubprocess(command)
return def fetch_videos_by_category(self, browse_url: str):
def fetch_videos_by_category(self, browse_url):
""" """
Fetches videos from a category (VERY TVNZ specific). TVNZ category browsing used to rely on the old public web/category API.
Args:
browse_url (str): URL of the category page. With the current envied TVNZ service the reliable path is:
Returns: category URL -> derive a search phrase -> envied TVNZ search().
None
""" """
term = self._search_term_from_url(browse_url)
if not term:
print(f"Cannot derive a TVNZ search term from {browse_url!r}.")
return None
return self.fetch_videos(term)
# ---------------------------------------------------------------------
# Envied adapter
# ---------------------------------------------------------------------
def _envied_tvnz(self, title: str, authenticate: bool = True) -> EnviedTVNZ:
"""
Build just enough Click context for envied.services.TVNZ.TVNZ to run
outside the envied CLI command.
This deliberately mirrors the envied command path:
ctx.obj.config is the service config;
ctx.obj.cdm may be None because Vinefeeder is only reading metadata;
ctx.obj.proxy_providers is populated from envied.yaml where possible.
"""
parent = click.Context(click.Command("dl"))
parent.params = {
"profile": self._option_value("--profile", "-p"),
"proxy": self._option_value("--proxy"),
"proxy_query": None,
"proxy_provider": None,
"no_proxy": self._has_option("--no-proxy"),
"vcodec": [],
"range_": [],
"best_available": False,
"no_cache": self._has_option("--no-cache"),
"reset_cache": self._has_option("--reset-cache"),
}
ctx = click.Context(click.Command("TVNZ"), parent=parent)
ctx.obj = SimpleNamespace(
config=self._load_envied_tvnz_config(),
cdm=None,
proxy_providers=self._load_envied_proxy_providers(parent.params["no_proxy"]),
)
service = EnviedTVNZ(ctx, title=title)
if authenticate:
# TVNZ.__init__ has already resolved the correct credential/cache
# object. Passing that same credential avoids Service.authenticate()
# replacing it with None.
service.authenticate(None, getattr(service, "credential", None))
return service
def _load_envied_tvnz_config(self) -> dict[str, Any]:
packaged_config: dict[str, Any] = {}
try: try:
req = self.get_data(browse_url, headers=self.headers) config_text = files("envied.services.TVNZ").joinpath("config.yaml").read_text(
encoding="utf-8"
)
packaged_config = yaml.safe_load(config_text) or {}
except Exception:
packaged_config = {}
# Parse the json returned user_config = copy.deepcopy(envied_config.services.get("TVNZ") or {})
parsed_data = self.parse_data(req) merged = self._deep_merge(packaged_config, user_config)
# for development only if not merged:
"""console.print_json(data=parsed_data) raise RuntimeError(
f = open("cat_tvnz.json",'w') "No envied TVNZ service config found. Check envied.yaml and "
f.write(json.dumps(parsed_data)) "packages/envied/src/envied/services/TVNZ/config.yaml."
f.close()""" )
i = 1 return merged
beaupylist = []
linkList = []
for item_key, item in parsed_data["_embedded"].items(): @classmethod
try: def _load_envied_proxy_providers(cls, no_proxy: bool) -> list[Any]:
if item.get("type") == "category": if no_proxy:
continue return []
elif item.get("type") == "showVideo":
myurl = "https://www.tvnz.co.nz" + item.get("page", {}).get( providers: list[Any] = []
"url", "" proxy_cfg = envied_config.proxy_providers or {}
)
showType = item.get("showType", "unknown") try:
elif item.get("type") == "show": if proxy_cfg.get("basic"):
showType = item.get("showType", "unknown") providers.append(Basic(**proxy_cfg["basic"]))
myurl = "https://www.tvnz.co.nz" + item.get( if proxy_cfg.get("nordvpn"):
"watchAction", {} providers.append(NordVPN(**proxy_cfg["nordvpn"]))
).get("link", "") if proxy_cfg.get("surfsharkvpn"):
elif item.get("type") == "sportVideo": providers.append(SurfsharkVPN(**proxy_cfg["surfsharkvpn"]))
showType = item.get("showType", "unknown") if proxy_cfg.get("windscribevpn"):
myurl = "https://www.tvnz.co.nz" + item.get("page", {}).get( providers.append(WindscribeVPN(**proxy_cfg["windscribevpn"]))
"url", "" if proxy_cfg.get("gluetun"):
) providers.append(Gluetun(**proxy_cfg["gluetun"]))
title = item.get("title", "Unknown Title") if binaries.HolaProxy:
# we are adding an episode to the series data using title as series name providers.append(Hola())
# There will be some episodes with the same series name.
# in the context of category browsing TVNZ allows duplicates.
# Below we only take the fist item with a series name
# and add that to our beaupylist for display. Thus duplicates are not added.
episode = {
"index": i,
"title": item.get("title", "Unknown Title"),
"synopsis": item.get("synopsis", "No synopsis available"),
}
self.add_episode(title, episode)
# linked list allows keeping the url away from beaupy list
# we pull the url from selected by referencing the index in linkList
linkList.append([myurl, showType])
i += 1
except Exception:
continue
except Exception as e: except Exception as e:
print(f"Error fetching category data: {e}") print(f"Could not load envied proxy providers for TVNZ metadata lookup: {e}")
return
return providers
# ---------------------------------------------------------------------
# Download command construction
# ---------------------------------------------------------------------
def _download_url(self, url: str):
url = self._normalise_tvnz_url(url)
command = ["uv", "run", "envied", "dl", *self.options_list, "TVNZ", url]
self.runsubprocess(command)
return None
# ---------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------
def _normalised_options(self) -> list[str]:
try:
return [x for x in split_options(TvnzLoader.options or "") if x]
except Exception:
return []
def _option_value(self, long_name: str, short_name: str | None = None) -> str | None:
names = {long_name}
if short_name:
names.add(short_name)
for i, token in enumerate(self.options_list):
if token in names and i + 1 < len(self.options_list):
return self.options_list[i + 1]
if token.startswith(f"{long_name}="):
return token.split("=", 1)[1]
return None
def _has_option(self, name: str) -> bool:
return name in self.options_list
@staticmethod
def _looks_like_url(value: str) -> bool:
return value.startswith("http://") or value.startswith("https://")
@staticmethod
def _normalise_tvnz_url(url: str) -> str:
# Envied's TITLE_RE accepts tvnz.co.nz and www.tvnz.co.nz. Keep this
# mainly to remove accidental whitespace and make search-created URLs
# consistent.
return url.strip().replace("https://www.tvnz.co.nz/", "https://tvnz.co.nz/")
def _selected_to_url(self, selected: str) -> str | None:
if self._looks_like_url(selected):
return selected
data = self.get_series_data() data = self.get_series_data()
# unique to TVNZ rows = data.get(selected) or []
# get first item in each list to create a beaupylist if not rows:
# without duplicates return None
for key, items in data.items():
if items: # Check if the list is not empty
first_item = items[
0
] # Get the first dictionary, any more may be duplicates
index = first_item["index"]
title = first_item["title"]
synopsis = first_item["synopsis"]
beaupylist.append([index, title, synopsis])
found = self.list_display_beaupylist(beaupylist) return rows[0].get("url")
self.clear_series_data()
if found: @staticmethod
ind = found[0] def _title_to_url(title: Any, fallback: str) -> str:
url, showType = linkList[int(ind) - 1] data = getattr(title, "data", None) or {}
if showType == "Movie" or showType == "NonEpisodic":
# direct download content_type = data.get("cty")
return self.receive(inx=1, search_term=url) content_id = data.get("nu") or getattr(title, "id", None)
if content_type and content_id:
return f"https://tvnz.co.nz/{content_type}/{content_id}"
# Defensive fallbacks for possible future TVNZ shapes.
for key in ("url", "href", "path"):
value = data.get(key)
if isinstance(value, str) and value:
if value.startswith("http"):
return value
return f"https://tvnz.co.nz{value if value.startswith('/') else '/' + value}"
page = data.get("page")
if isinstance(page, dict):
value = page.get("url")
if isinstance(value, str) and value:
if value.startswith("http"):
return value
return f"https://tvnz.co.nz{value if value.startswith('/') else '/' + value}"
return fallback
@staticmethod
def _season_episode_label(episode: Any) -> str:
season = getattr(episode, "season", 0)
number = getattr(episode, "number", 0)
try:
return f"S{int(season):02}E{int(number):02}"
except Exception:
return f"S{season}E{number}"
@classmethod
def _synopsis(cls, data: Any) -> str:
if not isinstance(data, dict):
return "No synopsis available."
# TVNZ catalogue values are often localised lists like:
# [{"n": "Some text"}]
for key in ("losd", "sd", "synopsis", "description"):
value = data.get(key)
text = cls._first_localised_text(value)
if text:
return text
return "No synopsis available."
@staticmethod
def _first_localised_text(value: Any) -> str | None:
if isinstance(value, str):
return value
if isinstance(value, list) and value:
first = value[0]
if isinstance(first, dict):
text = first.get("n")
if isinstance(text, str):
return text
if isinstance(first, str):
return first
if isinstance(value, dict):
text = value.get("n")
if isinstance(text, str):
return text
return None
@staticmethod
def _search_term_from_url(url: str) -> str:
path = urlparse(url).path.strip("/")
if not path:
return ""
# Usually the last useful slug is the most specific category/title text.
slug = path.split("/")[-1]
return slug.replace("-", " ").replace("_", " ").strip()
@classmethod
def _deep_merge(cls, defaults: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
result = copy.deepcopy(defaults or {})
for key, value in (overrides or {}).items():
if isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = cls._deep_merge(result[key], value)
else: else:
# greedy search result[key] = copy.deepcopy(value)
search_term = url.split("/")[4].replace("-", " ")
return self.receive(inx=3, search_term=search_term)
else:
print("No video selected.")
return
return result
@@ -1,6 +1,6 @@
service: TVNZ service: TVNZ
options: --select-titles options:
media_dict: media_dict:
Drama: https://apis-edge-prod.tech.tvnz.co.nz/api/v1/web/play/page/categories/drama Drama: https://apis-edge-prod.tech.tvnz.co.nz/api/v1/web/play/page/categories/drama