├── resources ├── lib │ ├── os │ │ ├── model │ │ │ ├── response │ │ │ │ └── .placeholder │ │ │ └── request │ │ │ │ ├── abstract.py │ │ │ │ ├── download.py │ │ │ │ └── subtitles.py │ │ └── provider.py │ ├── exceptions.py │ ├── utilities.py │ ├── cache.py │ ├── file_operations.py │ ├── subtitle_downloader.py │ └── data_collector.py ├── media │ ├── os_fanart.jpg │ ├── screenshot_1.jpg │ ├── screenshot_2.jpg │ ├── screenshot_3.jpg │ └── os_logo_512x512.png ├── language │ ├── resource.language.zh_CN │ │ └── strings.po │ ├── resource.language.en_GB │ │ └── strings.po │ ├── resource.language.fr_FR │ │ └── strings.po │ ├── resource.language.sv_SE │ │ └── strings.po │ └── resource.language.hu_HU │ │ └── strings.po └── settings.xml ├── fanart.jpg ├── icon.png ├── logo.png ├── service.py ├── .travis.yml ├── changelog.txt ├── README.md ├── addon.xml └── LICENSE.txt /resources/lib/os/model/response/.placeholder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fanart.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opensubtitlesdev/service.subtitles.opensubtitles-com/HEAD/fanart.jpg -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opensubtitlesdev/service.subtitles.opensubtitles-com/HEAD/icon.png -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opensubtitlesdev/service.subtitles.opensubtitles-com/HEAD/logo.png -------------------------------------------------------------------------------- /resources/media/os_fanart.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opensubtitlesdev/service.subtitles.opensubtitles-com/HEAD/resources/media/os_fanart.jpg -------------------------------------------------------------------------------- /resources/media/screenshot_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opensubtitlesdev/service.subtitles.opensubtitles-com/HEAD/resources/media/screenshot_1.jpg -------------------------------------------------------------------------------- /resources/media/screenshot_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opensubtitlesdev/service.subtitles.opensubtitles-com/HEAD/resources/media/screenshot_2.jpg -------------------------------------------------------------------------------- /resources/media/screenshot_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opensubtitlesdev/service.subtitles.opensubtitles-com/HEAD/resources/media/screenshot_3.jpg -------------------------------------------------------------------------------- /resources/media/os_logo_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opensubtitlesdev/service.subtitles.opensubtitles-com/HEAD/resources/media/os_logo_512x512.png -------------------------------------------------------------------------------- /service.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | import xbmcplugin 4 | 5 | from resources.lib.subtitle_downloader import SubtitleDownloader 6 | 7 | 8 | SubtitleDownloader().handle_action() 9 | 10 | xbmcplugin.endOfDirectory(int(sys.argv[1])) 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: "3.6" 3 | install: echo "Install test dependencies" 4 | script: echo "Run tests" 5 | before_deploy: 6 | - pip install git+https://github.com/romanvm/kodi-addon-submitter.git 7 | - submit-addon -z service.subtitles.opensubtitles-com # Create an installable ZIP 8 | - export RELEASE_ZIP=$(ls *.zip) 9 | deploy: 10 | # Publish an installable ZIP to GitHub Releases 11 | - provider: releases 12 | api_key: $GH_TOKEN 13 | file_glob: true 14 | file: $RELEASE_ZIP 15 | skip_cleanup: true 16 | on: 17 | tags: true 18 | # Submit to the official Kodi repo 19 | - provider: script 20 | # script: submit-addon -r repo-plugins -b leia --pull-request service.subtitles.opensubtitles-com 21 | script: submit-addon -r repo-plugins -b leia service.subtitles.opensubtitles-com 22 | on: 23 | tags: true 24 | notifications: 25 | email: false 26 | -------------------------------------------------------------------------------- /resources/lib/os/model/request/abstract.py: -------------------------------------------------------------------------------- 1 | 2 | from resources.lib.utilities import log 3 | 4 | 5 | def logging(msg): 6 | log(__name__, msg) 7 | 8 | 9 | class OpenSubtitlesRequest: 10 | def __init__(self): 11 | self._instance = True 12 | 13 | # ordered request params with defaults 14 | self.DEFAULT_LIST = dict() 15 | 16 | def request_params(self): 17 | if not self._instance: 18 | raise ReferenceError("Should pass params to the class by initiating it first.") 19 | request_params = {} 20 | logging("DEFAULT_LIST: ") 21 | logging(self.DEFAULT_LIST) 22 | for key, default_value in list(self.DEFAULT_LIST.items()): 23 | current_value = getattr(self, key) 24 | logging(f"Some property {key}: {default_value}, {current_value}") 25 | if current_value and current_value != default_value: 26 | request_params[key] = current_value 27 | 28 | return request_params 29 | -------------------------------------------------------------------------------- /resources/lib/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class ProviderError(Exception): 3 | """Exception raised by providers.""" 4 | pass 5 | 6 | 7 | class ConfigurationError(ProviderError): 8 | """Exception raised by providers when badly configured.""" 9 | pass 10 | 11 | 12 | class AuthenticationError(ProviderError): 13 | """Exception raised by providers when authentication failed.""" 14 | pass 15 | 16 | 17 | class ServiceUnavailable(ProviderError): 18 | """Exception raised when status is '503 Service Unavailable'.""" 19 | pass 20 | 21 | 22 | class DownloadLimitExceeded(ProviderError): 23 | """Exception raised by providers when download limit is exceeded.""" 24 | pass 25 | 26 | 27 | class TooManyRequests(ProviderError): 28 | """Exception raised by providers when too many requests are made.""" 29 | pass 30 | 31 | 32 | class BadUsernameError(ProviderError): 33 | """Exception raised by providers when user entered the email instead of the username in the username field.""" 34 | pass -------------------------------------------------------------------------------- /changelog.txt: -------------------------------------------------------------------------------- 1 | 2 | v1.0.8 (2025-09-04) 3 | - performs a query to kodi library if imdb or tmdb ID is missing (thanks you cvanderkam) 4 | 5 | v1.0.7 (2025-08-26) 6 | - added IMDB and TMDB collection on files for more accurate search to the API 7 | 8 | v1.0.6 (2024-11-29) 9 | - fixed issue with RAR archives (thanks ninjacarr) 10 | - handles default chinese language to zh-cn (thanks ninjacarr) 11 | 12 | v1.0.5 (2024-07-30) 13 | - fixed issue with portuguese file names 14 | - added AI translated filter (thanks Kate6) 15 | 16 | v1.0.4 (2024-01-17) 17 | - Sanitize language query 18 | - Improved sorting 19 | - Improved error messages 20 | - Improved usage of moviehash 21 | 22 | v1.0.3 (2023-12-18) 23 | - Fixed issue with file path 24 | 25 | v1.0.2 (2023-08-28) 26 | - Update user agent header 27 | 28 | v1.0.1 (2023-07-28) 29 | - Remove limit of 10 subtitles for the returned values 30 | - Fix Portuguese and Brazilian flags 31 | 32 | 1.0.0 33 | Initial version, forked from https://github.com/juokelis/service.subtitles.opensubtitles 34 | Search fixed and improved -------------------------------------------------------------------------------- /resources/lib/utilities.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | import unicodedata 4 | 5 | import xbmc 6 | import xbmcaddon 7 | import xbmcgui 8 | 9 | from urllib.parse import parse_qsl 10 | 11 | __addon__ = xbmcaddon.Addon() 12 | __addon_name__ = __addon__.getAddonInfo("name") 13 | __language__ = __addon__.getLocalizedString 14 | 15 | 16 | def log(module, msg): 17 | xbmc.log(f"### [{__addon_name__}:{module}] - {msg}", level=xbmc.LOGDEBUG) 18 | 19 | 20 | # prints out msg to log and gives Kodi message with msg_id to user if msg_id provided 21 | def error(module, msg_id=None, msg=""): 22 | if msg: 23 | message = msg 24 | elif msg_id: 25 | message = __language__(msg_id) 26 | else: 27 | message = "Add-on error with empty message" 28 | log(module, message) 29 | if msg_id: 30 | xbmcgui.Dialog().ok(__addon_name__, f"{__language__(2103)}\n{__language__(msg_id)}") 31 | 32 | 33 | def get_params(string=""): 34 | param = [] 35 | if string == "": 36 | param_string = sys.argv[2][1:] 37 | else: 38 | param_string = string 39 | 40 | if len(param_string) >= 2: 41 | param = dict(parse_qsl(param_string)) 42 | 43 | return param 44 | 45 | 46 | def normalize_string(str_): 47 | return unicodedata.normalize("NFKD", str_) 48 | -------------------------------------------------------------------------------- /resources/lib/cache.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from time import time 4 | 5 | import xbmcgui 6 | 7 | from resources.lib.utilities import log 8 | 9 | 10 | class Cache(object): 11 | """Caches Python values as JSON.""" 12 | 13 | def __init__(self, key_prefix=""): 14 | self.key_prefix = key_prefix 15 | self._win = xbmcgui.Window(10000) 16 | 17 | def set(self, key, value, expires=60 * 60 * 24 * 7): 18 | 19 | log(__name__, f"caching {key}") 20 | if self.key_prefix: 21 | key = f"{self.key_prefix}:{key}" 22 | 23 | expires += time() 24 | 25 | cache_data_str = json.dumps(dict(value=value, expires=expires)) 26 | 27 | self._win.setProperty(key, cache_data_str) 28 | 29 | def get(self, key, default=None): 30 | 31 | log(__name__, f"got request for {key} from cache") 32 | result = default 33 | 34 | if self.key_prefix: 35 | key = f"{self.key_prefix}:{key}" 36 | 37 | cache_data_str = self._win.getProperty(key) 38 | 39 | if cache_data_str: 40 | cache_data = json.loads(cache_data_str) 41 | if cache_data["expires"] > time(): 42 | result = cache_data["value"] 43 | log(__name__, f"got {key} from cache") 44 | 45 | return result 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | OpenSubtitles.com KODI add-on 2 | ============================= 3 | Search and download subtitles for movies and TV-Series from OpenSubtitles.com. Search in 75 languages, 8.000.000+ subtitles, daily updates. 4 | 5 | REST API implementation based on tomburke25 [python-opensubtitles-rest-api](https://github.com/tomburke25/python-opensubtitles-rest-api) 6 | 7 | v1.0.8 (2025-09-04) 8 | - performs a query to kodi library if imdb or tmdb ID is missing (thanks you cvanderkam) 9 | 10 | v1.0.7 (2025-08-26) 11 | - added IMDB and TMDB collection on files for more accurate search to the API 12 | 13 | v1.0.6 (2024-11-29) 14 | - fixed issue with RAR archives (thanks ninjacarr) 15 | - handles default chinese language to zh-cn (thanks ninjacarr) 16 | 17 | v1.0.5 (2024-07-30) 18 | - fixed issue with portuguese file names 19 | - added AI translated filter (thanks Kate6) 20 | 21 | v1.0.4 (2024-01-15) 22 | - Sanitize language query 23 | - Improved sorting 24 | - Improved error messages 25 | - Improved usage of moviehash 26 | 27 | v1.0.3 (2023-12-18) 28 | - Fixed issue with file path 29 | 30 | v1.0.2 (2023-08-28) 31 | - Update user agent header 32 | 33 | v1.0.1 (2023-07-28) 34 | - Remove limit of 10 subtitles for the returned values 35 | - Fix Portuguese and Brazilian flags 36 | 37 | 1.0.0 38 | Initial version, forked from https://github.com/juokelis/service.subtitles.opensubtitles 39 | Search fixed and improved -------------------------------------------------------------------------------- /resources/language/resource.language.zh_CN/strings.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | 4 | msgctxt "#32001" 5 | msgid "Error searching for subtitles" 6 | msgstr "字幕搜索出错" 7 | 8 | msgctxt "#32002" 9 | msgid "Plugin is not properly configured, insert valid OpenSubtitles.com username and password." 10 | msgstr "插件配置不正确,请输入有效的 OpenSubtitles.com 用户名和密码。" 11 | 12 | msgctxt "#32003" 13 | msgid "Unable to authenticate. Check your OpenSubtitles.com username and password." 14 | msgstr "无法验证。请检查您的 OpenSubtitles.com 用户名和密码。" 15 | 16 | msgctxt "#32004" 17 | msgid "You have reached OpenSubtitles.com download limit. Get a VIP subscription or try again tomorrow." 18 | msgstr "您已达到 OpenSubtitles.com 的下载上限。获取 VIP 订阅或明天再试。" 19 | 20 | msgctxt "#32006" 21 | msgid "You have reached OpenSubtitles.com download limit for unauthenticated users. Create an account on opensubtitles.com and enter your credentials in addon settings, or try again tomorrow." 22 | msgstr "您已达到 OpenSubtitles.com 未认证用户的下载上限。请在 opensubtitles.com 上创建一个账户并在插件设置中输入您的凭据,或明天再试。" 23 | 24 | msgctxt "#32101" 25 | msgid "Login Details" 26 | msgstr "登录详细信息" 27 | 28 | msgctxt "#32201" 29 | msgid "Username" 30 | msgstr "用户名" 31 | 32 | msgctxt "#32202" 33 | msgid "Password" 34 | msgstr "密码" 35 | 36 | msgctxt "#32203" 37 | msgid "API Key" 38 | msgstr "" 39 | 40 | msgctxt "#32102" 41 | msgid "Subtitle Options" 42 | msgstr "字幕选项" 43 | 44 | msgctxt "#32211" 45 | msgid "Hearing Impaired" 46 | msgstr "听障人士" 47 | 48 | msgctxt "#32212" 49 | msgid "Foreign Parts Only" 50 | msgstr "仅限外国零件" 51 | 52 | msgctxt "#32213" 53 | msgid "Machine Translated" 54 | msgstr 机器翻译" 55 | 56 | msgctxt "#32214" 57 | msgid "Bad username. Make sure you have entered your username and not your email in the username field" 58 | msgstr "用户名错误。请确保在用户名栏中输入的是用户名而不是电子邮件." 59 | 60 | -------------------------------------------------------------------------------- /resources/language/resource.language.en_GB/strings.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: XBMC Main Translation Project (Frodo)\n" 4 | "Report-Msgid-Bugs-To: http://trac.xbmc.org/\n" 5 | "POT-Creation-Date: YEAR-MO-DA HO:MI+ZONE\n" 6 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 7 | "Last-Translator: XBMC Translation Team\n" 8 | "Language-Team: English (http://www.transifex.com/projects/p/XBMC-Main-Frodo/language/en/)\n" 9 | "MIME-Version: 1.0\n" 10 | "Content-Type: text/plain; charset=UTF-8\n" 11 | "Content-Transfer-Encoding: 8bit\n" 12 | "Language: en\n" 13 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 14 | 15 | msgctxt "#32001" 16 | msgid "Error searching for subtitles" 17 | msgstr "" 18 | 19 | msgctxt "#32002" 20 | msgid "Plugin is not properly configured, insert valid OpenSubtitles.com username and password." 21 | msgstr "" 22 | 23 | msgctxt "#32003" 24 | msgid "Unable to authenticate. Check your OpenSubtitles.com username and password." 25 | msgstr "" 26 | 27 | msgctxt "#32004" 28 | msgid "You have reached OpenSubtitles.com download limit. Get a VIP subscription or try again tomorrow." 29 | msgstr "" 30 | 31 | msgctxt "#32006" 32 | msgid "You have reached OpenSubtitles.com download limit for unauthenticated users. Create an account on opensubtitles.com and enter your credentials in addon settings, or try again tomorrow." 33 | msgstr "" 34 | 35 | msgctxt "#32101" 36 | msgid "Login Details" 37 | msgstr "" 38 | 39 | msgctxt "#32201" 40 | msgid "Username" 41 | msgstr "" 42 | 43 | msgctxt "#32202" 44 | msgid "Password" 45 | msgstr "" 46 | 47 | msgctxt "#32203" 48 | msgid "API Key" 49 | msgstr "" 50 | 51 | msgctxt "#32102" 52 | msgid "Subtitle Options" 53 | msgstr "" 54 | 55 | msgctxt "#32211" 56 | msgid "Hearing Impaired" 57 | msgstr "" 58 | 59 | msgctxt "#32212" 60 | msgid "Foreign Parts Only" 61 | msgstr "" 62 | 63 | msgctxt "#32213" 64 | msgid "Machine Translated" 65 | msgstr "" 66 | 67 | msgctxt "#32214" 68 | msgid "Bad username. Make sure you have entered your username and not your email in the username field." 69 | msgstr "" 70 | 71 | msgctxt "#32215" 72 | msgid "AI Translated" 73 | msgstr "" -------------------------------------------------------------------------------- /resources/language/resource.language.fr_FR/strings.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: \n" 4 | "POT-Creation-Date: \n" 5 | "PO-Revision-Date: \n" 6 | "Last-Translator: \n" 7 | "Language-Team: \n" 8 | "Language: fr\n" 9 | "MIME-Version: 1.0\n" 10 | "Content-Type: text/plain; charset=UTF-8\n" 11 | "Content-Transfer-Encoding: 8bit\n" 12 | "X-Generator: Poedit 3.0.1\n" 13 | 14 | msgctxt "#32001" 15 | msgid "Error searching for subtitles" 16 | msgstr "Le resultat de la recherche a retourne une erreur" 17 | 18 | msgctxt "#32002" 19 | msgid "Plugin is not properly configured, insert valid OpenSubtitles.com username and password." 20 | msgstr "Le nom d'utilisateur et le mot de passe OpenSubtitles.com est vide ou faux" 21 | 22 | msgctxt "#32003" 23 | msgid "Unable to authenticate. Check your OpenSubtitles.com username, password." 24 | msgstr "Impossible de s’authentifier. Vérifiez votre nom d’utilisateur OpenSubtitles.com, votre mot de passe." 25 | 26 | msgctxt "#32004" 27 | msgid "Vous avez atteint la limite quotidienne sur OpenSubtitles.com. Obtenez plus de téléchargements en devenant VIP, ou attendez 24h" 28 | msgstr "Vous avez atteint la limite quotidienne sur OpenSubtitles.com. Obtenez plus de téléchargements en devenant VIP, ou attendez 24h" 29 | 30 | msgctxt "#32101" 31 | msgid "Login Details" 32 | msgstr "Identification" 33 | 34 | msgctxt "#32201" 35 | msgid "Username" 36 | msgstr "Utilisateur" 37 | 38 | msgctxt "#32202" 39 | msgid "Password" 40 | msgstr "Mot de passe" 41 | 42 | msgctxt "#32203" 43 | msgid "API Key" 44 | msgstr "" 45 | 46 | msgctxt "#32006" 47 | msgid "Vous avez atteint la limit de téléchargement sans login sur OpenSubtitles.com. Veuillez créer un compte sur opensubtitles.com et entrez votre login/mot de passe dans la configuration du plugin " 48 | msgstr "Vous avez atteint la limit de téléchargement sans login sur OpenSubtitles.com. Veuillez créer un compte sur opensubtitles.com et entrez votre login/mot de passe dans la configuration du plugin " 49 | 50 | msgctxt "#32102" 51 | msgid "Subtitle Options" 52 | msgstr "Options de sous-titres" 53 | 54 | msgctxt "#32211" 55 | msgid "Hearing Impaired" 56 | msgstr "Malentendants" 57 | 58 | msgctxt "#32212" 59 | msgid "Foreign Parts Only" 60 | msgstr "Pièces étrangères uniquement" 61 | 62 | msgctxt "#32213" 63 | msgid "Machine Translated" 64 | msgstr "Traduction automatique" 65 | 66 | msgctxt "#32214" 67 | msgid "Bad username. Make sure you have entered your username and not your email in the username field" 68 | msgstr "Mauvais nom d'utilisateur. Assurez-vous d'avoir saisi votre nom d'utilisateur et non votre adresse email dans le champ du nom d'utilisateur." 69 | 70 | msgctxt "#32215" 71 | msgid "Traductions AI" 72 | msgstr "" -------------------------------------------------------------------------------- /resources/language/resource.language.sv_SE/strings.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: XBMC Main Translation Project (Frodo)\n" 4 | "Report-Msgid-Bugs-To: http://trac.xbmc.org/\n" 5 | "POT-Creation-Date: YEAR-MO-DA HO:MI+ZONE\n" 6 | "PO-Revision-Date: 2023-01-27 HO:MI+ZONE\n" 7 | "Last-Translator: Sopor\n" 8 | "Language-Team: English (http://www.transifex.com/projects/p/XBMC-Main-Frodo/language/en/)\n" 9 | "MIME-Version: 1.0\n" 10 | "Content-Type: text/plain; charset=UTF-8\n" 11 | "Content-Transfer-Encoding: 8bit\n" 12 | "Language: sv\n" 13 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 14 | 15 | msgctxt "#32001" 16 | msgid "Error searching for subtitles" 17 | msgstr "Fel vid sökning efter undertexter" 18 | 19 | msgctxt "#32002" 20 | msgid "Plugin is not properly configured, insert valid OpenSubtitles.com username and password." 21 | msgstr "Tillägget är inte korrekt konfigurerad, ange ett giltigt användarnamn och lösenord för opensubtitles.com." 22 | 23 | msgctxt "#32003" 24 | msgid "Unable to authenticate. Check your OpenSubtitles.com username, password." 25 | msgstr "Det går inte att logga in. Kontrollera ditt användarnamn och lösenord för opensubtitles.com." 26 | 27 | msgctxt "#32004" 28 | msgid "You have reached OpenSubtitles.com download limit. Get a VIP subscription or try again tomorrow." 29 | msgstr "Du har nått nerladdningsgränsen för OpenSubtitles.com. Skaffa en VIP-prenumeration eller försök igen i morgon." 30 | 31 | msgctxt "#32006" 32 | msgid "You have reached OpenSubtitles.com download limit for unauthenticated users. Create an account on opensubtitles.com and enter your credentials in addon settings, or try again tomorrow." 33 | msgstr "Du har nått nerladdningsgränsen för ej inloggade användare på opensubtitles.com. Skaffa en konto på opensubtitles.com och ange dina inloggningsuppgifter i tilläggets inställningar eller försök igen i morgon." 34 | 35 | msgctxt "#32101" 36 | msgid "Login Details" 37 | msgstr "Inloggningsdetaljer" 38 | 39 | msgctxt "#32201" 40 | msgid "Username" 41 | msgstr "Användarnamn" 42 | 43 | msgctxt "#32202" 44 | msgid "Password" 45 | msgstr "Lösenord" 46 | 47 | msgctxt "#32203" 48 | msgid "API Key" 49 | msgstr "API-nyckel" 50 | 51 | msgctxt "#32102" 52 | msgid "Subtitle Options" 53 | msgstr "Undertextalternativ" 54 | 55 | msgctxt "#32211" 56 | msgid "Hearing Impaired" 57 | msgstr "Hörselskadad" 58 | 59 | msgctxt "#32212" 60 | msgid "Foreign Parts Only" 61 | msgstr "Endast delar med främmande språk" 62 | 63 | msgctxt "#32213" 64 | msgid "Machine Translated" 65 | msgstr "Maskinöversatt" 66 | 67 | msgctxt "#32214" 68 | msgid "Bad username. Make sure you have entered your username and not your email in the username field" 69 | msgstr "Felaktigt användarnamn. Kontrollera att du har angett ditt användarnamn och inte din e-postadress i fältet för användarnamn." 70 | -------------------------------------------------------------------------------- /resources/language/resource.language.hu_HU/strings.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: XBMC Main Translation Project (Frodo)\n" 4 | "Report-Msgid-Bugs-To: http://trac.xbmc.org/\n" 5 | "POT-Creation-Date: YEAR-MO-DA HO:MI+ZONE\n" 6 | "PO-Revision-Date: 2023-01-21 HO:MI+ZONE\n" 7 | "Last-Translator: XBMC Translation Team\n" 8 | "Language-Team: English (http://www.transifex.com/projects/p/XBMC-Main-Frodo/language/en/)\n" 9 | "MIME-Version: 1.0\n" 10 | "Content-Type: text/plain; charset=UTF-8\n" 11 | "Content-Transfer-Encoding: 8bit\n" 12 | "Language: en\n" 13 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 14 | 15 | msgctxt "#32001" 16 | msgid "Error searching for subtitles" 17 | msgstr "Hiba a feliratok keresése közben" 18 | 19 | msgctxt "#32002" 20 | msgid "Plugin is not properly configured, insert valid OpenSubtitles.com username and password." 21 | msgstr "A beépülő modul nincs megfelelően konfigurálva, írjon be érvényes OpenSubtitles.com felhasználónevet és jelszót" 22 | 23 | msgctxt "#32003" 24 | msgid "Unable to authenticate. Check your OpenSubtitles.com username, password." 25 | msgstr "Nem sikerült hitelesíteni. Ellenőrizze OpenSubtitles.com felhasználónevét és jelszavát" 26 | 27 | msgctxt "#32004" 28 | msgid "You have reached OpenSubtitles.com download limit. Get a VIP subscription or try again tomorrow." 29 | msgstr "Elérte az OpenSubtitles.com letöltési korlátját. Vegyél egy VIP-előfizetést, vagy próbáld újra holnap" 30 | 31 | msgctxt "#32006" 32 | msgid "You have reached OpenSubtitles.com download limit for unauthenticated users. Create an account on opensubtitles.com and enter your credentials in addon settings, or try again tomorrow." 33 | msgstr "Elérte az OpenSubtitles.com letöltési korlátját a nem hitelesített felhasználók számára. Hozzon létre egy fiókot az opensubtitles.com oldalon, és adja meg hitelesítő adatait a kiegészítő beállításaiban, vagy próbálkozzon újra holnap." 34 | 35 | msgctxt "#32101" 36 | msgid "Login Details" 37 | msgstr "Bejelentkezési adatok" 38 | 39 | msgctxt "#32201" 40 | msgid "Username" 41 | msgstr "Felhasználónév" 42 | 43 | msgctxt "#32202" 44 | msgid "Password" 45 | msgstr "Jelszó" 46 | 47 | msgctxt "#32203" 48 | msgid "API Key" 49 | msgstr "API kulcs" 50 | 51 | msgctxt "#32102" 52 | msgid "Subtitle Options" 53 | msgstr "Feliratok beállításai" 54 | 55 | msgctxt "#32211" 56 | msgid "Hearing Impaired" 57 | msgstr "Hallássérült" 58 | 59 | msgctxt "#32212" 60 | msgid "Foreign Parts Only" 61 | msgstr "Csak külföldi" 62 | 63 | msgctxt "#32213" 64 | msgid "Machine Translated" 65 | msgstr "Gépi fordítás" 66 | 67 | msgctxt "#32214" 68 | msgid "Bad username. Make sure you have entered your username and not your email in the username field" 69 | msgstr "Rossz felhasználónév. Győződjön meg róla, hogy a felhasználónév mezőbe a felhasználónevét írta be, nem pedig az e-mail címét." 70 | -------------------------------------------------------------------------------- /resources/lib/os/model/request/download.py: -------------------------------------------------------------------------------- 1 | 2 | from resources.lib.os.model.request.abstract import OpenSubtitlesRequest 3 | 4 | SUB_FORMAT_LIST = ["srt", "sub", "mpl", "webvtt", "dfxp", "txt"] 5 | 6 | 7 | class OpenSubtitlesDownloadRequest(OpenSubtitlesRequest): 8 | def __init__(self, file_id: int, sub_format="", file_name="", in_fps: float = None, out_fps: float = None, 9 | timeshift: float = None, force_download: bool = None, **catch_overflow): 10 | self._file_id = file_id 11 | self._sub_format = sub_format 12 | self._file_name = file_name 13 | self._in_fps = in_fps 14 | self._out_fps = out_fps 15 | self._timeshift = timeshift 16 | self._force_download = force_download 17 | 18 | super().__init__() 19 | 20 | # ordered request params with defaults 21 | self.DEFAULT_LIST = dict(file_id=None, file_name="", force_download=None, in_fps=None, out_fps=None, 22 | sub_format="", timeshift=None) 23 | 24 | @property 25 | def file_id(self): 26 | return self._file_id 27 | 28 | @file_id.setter 29 | def file_id(self, value): 30 | if value <= 0: 31 | raise ValueError("file_id should be positive integer.") 32 | self._file_id = value 33 | 34 | @property 35 | def sub_format(self): 36 | return self._sub_format 37 | 38 | @sub_format.setter 39 | def sub_format(self, value): 40 | if value not in SUB_FORMAT_LIST: 41 | raise ValueError("sub_format should be one of \'{0}\'.".format("', '".join(SUB_FORMAT_LIST))) 42 | self._sub_format = value 43 | 44 | @property 45 | def file_name(self): 46 | return self._file_name 47 | 48 | @file_name.setter 49 | def file_name(self, value): 50 | self._file_name = value 51 | 52 | @property 53 | def in_fps(self): 54 | return self._in_fps 55 | 56 | @in_fps.setter 57 | def in_fps(self, value): 58 | if value <= 0: 59 | raise ValueError("in_fps should be positive number.") 60 | self._in_fps = value 61 | 62 | @property 63 | def out_fps(self): 64 | return self._out_fps 65 | 66 | @out_fps.setter 67 | def out_fps(self, value): 68 | if value <= 0: 69 | raise ValueError("out_fps should be positive number.") 70 | self._out_fps = value 71 | 72 | @property 73 | def timeshift(self): 74 | return self._timeshift 75 | 76 | @timeshift.setter 77 | def timeshift(self, value): 78 | if value <= 0: 79 | raise ValueError("timeshift should be positive number.") 80 | self._timeshift = value 81 | 82 | @property 83 | def force_download(self): 84 | return self._force_download 85 | 86 | @force_download.setter 87 | def force_download(self, value): 88 | self._force_download = value 89 | -------------------------------------------------------------------------------- /resources/settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | 0 8 | 9 | 10 | true 11 | 12 | 13 | 32201 14 | 15 | 16 | 17 | 0 18 | 19 | 20 | true 21 | 22 | 23 | 32202 24 | true 25 | 26 | 27 | 28 | 4 29 | qo2wQs1PXwIHJsXvIiWXu1ZbVjaboPh6 30 | 31 | 32 | 33 | 34 | 35 | 36 | 0 37 | exclude 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 32211 47 | 48 | 49 | 50 | 0 51 | include 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 32212 61 | 62 | 63 | 64 | 0 65 | exclude 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 32213 75 | 76 | 77 | 78 | 0 79 | include 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 32215 89 | 90 | 91 | 92 | 93 |
94 |
95 | -------------------------------------------------------------------------------- /resources/lib/file_operations.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import struct 4 | 5 | import xbmcvfs, xbmc 6 | 7 | from urllib.parse import unquote 8 | from resources.lib.utilities import log 9 | 10 | 11 | def get_file_data(file_original_path): 12 | item = {"temp": False, "rar": False, "file_original_path": file_original_path} 13 | log(__name__, f"Processing item: {item}") 14 | 15 | 16 | if file_original_path.find("http") > -1: 17 | orig_path = xbmc.getInfoLabel('Window(10000).Property(videoinfo.current_path)') 18 | orig_size = xbmc.getInfoLabel('Window(10000).Property(videoinfo.current_size)') 19 | orig_oshash = xbmc.getInfoLabel('Window(10000).Property(videoinfo.current_oshash)') 20 | if orig_path: 21 | orig_path = str(orig_path) 22 | item["basename"] = os.path.basename(orig_path) 23 | item["file_original_path"] = orig_path 24 | if orig_size: 25 | item["file_size"] = int(orig_size) 26 | if orig_oshash: 27 | item["moviehash"] = orig_oshash 28 | 29 | if any((orig_path, orig_size, orig_oshash)): 30 | return item 31 | 32 | item["temp"] = True 33 | 34 | elif file_original_path.find("rar://") > -1: 35 | # item["rar"] = True 36 | # item["file_original_path"] = os.path.dirname(file_original_path[6:]) 37 | item["rar"] = True 38 | item["file_original_path"] = os.path.dirname(file_original_path[6:]) 39 | item["basename"] = os.path.basename(file_original_path) 40 | 41 | elif file_original_path.find("stack://") > -1: 42 | stack_path = file_original_path.split(" , ") 43 | item["file_original_path"] = stack_path[0][8:] 44 | 45 | if not item["temp"]: 46 | item["basename"]=os.path.basename(file_original_path[6:]) 47 | item["file_size"], item["moviehash"] = hash_file(item["file_original_path"], item["rar"]) 48 | #else: 49 | # item["basename"]=os.path.basename(file_original_path[6:]) 50 | return item 51 | 52 | 53 | def hash_file(file_path, rar): 54 | log(__name__, f"Processing file: {file_path} - Is RAR: {rar}") 55 | 56 | if rar: 57 | # The rar VFS uses the following scheme: rar://urlencoded_rar_path/archive_content 58 | # file_path is thus urlencoded at this point and must be unquoted 59 | return hash_rar(unquote(file_path)) 60 | 61 | log(__name__, "Hash Standard file") 62 | long_long_format = "q" # long long 63 | byte_size = struct.calcsize(long_long_format) 64 | with xbmcvfs.File(file_path) as f: 65 | file_size = f.size() 66 | hash_ = file_size 67 | 68 | if file_size < 65536 * 2: 69 | return "SizeError" 70 | 71 | buffer = f.readBytes(65536) 72 | f.seek(max(0, file_size - 65536), 0) 73 | buffer += f.readBytes(65536) 74 | f.close() 75 | 76 | for x in range(int(65536 / byte_size) * 2): 77 | size = x * byte_size 78 | (l_value,) = struct.unpack(long_long_format, buffer[size:size + byte_size]) 79 | hash_ += l_value 80 | hash_ = hash_ & 0xFFFFFFFFFFFFFFFF 81 | 82 | return_hash = "%016x" % hash_ 83 | return file_size, return_hash 84 | 85 | def hash_rar(first_rar_file): 86 | log(__name__, "Hash Rar file") 87 | f = xbmcvfs.File(first_rar_file) 88 | a = f.readBytes(4) 89 | log(__name__, "Hash Rar a: %s" % a) 90 | # Ensure comparison is done with a byte string 91 | if a != b"Rar!": 92 | raise Exception("ERROR: This is not rar file.") 93 | 94 | seek = 0 95 | for i in range(4): 96 | f.seek(max(0, seek), 0) 97 | a = f.readBytes(100) 98 | type_, flag, size = struct.unpack(" k19 125 | dir_path = xbmcvfs.translatePath('special://temp/oss') 126 | except: # kodi < k19 127 | dir_path = xbmc.translatePath('special://temp/oss') 128 | 129 | # Kodi lang-code difference vs OS.com API langcodes return 130 | if self.params["language"].lower() == 'pt-pt': self.params["language"] = 'pt' 131 | elif self.params["language"].lower() == 'pt-pb': self.params["language"] = 'pb' 132 | 133 | if xbmcvfs.exists(dir_path): # lets clean files from last usage 134 | dirs, files = xbmcvfs.listdir(dir_path) 135 | for file in files: 136 | xbmcvfs.delete(os.path.join(dir_path, file)) 137 | 138 | if not xbmcvfs.exists(dir_path): # lets create custom OSS sub directory if not exists 139 | xbmcvfs.mkdir(dir_path) 140 | 141 | subtitle_path = os.path.join(dir_path, "{0}.{1}.{2}".format('TempSubtitle', self.params["language"], self.sub_format)) 142 | 143 | log(__name__, "XYXYXX download subtitle_path: {}".format(subtitle_path)) 144 | 145 | 146 | if (valid==1): 147 | tmp_file = open(subtitle_path, "w" + "b") 148 | tmp_file.write(self.file["content"]) 149 | tmp_file.close() 150 | 151 | 152 | list_item = xbmcgui.ListItem(label=subtitle_path) 153 | xbmcplugin.addDirectoryItem(handle=self.handle, url=subtitle_path, listitem=list_item, isFolder=False) 154 | 155 | return 156 | 157 | """old code""" 158 | # subs = Download(params["ID"], params["link"], params["format"]) 159 | # for sub in subs: 160 | # listitem = xbmcgui.ListItem(label=sub) 161 | # xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]), url=sub, listitem=listitem, isFolder=False) 162 | 163 | def list_subtitles(self): 164 | """TODO rewrite using new data. do not forget Series/Episodes""" 165 | if self.subtitles: 166 | for subtitle in reversed(sorted(self.subtitles, key=lambda x: ( 167 | bool(x["attributes"].get("from_trusted", False)), 168 | x["attributes"].get("votes", 0) or 0, 169 | x["attributes"].get("ratings", 0) or 0, 170 | x["attributes"].get("download_count", 0) or 0))): 171 | attributes = subtitle["attributes"] 172 | language = convert_language(attributes["language"], True) 173 | log(__name__, attributes) 174 | clean_name = clean_feature_release_name(attributes["feature_details"]["title"], attributes["release"], 175 | attributes["feature_details"]["movie_name"]) 176 | list_item = xbmcgui.ListItem(label=language, 177 | label2=clean_name) 178 | list_item.setArt({ 179 | "icon": str(int(round(float(attributes["ratings"]) / 2))), 180 | "thumb": get_flag(attributes["language"])}) 181 | # list_item.setArt({ 182 | # "icon": str(int(round(float(attributes["ratings"]) / 2))), 183 | # "thumb": get_flag(language)}) 184 | 185 | log(__name__, "XYXYXX download get_flag: language in url {}".format(get_flag(attributes["language"]))) 186 | 187 | 188 | list_item.setProperty("sync", "true" if ("moviehash_match" in attributes and attributes["moviehash_match"]) else "false") 189 | list_item.setProperty("hearing_imp", "true" if attributes["hearing_impaired"] else "false") 190 | """TODO take care of multiple cds id&id or something""" 191 | #url = f"plugin://{__scriptid__}/?action=download&id={attributes['files'][0]['file_id']}" 192 | url = f"plugin://{__scriptid__}/?action=download&id={attributes['files'][0]['file_id']}&language={language}" 193 | log(__name__, "XYXYXX download list_subtitles: language in url {url}") 194 | 195 | xbmcplugin.addDirectoryItem(handle=self.handle, url=url, listitem=list_item, isFolder=False) 196 | xbmcplugin.endOfDirectory(self.handle) 197 | -------------------------------------------------------------------------------- /addon.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | OpenSubtitles.com 14 | Search and download subtitles for movies and TV-Series from OpenSubtitles.com. Search in 75 languages, 8.000.000+ subtitles, daily updates. Register/Import your account on OpenSubtitles.com before use. 15 | Pelis and Subtítulos TV en munches llingües, milenta de subtítulos traducíos y xubíos caldía. Descarga llibre dende la fonte, sofitu API, millones d'usuarios. 16 | Istitloù Filmoù ha TV e meur a yezh, miliadoù a istitloù troet hag uskarget bemdez. Pellgargadenn digoust diouzh ar vammenn, skoazell an API, millionoù a implijerien. 17 | Subtítols de films i televisió en múltiples idiomes, milers de subtítols traduïts carregats diàriament. Descàrrega gratuïta des de la font, suport de l'API, amb milions d'usuaris. 18 | Titulky k filmům a seriálům v mnoha jazycích, denně tisíce nahraných přeložených titulků. Stažení zadarmo přímo od zdroje, podpora API, milióny uživatelů. 19 | Film- und TV-Untertitel in vielen Sprachen, täglicher Upload von tausenden übersetzten Untertiteln. Freier Download von der Quelle, API-Unterstützung, Millionen Nutzer 20 | Ταινίες και Σειρές σε όλες τις γλώσσες, χιλιάδες μεταφρασμένοι υπότιτλοι ανεβαίνουν καθημερινά, κατεβάστε δωρεάν , υποστήριξη API από εκατομμύρια χρήστες 21 | Filmaj kaj Televidaj subtekstoj en multaj lingvoj, miloj da tradukitaj subtekstoj ĉiutage alŝutataj. Senpaga elŝuto de fonto, API-subteno, miloj da uzantoj. 22 | Películas y Subtítulos en diversos idiomas, miles de subtítulos traducidos subidos diariamente. Descarge gratis, soporte API, millones de usuarios. 23 | Sadu Filmi ja TV Serjaalide subtiitrid erinevates keeltes laetakse üles igapäev. tasuta allalaadimine ja abi, rohkem kui miljon kasutajat. 24 | Film eta Telebista azpitituluak hizkuntza anitzetan, milaka azpititulu itzuliak igotzen dira egunero. Doan jaitsi iturburutik, API sostengua, milioika erabiltzaile. 25 | Tekstityksiä useilla kielillä elokuviin ja TV-Sarjoihin, tuhansia uusia tekstityksiä päivittäin, ilmainen lataus, API tuki, miljoonia käyttäjiä. 26 | Les sous-titres de film et téléfilm en plusieurs langues, des milliers de sous-titres traduits tous les jours. Bénéficiez de téléchargements gratuits depuis la source, du support API, une d'une communauté de millions d'utilisateurs. 27 | मूवी और टीवी उपशीर्षक कई भाषाओं में अनुवाद उपशीर्षक के हजारों दैनिक अपलोड की गई। मुफ्त डाउनलोड स्रोत से , एपीआई समर्थन, उपयोगकर्ताओं के लाखों लोगों की । 28 | Titlovi za filmove i TV na mnogim jezicima, tisuće prijevoda postavljenih svaki dan. Besplatno preuzimanje s izvora, podrška za API, milijuni korisnika. 29 | Film és TV feliratok több nyelven, naponta több ezer lefordított felirat feltöltés. Ingyenes letöltés a forrástól, API támogatás, több millió felhasználó. 30 | Subjudul film dan serial TV dalam multibahasa, ribuan subjudul diterjemah dan diunggah setiap harinya. Pengunduhan gratis dari sumber, didukung fitur antarmuka pemrograman aplikasi, dan jutaan pengguna. 31 | Textar fyrir myndir og sjónvarpsþ. á mörgum tungumálum, þúsundir af þýddum textum upphlaðið daglega. Frítt niðurhal frá síðu, API stuðningur, milljónir notenda. 32 | Sottotitoli di Film e TV in più lingue,migliaia di sottotitoli tradotti caricati ogni giorno.Download gratuito,supporto API, milioni di utenti. 33 | 映画やテレビの字幕をさまざまな言語で。毎日数多くの翻訳字幕がアップロードされています。ソースを無料ダウンロード、API 対応、数百万を超えるユーザー。 34 | სუბტიტრები ფილმებისა და ტვ-სთვის მრავალ ენაზე. ათასობიტ თარგმნილი სუბტიტრის ატვირთვა ყოველდღიურად. უდასო ჩამოტვირტვა წყაროდან, API მხარდაჭერა, მილიონობით მომხმარებელი 35 | អត្ថបទរឿងសម្រាប់ ខ្សែភាពយន្ត និងកម្មវិធីទូរទស្សន៍​ ជាភាសាជាច្រើន រួមនឹងអត្ថបទរឿងបកប្រែរាប់ពាន់​រឿង​ដែល​ត្រូវបាន​អ័ពឡូដជារៀងរាល់ថ្ងៃ។ ទាញយកដោយឥតគិតថ្លៃពីប្រភពដើម គាំទ្រ API និងមានអ្នកប្រើប្រាស់រាប់លាននាក់។ 36 | Movie and TV Subtitles in multiple languages, thousands of translated subtitles uploaded daily. Free download from source, API support, millions of users. 37 | Sarikata TV dan Movie dalam pelbagai bahasa, ribuan terjemahan sarikata dimuat-naik setiap hari.Muat-turun percuma dari sumber utama, sokongan API, jutaan pengguna. 38 | Ondertitels voor films en tv-series in meerdere talen, dagelijks upload van duizenden vertaalde ondertitels. Gratis downloaden van de bron, API ondersteuning, miljoenen gebruikers. 39 | Undertekster for film og TV på mange språk, med tusener av nye oversettelser hver dag. Vi har gratis nedlasting av undertekster, API-support, og mange millioner brukere. 40 | Napisy do filmów i seriali w wielu językach. Tysiące napisów dodawanych codziennie. Darmowe pobranie, miliony użytkowników. 41 | Milhares de legendas para filmes e seriados de TV, em vários idiomas, são traduzidas e enviadas diariamente. Download grátis, suporte API e milhões de usuários. 42 | Legendas de filmes e séries de TV em vários idiomas, milhares de legendas traduzidas e enviadas diariamente. Download grátis a partir da fonte, suporte API, milhões de utilizadores. 43 | Subtitrari pentru filme in multe limbi, mii de subtitrari traduse si încărcate in fiecare zi. Descărca gratuit de la sursă, suport API, milioane de utilizatori. 44 | Кино и ТВ субтитры на нескольких языках, тысячи переведенными субтитрами загружены ежедневно. Бесплатно скачать из исходных текстов, поддержка API, миллионы пользователей. 45 | Titulky pre filmy a TV seriály, denne nahratých tisíce titulkov preložených vo viacerých jazykoch. Sťahuj zadarmo zo zdroja, podpora API, milióny používateľov. 46 | Filmski in televizijski podnapisi v mnogo jezikih, na tisoče prevedenih podnapisov dnevno. Brezplačen prenos iz vira, podpora API, miljoni uporabnikov. 47 | Titra filmash dhe serialesh në shumë gjuhë, mijëra titra të përkthyera që vendosen çdo ditë. Shkarko nga burimi, apo nga mbështetja e API-së, miliona përdorues. 48 | Titlovi za filmove i TV na mnogim jezicima, hiljade prevoda okačenih svakodnevno. Besplatno skidanje sa izvora, podrška za API, milioni korisnika. 49 | Filmer och Tv-undertexter med olika språk, tusentals översatta undertexter uppladdade dagligen. Gratis nerladdning från källor, API support, miljoner av användare. 50 | Her gün eklenen, pek çok dildeki binlerce film ve dizi altyazısı. Kaynağından ücretsiz indirme, API desteği ve milyonlarca kullanıcı. 51 | Film va TV Taglavhalari ko'p tillarda, minglab tarjima qilingan taglavhalar har kuni tizimga yuklanadi. Bepul resursdan yuklab oling, API qo'llaydi, millionlab foydalanuvchilar. 52 | 多语种的电影及剧集字幕,每日更新千余条翻译好的字幕。免费下载,提供API接口,已拥有上百万的用户。 53 | Users need to provide OpenSubtitles.com username and password in add-on configuration. This is our new extension, old opensubtitles.org will not work on this, but the account can be easily imported on opensubtitles.com. 54 | 55 | v1.0.8 (2025-09-04) 56 | - performs a query to kodi library if imdb or tmdb ID is missing (thanks you cvanderkam) 57 | 58 | v1.0.7 (2025-08-26) 59 | - added IMDB and TMDB collection on files for more accurate search to the API 60 | 61 | v1.0.6 (2024-11-29) 62 | - fixed issue with RAR archives (thanks ninjacarr) 63 | - handles default chinese language to zh-cn (thanks ninjacarr) 64 | 65 | v1.0.5 (2024-07-30) 66 | - fixed issue with portuguese file names 67 | - added AI translated filter (thanks Kate6) 68 | 69 | v1.0.4 (2024-01-15) 70 | - Sanitize language query 71 | - Improved sorting 72 | - Improved error messages 73 | - Improved usage of moviehash 74 | 75 | v1.0.3 (2023-12-18) 76 | - Fixed issue with file path 77 | 78 | v1.0.2 (2023-08-28) 79 | - Update user agent header 80 | 81 | v1.0.1 (2023-07-28) 82 | - Remove limit of 10 subtitles for the returned values 83 | - Fix Portuguese and Brazilian flags 84 | 85 | all 86 | GPL-2.0-only 87 | https://forum.opensubtitles.com/t/new-kodi-extension/1673 88 | https://www.opensubtitles.com/en 89 | cto [at] opensubtitles {dot} org 90 | 91 | resources/media/os_logo_512x512.png 92 | resources/media/os_fanart.jpg 93 | resources/media/screenshot_1.jpg 94 | resources/media/screenshot_2.jpg 95 | resources/media/screenshot_3.jpg 96 | 97 | https://github.com/opensubtitlesdev/service.subtitles.opensubtitles-com 98 | 99 | 100 | -------------------------------------------------------------------------------- /resources/lib/os/model/request/subtitles.py: -------------------------------------------------------------------------------- 1 | 2 | from datetime import date 3 | 4 | from resources.lib.os.model.request.abstract import OpenSubtitlesRequest 5 | 6 | INCLUDE_LIST = ["include", "exclude", "only"] 7 | INCLUDE_ONLY_LIST = ["include", "only"] 8 | INCLUDE_EXCLUDE_LIST = ["include", "exclude"] 9 | TYPE_LIST = ["movie", "episode", "all"] 10 | ORDER_PARAM_LIST = ["language", "download_count", "new_download_count", "download_count", "hd", "fps", "votes", 11 | "ratings", "from_trusted", "foreign_parts_only", "upload_date", "ai_translated", 12 | "machine_translated"] 13 | ORDER_DIRECTION_LIST = ["asc", "desc"] 14 | LANGUAGE_LIST = ["af", "sq", "ar", "an", "hy", "at", "eu", "be", "bn", "bs", "br", "bg", "my", "ca", "zh-cn", "cs", 15 | "da", "nl", "en", "eo", "et", "fi", "fr", "ka", "de", "gl", "el", "he", "hi", "hr", "hu", "is", "id", 16 | "it", "ja", "kk", "km", "ko", "lv", "lt", "lb", "mk", "ml", "ms", "ma", "mn", "no", "oc", "fa", "pl", 17 | "pt-pt", "ru", "sr", "si", "sk", "sl", "es", "sw", "sv", "sy", "ta", "te", "tl", "th", "tr", "uk", 18 | "ur", "uz", "vi", "ro", "pt-br", "me", "zh-tw", "ze", "se"] 19 | 20 | 21 | class OpenSubtitlesSubtitlesRequest(OpenSubtitlesRequest): 22 | def __init__(self, id_: int = None, imdb_id: int = None, tmdb_id: int = None, type_="all", query="", languages="", 23 | moviehash="", user_id: int = None, hearing_impaired="include", foreign_parts_only="include", 24 | trusted_sources="include", machine_translated="exclude", ai_translated="include", order_by="", 25 | order_direction="", parent_feature_id: int = None, parent_imdb_id: int = None, 26 | parent_tmdb_id: int = None, season_number: int = None, episode_number: int = None, year: int = None, 27 | moviehash_match="include", page: int = None, **catch_overflow): 28 | self._id = id_ 29 | self._imdb_id = imdb_id 30 | self._tmdb_id = tmdb_id 31 | self._type = type_ 32 | self._query = query 33 | self._languages = languages 34 | self._moviehash = moviehash 35 | self._user_id = user_id 36 | self._hearing_impaired = hearing_impaired 37 | self._foreign_parts_only = foreign_parts_only 38 | self._trusted_sources = trusted_sources 39 | self._machine_translated = machine_translated 40 | self._ai_translated = ai_translated 41 | self._order_by = order_by 42 | self._order_direction = order_direction 43 | self._parent_feature_id = parent_feature_id 44 | self._parent_imdb_id = parent_imdb_id 45 | self._parent_tmdb_id = parent_tmdb_id 46 | self._season_number = season_number 47 | self._episode_number = episode_number 48 | self._year = year 49 | self._moviehash_match = moviehash_match 50 | self._page = page 51 | 52 | super().__init__() 53 | 54 | # ordered request params with defaults 55 | self.DEFAULT_LIST = dict(ai_translated="include", episode_number=None, foreign_parts_only="include", 56 | hearing_impaired="include", id=None, imdb_id=None, languages="", 57 | machine_translated="exclude", moviehash="", moviehash_match="include", order_by="", 58 | order_direction="desc", page=None, parent_feature_id=None, parent_imdb_id=None, 59 | parent_tmdb_id=None, query="", season_number=None, tmdb_id=None, 60 | trusted_sources="include", type="all", user_id=None, year=None) 61 | 62 | @property 63 | def id(self): 64 | return self._id 65 | 66 | @id.setter 67 | def id(self, value): 68 | if value > 0: 69 | raise ValueError("id should be positive integer.") 70 | self._id = value 71 | 72 | @property 73 | def imdb_id(self): 74 | return self._imdb_id 75 | 76 | @imdb_id.setter 77 | def imdb_id(self, value): 78 | if value <= 0: 79 | raise ValueError("imdb_id should be positive integer.") 80 | self._imdb_id = value 81 | 82 | @property 83 | def tmdb_id(self): 84 | return self._tmdb_id 85 | 86 | @tmdb_id.setter 87 | def tmdb_id(self, value): 88 | if value <= 0: 89 | raise ValueError("tmdb_id should be positive integer.") 90 | self._tmdb_id = value 91 | 92 | @property 93 | def type(self): 94 | return self._type 95 | 96 | @type.setter 97 | def type(self, value): 98 | if value not in TYPE_LIST: 99 | raise ValueError("type should be one of \'{0}\'. (default: \'all\').".format("', '".join(TYPE_LIST))) 100 | self._type = value 101 | 102 | @property 103 | def query(self): 104 | return self._query 105 | 106 | @query.setter 107 | def query(self, value): 108 | self._query = value 109 | 110 | @property 111 | def languages(self): 112 | return self._languages 113 | 114 | @languages.setter 115 | def languages(self, value): 116 | languages_error = "languages should be a list or a string with coma separated languages (en,fr)." 117 | if value is str: 118 | language_list = value.split(',') 119 | elif value is list: 120 | language_list = value 121 | else: 122 | raise ValueError(languages_error) 123 | for lang in language_list: 124 | if lang not in LANGUAGE_LIST: 125 | raise ValueError(languages_error) 126 | self._languages = ",".join(value) 127 | 128 | @property 129 | def moviehash(self): 130 | return self._moviehash 131 | 132 | @moviehash.setter 133 | def moviehash(self, value): 134 | if value.length() != 16: 135 | raise ValueError("moviehash should be 16 symbol hash. with leading 0 if needed.") 136 | self._moviehash = value 137 | 138 | @property 139 | def user_id(self): 140 | return self._user_id 141 | 142 | @user_id.setter 143 | def user_id(self, value): 144 | if value <= 0: 145 | raise ValueError("user_id should be positive integer.") 146 | self._user_id = value 147 | 148 | @property 149 | def hearing_impaired(self): 150 | return self._hearing_impaired 151 | 152 | @hearing_impaired.setter 153 | def hearing_impaired(self, value): 154 | if value not in INCLUDE_LIST: 155 | raise ValueError( 156 | "hearing_impaired should be one of \'{0}\'. (default: \'include\').".format("', '".join(INCLUDE_LIST))) 157 | self._hearing_impaired = value 158 | 159 | @property 160 | def foreign_parts_only(self): 161 | return self._foreign_parts_only 162 | 163 | @foreign_parts_only.setter 164 | def foreign_parts_only(self, value): 165 | if value not in INCLUDE_LIST: 166 | raise ValueError( 167 | "foreign_parts_only should be one of \'{0}\'. (default: \'include\').".format( 168 | "', '".join(INCLUDE_LIST))) 169 | self._foreign_parts_only = value 170 | 171 | @property 172 | def trusted_sources(self): 173 | return self._trusted_sources 174 | 175 | @trusted_sources.setter 176 | def trusted_sources(self, value): 177 | if value not in INCLUDE_ONLY_LIST: 178 | raise ValueError( 179 | "trusted_sources should be one of \'{0}\'. (default: \'include\').".format( 180 | "', '".join(INCLUDE_ONLY_LIST))) 181 | self._trusted_sources = value 182 | 183 | @property 184 | def machine_translated(self): 185 | return self._machine_translated 186 | 187 | @machine_translated.setter 188 | def machine_translated(self, value): 189 | if value not in INCLUDE_EXCLUDE_LIST: 190 | raise ValueError( 191 | "machine_translated should be one of \'{0}\'. (default: \'exclude\').".format( 192 | "', '".join(INCLUDE_EXCLUDE_LIST))) 193 | self._machine_translated = value 194 | 195 | @property 196 | def ai_translated(self): 197 | return self._ai_translated 198 | 199 | @ai_translated.setter 200 | def ai_translated(self, value): 201 | if value not in INCLUDE_EXCLUDE_LIST: 202 | raise ValueError( 203 | "ai_translated should be one of \'{0}\'. (default: \'exclude\').".format( 204 | "', '".join(INCLUDE_EXCLUDE_LIST))) 205 | self._ai_translated = value 206 | 207 | @property 208 | def order_by(self): 209 | return self._order_by 210 | 211 | @order_by.setter 212 | def order_by(self, value): 213 | # TODO discuss and implement (if needed) multiple order params 214 | if value not in ORDER_PARAM_LIST: 215 | raise ValueError("order_by should be one of search params.") 216 | self._order_by = value 217 | 218 | @property 219 | def order_direction(self): 220 | return self._order_direction 221 | 222 | @order_direction.setter 223 | def order_direction(self, value): 224 | if value not in ORDER_DIRECTION_LIST: 225 | raise ValueError("order_direction should be one of \'{0}\'.".format("', '".join(ORDER_DIRECTION_LIST))) 226 | self._order_direction = value 227 | 228 | @property 229 | def parent_feature_id(self): 230 | return self._parent_feature_id 231 | 232 | @parent_feature_id.setter 233 | def parent_feature_id(self, value): 234 | if value > 0: 235 | raise ValueError("parent_feature_id should be positive integer.") 236 | self._parent_feature_id = value 237 | 238 | @property 239 | def parent_imdb_id(self): 240 | return self._parent_imdb_id 241 | 242 | @parent_imdb_id.setter 243 | def parent_imdb_id(self, value): 244 | if value <= 0: 245 | raise ValueError("parent_imdb_id should be positive integer.") 246 | self._parent_imdb_id = value 247 | 248 | @property 249 | def parent_tmdb_id(self): 250 | return self._parent_tmdb_id 251 | 252 | @parent_tmdb_id.setter 253 | def parent_tmdb_id(self, value): 254 | if value <= 0: 255 | raise ValueError("parent_tmdb_id should be positive integer.") 256 | self._parent_tmdb_id = value 257 | 258 | @property 259 | def season_number(self): 260 | return self._season_number 261 | 262 | @season_number.setter 263 | def season_number(self, value): 264 | if value > 0: 265 | raise ValueError("season_number should be positive integer.") 266 | self._season_number = value 267 | 268 | @property 269 | def episode_number(self): 270 | return self._episode_number 271 | 272 | @episode_number.setter 273 | def episode_number(self, value): 274 | if value <= 0: 275 | raise ValueError("episode_number should be positive integer.") 276 | self._episode_number = value 277 | 278 | @property 279 | def year(self): 280 | return self._year 281 | 282 | @year.setter 283 | def year(self, value): 284 | if value < 1927 or value > date.today().year + 1: 285 | raise ValueError("year should be valid year.") 286 | self._year = value 287 | 288 | @property 289 | def moviehash_match(self): 290 | return self._moviehash_match 291 | 292 | @moviehash_match.setter 293 | def moviehash_match(self, value): 294 | if value not in INCLUDE_ONLY_LIST: 295 | raise ValueError( 296 | "moviehash_match should be one of \'{0}\'. (default: \'include\').".format( 297 | "', '".join(INCLUDE_ONLY_LIST))) 298 | self._moviehash_match = value 299 | 300 | @property 301 | def page(self): 302 | return self._page 303 | 304 | @page.setter 305 | def page(self, value): 306 | if value <= 0: 307 | raise ValueError("page should be positive integer.") 308 | self._page = value 309 | -------------------------------------------------------------------------------- /resources/lib/os/provider.py: -------------------------------------------------------------------------------- 1 | 2 | from typing import Union 3 | 4 | from requests import Session, ConnectionError, HTTPError, ReadTimeout, Timeout, RequestException 5 | 6 | from resources.lib.os.model.request.subtitles import OpenSubtitlesSubtitlesRequest 7 | from resources.lib.os.model.request.download import OpenSubtitlesDownloadRequest 8 | 9 | '''local kodi module imports. replace by any other exception, cache, log provider''' 10 | from resources.lib.exceptions import AuthenticationError, ConfigurationError, DownloadLimitExceeded, ProviderError, \ 11 | ServiceUnavailable, TooManyRequests, BadUsernameError 12 | from resources.lib.cache import Cache 13 | from resources.lib.utilities import log 14 | 15 | API_URL = "https://api.opensubtitles.com/api/v1/" 16 | API_LOGIN = "login" 17 | API_SUBTITLES = "subtitles" 18 | API_DOWNLOAD = "download" 19 | 20 | 21 | CONTENT_TYPE = "application/json" 22 | REQUEST_TIMEOUT = 30 23 | 24 | class_lookup = {"OpenSubtitlesSubtitlesRequest": OpenSubtitlesSubtitlesRequest, 25 | "OpenSubtitlesDownloadRequest": OpenSubtitlesDownloadRequest} 26 | 27 | 28 | # TODO implement search for features, logout, infos, guessit. Response(-s) objects 29 | 30 | # Replace with any other log implementation outside fo module/Kodi 31 | def logging(msg): 32 | return log(__name__, msg) 33 | 34 | 35 | def query_to_params(query, _type): 36 | logging("type: ") 37 | logging(type(query)) 38 | logging("query: ") 39 | logging(query) 40 | if type(query) is dict: 41 | try: 42 | request = class_lookup[_type](**query) 43 | except ValueError as e: 44 | raise ValueError(f"Invalid request data provided: {e}") 45 | elif type(query) is _type: 46 | request = query 47 | else: 48 | raise ValueError("Invalid request data provided. Invalid query type") 49 | 50 | logging("request vars: ") 51 | logging(vars(request)) 52 | params = request.request_params() 53 | logging("params: ") 54 | logging(params) 55 | return params 56 | 57 | 58 | class OpenSubtitlesProvider: 59 | 60 | def __init__(self, api_key, username, password): 61 | 62 | # if not all((username, password)): 63 | # raise ConfigurationError("Username and password must be specified") 64 | 65 | if not api_key: 66 | raise ConfigurationError("Api_key must be specified") 67 | 68 | self.api_key = api_key 69 | self.username = username 70 | self.password = password 71 | 72 | if not self.username or not self.password: 73 | logging(f"Username: {self.username}, Password: {self.password}") 74 | 75 | 76 | self.request_headers = {"Api-Key": self.api_key, "User-Agent": "Opensubtitles.com Kodi plugin v1.0.8" ,"Content-Type": CONTENT_TYPE, "Accept": CONTENT_TYPE} 77 | 78 | self.session = Session() 79 | self.session.headers = self.request_headers 80 | 81 | # Use any other cache outside of module/Kodi 82 | self.cache = Cache(key_prefix="os_com") 83 | 84 | # make login request. Sets auth token 85 | def login(self): 86 | 87 | # build login request 88 | login_url = API_URL + API_LOGIN 89 | login_body = {"username": self.username, "password": self.password} 90 | 91 | logging(f"Login attempt to: {login_url}") 92 | logging(f"Login body: {{'username': '{self.username}', 'password': '***'}}") 93 | 94 | try: 95 | r = self.session.post(login_url, json=login_body, allow_redirects=False, timeout=REQUEST_TIMEOUT) 96 | logging(f"Login response URL: {r.url}") 97 | logging(f"Login response status: {r.status_code}") 98 | logging(f"Login response headers: {dict(r.headers)}") 99 | 100 | # Log response body for debugging 101 | try: 102 | response_text = r.text 103 | logging(f"Login response body: {response_text}") 104 | except: 105 | logging("Failed to get response text") 106 | 107 | r.raise_for_status() 108 | except (ConnectionError, Timeout, ReadTimeout) as e: 109 | logging(f"Connection error during login: {e}") 110 | raise ServiceUnavailable(f"Unknown Error: {e.response.status_code}: {e!r}") 111 | except HTTPError as e: 112 | status_code = e.response.status_code 113 | logging(f"HTTP error during login: {status_code}") 114 | 115 | # Log the error response body for debugging 116 | try: 117 | error_response = e.response.text 118 | logging(f"Login error response body: {error_response}") 119 | except: 120 | logging("Failed to get error response text") 121 | 122 | if status_code == 401: 123 | raise AuthenticationError(f"Login failed: {e}") 124 | elif status_code == 400: 125 | raise BadUsernameError(f"Login failed: {e}") 126 | elif status_code == 429: 127 | raise TooManyRequests() 128 | elif status_code == 503: 129 | raise ProviderError(e) 130 | else: 131 | raise ProviderError(f"Bad status code on login: {status_code}") 132 | else: 133 | try: 134 | response_json = r.json() 135 | logging(f"Login successful response JSON: {response_json}") 136 | self.user_token = response_json["token"] 137 | logging(f"Token extracted successfully") 138 | except ValueError as e: 139 | logging(f"Failed to parse login response JSON: {e}") 140 | raise ValueError("Invalid JSON returned by provider") 141 | 142 | @property 143 | def user_token(self): 144 | return self.cache.get(key="user_token") 145 | 146 | @user_token.setter 147 | def user_token(self, value): 148 | self.cache.set(key="user_token", value=value) 149 | 150 | def search_subtitles(self, query: Union[dict, OpenSubtitlesSubtitlesRequest]): 151 | 152 | params = query_to_params(query, 'OpenSubtitlesSubtitlesRequest') 153 | 154 | if not len(params): 155 | raise ValueError("Invalid subtitle search data provided. Empty Object built") 156 | 157 | # Check if we have a user token for authentication 158 | current_token = self.user_token 159 | logging(f"Current user token: {current_token[:20] if current_token else None}...") 160 | 161 | try: 162 | # build query request 163 | subtitles_url = API_URL + API_SUBTITLES 164 | logging(f"Search request URL: {subtitles_url}") 165 | logging(f"Search request params: {params}") 166 | 167 | r = self.session.get(subtitles_url, params=params, timeout=30) 168 | logging(f"Search response URL: {r.url}") 169 | logging(f"Search response status: {r.status_code}") 170 | logging(f"Search request headers sent: {dict(r.request.headers)}") 171 | logging(f"Search response headers: {dict(r.headers)}") 172 | 173 | # Log response body for debugging 174 | try: 175 | response_text = r.text 176 | logging(f"Search response body: {response_text}") 177 | except: 178 | logging("Failed to get search response text") 179 | 180 | r.raise_for_status() 181 | except (ConnectionError, Timeout, ReadTimeout) as e: 182 | logging(f"Connection error during search: {e}") 183 | raise ServiceUnavailable(f"Unknown Error, empty response: {e.status_code}: {e!r}") 184 | except HTTPError as e: 185 | status_code = e.response.status_code 186 | logging(f"HTTP error during subtitle search: {e}") 187 | 188 | # Log the error response body for debugging 189 | try: 190 | error_response = e.response.text 191 | logging(f"Search error response body: {error_response}") 192 | except: 193 | logging("Failed to get search error response text") 194 | 195 | if status_code == 401: 196 | logging("401 error - authentication required. Checking if login was attempted...") 197 | raise ProviderError(f"Authentication failed during search: {status_code}") 198 | elif status_code == 429: 199 | raise TooManyRequests() 200 | elif status_code == 503: 201 | raise ProviderError(e) 202 | else: 203 | raise ProviderError(f"Bad status code on search: {status_code}") 204 | 205 | try: 206 | result = r.json() 207 | logging(f"Search successful response JSON keys: {list(result.keys()) if result else None}") 208 | if "data" not in result: 209 | raise ValueError 210 | except ValueError as e: 211 | logging(f"Failed to parse search response JSON: {e}") 212 | raise ProviderError("Invalid JSON returned by provider") 213 | else: 214 | logging(f"Query returned {len(result['data'])} subtitles") 215 | 216 | if len(result["data"]): 217 | return result["data"] 218 | 219 | return None 220 | 221 | # def download_subtitle(self, query: Union[dict, OpenSubtitlesDownloadRequest]): 222 | # if self.user_token is None: 223 | # logging("No cached token, we'll try to login again.") 224 | # try: 225 | # self.login() 226 | # except AuthenticationError as e: 227 | # logging("Unable to authenticate.") 228 | # raise AuthenticationError("Unable to authenticate.") 229 | # except (ServiceUnavailable, TooManyRequests, ProviderError, ValueError) as e: 230 | # logging("Unable to obtain an authentication token.") 231 | # raise ProviderError(f"Unable to obtain an authentication token: {e}") 232 | # if self.user_token == "": 233 | # logging("Unable to obtain an authentication token.") 234 | # #raise ProviderError("Unable to obtain an authentication token") 235 | 236 | def download_subtitle(self, query: Union[dict, OpenSubtitlesDownloadRequest]): 237 | if self.user_token is None and self.username and self.password: 238 | logging("No cached token, we'll try to login again.") 239 | try: 240 | self.login() 241 | except AuthenticationError as e: 242 | logging("Unable to authenticate.") 243 | raise AuthenticationError("Unable to authenticate.") 244 | except BadUsernameError as e: 245 | logging("Bad username, email instead of useername.") 246 | raise BadUsernameError("Bad username. Email instead of username. ") 247 | except (ServiceUnavailable, TooManyRequests, ProviderError, ValueError) as e: 248 | logging("Unable to obtain an authentication token.") 249 | raise ProviderError(f"Unable to obtain an authentication token: {e}") 250 | elif self.user_token is None: 251 | logging("No cached token, but username or password is missing. Proceeding with free downloads.") 252 | if self.user_token == "": 253 | logging("Unable to obtain an authentication token.") 254 | #raise ProviderError("Unable to obtain an authentication token") 255 | 256 | logging(f"user token is {self.user_token}") 257 | 258 | params = query_to_params(query, "OpenSubtitlesDownloadRequest") 259 | 260 | logging(f"Downloading subtitle {params['file_id']!r} ") 261 | 262 | # build download request 263 | download_url = API_URL + API_DOWNLOAD 264 | download_headers= {} 265 | if not self.user_token==None: 266 | download_headers = {"Authorization": "Bearer " + self.user_token} 267 | 268 | download_params = {"file_id": params["file_id"], "sub_format": "srt"} 269 | 270 | try: 271 | r = self.session.post(download_url, headers=download_headers, json=download_params, timeout=REQUEST_TIMEOUT) 272 | logging(r.url) 273 | r.raise_for_status() 274 | except (ConnectionError, Timeout, ReadTimeout) as e: 275 | raise ServiceUnavailable(f"Unknown Error, empty response: {e.status_code}: {e!r}") 276 | except HTTPError as e: 277 | status_code = e.response.status_code 278 | if status_code == 401: 279 | raise AuthenticationError(f"Login failed: {e.response.reason}") 280 | elif status_code == 429: 281 | raise TooManyRequests() 282 | elif status_code == 406: 283 | raise DownloadLimitExceeded(f"Daily download limit reached: {e.response.reason}") 284 | elif status_code == 503: 285 | raise ProviderError(e) 286 | else: 287 | raise ProviderError(f"Bad status code on download: {status_code}") 288 | 289 | try: 290 | subtitle = r.json() 291 | download_link = subtitle["link"] 292 | except ValueError: 293 | raise ProviderError("Invalid JSON returned by provider") 294 | else: 295 | res = self.session.get(download_link, timeout=REQUEST_TIMEOUT) 296 | 297 | subtitle["content"] = res.content 298 | 299 | if not subtitle["content"]: 300 | logging(f"Could not download subtitle from {subtitle.download_link}") 301 | 302 | return subtitle -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /resources/lib/data_collector.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import unquote 2 | from difflib import SequenceMatcher 3 | import json 4 | import xml.etree.ElementTree as ET 5 | 6 | import xbmc 7 | import xbmcaddon 8 | 9 | from resources.lib.utilities import log, normalize_string 10 | 11 | # Simple cache for library queries to avoid repeated calls 12 | _library_cache = {} 13 | _cache_max_age = 300 # 5 minutes 14 | 15 | def _get_cache_key(method, params): 16 | """Generate a cache key for library queries""" 17 | import hashlib 18 | cache_str = f"{method}:{json.dumps(params, sort_keys=True) if params else 'None'}" 19 | return hashlib.md5(cache_str.encode()).hexdigest() 20 | 21 | def _is_cache_valid(cache_entry): 22 | """Check if cache entry is still valid""" 23 | import time 24 | return time.time() - cache_entry.get('timestamp', 0) < _cache_max_age 25 | 26 | def _get_from_cache(method, params): 27 | """Get result from cache if available and valid""" 28 | cache_key = _get_cache_key(method, params) 29 | if cache_key in _library_cache: 30 | cache_entry = _library_cache[cache_key] 31 | if _is_cache_valid(cache_entry): 32 | log(__name__, f"📋 Cache hit for {method}") 33 | return cache_entry['result'] 34 | else: 35 | # Remove expired entry 36 | del _library_cache[cache_key] 37 | return None 38 | 39 | def _store_in_cache(method, params, result): 40 | """Store result in cache""" 41 | import time 42 | cache_key = _get_cache_key(method, params) 43 | _library_cache[cache_key] = { 44 | 'result': result, 45 | 'timestamp': time.time() 46 | } 47 | log(__name__, f"📋 Cached result for {method}") 48 | 49 | __addon__ = xbmcaddon.Addon() 50 | 51 | 52 | def get_file_path(): 53 | return xbmc.Player().getPlayingFile() 54 | 55 | 56 | # ---------- Small helpers ---------- 57 | 58 | def _strip_imdb_tt(value): 59 | if not value: 60 | return None 61 | s = str(value).strip() 62 | if s.startswith("tt"): 63 | s = s[2:] 64 | return s if s.isdigit() else None 65 | 66 | 67 | def _extract_basic_tv_info(filename): 68 | """Extract basic TV show info from filename using simple regex""" 69 | import re 70 | 71 | # Remove file extension 72 | name = filename.rsplit('.', 1)[0] if '.' in filename else filename 73 | 74 | # Pattern to match TV show episodes: S##E## or Season##Episode## 75 | season_episode_patterns = [ 76 | r'[Ss](\d{1,2})[Ee](\d{1,2})', # S01E01, s01e01 77 | r'(\d{1,2})x(\d{1,2})', # 1x01 78 | ] 79 | 80 | for pattern in season_episode_patterns: 81 | match = re.search(pattern, name, re.IGNORECASE) 82 | if match: 83 | season_num = match.group(1) 84 | episode_num = match.group(2) 85 | # Extract show title (everything before the season/episode pattern) 86 | show_title = name[:match.start()].strip() 87 | # Clean up the show title 88 | show_title = re.sub(r'[._-]', ' ', show_title).strip() 89 | show_title = re.sub(r'\s+', ' ', show_title) # Multiple spaces to single 90 | return show_title, season_num, episode_num 91 | 92 | return None, None, None 93 | 94 | 95 | 96 | def _query_kodi_library_for_movie(movie_title, year=None, dbid=None): 97 | """Query Kodi library for movie IDs""" 98 | if not movie_title and not dbid: 99 | return None, None, None 100 | 101 | try: 102 | # If we have a specific database ID, query that movie directly 103 | if dbid and str(dbid).isdigit(): 104 | query_params = { 105 | "movieid": int(dbid), 106 | "properties": ["imdbnumber", "uniqueid", "title", "year"] 107 | } 108 | result = _jsonrpc("VideoLibrary.GetMovieDetails", query_params, use_cache=False) 109 | if result and "moviedetails" in result: 110 | movie = result["moviedetails"] 111 | return _extract_movie_ids(movie) 112 | 113 | # Search by title if no dbid or dbid query failed 114 | if movie_title: 115 | query_params = { 116 | "properties": ["imdbnumber", "uniqueid", "title", "year"], 117 | "limits": {"end": 100} 118 | } 119 | result = _jsonrpc("VideoLibrary.GetMovies", query_params, use_cache=False) 120 | 121 | if result and "movies" in result and result["movies"]: 122 | matching_movies = [] 123 | for movie in result["movies"]: 124 | movie_title_lib = movie.get('title', '').lower() 125 | search_title_lower = movie_title.lower() 126 | 127 | if (search_title_lower in movie_title_lib or 128 | movie_title_lib in search_title_lower): 129 | matching_movies.append(movie) 130 | 131 | if matching_movies: 132 | best_movie = _select_best_movie_match(matching_movies, movie_title, year) 133 | if best_movie: 134 | return _extract_movie_ids(best_movie) 135 | 136 | except Exception as e: 137 | log(__name__, f"Failed to query library for movie: {e}") 138 | 139 | return None, None, None 140 | 141 | def _select_best_movie_match(movies, search_title, search_year=None): 142 | """Select the best matching movie from library results""" 143 | if not movies: 144 | return None 145 | 146 | if len(movies) == 1: 147 | return movies[0] 148 | 149 | best_score = 0 150 | best_movie = None 151 | 152 | for movie in movies: 153 | score = 0 154 | movie_title = movie.get('title', '') 155 | movie_year = movie.get('year') 156 | 157 | # Title matching score 158 | if search_title: 159 | title_similarity = SequenceMatcher(None, search_title.lower(), movie_title.lower()).ratio() * 100 160 | score += title_similarity 161 | 162 | # Exact title match bonus 163 | if search_title.lower() == movie_title.lower(): 164 | score += 50 165 | 166 | # Year matching bonus 167 | if search_year and movie_year: 168 | year_diff = abs(int(search_year) - movie_year) 169 | if year_diff == 0: 170 | score += 25 171 | elif year_diff <= 1: 172 | score += 15 173 | 174 | if score > best_score: 175 | best_score = score 176 | best_movie = movie 177 | 178 | return best_movie 179 | 180 | 181 | def _extract_movie_ids(movie): 182 | """Extract IMDb and TMDb IDs from movie data, return (imdb_id, tmdb_id, file_path)""" 183 | movie_imdb = None 184 | movie_tmdb = None 185 | file_path = movie.get('file', '') 186 | 187 | # IMDb ID extraction 188 | imdb_raw = movie.get("imdbnumber", "") 189 | imdb_digits = _strip_imdb_tt(imdb_raw) 190 | if imdb_digits and 6 <= len(imdb_digits) <= 8: 191 | movie_imdb = int(imdb_digits) 192 | log(__name__, f"Found Movie IMDb: {movie_imdb}") 193 | 194 | # TMDb ID from uniqueid 195 | uniqueids = movie.get("uniqueid", {}) 196 | if isinstance(uniqueids, dict): 197 | tmdb_raw = uniqueids.get("tmdb", "") 198 | if tmdb_raw and str(tmdb_raw).isdigit(): 199 | movie_tmdb = int(tmdb_raw) 200 | log(__name__, f"Found Movie TMDb: {movie_tmdb}") 201 | 202 | return movie_imdb, movie_tmdb, file_path 203 | 204 | def _query_kodi_library_for_show(show_title, year=None): 205 | """Query Kodi library for TV show IDs""" 206 | if not show_title: 207 | return None, None, None 208 | 209 | try: 210 | query_params = { 211 | "properties": ["imdbnumber", "uniqueid", "title", "episodeguide"], 212 | "limits": {"end": 50} 213 | } 214 | result = _jsonrpc("VideoLibrary.GetTVShows", query_params, use_cache=False) 215 | 216 | if result and "tvshows" in result and result["tvshows"]: 217 | matching_shows = [] 218 | for show in result["tvshows"]: 219 | show_title_lib = show.get('title', '').lower() 220 | search_title_lower = show_title.lower() 221 | if (search_title_lower in show_title_lib or 222 | show_title_lib in search_title_lower): 223 | matching_shows.append(show) 224 | 225 | if matching_shows: 226 | best_show = _select_best_show_match(matching_shows, show_title, year) 227 | if best_show: 228 | return _extract_show_ids(best_show) 229 | 230 | except Exception as e: 231 | log(__name__, f"Failed to query library for show: {e}") 232 | 233 | return None, None, None 234 | 235 | def _select_best_show_match(tvshows, search_title, search_year=None): 236 | """Select the best matching TV show from library results""" 237 | if not tvshows: 238 | return None 239 | 240 | if len(tvshows) == 1: 241 | return tvshows[0] 242 | 243 | best_score = 0 244 | best_show = None 245 | 246 | for show in tvshows: 247 | score = 0 248 | show_title = show.get('title', '') 249 | show_orig_title = show.get('originaltitle', '') 250 | show_year = show.get('year') 251 | 252 | # Title matching (0-100) 253 | if search_title: 254 | title_similarity = SequenceMatcher(None, search_title.lower(), show_title.lower()).ratio() * 100 255 | if show_orig_title: 256 | orig_title_similarity = SequenceMatcher(None, search_title.lower(), show_orig_title.lower()).ratio() * 100 257 | score += max(title_similarity, orig_title_similarity) 258 | else: 259 | score += title_similarity 260 | 261 | # Exact match bonus 262 | if search_title.lower() == show_title.lower() or search_title.lower() == show_orig_title.lower(): 263 | score += 50 264 | 265 | # Year bonus (0-25) 266 | if search_year and show_year: 267 | year_diff = abs(int(search_year) - show_year) 268 | if year_diff == 0: 269 | score += 25 270 | elif year_diff <= 2: 271 | score += 10 272 | 273 | if score > best_score: 274 | best_score = score 275 | best_show = show 276 | 277 | return best_show 278 | 279 | def _extract_show_ids(tvshow): 280 | """Extract IMDb and TMDb IDs from TV show data, return (imdb_id, tmdb_id, tvshow_id)""" 281 | parent_imdb = None 282 | parent_tmdb = None 283 | tvshow_id = tvshow.get('tvshowid') 284 | 285 | # IMDb ID 286 | imdb_raw = tvshow.get("imdbnumber", "") 287 | imdb_digits = _strip_imdb_tt(imdb_raw) 288 | if imdb_digits and 6 <= len(imdb_digits) <= 8: 289 | parent_imdb = int(imdb_digits) 290 | log(__name__, f"Found Parent IMDb: {parent_imdb}") 291 | 292 | # TMDb ID from uniqueid 293 | uniqueids = tvshow.get("uniqueid", {}) 294 | if isinstance(uniqueids, dict): 295 | tmdb_raw = uniqueids.get("tmdb", "") 296 | if tmdb_raw and str(tmdb_raw).isdigit(): 297 | parent_tmdb = int(tmdb_raw) 298 | log(__name__, f"Found Parent TMDb: {parent_tmdb}") 299 | 300 | # Alternative TMDb extraction from episodeguide 301 | if not parent_tmdb: 302 | episodeguide = tvshow.get("episodeguide", "") 303 | if episodeguide: 304 | try: 305 | import re 306 | tmdb_match = re.search(r'tmdb["\']?[:\s]*([0-9]+)', episodeguide, re.IGNORECASE) 307 | if tmdb_match: 308 | parent_tmdb = int(tmdb_match.group(1)) 309 | log(__name__, f"Found Parent TMDb from episodeguide: {parent_tmdb}") 310 | except Exception: 311 | pass 312 | 313 | return parent_imdb, parent_tmdb, tvshow_id 314 | 315 | def _call_guessit_api(filename): 316 | """Call OpenSubtitles guessit API to parse filename""" 317 | try: 318 | import urllib.request 319 | import urllib.parse 320 | import json 321 | 322 | # Get API key from addon settings 323 | api_key = __addon__.getSetting("APIKey") 324 | if not api_key: 325 | log(__name__, "No API key found for guessit call") 326 | return None 327 | 328 | # Prepare the request 329 | base_url = "https://api.opensubtitles.com/api/v1/utilities/guessit" 330 | params = {"filename": filename} 331 | url = f"{base_url}?{urllib.parse.urlencode(params)}" 332 | 333 | # Create request with headers 334 | req = urllib.request.Request(url) 335 | req.add_header("Api-Key", api_key) 336 | req.add_header("User-Agent", f"Kodi OpenSubtitles.com v{__addon__.getAddonInfo('version')}") 337 | req.add_header("Accept", "application/json") 338 | 339 | log(__name__, f"🔍 Calling guessit API for: {filename}") 340 | 341 | # Make the request 342 | with urllib.request.urlopen(req) as response: 343 | if response.getcode() == 200: 344 | data = json.loads(response.read().decode('utf-8')) 345 | log(__name__, f"✅ Guessit API response: {data}") 346 | return data 347 | else: 348 | log(__name__, f"❌ Guessit API error: HTTP {response.getcode()}") 349 | return None 350 | 351 | except Exception as e: 352 | log(__name__, f"❌ Failed to call guessit API: {e}") 353 | return None 354 | 355 | def _jsonrpc(method, params=None, use_cache=True): 356 | """JSON-RPC call with caching and error handling""" 357 | # Check cache first for library queries 358 | if use_cache and method.startswith('VideoLibrary.'): 359 | cached_result = _get_from_cache(method, params) 360 | if cached_result is not None: 361 | return cached_result 362 | 363 | try: 364 | payload = {"jsonrpc": "2.0", "id": 1, "method": method} 365 | if params: 366 | payload["params"] = params 367 | 368 | resp = xbmc.executeJSONRPC(json.dumps(payload)) 369 | data = json.loads(resp) 370 | 371 | # Check for JSON-RPC errors 372 | if "error" in data: 373 | error_info = data["error"] 374 | log(__name__, f"JSON-RPC error in {method}: {error_info.get('message', 'Unknown error')}") 375 | return None 376 | 377 | result = data.get("result") 378 | 379 | # Cache library query results 380 | if use_cache and method.startswith('VideoLibrary.') and result: 381 | _store_in_cache(method, params, result) 382 | 383 | return result 384 | 385 | except json.JSONDecodeError as e: 386 | log(__name__, f"JSON decode error in {method}: {e}") 387 | return None 388 | except Exception as e: 389 | log(__name__, f"JSON-RPC error in {method}: {e}") 390 | return None 391 | 392 | 393 | def get_media_data(): 394 | 395 | item = {"query": None, 396 | "year": xbmc.getInfoLabel("VideoPlayer.Year"), 397 | "season_number": str(xbmc.getInfoLabel("VideoPlayer.Season")), 398 | "episode_number": str(xbmc.getInfoLabel("VideoPlayer.Episode")), 399 | "tv_show_title": normalize_string(xbmc.getInfoLabel("VideoPlayer.TVshowtitle")), 400 | "original_title": normalize_string(xbmc.getInfoLabel("VideoPlayer.OriginalTitle")), 401 | "parent_tmdb_id": None, 402 | "parent_imdb_id": None, 403 | "imdb_id": None, 404 | "tmdb_id": None} 405 | log(__name__, f"Initial media data from InfoLabels: {item}") 406 | 407 | # Check if we're dealing with a non-library file (all InfoLabels empty) 408 | if not any([item["tv_show_title"], item["original_title"], item["year"], 409 | item["season_number"], item["episode_number"]]): 410 | log(__name__, "⚠️ All InfoLabels are empty - likely non-library file playback") 411 | 412 | try: 413 | playing_file = get_file_path() 414 | if playing_file: 415 | log(__name__, f"📁 Playing file path: {playing_file}") 416 | import os 417 | filename = os.path.basename(playing_file) 418 | log(__name__, f"📝 Filename to parse: {filename}") 419 | 420 | # STEP 1: Try basic filename parsing for TV shows 421 | show_title, season_num, episode_num = _extract_basic_tv_info(filename) 422 | if show_title and season_num and episode_num: 423 | log(__name__, f"🎬 Basic parsing found TV show: '{show_title}' S{season_num}E{episode_num}") 424 | 425 | # STEP 2: Try to find this show in Kodi library 426 | parent_imdb, parent_tmdb, tvshow_id = _query_kodi_library_for_show(show_title) 427 | if parent_imdb or parent_tmdb: 428 | # Success! We have parent IDs from library 429 | item["tv_show_title"] = show_title 430 | item["season_number"] = season_num 431 | item["episode_number"] = episode_num 432 | if parent_imdb: 433 | item["parent_imdb_id"] = parent_imdb 434 | if parent_tmdb: 435 | item["parent_tmdb_id"] = parent_tmdb 436 | if tvshow_id: 437 | item["tvshowid"] = str(tvshow_id) 438 | log(__name__, f"✅ Found in library with parent IDs - IMDb: {parent_imdb}, TMDb: {parent_tmdb}, DBID: {tvshow_id}") 439 | else: 440 | # Library search failed, set basic TV info for title search 441 | item["tv_show_title"] = show_title 442 | item["season_number"] = season_num 443 | item["episode_number"] = episode_num 444 | log(__name__, f"📚 Not in library, will search by title: '{show_title}' S{season_num}E{episode_num}") 445 | else: 446 | # STEP 3: Fallback to guessit API for complex parsing 447 | log(__name__, "🔍 Basic parsing failed, trying guessit API...") 448 | guessed_data = _call_guessit_api(filename) 449 | if guessed_data: 450 | if guessed_data.get("type") == "episode": 451 | # TV show episode 452 | item["tv_show_title"] = guessed_data.get("title", "") 453 | item["season_number"] = str(guessed_data.get("season", "")) 454 | item["episode_number"] = str(guessed_data.get("episode", "")) 455 | item["year"] = guessed_data.get("year") 456 | log(__name__, f"🎬 Guessit parsed TV episode: {item['tv_show_title']} S{item['season_number']}E{item['episode_number']}") 457 | elif guessed_data.get("type") == "movie": 458 | # Movie 459 | movie_title = guessed_data.get("title", "") 460 | movie_year = guessed_data.get("year") 461 | item["original_title"] = movie_title 462 | item["query"] = movie_title # Set query to clean title 463 | item["year"] = str(movie_year) if movie_year else "" 464 | log(__name__, f"🎬 Guessit parsed movie: {movie_title} ({movie_year})") 465 | log(__name__, f"🔍 Set query to: '{item['query']}'") 466 | 467 | # Try to find this movie in Kodi library 468 | movie_imdb, movie_tmdb, file_path = _query_kodi_library_for_movie(movie_title, movie_year) 469 | if movie_imdb or movie_tmdb: 470 | if movie_imdb: 471 | item["imdb_id"] = movie_imdb 472 | if movie_tmdb: 473 | item["tmdb_id"] = movie_tmdb 474 | if file_path: 475 | item["file_path"] = file_path 476 | log(__name__, f"✅ Found movie in library with IDs - IMDb: {movie_imdb}, TMDb: {movie_tmdb}") 477 | else: 478 | log(__name__, f"📚 Movie not in library, will search by title: '{movie_title}' ({movie_year})") 479 | else: 480 | log(__name__, f"🎬 Guessit detected type: {guessed_data.get('type')}") 481 | else: 482 | log(__name__, "❌ All parsing methods failed, will use filename as query") 483 | except Exception as e: 484 | log(__name__, f"Failed to parse filename: {e}") 485 | 486 | # ---------------- TV SHOW (Episode) ---------------- 487 | if item["tv_show_title"]: 488 | item["tvshowid"] = xbmc.getInfoLabel("VideoPlayer.TvShowDBID") 489 | item["query"] = item["tv_show_title"] 490 | item["year"] = None # Safer for OS search 491 | 492 | # 1) Try to get TRUE parent show IDs first (these are more reliable) 493 | try: 494 | # True parent show IMDb ID from TvShow properties 495 | parent_imdb_raw = (xbmc.getInfoLabel("ListItem.Property(TvShow.IMDBNumber)") 496 | or xbmc.getInfoLabel("VideoPlayer.TvShow.IMDBNumber")) 497 | imdb_digits = _strip_imdb_tt(parent_imdb_raw) 498 | if imdb_digits and 6 <= len(imdb_digits) <= 8: 499 | item["parent_imdb_id"] = int(imdb_digits) 500 | log(__name__, f"TRUE Parent Show IMDb ID: {item['parent_imdb_id']}") 501 | 502 | # True parent show TMDb ID (less common but check if available) 503 | parent_tmdb_raw = xbmc.getInfoLabel("VideoPlayer.TvShow.UniqueID(tmdb)") 504 | if parent_tmdb_raw and parent_tmdb_raw.isdigit(): 505 | item["parent_tmdb_id"] = int(parent_tmdb_raw) 506 | log(__name__, f"TRUE Parent Show TMDb ID: {item['parent_tmdb_id']}") 507 | except Exception as e: 508 | log(__name__, f"Failed to read true parent IDs from InfoLabels: {e}") 509 | 510 | # 2) If no true parent IDs found, check if we have episode-specific IDs 511 | if not item.get("parent_imdb_id") and not item.get("parent_tmdb_id"): 512 | try: 513 | # These might be episode IDs, not parent IDs 514 | possible_episode_imdb = (xbmc.getInfoLabel("VideoPlayer.UniqueID(imdb)") 515 | or xbmc.getInfoLabel("VideoPlayer.IMDBNumber") 516 | or xbmc.getInfoLabel("ListItem.IMDBNumber")) 517 | imdb_digits = _strip_imdb_tt(possible_episode_imdb) 518 | if imdb_digits and 6 <= len(imdb_digits) <= 8: 519 | item["imdb_id"] = int(imdb_digits) 520 | log(__name__, f"Episode-specific IMDb ID (not parent): {item['imdb_id']}") 521 | 522 | possible_episode_tmdb = xbmc.getInfoLabel("VideoPlayer.UniqueID(tmdb)") 523 | if possible_episode_tmdb and possible_episode_tmdb.isdigit(): 524 | item["tmdb_id"] = int(possible_episode_tmdb) 525 | log(__name__, f"Episode-specific TMDb ID (not parent): {item['tmdb_id']}") 526 | except Exception as e: 527 | log(__name__, f"Failed to read episode IDs from InfoLabels: {e}") 528 | 529 | # 3) If still missing, fall back to library JSON-RPC (when the show is in the library) 530 | if len(item["tvshowid"]) != 0 and (not item["parent_tmdb_id"] or not item["parent_imdb_id"]): 531 | try: 532 | TVShowDetails = xbmc.executeJSONRPC( 533 | '{ "jsonrpc": "2.0", "id":"1", "method": "VideoLibrary.GetTVShowDetails", ' 534 | '"params":{"tvshowid":' + item["tvshowid"] + ', "properties": ["episodeguide", "imdbnumber", "uniqueid"]} }' 535 | ) 536 | TVShowDetails_dict = json.loads(TVShowDetails) 537 | if "result" in TVShowDetails_dict and "tvshowdetails" in TVShowDetails_dict["result"]: 538 | tvshow_details = TVShowDetails_dict["result"]["tvshowdetails"] 539 | 540 | # parent IMDb 541 | if not item["parent_imdb_id"]: 542 | imdb_raw = str(tvshow_details.get("imdbnumber") or "") 543 | imdb_digits = _strip_imdb_tt(imdb_raw) 544 | if imdb_digits and 6 <= len(imdb_digits) <= 8: 545 | item["parent_imdb_id"] = int(imdb_digits) 546 | log(__name__, f"Parent IMDb via JSON-RPC: {item['parent_imdb_id']}") 547 | 548 | # parent TMDb (first try uniqueid, then episodeguide fallback) 549 | if not item["parent_tmdb_id"]: 550 | # Method 1: Try uniqueid field first (more reliable) 551 | uniqueids = tvshow_details.get("uniqueid", {}) 552 | if isinstance(uniqueids, dict): 553 | tmdb_raw = uniqueids.get("tmdb", "") 554 | if tmdb_raw and str(tmdb_raw).isdigit(): 555 | item["parent_tmdb_id"] = int(tmdb_raw) 556 | log(__name__, f"Parent TMDb via JSON-RPC (uniqueid): {item['parent_tmdb_id']}") 557 | 558 | # Method 2: Fallback to episodeguide if uniqueid didn't work 559 | if not item["parent_tmdb_id"]: 560 | episodeguideXML = tvshow_details.get("episodeguide") 561 | if episodeguideXML: 562 | try: 563 | episodeguide = ET.fromstring(episodeguideXML) 564 | if episodeguide.text: 565 | guide_json = json.loads(episodeguide.text) 566 | tmdb = guide_json.get("tmdb") 567 | if tmdb and str(tmdb).isdigit(): 568 | item["parent_tmdb_id"] = int(tmdb) 569 | log(__name__, f"Parent TMDb via JSON-RPC (episodeguide): {item['parent_tmdb_id']}") 570 | except (ET.ParseError, json.JSONDecodeError, ValueError): 571 | pass # Silent fail for malformed XML/JSON 572 | except (json.JSONDecodeError, ET.ParseError, ValueError, KeyError) as e: 573 | log(__name__, f"Failed to extract TV show IDs via JSON-RPC: {e}") 574 | 575 | # 4) Try to get specific episode IDs from dedicated episode fields (if available) 576 | try: 577 | ep_tmdb = xbmc.getInfoLabel("VideoPlayer.UniqueID(tmdbepisode)") 578 | if ep_tmdb and ep_tmdb.isdigit(): 579 | item["tmdb_id"] = int(ep_tmdb) 580 | log(__name__, f"Dedicated Episode TMDb ID: {item['tmdb_id']}") 581 | ep_imdb = xbmc.getInfoLabel("VideoPlayer.UniqueID(imdbepisode)") 582 | ep_imdb_digits = _strip_imdb_tt(ep_imdb) 583 | if ep_imdb_digits and ep_imdb_digits.isdigit(): 584 | item["imdb_id"] = int(ep_imdb_digits) 585 | log(__name__, f"Dedicated Episode IMDb ID: {item['imdb_id']}") 586 | except Exception as e: 587 | log(__name__, f"Failed to read dedicated episode IDs from InfoLabels: {e}") 588 | 589 | # ---------------- MOVIE ---------------- 590 | elif item["original_title"]: 591 | item["query"] = item["original_title"] 592 | movie_dbid = xbmc.getInfoLabel("VideoPlayer.DBID") 593 | 594 | # First try to get IDs from InfoLabels (most reliable for library content) 595 | try: 596 | imdb_raw = (xbmc.getInfoLabel("VideoPlayer.UniqueID(imdb)") 597 | or xbmc.getInfoLabel("VideoPlayer.IMDBNumber")) 598 | imdb_digits = _strip_imdb_tt(imdb_raw) 599 | if imdb_digits and 6 <= len(imdb_digits) <= 8: 600 | item["imdb_id"] = int(imdb_digits) 601 | log(__name__, f"Found IMDB ID for movie from InfoLabel: {item['imdb_id']}") 602 | 603 | tmdb_raw = xbmc.getInfoLabel("VideoPlayer.UniqueID(tmdb)") 604 | if tmdb_raw and str(tmdb_raw).isdigit(): 605 | tmdb_id = int(tmdb_raw) 606 | if tmdb_id > 0: 607 | item["tmdb_id"] = tmdb_id 608 | log(__name__, f"Found TMDB ID for movie from InfoLabel: {item['tmdb_id']}") 609 | except (ValueError, KeyError) as e: 610 | log(__name__, f"Failed to extract movie IDs from InfoLabels: {e}") 611 | 612 | # If no IDs found and we have a database ID, query the library directly 613 | if not item.get("imdb_id") and not item.get("tmdb_id") and movie_dbid and movie_dbid.isdigit(): 614 | log(__name__, f"🔍 No IDs from InfoLabels, trying library query with DBID: {movie_dbid}") 615 | movie_imdb, movie_tmdb, file_path = _query_kodi_library_for_movie(None, None, movie_dbid) 616 | if movie_imdb: 617 | item["imdb_id"] = movie_imdb 618 | log(__name__, f"Found IMDB ID from library query: {movie_imdb}") 619 | if movie_tmdb: 620 | item["tmdb_id"] = movie_tmdb 621 | log(__name__, f"Found TMDB ID from library query: {movie_tmdb}") 622 | 623 | # Last resort: search library by title and year 624 | if not item.get("imdb_id") and not item.get("tmdb_id"): 625 | log(__name__, f"🔍 No IDs found, searching library by title: '{item['original_title']}' ({item.get('year')})") 626 | movie_imdb, movie_tmdb, file_path = _query_kodi_library_for_movie(item["original_title"], item.get("year")) 627 | if movie_imdb: 628 | item["imdb_id"] = movie_imdb 629 | log(__name__, f"Found IMDB ID from title search: {movie_imdb}") 630 | if movie_tmdb: 631 | item["tmdb_id"] = movie_tmdb 632 | log(__name__, f"Found TMDB ID from title search: {movie_tmdb}") 633 | 634 | # ---------- Cleanup & precedence ---------- 635 | for k in ("parent_tmdb_id", "parent_imdb_id", "tmdb_id", "imdb_id"): 636 | v = item.get(k) 637 | if v in (0, "0", "", None): 638 | item[k] = None 639 | 640 | # Prefer parent IMDb over parent TMDb for TV 641 | if item.get("parent_tmdb_id") and item.get("parent_imdb_id"): 642 | log(__name__, f"Both parent TMDB and IMDB IDs found, preferring IMDB ID: {item['parent_imdb_id']}") 643 | item["parent_tmdb_id"] = None 644 | 645 | # Prefer IMDb over TMDb for item-level IDs 646 | if item.get("tmdb_id") and item.get("imdb_id"): 647 | log(__name__, f"Both TMDB and IMDB IDs found for item, preferring IMDB ID: {item['imdb_id']}") 648 | item["tmdb_id"] = None 649 | 650 | # ---------- Final ID Strategy Selection (TV Episodes Only) ---------- 651 | # Ensure we only use ONE strategy: parent IDs + season/episode OR episode-specific IDs 652 | if item.get("tv_show_title"): 653 | if item.get("parent_imdb_id"): 654 | # Strategy: Use parent IMDb ID with season/episode 655 | item["parent_tmdb_id"] = None # Clear conflicting parent ID 656 | item["imdb_id"] = None # Clear episode-specific IDs 657 | item["tmdb_id"] = None 658 | log(__name__, f"✅ Final Strategy: parent_imdb_id={item['parent_imdb_id']} + season/episode") 659 | elif item.get("parent_tmdb_id"): 660 | # Strategy: Use parent TMDb ID with season/episode 661 | item["parent_imdb_id"] = None # Clear conflicting parent ID 662 | item["imdb_id"] = None # Clear episode-specific IDs 663 | item["tmdb_id"] = None 664 | log(__name__, f"✅ Final Strategy: parent_tmdb_id={item['parent_tmdb_id']} + season/episode") 665 | elif item.get("imdb_id"): 666 | # Strategy: Use episode-specific IMDb ID only 667 | item["parent_imdb_id"] = None # Clear parent IDs 668 | item["parent_tmdb_id"] = None 669 | item["tmdb_id"] = None # Clear conflicting episode ID 670 | log(__name__, f"✅ Final Strategy: episode imdb_id={item['imdb_id']} (no season/episode)") 671 | elif item.get("tmdb_id"): 672 | # Strategy: Use episode-specific TMDb ID only 673 | item["parent_imdb_id"] = None # Clear parent IDs 674 | item["parent_tmdb_id"] = None 675 | item["imdb_id"] = None # Clear conflicting episode ID 676 | log(__name__, f"✅ Final Strategy: episode tmdb_id={item['tmdb_id']} (no season/episode)") 677 | 678 | # ---------- API Query Strategy Logging ---------- 679 | # For TV episodes: Prioritize parent show IDs + season/episode, fallback to specific episode IDs 680 | if item.get("tv_show_title"): 681 | if item.get("parent_imdb_id"): 682 | log(__name__, f"🎯 API Strategy: parent_imdb_id={item['parent_imdb_id']}, season={item['season_number']}, episode={item['episode_number']}") 683 | elif item.get("parent_tmdb_id"): 684 | log(__name__, f"🎯 API Strategy: parent_tmdb_id={item['parent_tmdb_id']}, season={item['season_number']}, episode={item['episode_number']}") 685 | elif item.get("imdb_id"): 686 | log(__name__, f"🎯 API Strategy: imdb_id={item['imdb_id']} (episode-specific, no season/episode needed)") 687 | elif item.get("tmdb_id"): 688 | log(__name__, f"🎯 API Strategy: tmdb_id={item['tmdb_id']} (episode-specific, no season/episode needed)") 689 | else: 690 | log(__name__, f"🎯 API Strategy: title search only '{item['query']}' (no IDs available)") 691 | else: 692 | # For movies: Use specific movie IDs 693 | if item.get("imdb_id"): 694 | log(__name__, f"🎯 API Strategy: imdb_id={item['imdb_id']} (movie)") 695 | elif item.get("tmdb_id"): 696 | log(__name__, f"🎯 API Strategy: tmdb_id={item['tmdb_id']} (movie)") 697 | else: 698 | log(__name__, f"🎯 API Strategy: title search only '{item['query']}' (movie, no IDs available)") 699 | 700 | if not item.get("query"): 701 | fallback_title = normalize_string(xbmc.getInfoLabel("VideoPlayer.Title")) 702 | if fallback_title: 703 | item["query"] = fallback_title 704 | else: 705 | # Last resort: use filename 706 | try: 707 | playing_file = get_file_path() 708 | if playing_file: 709 | import os 710 | filename = os.path.basename(playing_file) 711 | item["query"] = filename 712 | except: 713 | item["query"] = "Unknown" 714 | 715 | # Specials handling 716 | if isinstance(item.get("episode_number"), str) and item["episode_number"] and item["episode_number"].lower().find("s") > -1: 717 | item["season_number"] = "0" 718 | item["episode_number"] = item["episode_number"][-1:] 719 | 720 | # Remove internal-only key 721 | if "tvshowid" in item: 722 | del item["tvshowid"] 723 | 724 | log(__name__, f"Media data result: {item.get('query')} - IMDb:{item.get('imdb_id') or item.get('parent_imdb_id')} TMDb:{item.get('tmdb_id') or item.get('parent_tmdb_id')}") 725 | 726 | return item 727 | 728 | 729 | def get_language_data(params): 730 | search_languages = unquote(params.get("languages")).split(",") 731 | search_languages_str = "" 732 | preferred_language = params.get("preferredlanguage") 733 | 734 | if preferred_language and preferred_language not in search_languages and preferred_language != "Unknown" and preferred_language != "Undetermined": 735 | search_languages.append(preferred_language) 736 | search_languages_str = search_languages_str + "," + preferred_language 737 | 738 | for language in search_languages: 739 | lang = convert_language(language) 740 | if lang: 741 | log(__name__, f"Language found: '{lang}' search_languages_str:'{search_languages_str}") 742 | if search_languages_str == "": 743 | search_languages_str = lang 744 | else: 745 | search_languages_str = search_languages_str + "," + lang 746 | else: 747 | log(__name__, f"Language code not found: '{language}'") 748 | 749 | item = { 750 | "hearing_impaired": __addon__.getSetting("hearing_impaired"), 751 | "foreign_parts_only": __addon__.getSetting("foreign_parts_only"), 752 | "machine_translated": __addon__.getSetting("machine_translated"), 753 | "ai_translated": __addon__.getSetting("ai_translated"), 754 | "languages": search_languages_str 755 | } 756 | 757 | return item 758 | 759 | 760 | def convert_language(language, reverse=False): 761 | language_list = { 762 | "English": "en", 763 | "Portuguese (Brazil)": "pt-br", 764 | "Portuguese": "pt-pt", 765 | "Chinese": "zh-cn", 766 | "Chinese (simplified)": "zh-cn", 767 | "Chinese (traditional)": "zh-tw"} 768 | 769 | reverse_language_list = {v: k for k, v in list(language_list.items())} 770 | 771 | if reverse: 772 | iterated_list = reverse_language_list 773 | xbmc_param = xbmc.ENGLISH_NAME 774 | else: 775 | iterated_list = language_list 776 | xbmc_param = xbmc.ISO_639_1 777 | 778 | if language in iterated_list: 779 | return iterated_list[language] 780 | else: 781 | return xbmc.convertLanguage(language, xbmc_param) 782 | 783 | 784 | def get_flag(language_code): 785 | language_list = { 786 | "pt-pt": "pt", 787 | "pt-br": "pb", 788 | "zh-cn": "zh", 789 | "zh-tw": "-" 790 | } 791 | return language_list.get(language_code.lower(), language_code) 792 | 793 | 794 | def clean_feature_release_name(title, release, movie_name=""): 795 | if not title: 796 | if not movie_name: 797 | if not release: 798 | raise ValueError("None of title, release, movie_name contains a string") 799 | return release 800 | else: 801 | if not movie_name[0:4].isnumeric(): 802 | name = movie_name 803 | else: 804 | name = movie_name[7:] 805 | else: 806 | name = title 807 | 808 | match_ratio = SequenceMatcher(None, name, release).ratio() 809 | log(__name__, f"name: {name}, release: {release}, match_ratio: {match_ratio}") 810 | if name in release: 811 | return release 812 | elif match_ratio > 0.3: 813 | return release 814 | else: 815 | return f"{name} {release}" 816 | --------------------------------------------------------------------------------