OverviewFilesCommitsIssuesDocsReleasesPeers

kodi-jacktook / plugin.video.jacktook / lib / clients / subtitle / submanager.py

254 lines · python ref:
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