├── 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 |
--------------------------------------------------------------------------------