import json
import os
from typing import Any, Dict, List, Optional
import xbmc
import xbmcgui
from lib.clients.subtitle.deepl import DeepLTranslator
from lib.clients.subtitle.library import LibrarySubtitleClient
from lib.clients.subtitle.opensubstremio import OpenSubtitleStremioClient
from lib.clients.subtitle.wyzie import WyzieClient
from lib.utils.kodi.utils import (
ADDON_PROFILE_PATH,
get_setting,
kodilog,
translation,
)
class KodiJsonRpcClient:
def json_rpc(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Send a JSON-RPC request to Kodi."""
request_data = {
"jsonrpc": "2.0",
"method": method,
"id": 1,
"params": params or {},
}
response = json.loads(xbmc.executeJSONRPC(json.dumps(request_data)))
if "error" in response:
# If you want logging here, you can add a log method or pass a logger
pass
return response.get("result", {})
def resolve_subtitle_ident(ids):
"""The id to ask Wyzie for, out of whatever shape `ids` arrived in.
Measured on the Pi playing a flixlab-catalog title, the payload was:
{"tmdb_id": "", "tvdb_id": "", "imdb_id": "", "original_id": "fxl:639988"}
Three things in one: the keys are PRESENT and EMPTY (so a None check sails
past them), the only real id is under `original_id`, and it carries a
catalog prefix the endpoint does not understand. Those catalogs are exactly
the ones with no imdb id, i.e. the ones this was meant to rescue.
A prefix is stripped only when what follows is a bare number -- `fxl:639988`
and `tmdb:639988` are a tmdb id wearing a hat, while `kitsu:anime-42` is not
and must not be cut in half. `tt...` ids pass through untouched; the
endpoint takes those directly.
"""
ids = ids or {}
for key in ("imdb_id", "tmdb_id"):
value = str(ids.get(key) or "").strip()
if value:
return value
original = str(ids.get("original_id") or "").strip()
if not original:
return None
prefix, sep, rest = original.partition(":")
if sep and rest.isdigit():
return rest
return original
class SubtitleManager(KodiJsonRpcClient):
def __init__(self, data: Any, notification: Any):
self.data = data
self.notification = notification
self.opensub_client = OpenSubtitleStremioClient(notification)
self.wyzie_client = WyzieClient(notification)
self.from_library = False
self.library_client = LibrarySubtitleClient(
os.path.join(ADDON_PROFILE_PATH, "Subtitles", "library"))
self.translator = DeepLTranslator(notification)
self.last_fetch_status = None
def convert_language_iso(self, from_value: str) -> str:
"""Convert language to ISO 639-1 format."""
return xbmc.convertLanguage(from_value, xbmc.ISO_639_1)
def get_kodi_preferred_subtitle_language(self, iso_format: bool = False) -> str:
"""
Get the preferred subtitle language from Kodi settings.
Returns the language in ISO format if iso_format is True.
"""
subtitle_language = self.json_rpc(
"Settings.GetSettingValue", {"setting": "locale.subtitlelanguage"}
)
value = subtitle_language.get("value", "")
if value in ["forced_only", "original", "default", "none"]:
return value
return self.convert_language_iso(value) if iso_format else value
def get_downloaded_subtitle_paths(self, folder_path: str) -> List[str]:
subtitle_files = []
for root, _, files in os.walk(folder_path):
for f in files:
if f.endswith(".srt"):
subtitle_files.append(os.path.join(root, f))
return subtitle_files
def fetch_subtitles(
self,
auto_select: bool = False,
folder_path: Optional[str] = None,
imdb_id: Optional[str] = None,
title: Optional[str] = None,
) -> Optional[List[str]]:
self.last_fetch_status = None
self.from_library = False
# A vsembed library copy brings its own subtitles, measured against
# its own audio on the server. Asked before wyzie AND before the
# per-title cache below: on 2026-10-18 that cache held the unsynced
# English for No Other Choice and was reused on every play.
library_paths = self.library_client.get_subtitles(self.data.get("url"))
if library_paths:
self.from_library = True
return library_paths
title = title or self.data.get("title")
mode = self.data.get("mode")
ids = self.data.get("ids") or {}
imdb_id = imdb_id or ids.get("imdb_id")
tv_data = self.data.get("tv_data", {})
episode = tv_data.get("episode")
season = tv_data.get("season")
# Wyzie is keyed on a tmdb OR an imdb id with no translation between
# them (verified 2026-09-04: id=1396 and id=tt0903747 return
# byte-identical bodies), so a title from the tmdb-only catalogs is no
# longer refused outright the way the imdb-keyed path had to refuse it.
ident = imdb_id or resolve_subtitle_ident(ids)
# "" is what these keys actually hold when unset, so normalise before
# anything downstream treats an empty string as an id.
imdb_id = imdb_id or (str(ids.get("imdb_id") or "").strip() or None)
if not ident:
kodilog("No IMDb or TMDB ID found for the current video, skipping subtitles")
self.last_fetch_status = "no_imdb"
return None
ident = str(ident)
if folder_path is None:
folder_path = (
os.path.join(ADDON_PROFILE_PATH, "Subtitles", ident, str(season), str(episode))
if mode == "tv"
else os.path.join(ADDON_PROFILE_PATH, "Subtitles", ident)
)
if not os.path.exists(folder_path):
os.makedirs(folder_path)
subtitle_files = self.get_downloaded_subtitle_paths(folder_path)
if subtitle_files:
# Skip "use existing?" dialog when auto-download or auto-select is
# enabled -- and when Wyzie is, because it already fetched every
# language it has. Re-downloading cannot produce anything the cache
# does not hold, so the dialog would be a modal box on every replay
# of every title in exchange for nothing. (It stays for the
# OpenSubtitles path, where a re-download genuinely can return a
# different and better subtitle.)
if (auto_select
or get_setting("auto_subtitle_download")
or get_setting("wyzie_subtitle_enabled")):
return subtitle_files
dialog = xbmcgui.Dialog()
use_existing = dialog.yesno(
translation(90250),
translation(90251),
yeslabel=translation(90627),
nolabel=translation(90628),
)
if use_existing:
return subtitle_files
# Wyzie first: it is the source the vidcore player itself uses, needs no
# key, and returned 32 tracks for No Other Choice where OpenSubtitles'
# stremio addon needs an imdb id we may not have. Not exclusive -- a
# title it lacks is exactly when the other source earns its place.
if get_setting("wyzie_subtitle_enabled"):
tracks = self.wyzie_client.get_subtitles(mode, ident, season, episode)
if tracks:
wyzie_paths = self.wyzie_client.download_subtitles_batch(
tracks,
title=title,
season=season,
episode=episode,
folder_path=folder_path,
)
if wyzie_paths:
return wyzie_paths
if not get_setting("stremio_subtitle_enabled"):
self.last_fetch_status = "not_found"
self.notification(translation(90252))
return None
if not imdb_id:
# The OpenSubtitles stremio addon is imdb-keyed only, so a tmdb-only
# title reaches the end of the road here rather than at the top.
kodilog("No IMDb ID for OpenSubtitles, skipping")
self.last_fetch_status = "no_imdb"
return None
subtitles = self.opensub_client.get_subtitles(
mode, imdb_id, season, episode, auto_select=auto_select
)
if subtitles is None:
self.last_fetch_status = "not_found"
self.notification(translation(90252))
return None
if not subtitles:
self.last_fetch_status = "not_selected"
self.notification(translation(90253))
return None
subtitle_paths = self.opensub_client.download_subtitles_batch(
subtitles,
imdb_id,
title=title,
season=season,
episode=episode,
folder_path=folder_path,
)
if get_setting("deepl_enabled"):
if auto_select:
translated_subtitles_paths = self.translator.translate_multiple_subtitles(
subtitle_paths, imdb_id, season, episode
)
return translated_subtitles_paths or subtitle_paths
dialog = xbmcgui.Dialog()
yes = dialog.yesno(
translation(90254),
translation(90255),
)
if yes:
translated_subtitles_paths = self.translator.translate_multiple_subtitles(
subtitle_paths, imdb_id, season, episode
)
return translated_subtitles_paths or subtitle_paths
return subtitle_paths