"""Subtitles from the endpoint the vidcore player itself uses.
The streams Jacktook plays carry no subtitles of their own. Measured 2026-09-04
on No Other Choice (tmdb 639988): the HLS master lists four muxed A/V variants
and no #EXT-X-MEDIA at all, so there is nothing in the stream for a player to
find. The 2026-07-21 deobfuscation of vidcore's bundle showed where the player
gets them instead -- `vidcore.net/wyzie`, a proxy in front of Wyzie -- and that
endpoint is plain HTTP, needs no browser, and needs no key of ours (sub.wyzie.ru
direct now answers `401 API key required`).
It is keyed on a tmdb OR imdb id with no translation between them (verified:
`id=1396` and `id=tt0903747` return byte-identical bodies), which is what makes
it usable for every source Jacktook plays rather than only for vidcore.
Everything available is taken. Measured on the live endpoint: all 32 tracks for
that movie in 2.9s / 3.6 MB at 8 workers, every one plain SRT. Filtering to one
language would save nothing worth the loss of the player's own subtitle menu.
"""
import os
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Dict, List, Optional
import requests
from lib.clients.subtitle.utils import slugify_title
from lib.utils.general.utils import USER_AGENT_HEADER
from lib.utils.kodi.utils import kodilog
WYZIE_URL = "https://vidcore.net/wyzie"
# The CDN behind this host 403s a request without a Referer, and the proxy sits
# on the same host, so it is sent rather than discovered to be needed later.
WYZIE_HEADERS = dict(USER_AGENT_HEADER, Referer="https://vidcore.net/")
QUERY_TIMEOUT = 15
DOWNLOAD_TIMEOUT = 25
# Measured: 32 tracks in 2.9s at 8, slowest single request 0.95s. Sequential
# would be ~20s, which is past the point Kodi abandons opening a stream.
DOWNLOAD_WORKERS = 8
class WyzieClient:
def __init__(self, notification: Callable[[str], None]):
self.notification = notification
def _build_url(
self, ident: str, season: Optional[int], episode: Optional[int]
) -> str:
url = "{0}?id={1}".format(WYZIE_URL, ident)
if season and episode:
url += "&season={0}&episode={1}".format(season, episode)
return url
def get_subtitles(
self,
mode: str,
ident: str,
season: Optional[int] = None,
episode: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""Available tracks, or [] -- never raises.
A title with no subtitles answers 404 with a zero-length body (verified
on X-Men '97 S2E8, which genuinely has none while S1E1 has 34). That is
an ordinary outcome and must not surface as an error to someone who was
only trying to press play.
"""
url = self._build_url(ident, season, episode)
try:
response = requests.get(url, timeout=QUERY_TIMEOUT, headers=WYZIE_HEADERS)
if response.status_code != 200:
kodilog("Wyzie: no subtitles for {0} (HTTP {1})".format(
ident, response.status_code))
return []
data = response.json()
except Exception as e:
kodilog("Wyzie: subtitle lookup failed for {0}: {1}".format(ident, e))
return []
if not isinstance(data, list):
kodilog("Wyzie: unexpected answer shape for {0}".format(ident))
return []
tracks = [t for t in data if isinstance(t, dict) and t.get("url")]
kodilog("Wyzie: {0} subtitle track(s) for {1}".format(len(tracks), ident))
return tracks
def _label_for(self, track: Dict[str, Any]) -> str:
"""The filename, which IS the label Kodi shows in its subtitle menu.
wyzie's own `display` rather than a mapped language code: it already
distinguishes 'Portuguese (BR)' from 'Portuguese', which pb/pt only do
if we happen to know that mapping.
`display` is remote text on its way into a filename, so it is flattened
(slugify_title keeps \s, newlines included) and stripped of any path
separator before use.
"""
raw = str(track.get("display") or track.get("language") or "und")
label = slugify_title(" ".join(raw.split()))
return os.path.basename(label).strip(". ") or "und"
def _paths_for(
self, subtitles: List[Dict[str, Any]], title: str, folder_path: str
) -> List[tuple]:
"""(path, track) per track, with names that cannot collide or escape.
The title is deliberately NOT in the name. `data["title"]` for a stremio
row is the ROW LABEL, not the film -- measured on the Pi, all 32 tracks
were written as `in library seekable\nlocal copy.<Language>.srt`, which
is a newline inside a filename and a subtitle menu where every entry
read "in library seekable local copy". The folder is already per-title,
so the name carries the one thing that varies within it.
"""
seen: Dict[str, int] = {}
out = []
for track in subtitles:
label = self._label_for(track)
n = seen[label] = seen.get(label, 0) + 1
stem = label if n == 1 else "{0}.{1}".format(label, n)
out.append((os.path.join(folder_path, "{0}.srt".format(stem)), track))
return out
def download_subtitles_batch(
self,
subtitles: List[Dict[str, Any]],
title: str,
season: Optional[int] = None,
episode: Optional[int] = None,
folder_path: Optional[str] = None,
) -> List[str]:
"""Fetch `subtitles` into `folder_path`. Returns the paths that exist.
One track failing costs that track and nothing else: a player with 31
subtitles is not meaningfully worse than one with 32, and is very much
better than one with none because the 32nd timed out.
"""
if not subtitles or not folder_path:
return []
os.makedirs(folder_path, exist_ok=True)
planned = self._paths_for(subtitles, title, folder_path)
have = [p for p, _ in planned
if os.path.exists(p) and os.path.getsize(p) > 0]
todo = [(p, t) for p, t in planned if p not in set(have)]
def _one(item):
path, track = item
try:
response = requests.get(track["url"], timeout=DOWNLOAD_TIMEOUT,
headers=WYZIE_HEADERS)
if response.status_code != 200:
kodilog("Wyzie: {0} -> HTTP {1}".format(
os.path.basename(path), response.status_code))
return None
body = response.content
except Exception as e:
kodilog("Wyzie: {0} failed ({1})".format(os.path.basename(path), e))
return None
if not body:
# An empty file is worse than none: the player offers a track
# that shows nothing, and the on-disk check above would keep it.
kodilog("Wyzie: {0} came back empty".format(os.path.basename(path)))
return None
try:
with open(path, "wb") as f:
f.write(body)
except OSError as e:
kodilog("Wyzie: could not write {0} ({1})".format(
os.path.basename(path), e))
return None
return path
paths = list(have)
if todo:
with ThreadPoolExecutor(max_workers=DOWNLOAD_WORKERS) as pool:
paths += [p for p in pool.map(_one, todo) if p]
return sorted(paths)