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,201 +1,209 @@
# 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 self.fetch_videos(search_term)
# POP-UP MENU alternates:
elif inx == 0:
# from greedy-search OR selecting Browse-category
# need a search keyword(s) from url
# split and select series name
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)
elif "http" in search_term and inx == 2:
self.category = category
self.fetch_videos_by_category(
search_term
) # search_term here holds a URL!!!
else:
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 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)
# Greedy mode: URL means expand it; plain text means search it.
if inx == 0:
if self._looks_like_url(search_term):
return self.second_fetch(self._normalise_tvnz_url(search_term))
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.
if inx == 2 and self._looks_like_url(search_term):
self.category = category
return self.fetch_videos_by_category(search_term)
print(f"Unknown TVNZ request: inx={inx!r}, search_term={search_term!r}")
return None
def fetch_videos(self, search_term: str):
"""
First pass: use envied TVNZ search(), then let Vinefeeder choose a title.
The data stored in BaseLoader.series_data is deliberately small:
just enough for Vinefeeder's programme picker and second_fetch().
"""
self.clear_series_data()
try:
service = self._envied_tvnz(search_term, authenticate=True)
self._tvnz_service = service
results = list(service.search())
except SystemExit:
return None
except Exception as e:
print(f"TVNZ search failed: {e}")
return None
if not results:
print(f"No TVNZ matches found for {search_term!r}.")
return None
for result in results:
title = result.title or "Unknown Title"
self.add_episode(
title,
{
"type": result.label,
"title": result.title,
"url": self._normalise_tvnz_url(result.url or str(result.id)),
"synopsis": result.description or "No synopsis available.",
},
)
selected_series = self.display_series_list() selected_series = self.display_series_list()
if selected_series: if selected_series:
return self.second_fetch( return self.second_fetch(selected_series)
selected_series
) # Return URLs for selected episodes
return None return None
def second_fetch(self, selected): def second_fetch(self, selected: 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. Expand the selected TVNZ result.
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. * tvseries with 2+ episodes: show a beaupy multi-select list.
It will return the URLs for the selected episodes. * tvseries with 1 episode: call envied for that one episode.
* movie/event/highlight/news clip/sport clip: call envied directly.
""" """
if "https" in selected: # direct url provided skip url preparation self.options_list = self._normalised_options()
url = selected
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:
# initial pass to find type # Reuse the authenticated envied TVNZ instance,
series_name = ( # but point it at the selected TVNZ URL before get_titles_cached().
selected.lower().replace(" ", "-").replace(":", "").replace(",", "") service.title = selected_url
)
episodes = self.get_series_data() titles = service.get_titles_cached()
episode_test = episodes[selected][0]
type = episode_test.get("type") 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 self._download_url(self._title_to_url(episodes[0], fallback=selected_url))
beaupylist = [] beaupylist = []
# if sport video / news video - assume no episodes for episode in episodes:
# use data from first fetch directly url = self._title_to_url(episode, fallback=selected_url)
if type == "tvseries":
for item in episodes[selected]: # existing data
url = "https://www.tvnz.co.nz/" + item.get("url").split("/page/")[1]
beaupylist.append( beaupylist.append(
[ [
item.get("title"), self._season_episode_label(episode),
episode.name or episode.title,
url, url,
item.get("synopsis", "No synopsis available."), self._synopsis(episode.data),
] ]
) )
selected = select_multiple( selected_episodes = select_multiple(
beaupylist, beaupylist,
preprocessor=lambda val: list_prettify(val), preprocessor=lambda val: list_prettify(val),
minimal_count=1, minimal_count=1,
@@ -204,233 +212,271 @@ class TvnzLoader(BaseLoader):
page_size=8, page_size=8,
) )
for item in selected: for item in selected_episodes:
url = item[1] url = item[2]
if self.options_list[0] == "": if not url:
command = ['uv', 'run', 'envied', "dl", "TVNZ", url] print(f"No valid TVNZ URL for {item[0]} {item[1]}")
else: continue
command = ['uv', 'run', 'envied', "dl", *self.options_list, "TVNZ", url] self._download_url(url)
return None
def fetch_videos_by_category(self, browse_url: str):
"""
TVNZ category browsing used to rely on the old public web/category API.
With the current envied TVNZ service the reliable path is:
category URL -> derive a search phrase -> envied TVNZ search().
"""
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:
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 = {}
user_config = copy.deepcopy(envied_config.services.get("TVNZ") or {})
merged = self._deep_merge(packaged_config, user_config)
if not merged:
raise RuntimeError(
"No envied TVNZ service config found. Check envied.yaml and "
"packages/envied/src/envied/services/TVNZ/config.yaml."
)
return merged
@classmethod
def _load_envied_proxy_providers(cls, no_proxy: bool) -> list[Any]:
if no_proxy:
return []
providers: list[Any] = []
proxy_cfg = envied_config.proxy_providers or {}
try:
if proxy_cfg.get("basic"):
providers.append(Basic(**proxy_cfg["basic"]))
if proxy_cfg.get("nordvpn"):
providers.append(NordVPN(**proxy_cfg["nordvpn"]))
if proxy_cfg.get("surfsharkvpn"):
providers.append(SurfsharkVPN(**proxy_cfg["surfsharkvpn"]))
if proxy_cfg.get("windscribevpn"):
providers.append(WindscribeVPN(**proxy_cfg["windscribevpn"]))
if proxy_cfg.get("gluetun"):
providers.append(Gluetun(**proxy_cfg["gluetun"]))
if binaries.HolaProxy:
providers.append(Hola())
except Exception as e:
print(f"Could not load envied proxy providers for TVNZ metadata lookup: {e}")
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) self.runsubprocess(command)
return None 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" # Small helpers
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: def _normalised_options(self) -> list[str]:
print(
"Error downloading video:",
e,
"Is self.DOWNLOAD_ORCHESTRATOR installed correctly via 'pip install self.DOWNLOAD_ORCHESTRATOR?",
)
return
try: try:
href_list = [] return [x for x in split_options(TvnzLoader.options or "") if x]
# 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: except Exception:
# object►layout►slots►main►modules►0►mobiledoc►sections►0►2►0►3 return []
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:
for url in href_list: # for all seasons
url = "https://apis-edge-prod.tech.tvnz.co.nz" + url
myhtml = self.get_data(url=url)
parsed_data = self.parse_data(myhtml)
if parsed_data and "_embedded" in parsed_data:
try:
for item_key, item in parsed_data["_embedded"].items():
series_no = item.get("seasonNumber", "100")
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: def _option_value(self, long_name: str, short_name: str | None = None) -> str | None:
print(f"No valid data at {url} found.\n Exiting") names = {long_name}
return if short_name:
except Exception: names.add(short_name)
print(f"No valid data returned for {url}")
return
self.options_list = split_options(self.options) for i, token in enumerate(self.options_list):
# direct download single items if token in names and i + 1 < len(self.options_list):
if self.get_number_of_episodes(series_name) == 1: return self.options_list[i + 1]
item = self.get_series(series_name)[0] if token.startswith(f"{long_name}="):
# check if has https://www.tvnz.co.nz return token.split("=", 1)[1]
if "https://www.tvnz.co.nz" in item["url"]:
url = item["url"]
else:
url = f'https://www.tvnz.co.nz{item["url"]}'
try:
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 return None
except Exception as e:
print(
"Error downloading video:",
e,
"Is self.DOWNLOAD_ORCHESTRATOR installed correctly via 'pip install self.DOWNLOAD_ORCHESTRATOR?",
)
return
# else present list of series and display for multiple selection
self.prepare_series_for_episode_selection(
series_name
) # creates list of series; allows user selection of wanted series prepares an episode list over chosen series
self.final_episode_data = self.sort_episodes(self.get_final_episode_list())
selected_final_episodes = self.display_final_episode_list(
self.final_episode_data
)
for item in selected_final_episodes: def _has_option(self, name: str) -> bool:
url = item.split(",")[2].lstrip() return name in self.options_list
if url == "None": @staticmethod
print(f"No valid URL for {item.split(',')[1]}") def _looks_like_url(value: str) -> bool:
continue return value.startswith("http://") or value.startswith("https://")
if self.options_list[0] == "": @staticmethod
command = ['uv', 'run', 'envied', "dl", "TVNZ", url] def _normalise_tvnz_url(url: str) -> str:
else: # Envied's TITLE_RE accepts tvnz.co.nz and www.tvnz.co.nz. Keep this
command = ['uv', 'run', 'envied', "dl", *self.options_list, "TVNZ", url] # mainly to remove accidental whitespace and make search-created URLs
self.runsubprocess(command) # consistent.
return url.strip().replace("https://www.tvnz.co.nz/", "https://tvnz.co.nz/")
return def _selected_to_url(self, selected: str) -> str | None:
if self._looks_like_url(selected):
def fetch_videos_by_category(self, browse_url): return selected
"""
Fetches videos from a category (VERY TVNZ specific).
Args:
browse_url (str): URL of the category page.
Returns:
None
"""
try:
req = self.get_data(browse_url, headers=self.headers)
# Parse the json returned
parsed_data = self.parse_data(req)
# for development only
"""console.print_json(data=parsed_data)
f = open("cat_tvnz.json",'w')
f.write(json.dumps(parsed_data))
f.close()"""
i = 1
beaupylist = []
linkList = []
for item_key, item in parsed_data["_embedded"].items():
try:
if item.get("type") == "category":
continue
elif item.get("type") == "showVideo":
myurl = "https://www.tvnz.co.nz" + item.get("page", {}).get(
"url", ""
)
showType = item.get("showType", "unknown")
elif item.get("type") == "show":
showType = item.get("showType", "unknown")
myurl = "https://www.tvnz.co.nz" + item.get(
"watchAction", {}
).get("link", "")
elif item.get("type") == "sportVideo":
showType = item.get("showType", "unknown")
myurl = "https://www.tvnz.co.nz" + item.get("page", {}).get(
"url", ""
)
title = item.get("title", "Unknown Title")
# we are adding an episode to the series data using title as series name
# 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:
print(f"Error fetching category data: {e}")
return
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