├── lib ├── __init__.py ├── api │ ├── __init__.py │ ├── plex │ │ ├── __init__.py │ │ ├── settings.py │ │ ├── utils.py │ │ └── plex.py │ ├── tvdbapi │ │ ├── __init__.py │ │ └── tvdbapi.py │ ├── tmdbv3api │ │ ├── objs │ │ │ ├── __init__.py │ │ │ ├── credit.py │ │ │ ├── review.py │ │ │ ├── group.py │ │ │ ├── genre.py │ │ │ ├── keyword.py │ │ │ ├── certification.py │ │ │ ├── collection.py │ │ │ ├── network.py │ │ │ ├── provider.py │ │ │ ├── company.py │ │ │ ├── discover.py │ │ │ ├── configuration.py │ │ │ ├── change.py │ │ │ ├── trending.py │ │ │ ├── auth.py │ │ │ ├── find.py │ │ │ ├── list.py │ │ │ └── anime.py │ │ ├── exceptions.py │ │ ├── utils.py │ │ ├── __init__.py │ │ └── as_obj.py │ ├── trakt │ │ ├── lists_cache.py │ │ └── main_cache.py │ ├── debrid │ │ ├── easydebrid.py │ │ └── torbox.py │ └── fanart │ │ └── fanart.py ├── jacktook │ ├── __init__.py │ ├── listener.py │ ├── utils.py │ ├── providers.py │ └── client.py ├── vendor │ ├── bencodepy │ │ ├── bencodepy │ │ │ ├── exceptions.py │ │ │ ├── common.py │ │ │ └── compat.py │ │ └── bencode │ │ │ ├── exceptions.py │ │ │ ├── BTL.py │ │ │ └── __init__.py │ └── torf │ │ └── __init__.py ├── gui │ ├── play_next_window.py │ ├── filter_items_window.py │ ├── resume_window.py │ ├── custom_progress.py │ ├── source_pack_window.py │ ├── filter_type_window.py │ ├── qr_progress_dialog.py │ ├── play_window.py │ └── source_pack_select.py ├── clients │ ├── trakt │ │ ├── utils.py │ │ └── paginator.py │ ├── anizip.py │ ├── base.py │ ├── plex.py │ ├── simkl.py │ ├── peerflix.py │ ├── elfhosted.py │ ├── stremio │ │ ├── stream.py │ │ └── client.py │ ├── fma.py │ ├── jackgram │ │ └── client.py │ ├── tmdb │ │ ├── anime.py │ │ └── base.py │ ├── torrentio.py │ ├── debrid │ │ ├── premiumize.py │ │ └── torbox.py │ ├── subtitle │ │ └── utils.py │ └── zilean.py ├── domain │ └── torrent.py ├── utils │ ├── kodi │ │ └── settings.py │ ├── stremio │ │ └── catalogs_utils.py │ ├── views │ │ ├── last_files.py │ │ └── last_titles.py │ ├── plex │ │ └── utils.py │ ├── torrentio │ │ └── utils.py │ ├── debrid │ │ └── qrcode_utils.py │ ├── clients │ │ └── utils.py │ └── torrent │ │ └── flatbencode.py └── db │ ├── anime.py │ └── pickle_db.py ├── resources ├── __init__.py ├── img │ ├── tv.png │ ├── anime.png │ ├── clear.png │ ├── cloud.png │ ├── cloud2.png │ ├── donate.png │ ├── genre.png │ ├── lang.png │ ├── magnet.png │ ├── movies.png │ ├── search.png │ ├── status.png │ ├── tmdb.png │ ├── trakt.png │ ├── download.png │ ├── history.png │ ├── magnet2.png │ ├── mdblist.png │ ├── nextpage.png │ ├── settings.png │ ├── telegram.png │ ├── trending.png │ ├── download2.png │ └── torrentio.png ├── screenshots │ ├── tv.png │ ├── home.png │ └── settings.png └── skins │ └── Default │ ├── media │ ├── white.png │ ├── circle.png │ ├── spinner.png │ ├── texture.png │ ├── jtk_fanart.png │ ├── Button │ │ └── close.png │ ├── left-circle.png │ ├── jtk_clearlogo.png │ └── AddonWindow │ │ ├── black.png │ │ └── white.png │ └── 1080i │ ├── resume_dialog.xml │ ├── customdialog.xml │ ├── qr_dialog.xml │ ├── filter_type.xml │ ├── filter_items.xml │ ├── playing_next.xml │ └── custom_progress_dialog.xml ├── icon.png ├── fanart.png ├── jacktook.select.zip ├── jacktook.py ├── .gitignore ├── jacktook.select.json ├── addon.xml ├── scripts └── check_py37_compat.py └── service.py /lib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/api/plex/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/api/tvdbapi/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/jacktook/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/icon.png -------------------------------------------------------------------------------- /lib/api/tmdbv3api/exceptions.py: -------------------------------------------------------------------------------- 1 | class TMDbException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /fanart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/fanart.png -------------------------------------------------------------------------------- /jacktook.select.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/jacktook.select.zip -------------------------------------------------------------------------------- /jacktook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from lib.router import addon_router 4 | 5 | addon_router() 6 | -------------------------------------------------------------------------------- /resources/img/tv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/tv.png -------------------------------------------------------------------------------- /resources/img/anime.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/anime.png -------------------------------------------------------------------------------- /resources/img/clear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/clear.png -------------------------------------------------------------------------------- /resources/img/cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/cloud.png -------------------------------------------------------------------------------- /resources/img/cloud2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/cloud2.png -------------------------------------------------------------------------------- /resources/img/donate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/donate.png -------------------------------------------------------------------------------- /resources/img/genre.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/genre.png -------------------------------------------------------------------------------- /resources/img/lang.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/lang.png -------------------------------------------------------------------------------- /resources/img/magnet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/magnet.png -------------------------------------------------------------------------------- /resources/img/movies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/movies.png -------------------------------------------------------------------------------- /resources/img/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/search.png -------------------------------------------------------------------------------- /resources/img/status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/status.png -------------------------------------------------------------------------------- /resources/img/tmdb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/tmdb.png -------------------------------------------------------------------------------- /resources/img/trakt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/trakt.png -------------------------------------------------------------------------------- /resources/img/download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/download.png -------------------------------------------------------------------------------- /resources/img/history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/history.png -------------------------------------------------------------------------------- /resources/img/magnet2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/magnet2.png -------------------------------------------------------------------------------- /resources/img/mdblist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/mdblist.png -------------------------------------------------------------------------------- /resources/img/nextpage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/nextpage.png -------------------------------------------------------------------------------- /resources/img/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/settings.png -------------------------------------------------------------------------------- /resources/img/telegram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/telegram.png -------------------------------------------------------------------------------- /resources/img/trending.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/trending.png -------------------------------------------------------------------------------- /resources/img/download2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/download2.png -------------------------------------------------------------------------------- /resources/img/torrentio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/img/torrentio.png -------------------------------------------------------------------------------- /resources/screenshots/tv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/screenshots/tv.png -------------------------------------------------------------------------------- /resources/screenshots/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/screenshots/home.png -------------------------------------------------------------------------------- /resources/screenshots/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/screenshots/settings.png -------------------------------------------------------------------------------- /resources/skins/Default/media/white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/skins/Default/media/white.png -------------------------------------------------------------------------------- /resources/skins/Default/media/circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/skins/Default/media/circle.png -------------------------------------------------------------------------------- /resources/skins/Default/media/spinner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/skins/Default/media/spinner.png -------------------------------------------------------------------------------- /resources/skins/Default/media/texture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/skins/Default/media/texture.png -------------------------------------------------------------------------------- /resources/skins/Default/media/jtk_fanart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/skins/Default/media/jtk_fanart.png -------------------------------------------------------------------------------- /resources/skins/Default/media/Button/close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/skins/Default/media/Button/close.png -------------------------------------------------------------------------------- /resources/skins/Default/media/left-circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/skins/Default/media/left-circle.png -------------------------------------------------------------------------------- /resources/skins/Default/media/jtk_clearlogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/skins/Default/media/jtk_clearlogo.png -------------------------------------------------------------------------------- /resources/skins/Default/media/AddonWindow/black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/skins/Default/media/AddonWindow/black.png -------------------------------------------------------------------------------- /resources/skins/Default/media/AddonWindow/white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/HEAD/resources/skins/Default/media/AddonWindow/white.png -------------------------------------------------------------------------------- /lib/vendor/bencodepy/bencodepy/exceptions.py: -------------------------------------------------------------------------------- 1 | """bencode.py - exceptions.""" 2 | 3 | 4 | class BencodeDecodeError(Exception): 5 | """Bencode decode error.""" 6 | 7 | pass 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | *.log 4 | tmp/ 5 | tests/ 6 | test2/ 7 | *.py[cod] 8 | *.egg 9 | build 10 | htmlcov 11 | venv/ 12 | .venv/ 13 | __pycache__ 14 | .aider* 15 | settings.json 16 | -------------------------------------------------------------------------------- /lib/vendor/bencodepy/bencode/exceptions.py: -------------------------------------------------------------------------------- 1 | """bencode.py - encoder + decode exceptions.""" 2 | from bencodepy.exceptions import BencodeDecodeError 3 | 4 | __all__ = ( 5 | 'BencodeDecodeError', 6 | ) 7 | -------------------------------------------------------------------------------- /lib/vendor/bencodepy/bencodepy/common.py: -------------------------------------------------------------------------------- 1 | """bencode.py - common.""" 2 | 3 | 4 | class Bencached(object): 5 | __slots__ = ['bencoded'] 6 | 7 | def __init__(self, s): 8 | self.bencoded = s 9 | -------------------------------------------------------------------------------- /lib/api/plex/settings.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | class Settings(): 4 | identifier: str = str(uuid4()) 5 | product_name: str = 'Jacktook' 6 | plex_requests_timeout: int = 30 7 | plex_matching_token: str 8 | 9 | 10 | settings = Settings() 11 | -------------------------------------------------------------------------------- /lib/vendor/bencodepy/bencode/BTL.py: -------------------------------------------------------------------------------- 1 | """bencode.py - `BTL` backwards compatibility support.""" 2 | 3 | # Compatibility with previous versions: 4 | from bencode.exceptions import BencodeDecodeError as BTFailure # noqa: F401 5 | 6 | 7 | __all__ = ( 8 | 'BTFailure' 9 | ) 10 | -------------------------------------------------------------------------------- /lib/api/plex/utils.py: -------------------------------------------------------------------------------- 1 | 2 | class PlexUnauthorizedError(BaseException): 3 | pass 4 | 5 | 6 | class HTTPException(BaseException): 7 | def __init__( 8 | self, 9 | status_code: int, 10 | detail: str, 11 | ): 12 | self.status_code = status_code 13 | self.detail = detail 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/credit.py: -------------------------------------------------------------------------------- 1 | from lib.api.tmdbv3api.tmdb import TMDb 2 | 3 | class Credit(TMDb): 4 | _urls = { 5 | "details": "/credit/%s" 6 | } 7 | 8 | def details(self, credit_id): 9 | """ 10 | Get a movie or TV credit details by id. 11 | :param credit_id: int 12 | :return: 13 | """ 14 | return self._request_obj(self._urls["details"] % credit_id) 15 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/review.py: -------------------------------------------------------------------------------- 1 | from lib.api.tmdbv3api.tmdb import TMDb 2 | 3 | 4 | class Review(TMDb): 5 | _urls = { 6 | "details": "/review/%s", 7 | } 8 | 9 | def details(self, review_id): 10 | """ 11 | Get the primary person details by id. 12 | :param review_id: int 13 | :return: 14 | """ 15 | return self._request_obj(self._urls["details"] % review_id) 16 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/group.py: -------------------------------------------------------------------------------- 1 | from lib.api.tmdbv3api.tmdb import TMDb 2 | 3 | 4 | class Group(TMDb): 5 | _urls = { 6 | "details": "/tv/episode_group/%s" 7 | } 8 | 9 | def details(self, group_id): 10 | """ 11 | Get the details of a TV episode group. 12 | :param group_id: int 13 | :return: 14 | """ 15 | return self._request_obj(self._urls["details"] % group_id, key="groups") 16 | -------------------------------------------------------------------------------- /lib/gui/play_next_window.py: -------------------------------------------------------------------------------- 1 | from lib.gui.play_window import PlayWindow 2 | 3 | 4 | class PlayNext(PlayWindow): 5 | def __init__(self, xml_file, xml_location, item_information=None): 6 | super().__init__(xml_file, xml_location, item_information=item_information) 7 | self.default_action = 2 8 | 9 | def smart_play_action(self): 10 | if ( 11 | self.default_action == 1 12 | and self.playing_file == self.getPlayingFile() 13 | and not self.closed 14 | ): 15 | self.pause() 16 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | years_tvshows = [{'name': '2024', 'id': 2024}] 4 | 5 | def get_dates(days, reverse=True): 6 | current_date = get_current_date(return_str=False) 7 | if reverse: 8 | new_date = (current_date - datetime.timedelta(days=days)).strftime('%Y-%m-%d') 9 | else: 10 | new_date = (current_date + datetime.timedelta(days=days)).strftime('%Y-%m-%d') 11 | return str(current_date), new_date 12 | 13 | def get_current_date(return_str=True): 14 | if return_str: return str(datetime.date.today()) 15 | else: return datetime.date.today() -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/genre.py: -------------------------------------------------------------------------------- 1 | from lib.api.tmdbv3api.tmdb import TMDb 2 | 3 | 4 | class Genre(TMDb): 5 | _urls = { 6 | "movie_list": "/genre/movie/list", 7 | "tv_list": "/genre/tv/list" 8 | } 9 | 10 | def movie_list(self): 11 | """ 12 | Get the list of official genres for movies. 13 | :return: 14 | """ 15 | return self._request_obj(self._urls["movie_list"], key="genres") 16 | 17 | def tv_list(self): 18 | """ 19 | Get the list of official genres for TV shows. 20 | :return: 21 | """ 22 | return self._request_obj(self._urls["tv_list"], key="genres") 23 | -------------------------------------------------------------------------------- /lib/vendor/bencodepy/bencodepy/compat.py: -------------------------------------------------------------------------------- 1 | """bencode.py - compatibility helpers.""" 2 | 3 | import sys 4 | 5 | PY2 = sys.version_info[0] == 2 6 | PY3 = sys.version_info[0] == 3 7 | 8 | 9 | def is_binary(s): 10 | if PY3: 11 | return isinstance(s, bytes) 12 | 13 | return isinstance(s, str) 14 | 15 | 16 | def is_text(s): 17 | if PY3: 18 | return isinstance(s, str) 19 | 20 | return isinstance(s, unicode) # noqa: F821 21 | 22 | 23 | def to_binary(s): 24 | if is_binary(s): 25 | return s 26 | 27 | if is_text(s): 28 | return s.encode('utf-8', 'strict') 29 | 30 | raise TypeError("expected binary or text (found %s)" % type(s)) 31 | -------------------------------------------------------------------------------- /jacktook.select.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Jacktook", 3 | "plugin": "plugin.video.jacktook", 4 | "priority": 200, 5 | "is_resolvable": "true", 6 | "play_movie": "plugin://plugin.video.jacktook/?action=search&mode=movies&rescrape=True&query={title}&ids=%7B%22tmdb_id%22%3A+%22{tmdb}%22%2C+%22tvdb_id%22%3A+%22{tvdb}%22%2C+%22imdb_id%22%3A+%22{imdb}%22%7D", 7 | "play_episode": "plugin://plugin.video.jacktook/?action=search&mode=tv&rescrape=True&query={showname}&ids=%7B%22tmdb_id%22%3A+%22{tmdb}%22%2C+%22tvdb_id%22%3A+%22{tvdb}%22%2C+%22imdb_id%22%3A+%22{imdb}%22%7D&tv_data=%7B%22name%22%3A+%22{title}%22%2C+%22episode%22%3A+%22{episode}%22%2C+%22season%22%3A+%22{season}%22%7D" 8 | } 9 | 10 | 11 | -------------------------------------------------------------------------------- /lib/clients/trakt/utils.py: -------------------------------------------------------------------------------- 1 | from lib.utils.kodi.utils import ADDON_HANDLE 2 | 3 | from xbmcplugin import addDirectoryItem 4 | 5 | 6 | def add_kodi_dir_item( 7 | list_item, 8 | url, 9 | is_folder, 10 | is_playable=False, 11 | ): 12 | if is_playable: 13 | list_item.setProperty("IsPlayable", "true") 14 | addDirectoryItem(ADDON_HANDLE, url, list_item, isFolder=is_folder) 15 | 16 | 17 | def extract_ids(res, mode="tv"): 18 | ids = res.get("show" if mode == "tv" else "movie", {}).get("ids", {}) 19 | return { 20 | "tmdb_id": ids.get("tmdb"), 21 | "tvdb_id": ids.get("tvdb") if mode == "tv" else None, 22 | "imdb_id": ids.get("imdb"), 23 | } 24 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/keyword.py: -------------------------------------------------------------------------------- 1 | from lib.api.tmdbv3api.tmdb import TMDb 2 | 3 | class Keyword(TMDb): 4 | _urls = { 5 | "details": "/keyword/%s", 6 | "movies": "/keyword/%s/movies" 7 | } 8 | 9 | def details(self, keyword_id): 10 | """ 11 | Get a keywords details by id. 12 | :param keyword_id: int 13 | :return: 14 | """ 15 | return self._request_obj(self._urls["details"] % keyword_id) 16 | 17 | def movies(self, keyword_id): 18 | """ 19 | Get the movies of a keyword by id. 20 | :param keyword_id: int 21 | :return: 22 | """ 23 | return self._request_obj(self._urls["movies"] % keyword_id, key="results") 24 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/certification.py: -------------------------------------------------------------------------------- 1 | from lib.api.tmdbv3api.tmdb import TMDb 2 | 3 | class Certification(TMDb): 4 | _urls = { 5 | "movie_list": "/certification/movie/list", 6 | "tv_list": "/certification/tv/list", 7 | } 8 | 9 | def movie_list(self): 10 | """ 11 | Get an up to date list of the officially supported movie certifications on TMDB. 12 | :return: 13 | """ 14 | return self._request_obj(self._urls["movie_list"], key="certifications") 15 | 16 | def tv_list(self): 17 | """ 18 | Get an up to date list of the officially supported TV show certifications on TMDB. 19 | :return: 20 | """ 21 | return self._request_obj(self._urls["tv_list"], key="certifications") 22 | -------------------------------------------------------------------------------- /lib/clients/anizip.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | class AniZipApi: 5 | base_url = "https://api.ani.zip/" 6 | 7 | def mapping(self, anilist_id): 8 | params = {"anilist_id": anilist_id} 9 | res = self.make_request(url="mappings", params=params) 10 | if res: 11 | return res["mappings"] 12 | 13 | def episodes(self, anilist_id): 14 | params = {"anilist_id": anilist_id} 15 | res = self.make_request(url="mappings", params=params) 16 | if res: 17 | return res["episodes"] 18 | 19 | def make_request(self, url, params): 20 | res = requests.get( 21 | self.base_url + url, 22 | params=params, 23 | ) 24 | if res.status_code == 200: 25 | return res.json() 26 | -------------------------------------------------------------------------------- /lib/domain/torrent.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import List 3 | 4 | 5 | @dataclass 6 | class TorrentStream: 7 | # Identification 8 | title: str = "" 9 | type: str = "" # e.g., "torrent" or "debrid" or "direct" 10 | debridType: str = "" 11 | indexer: str = "" 12 | subindexer: str = "" 13 | guid: str = "" 14 | infoHash: str = "" 15 | 16 | # Stats 17 | size: int = 0 # in bytes 18 | seeders: int = 0 19 | peers: int = 0 20 | 21 | # Language info 22 | languages: List[str] = field(default_factory=list) 23 | fullLanguages: str = "" 24 | 25 | # Source info 26 | provider: str = "" 27 | publishDate: str = "" # ISO format preferred 28 | 29 | # Quality and URLs 30 | quality: str = "N/A" 31 | url: str = "" 32 | 33 | # Flags 34 | isPack: bool = False 35 | isCached: bool = False 36 | 37 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/__init__.py: -------------------------------------------------------------------------------- 1 | from .objs.account import Account 2 | from .objs.auth import Authentication 3 | from .objs.certification import Certification 4 | from .objs.change import Change 5 | from .objs.collection import Collection 6 | from .objs.company import Company 7 | from .objs.configuration import Configuration 8 | from .objs.credit import Credit 9 | from .objs.discover import Discover 10 | from .objs.episode import Episode 11 | from .objs.find import Find 12 | from .objs.genre import Genre 13 | from .objs.group import Group 14 | from .objs.keyword import Keyword 15 | from .objs.list import List 16 | from .objs.movie import Movie 17 | from .objs.network import Network 18 | from .objs.person import Person 19 | from .objs.provider import Provider 20 | from .objs.review import Review 21 | from .objs.search import Search 22 | from .objs.season import Season 23 | from .objs.trending import Trending 24 | from .objs.tv import TV 25 | from .tmdb import TMDb 26 | -------------------------------------------------------------------------------- /lib/vendor/torf/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of torf. 2 | # 3 | # torf is free software: you can redistribute it and/or modify it under the 4 | # terms of the GNU General Public License as published by the Free Software 5 | # Foundation, either version 3 of the License, or (at your option) any later 6 | # version. 7 | # 8 | # torf is distributed in the hope that it will be useful, but WITHOUT ANY 9 | # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 10 | # A PARTICULAR PURPOSE. See the GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with torf. If not, see . 14 | 15 | # flake8: noqa 16 | 17 | """ 18 | Create and parse torrent files and magnet URIs 19 | """ 20 | 21 | __version__ = '4.2.0' 22 | 23 | from ._errors import * 24 | from ._magnet import Magnet 25 | from ._stream import TorrentFileStream 26 | from ._torrent import Torrent 27 | from ._utils import File, Filepath 28 | -------------------------------------------------------------------------------- /lib/gui/filter_items_window.py: -------------------------------------------------------------------------------- 1 | import xbmcgui 2 | from lib.gui.base_window import BaseWindow 3 | 4 | class FilterWindow(BaseWindow): 5 | def __init__(self, xml_file, location, filter): 6 | super().__init__(xml_file, location, None) 7 | self.filter = filter 8 | self.selected_filter = None 9 | 10 | def onInit(self): 11 | self.list_control = self.getControl(2000) 12 | self.list_control.reset() 13 | 14 | for q in self.filter: 15 | item = xbmcgui.ListItem(label=q) 16 | self.list_control.addItem(item) 17 | 18 | self.set_default_focus(self.list_control, 2000, control_list_reset=True) 19 | 20 | 21 | def handle_action(self, action_id, control_id=None): 22 | if action_id == 7: # Select 23 | pos = self.list_control.getSelectedPosition() 24 | if pos < len(self.filter): 25 | self.selected_filter = self.filter[pos] 26 | self.close() 27 | elif action_id in (2, 9, 10, 13, 92): # Back/Escape 28 | self.close() -------------------------------------------------------------------------------- /lib/gui/resume_window.py: -------------------------------------------------------------------------------- 1 | from lib.gui.base_window import BaseWindow 2 | 3 | class ResumeDialog(BaseWindow): 4 | def __init__(self, xml_file, xml_location, **kwargs): 5 | super().__init__(xml_file, xml_location) 6 | self.resume = None 7 | self.resume_percent = kwargs.get("resume_percent", 0.0) 8 | 9 | def doModal(self): 10 | super().doModal() 11 | 12 | def onInit(self): 13 | super().onInit() 14 | self.getControl(1002).setLabel( 15 | f"Resume from {self.resume_percent:.1f}%" 16 | ) # Resume Button 17 | self.getControl(1003).setLabel("Start from Beginning") # Start Button 18 | 19 | def onClick(self, control_id): 20 | if control_id == 1002: # Resume button ID 21 | self.resume = True 22 | elif control_id == 1003: # Start button ID 23 | self.resume = False 24 | self.close() 25 | 26 | def handle_action(self, action_id, control_id=None): 27 | if action_id in (10, 92): # Back or Escape 28 | self.resume = None 29 | self.close() 30 | -------------------------------------------------------------------------------- /lib/jacktook/listener.py: -------------------------------------------------------------------------------- 1 | from .utils import NoProvidersError 2 | from .provider_base import ProviderListener 3 | from lib.utils.kodi.utils import ADDON_NAME 4 | from xbmcgui import DialogProgressBG 5 | 6 | 7 | class ProviderListenerDialog(ProviderListener): 8 | def __init__(self, providers, method, timeout=10): 9 | super(ProviderListenerDialog, self).__init__(providers, method, timeout=timeout) 10 | self._total = len(providers) 11 | self._count = 0 12 | self._dialog = DialogProgressBG() 13 | 14 | def on_receive(self, sender): 15 | self._count += 1 16 | self._dialog.update(int(100 * self._count / self._total)) 17 | 18 | def __enter__(self): 19 | ret = super(ProviderListenerDialog, self).__enter__() 20 | self._dialog.create(ADDON_NAME, "Getting results from providers...") 21 | return ret 22 | 23 | def __exit__(self, exc_type, exc_val, exc_tb): 24 | try: 25 | return super(ProviderListenerDialog, self).__exit__( 26 | exc_type, exc_val, exc_tb 27 | ) 28 | finally: 29 | self._dialog.close() 30 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/collection.py: -------------------------------------------------------------------------------- 1 | from lib.api.tmdbv3api.tmdb import TMDb 2 | 3 | 4 | class Collection(TMDb): 5 | _urls = { 6 | "details": "/collection/%s", 7 | "images": "/collection/%s/images", 8 | "translations": "/collection/%s/translations" 9 | } 10 | 11 | def details(self, collection_id): 12 | """ 13 | Get collection details by id. 14 | :param collection_id: int 15 | :return: 16 | """ 17 | return self._request_obj(self._urls["details"] % collection_id, key="parts") 18 | 19 | def images(self, collection_id): 20 | """ 21 | Get the images for a collection by id. 22 | :param collection_id: int 23 | :return: 24 | """ 25 | return self._request_obj(self._urls["images"] % collection_id, add_lang=False) 26 | 27 | def translations(self, collection_id): 28 | """ 29 | Get the list translations for a collection by id. 30 | :param collection_id: int 31 | :return: 32 | """ 33 | return self._request_obj(self._urls["translations"] % collection_id, key="translations") 34 | -------------------------------------------------------------------------------- /addon.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | video 10 | 11 | 12 | 13 | 14 | jacktook 15 | Kodi addon for torrent streaming 16 | GPL-2.0-only 17 | https://github.com/Sam-Max/plugin.video.jacktook 18 | 19 | icon.png 20 | fanart.png 21 | resources/screenshots/home.png 22 | resources/screenshots/tv.png 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/network.py: -------------------------------------------------------------------------------- 1 | from lib.api.tmdbv3api.tmdb import TMDb 2 | 3 | 4 | class Network(TMDb): 5 | _urls = { 6 | "details": "/network/%s", 7 | "alternative_names": "/network/%s/alternative_names", 8 | "images": "/network/%s/images" 9 | } 10 | 11 | def details(self, network_id): 12 | """ 13 | Get a networks details by id. 14 | :param network_id: int 15 | :return: 16 | """ 17 | return self._request_obj(self._urls["details"] % network_id) 18 | 19 | def alternative_names(self, network_id): 20 | """ 21 | Get the alternative names of a network. 22 | :param network_id: int 23 | :return: 24 | """ 25 | return self._request_obj( 26 | self._urls["alternative_names"] % network_id, 27 | key="results" 28 | ) 29 | 30 | def images(self, network_id): 31 | """ 32 | Get the TV network logos by id. 33 | :param network_id: int 34 | :return: 35 | """ 36 | return self._request_obj( 37 | self._urls["images"] % network_id, 38 | key="logos" 39 | ) 40 | -------------------------------------------------------------------------------- /lib/clients/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from requests import Session 3 | from typing import Any, List, Optional, Callable 4 | from lib.domain.torrent import TorrentStream 5 | from lib.utils.kodi.utils import notification 6 | 7 | 8 | class BaseClient(ABC): 9 | def __init__(self, host: Optional[str], notification: Optional[Callable]) -> None: 10 | self.host = host.rstrip("/") if host else "" 11 | self.notification = notification 12 | self.session = Session() 13 | 14 | @abstractmethod 15 | def search( 16 | self, 17 | tmdb_id: str, 18 | query: str, 19 | mode: str, 20 | media_type: str, 21 | season: Optional[int], 22 | episode: Optional[int], 23 | ) -> List[TorrentStream]: 24 | pass 25 | 26 | @abstractmethod 27 | def parse_response(self, res: Any) -> List[TorrentStream]: 28 | pass 29 | 30 | def handle_exception(self, exception: str) -> None: 31 | exception_message = str(exception) 32 | if len(exception_message) > 70: 33 | exception_message = exception_message[:70] + "..." 34 | notification(exception_message) 35 | -------------------------------------------------------------------------------- /lib/utils/kodi/settings.py: -------------------------------------------------------------------------------- 1 | from .utils import get_setting, get_property, ADDON_ID 2 | import xbmc 3 | 4 | 5 | EMPTY_USER = "unknown_user" 6 | 7 | 8 | def addon_settings(): 9 | return xbmc.executebuiltin(f"Addon.OpenSettings({ADDON_ID})") 10 | 11 | 12 | def auto_play_enabled(): 13 | return get_setting("auto_play") 14 | 15 | 16 | def get_int_setting(setting): 17 | return int(get_setting(setting)) 18 | 19 | 20 | def update_delay(default=45): 21 | return default 22 | 23 | 24 | def is_cache_enabled(): 25 | return get_setting("cache_enabled") 26 | 27 | 28 | def cache_clear_update(): 29 | return get_setting("clear_cache_update") 30 | 31 | 32 | def get_cache_expiration(): 33 | return get_int_setting("cache_expiration") 34 | 35 | 36 | def get_jackett_timeout(): 37 | return get_int_setting("jackett_timeout") 38 | 39 | 40 | def get_prowlarr_timeout(): 41 | return get_int_setting("prowlarr_timeout") 42 | 43 | 44 | def trakt_client(): 45 | return get_setting("trakt_client", "") 46 | 47 | 48 | def trakt_secret(): 49 | return get_setting("trakt_secret", "") 50 | 51 | 52 | def trakt_lists_sort_order(setting): 53 | return int(get_setting("trakt_sort_%s" % setting, "0")) 54 | -------------------------------------------------------------------------------- /lib/vendor/bencodepy/bencode/__init__.py: -------------------------------------------------------------------------------- 1 | # The contents of this file are subject to the BitTorrent Open Source License 2 | # Version 1.1 (the License). You may not copy or use this file, in either 3 | # source code or executable form, except in compliance with the License. You 4 | # may obtain a copy of the License at http://www.bittorrent.com/license/. 5 | # 6 | # Software distributed under the License is distributed on an AS IS basis, 7 | # WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License 8 | # for the specific language governing rights and limitations under the 9 | # License. 10 | 11 | # Written by Petru Paler 12 | 13 | """bencode.py - bencode encoder + decoder.""" 14 | 15 | from bencode.BTL import BTFailure 16 | from bencode.exceptions import BencodeDecodeError 17 | 18 | from bencodepy import Bencached, Bencode 19 | 20 | __all__ = ( 21 | 'BTFailure', 22 | 'Bencached', 23 | 'BencodeDecodeError', 24 | 'bencode', 25 | 'bdecode', 26 | 'bread', 27 | 'bwrite', 28 | 'encode', 29 | 'decode' 30 | ) 31 | 32 | 33 | DEFAULT = Bencode( 34 | encoding='utf-8', 35 | encoding_fallback='value', 36 | dict_ordered=True, 37 | dict_ordered_sort=True 38 | ) 39 | 40 | bdecode = DEFAULT.decode 41 | bencode = DEFAULT.encode 42 | bread = DEFAULT.read 43 | bwrite = DEFAULT.write 44 | 45 | decode = bdecode 46 | encode = bencode 47 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/provider.py: -------------------------------------------------------------------------------- 1 | from lib.api.tmdbv3api.tmdb import TMDb 2 | 3 | 4 | class Provider(TMDb): 5 | _urls = { 6 | "regions": "/watch/providers/regions", # TODO: 7 | "movie": "/watch/providers/movie", # TODO: 8 | "tv": "/watch/providers/tv", # TODO: 9 | } 10 | 11 | def available_regions(self): 12 | """ 13 | Returns a list of all of the countries we have watch provider (OTT/streaming) data for. 14 | :return: 15 | """ 16 | return self._request_obj( 17 | self._urls["regions"], 18 | key="results" 19 | ) 20 | 21 | def movie_providers(self, region=None): 22 | """ 23 | Returns a list of the watch provider (OTT/streaming) data we have available for movies. 24 | :return: 25 | """ 26 | return self._request_obj( 27 | self._urls["movie"], 28 | params="watch_region=%s" % region if region else "", 29 | key="results" 30 | ) 31 | 32 | def tv_providers(self, region=None): 33 | """ 34 | Returns a list of the watch provider (OTT/streaming) data we have available for TV series. 35 | :return: 36 | """ 37 | return self._request_obj( 38 | self._urls["tv"], 39 | params="watch_region=%s" % region if region else "", 40 | key="results" 41 | ) 42 | -------------------------------------------------------------------------------- /lib/gui/custom_progress.py: -------------------------------------------------------------------------------- 1 | from lib.utils.kodi.utils import kodilog 2 | import xbmcgui 3 | 4 | 5 | class CustomProgressDialog(xbmcgui.WindowXMLDialog): 6 | def __init__(self, xml_file: str, location: str): 7 | super().__init__(xml_file, location) 8 | self.title = "Downloading" 9 | self.message = "Please wait..." 10 | self.progress = 0 11 | self.cancelled = False 12 | 13 | def show_dialog(self): 14 | self.show() 15 | self.onInit() 16 | 17 | def close_dialog(self): 18 | self.close() 19 | 20 | def onInit(self): 21 | self.getControl(12001).setLabel(self.title) 22 | self.getControl(12002).setLabel(self.message) 23 | self.getControl(12004).setPercent(self.progress) 24 | self.setFocusId(12003) 25 | 26 | def update_progress(self, percent, message=None): 27 | self.progress = percent 28 | self.getControl(12004).setPercent(percent) 29 | if message: 30 | self.getControl(12002).setLabel(message) 31 | 32 | def onClick(self, controlId): 33 | if controlId == 12003: # Cancel button 34 | self.cancelled = True 35 | self.close_dialog() 36 | elif controlId == 12005: # Close button 37 | self.close_dialog() 38 | 39 | def onAction(self, action): 40 | if action.getId() in (10, 92): 41 | self.close_dialog() 42 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/company.py: -------------------------------------------------------------------------------- 1 | from lib.api.tmdbv3api.tmdb import TMDb 2 | 3 | 4 | class Company(TMDb): 5 | _urls = { 6 | "details": "/company/%s", 7 | "alternative_names": "/company/%s/alternative_names", 8 | "images": "/company/%s/images", 9 | "movies": "/company/%s/movies" 10 | } 11 | 12 | def details(self, company_id): 13 | """ 14 | Get a companies details by id. 15 | :param company_id: int 16 | :return: 17 | """ 18 | return self._request_obj(self._urls["details"] % company_id) 19 | 20 | def alternative_names(self, company_id): 21 | """ 22 | Get the alternative names of a company. 23 | :param company_id: int 24 | :return: 25 | """ 26 | return self._request_obj(self._urls["alternative_names"] % company_id, key="results") 27 | 28 | def images(self, company_id): 29 | """ 30 | Get the alternative names of a company. 31 | :param company_id: int 32 | :return: 33 | """ 34 | return self._request_obj(self._urls["images"] % company_id, key="logos") 35 | 36 | def movies(self, company_id, page=1): 37 | """ 38 | Get the movies of a company by id. 39 | :param company_id: int 40 | :param page: int 41 | :return: 42 | """ 43 | return self._request_obj( 44 | self._urls["movies"] % company_id, 45 | params="page=%s" % page, 46 | key="results" 47 | ) 48 | -------------------------------------------------------------------------------- /lib/api/trakt/lists_cache.py: -------------------------------------------------------------------------------- 1 | from lib.api.trakt.base_cache import BaseCache, get_timestamp 2 | 3 | 4 | GET_ALL = "SELECT id FROM lists" 5 | DELETE_ALL = "DELETE FROM lists" 6 | CLEAN = "DELETE from lists WHERE CAST(expires AS INT) <= ?" 7 | 8 | 9 | class ListsCache(BaseCache): 10 | def __init__(self): 11 | BaseCache.__init__(self, "lists_db", "lists") 12 | 13 | def delete_all_lists(self): 14 | try: 15 | dbcon = self.manual_connect("lists_db") 16 | for i in dbcon.execute(GET_ALL): 17 | self.delete_memory_cache(str(i[0])) 18 | dbcon.execute(DELETE_ALL) 19 | dbcon.execute("VACUUM") 20 | return True 21 | except: 22 | return False 23 | 24 | def clean_database(self): 25 | try: 26 | dbcon = self.manual_connect("lists_db") 27 | dbcon.execute(CLEAN, (get_timestamp(),)) 28 | dbcon.execute("VACUUM") 29 | return True 30 | except: 31 | return False 32 | 33 | 34 | lists_cache = ListsCache() 35 | 36 | 37 | def lists_cache_object(function, string, args, json=False, expiration=48): 38 | cache = lists_cache.get(string) 39 | if cache is not None: 40 | return cache 41 | if isinstance(args, list): 42 | args = tuple(args) 43 | else: 44 | args = (args,) 45 | if json: 46 | result = function(*args).json() 47 | else: 48 | result = function(*args) 49 | lists_cache.set(string, result, expiration=expiration) 50 | return result 51 | -------------------------------------------------------------------------------- /lib/utils/stremio/catalogs_utils.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from lib.clients.stremio.stremio import StremioAddonCatalogsClient 3 | from lib.db.cached import cache 4 | from lib.utils.kodi.utils import kodilog 5 | from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled 6 | 7 | 8 | def catalogs_get_cache(path, params, *args, **kwargs): 9 | identifier = f"{path}{params}{args}" 10 | data = cache.get(identifier) 11 | if data: 12 | return data 13 | 14 | handlers = { 15 | "search_catalog": lambda p: StremioAddonCatalogsClient(params).search_catalog(p), 16 | "list_catalog": lambda p: StremioAddonCatalogsClient(params).get_catalog_info( 17 | p 18 | ), 19 | "list_stremio_seasons": lambda: StremioAddonCatalogsClient( 20 | params 21 | ).get_meta_info(), 22 | "list_stremio_episodes": lambda: StremioAddonCatalogsClient( 23 | params 24 | ).get_meta_info(), 25 | "list_stremio_tv": lambda: StremioAddonCatalogsClient(params).get_stream_info(), 26 | } 27 | 28 | try: 29 | handler = handlers.get(path, lambda: None) 30 | if args or kwargs: 31 | data = handler(*args, **kwargs) 32 | else: 33 | data = handler() 34 | except Exception as e: 35 | kodilog(f"Error: {e}") 36 | return {} 37 | 38 | if data is not None: 39 | cache.set( 40 | identifier, 41 | data, 42 | timedelta(hours=get_cache_expiration() if is_cache_enabled() else 0), 43 | ) 44 | 45 | return data 46 | -------------------------------------------------------------------------------- /lib/clients/plex.py: -------------------------------------------------------------------------------- 1 | from lib.api.plex.media_server import convert_to_plex_id, get_media 2 | 3 | 4 | class Plex: 5 | def __init__(self, discovery_url, auth_token, access_token, notification): 6 | self.discovery_url = discovery_url 7 | self.auth_token = auth_token 8 | self.access_token = access_token 9 | self._notification = notification 10 | 11 | def search(self, imdb_id, mode, media_type, season, episode): 12 | plex_id = convert_to_plex_id( 13 | url=self.discovery_url, 14 | access_token=self.access_token, 15 | auth_token=self.auth_token, 16 | id=imdb_id, 17 | mode=mode, 18 | media_type=media_type, 19 | season=season, 20 | episode=episode, 21 | ) 22 | 23 | if not plex_id: 24 | return 25 | 26 | media = get_media( 27 | url=self.discovery_url, 28 | token=self.access_token, 29 | guid=plex_id, 30 | ) 31 | 32 | streams = [meta.get_streams() for meta in media] 33 | return self.parse_response(streams) 34 | 35 | def parse_response(self, streams): 36 | results = [] 37 | for stream in streams: 38 | for item in stream: 39 | results.append( 40 | { 41 | "title": item["title"], 42 | "indexer": item["indexer"], 43 | "downloadUrl": item["url"], 44 | "publishDate": "", 45 | } 46 | ) 47 | return results 48 | -------------------------------------------------------------------------------- /lib/gui/source_pack_window.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import xbmcgui 3 | from lib.gui.base_window import BaseWindow 4 | 5 | 6 | class SourcePackWindow(BaseWindow): 7 | def __init__(self, xml_file, location, source=None, pack_info=None, item_information=None): 8 | super().__init__(xml_file, location, item_information=item_information) 9 | self.pack_info = pack_info or {"files": []} 10 | self.source = source or type("EmptySource", (), {"type": "", "quality": ""})() 11 | self.display_list = None 12 | 13 | def onInit(self): 14 | self.display_list = self.getControlList(1000) 15 | self.populate_sources_list() 16 | self.set_default_focus(self.display_list, 1000, control_list_reset=True) 17 | super().onInit() 18 | 19 | def populate_sources_list(self): 20 | self.display_list.reset() 21 | 22 | files = self.pack_info.get("files", []) 23 | if files: 24 | for _, title in files: 25 | menu_item = xbmcgui.ListItem(label=title) 26 | menu_item.setProperty("title", title or "") 27 | menu_item.setProperty("type", getattr(self.source, "type", "") or "") 28 | menu_item.setProperty("quality", getattr(self.source, "quality", "") or "") 29 | self.display_list.addItem(menu_item) 30 | else: 31 | self.setProperty("resolving", "false") 32 | self.close() 33 | 34 | @abc.abstractmethod 35 | def handle_action(self, action_id, control_id=None): 36 | """Subclasses must implement this to handle user actions.""" 37 | pass 38 | -------------------------------------------------------------------------------- /lib/jacktook/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import xbmc 3 | import xbmcaddon 4 | 5 | 6 | ADDON = xbmcaddon.Addon() 7 | ADDON_PATH = ADDON.getAddonInfo("path") 8 | ADDON_ID = ADDON.getAddonInfo("id") 9 | ADDON_VERSION = ADDON.getAddonInfo("version") 10 | ADDON_NAME = ADDON.getAddonInfo("name") 11 | 12 | 13 | def kodilog(message, level=xbmc.LOGINFO): 14 | xbmc.log("[###JACKTOOKLOG###] " + str(message), level) 15 | 16 | 17 | def get_installed_addons(addon_type="", content="unknown", enabled="all"): 18 | data = execute_json_rpc( 19 | "Addons.GetAddons", type=addon_type, content=content, enabled=enabled 20 | ) 21 | addons = data["result"].get("addons") 22 | return [(a["addonid"], a["type"]) for a in addons] if addons else [] 23 | 24 | 25 | def notify_all(sender, message, data=None): 26 | params = {"sender": sender, "message": message} 27 | if data is not None: 28 | params["data"] = data 29 | return execute_json_rpc("JSONRPC.NotifyAll", **params).get("result") == "OK" 30 | 31 | 32 | def run_script(script_id, *args): 33 | xbmc.executebuiltin("RunScript({})".format(",".join((script_id,) + args))) 34 | 35 | 36 | def execute_json_rpc(method, rpc_version="2.0", rpc_id=1, **params): 37 | return json.loads( 38 | xbmc.executeJSONRPC( 39 | json.dumps( 40 | dict(jsonrpc=rpc_version, method=method, params=params, id=rpc_id) 41 | ) 42 | ) 43 | ) 44 | 45 | 46 | 47 | class ResolveTimeoutError(Exception): 48 | pass 49 | 50 | 51 | class NoProvidersError(Exception): 52 | pass 53 | 54 | 55 | def assure_str(s): 56 | return s 57 | 58 | 59 | def str_to_bytes(s): 60 | return s.encode() 61 | 62 | 63 | def bytes_to_str(b): 64 | return b.decode() 65 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/discover.py: -------------------------------------------------------------------------------- 1 | from lib.api.tmdbv3api.tmdb import TMDb 2 | from lib.api.tmdbv3api.utils import get_dates 3 | 4 | try: 5 | from urllib import urlencode 6 | except ImportError: 7 | from urllib.parse import urlencode 8 | 9 | 10 | class Discover(TMDb): 11 | _urls = {"movies": "/discover/movie", "tv": "/discover/tv"} 12 | 13 | def discover_movies(self, params): 14 | """ 15 | Discover movies by different types of data like average rating, number of votes, genres and certifications. 16 | :param params: dict 17 | :return: 18 | """ 19 | return self._request_obj(self._urls["movies"], urlencode(params), key="results") 20 | 21 | def discover_tv_shows(self, params): 22 | """ 23 | Discover TV shows by different types of data like average rating, number of votes, genres, 24 | the network they aired on and air dates. 25 | :param params: dict 26 | :return: 27 | """ 28 | return self._request_obj(self._urls["tv"], urlencode(params), key="results") 29 | 30 | def discover_tv_calendar(self, page): 31 | # TMDb network IDs for major streaming platforms 32 | # Netflix: 213, Amazon: 1024, Disney+: 2739, Hulu: 453, Apple TV+: 2552, HBO Max: 3186 33 | network_ids = "213,1024,2739,453,2552,3186" 34 | current_date, future_date = get_dates(7, reverse=False) 35 | params = ( 36 | "&with_original_language=en" 37 | "&air_date.gte=%s" 38 | "&air_date.lte=%s" 39 | "&with_networks=%s" 40 | "&page=%s" % (current_date, future_date, network_ids, page) 41 | ) 42 | return self._request_obj( 43 | self._urls["tv"], 44 | params=params, 45 | ) 46 | -------------------------------------------------------------------------------- /lib/jacktook/providers.py: -------------------------------------------------------------------------------- 1 | from lib.jacktook.provider_base import ( 2 | ProviderResult, 3 | get_providers, 4 | send_to_providers, 5 | ) 6 | from lib.jacktook.listener import ProviderListenerDialog 7 | from lib.jacktook.utils import NoProvidersError 8 | from lib.utils.kodi.utils import kodilog 9 | 10 | 11 | def burst_search(query): 12 | return search("search", query) 13 | 14 | 15 | def burst_search_movie(movie_id, query): 16 | return search("search_movie", movie_id, query) 17 | 18 | 19 | def burst_search_show(show_id, query): 20 | return search("search_show", show_id, query) 21 | 22 | 23 | def burst_search_season(show_id, show_title, season_number): 24 | return search("search_season", show_id, show_title, int(season_number)) 25 | 26 | 27 | def burst_search_episode(show_id, query, season_number, episode_number): 28 | return search( 29 | "search_episode", show_id, query, int(season_number), int(episode_number) 30 | ) 31 | 32 | 33 | def search(method, *args, **kwargs): 34 | providers = get_providers() 35 | if not providers: 36 | raise NoProvidersError("No providers available") 37 | with ProviderListenerDialog(providers, method, timeout=30) as listener: 38 | send_to_providers(providers, method, *args, **kwargs) 39 | results = [] 40 | for provider, provider_results in listener.data.items(): 41 | if not isinstance(provider_results, (tuple, list)): 42 | kodilog(f"Expecting list/tuple from {provider}:{method}") 43 | continue 44 | for provider_result in provider_results: 45 | try: 46 | results.append(ProviderResult(provider_result)) 47 | except Exception as e: 48 | kodilog(f"Invalid result from {provider}: {e}") 49 | if not results: 50 | raise Exception("No results found") 51 | return results 52 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/configuration.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from lib.api.tmdbv3api.tmdb import TMDb 3 | 4 | 5 | class Configuration(TMDb): 6 | _urls = { 7 | "api_configuration": "/configuration", 8 | "countries": "/configuration/countries", 9 | "jobs": "/configuration/jobs", 10 | "languages": "/configuration/languages", 11 | "primary_translations": "/configuration/primary_translations", 12 | "timezones": "/configuration/timezones" 13 | } 14 | 15 | def info(self): 16 | warnings.warn("info method is deprecated use tmdbv3api.Configuration().api_configuration()", 17 | DeprecationWarning) 18 | return self.api_configuration() 19 | 20 | def api_configuration(self): 21 | """ 22 | Get the system wide configuration info. 23 | """ 24 | return self._request_obj(self._urls["api_configuration"]) 25 | 26 | def countries(self): 27 | """ 28 | Get the list of countries (ISO 3166-1 tags) used throughout TMDb. 29 | """ 30 | return self._request_obj(self._urls["countries"]) 31 | 32 | def jobs(self): 33 | """ 34 | Get a list of the jobs and departments we use on TMDb. 35 | """ 36 | return self._request_obj(self._urls["jobs"]) 37 | 38 | def languages(self): 39 | """ 40 | Get the list of languages (ISO 639-1 tags) used throughout TMDb. 41 | """ 42 | return self._request_obj(self._urls["languages"]) 43 | 44 | def primary_translations(self): 45 | """ 46 | Get a list of the officially supported translations on TMDb. 47 | """ 48 | return self._request_obj(self._urls["primary_translations"]) 49 | 50 | def timezones(self): 51 | """ 52 | Get the list of timezones used throughout TMDb. 53 | """ 54 | return self._request_obj(self._urls["timezones"]) 55 | -------------------------------------------------------------------------------- /lib/utils/views/last_files.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from lib.db.pickle_db import PickleDatabase 4 | from lib.utils.general.utils import parse_time, set_pluging_category 5 | from lib.utils.kodi.utils import ADDON_HANDLE, ADDON_PATH, build_url, end_of_directory, translation 6 | 7 | from xbmcgui import ListItem 8 | from xbmcplugin import addDirectoryItem 9 | 10 | 11 | pickle_db = PickleDatabase() 12 | 13 | 14 | def show_last_files(): 15 | set_pluging_category(translation(90071)) 16 | 17 | list_item = ListItem(label="Clear Files") 18 | list_item.setArt( 19 | {"icon": os.path.join(ADDON_PATH, "resources", "img", "clear.png")} 20 | ) 21 | addDirectoryItem( 22 | ADDON_HANDLE, 23 | build_url("clear_history", type="lfh"), 24 | list_item, 25 | ) 26 | 27 | all_items = list(reversed(pickle_db.get_key("jt:lfh").items())) 28 | 29 | items = sorted(all_items, key=parse_time, reverse=True) 30 | 31 | for title, data in items: 32 | formatted_time = data["timestamp"] 33 | tv_data = data.get("tv_data", {}) 34 | if tv_data: 35 | season = tv_data.get("season") 36 | episode = tv_data.get("episode") 37 | name = tv_data.get("name", "") 38 | label = f"{title} S{season:02d}E{episode:02d} - {name} — {formatted_time}" 39 | else: 40 | label = f"{title}—{formatted_time}" 41 | 42 | list_item = ListItem(label=label) 43 | list_item.setArt( 44 | {"icon": os.path.join(ADDON_PATH, "resources", "img", "magnet.png")} 45 | ) 46 | list_item.setProperty("IsPlayable", "true") 47 | 48 | addDirectoryItem( 49 | ADDON_HANDLE, 50 | build_url( 51 | "play_torrent", 52 | data=json.dumps(data), 53 | ), 54 | list_item, 55 | False, 56 | ) 57 | end_of_directory() 58 | -------------------------------------------------------------------------------- /lib/clients/simkl.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | 4 | class SIMKL: 5 | def __init__(self): 6 | self.ClientID = ( 7 | "59dfdc579d244e1edf6f89874d521d37a69a95a1abd349910cb056a1872ba2c8" 8 | ) 9 | self.base_url = "https://api.simkl.com/" 10 | self.imagePath = "https://wsrv.nl/?url=https://simkl.in/episodes/%s_w.webp" 11 | 12 | def _to_url(self, url=""): 13 | if url.startswith("/"): 14 | url = url[1:] 15 | return "%s/%s" % (self.base_url[:-1], url) 16 | 17 | def make_request(self, endpoint, params): 18 | res = requests.get( 19 | f"{self.base_url}{endpoint}", 20 | params=params, 21 | ) 22 | if res.status_code == 200: 23 | return json.loads(res.text) 24 | else: 25 | raise Exception(f"Simkl Error::{res.text}") 26 | 27 | def get_anime_info(self, anilist_id): 28 | _, simkl_id = self.get_simkl_id("anilist", anilist_id) 29 | params = {"extended": "full", "client_id": self.ClientID} 30 | return self.make_request("anime/" + str(simkl_id), params=params) 31 | 32 | def get_anilist_episodes(self, mal_id): 33 | _, simkl_id = self.get_simkl_id("mal", mal_id) 34 | params = { 35 | "extended": "full", 36 | } 37 | return self.make_request("anime/episodes/" + str(simkl_id), params=params) 38 | 39 | def get_simkl_id(self, send_id, anime_id): 40 | params = { 41 | send_id: anime_id, 42 | "client_id": self.ClientID, 43 | } 44 | res = self.make_request("search/id", params=params) 45 | simkl = res[0]["ids"]["simkl"] 46 | return simkl 47 | 48 | def get_mapping_ids(self, send_id, anime_id): 49 | simkl_id = self.get_simkl_id(send_id, anime_id) 50 | params = {"extended": "full", "client_id": self.ClientID} 51 | res = self.make_request("anime/" + str(simkl_id), params=params) 52 | return res["ids"] 53 | -------------------------------------------------------------------------------- /lib/db/anime.py: -------------------------------------------------------------------------------- 1 | import os 2 | import threading 3 | from sqlite3 import dbapi2 as db 4 | from lib.utils.kodi.utils import notification 5 | 6 | import xbmcaddon 7 | from xbmcvfs import translatePath 8 | 9 | 10 | try: 11 | OTAKU_ADDON = xbmcaddon.Addon("script.otaku.mappings") 12 | TRANSLATEPATH = translatePath 13 | mappingPath = TRANSLATEPATH(OTAKU_ADDON.getAddonInfo("path")) 14 | mappingDB = os.path.join(mappingPath, "resources", "data", "anime_mappings.db") 15 | except: 16 | OTAKU_ADDON = None 17 | 18 | mappingDB_lock = threading.Lock() 19 | 20 | 21 | def get_all_ids(anilist_id): 22 | if OTAKU_ADDON is None: 23 | notification("Otaku (script.otaku.mappings) not found") 24 | return 25 | mappingDB_lock.acquire() 26 | conn = db.connect(mappingDB, timeout=60.0) 27 | conn.row_factory = _dict_factory 28 | conn.execute("PRAGMA FOREIGN_KEYS = 1") 29 | cursor = conn.cursor() 30 | cursor.execute("SELECT * FROM anime WHERE anilist_id IN ({0})".format(anilist_id)) 31 | mapping = cursor.fetchone() 32 | cursor.close() 33 | try_release_lock(mappingDB_lock) 34 | all_ids = {} 35 | if mapping: 36 | if mapping["thetvdb_id"] is not None: 37 | all_ids.update({"tvdb": str(mapping["thetvdb_id"])}) 38 | if mapping["themoviedb_id"] is not None: 39 | all_ids.update({"tmdb": str(mapping["themoviedb_id"])}) 40 | if mapping["anidb_id"] is not None: 41 | all_ids.update({"anidb": str(mapping["anidb_id"])}) 42 | if mapping["imdb_id"] is not None: 43 | all_ids.update({"imdb": str(mapping["imdb_id"])}) 44 | if mapping["trakt_id"] is not None: 45 | all_ids.update({"trakt": str(mapping["trakt_id"])}) 46 | return all_ids 47 | 48 | 49 | def try_release_lock(lock): 50 | if lock.locked(): 51 | lock.release() 52 | 53 | 54 | def _dict_factory(cursor, row): 55 | d = {} 56 | for idx, col in enumerate(cursor.description): 57 | d[col[0]] = row[idx] 58 | return d 59 | -------------------------------------------------------------------------------- /lib/api/debrid/easydebrid.py: -------------------------------------------------------------------------------- 1 | from lib.api.debrid.base import DebridClient, ProviderException 2 | 3 | 4 | class EasyDebrid(DebridClient): 5 | BASE_URL = "https://easydebrid.com/api/v1" 6 | 7 | def __init__(self, token, user_ip): 8 | self.user_ip = user_ip 9 | super().__init__(token) 10 | 11 | def initialize_headers(self): 12 | self.headers = {"Authorization": f"Bearer {self.token}"} 13 | if self.user_ip: 14 | self.headers["X-Forwarded-For"] = self.user_ip 15 | 16 | def disable_access_token(self): 17 | pass 18 | 19 | def _handle_service_specific_errors(self, error_data: dict, status_code: int): 20 | error_code = error_data.get("error") 21 | if error_code == "Unsupported link for direct download.": 22 | raise ProviderException( 23 | "Unsupported link for direct download.", 24 | ) 25 | 26 | def _make_request( 27 | self, 28 | method: str, 29 | url: str, 30 | data=None, 31 | params=None, 32 | json=None, 33 | is_return_none: bool = False, 34 | is_expected_to_fail: bool = False, 35 | ): 36 | params = params or {} 37 | url = self.BASE_URL + url 38 | return super()._make_request( 39 | method, 40 | url, 41 | data, 42 | params=params, 43 | json=json, 44 | is_return_none=is_return_none, 45 | is_expected_to_fail=is_expected_to_fail, 46 | ) 47 | 48 | def get_torrent_instant_availability(self, urls): 49 | return self._make_request( 50 | "POST", 51 | "/link/lookup", 52 | json={"urls": urls}, 53 | ) 54 | 55 | def create_download_link(self, magnet): 56 | return self._make_request( 57 | "POST", 58 | "/link/generate", 59 | json={"url": magnet}, 60 | ) 61 | 62 | def get_user_info(self): 63 | return self._make_request("GET", "/user/details") 64 | -------------------------------------------------------------------------------- /resources/skins/Default/1080i/resume_dialog.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1002 4 | 5 | 6 | 400 7 | 500 8 | 520 9 | 220 10 | 11 | 12 | 13 | vertical 14 | 150 15 | 15 16 | right 17 | 18 | 19 | 56 20 | auto 21 | font12 22 | 20 23 | ddffffff 24 | eeffffff 25 | ddffffff 26 | circle.png 27 | circle.png 28 | no 29 | 30 | 31 | 32 | 56 33 | auto 34 | font12 35 | 20 36 | DDFFFFFF 37 | EEFFFFFF 38 | DDFFFFFF 39 | circle.png 40 | circle.png 41 | no 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /lib/gui/filter_type_window.py: -------------------------------------------------------------------------------- 1 | import xbmcgui 2 | from lib.gui.base_window import BaseWindow 3 | 4 | 5 | class FilterTypeWindow(BaseWindow): 6 | def __init__(self, xml_file, location): 7 | super().__init__(xml_file, location, None) 8 | self.selected_type = None 9 | 10 | def onInit(self): 11 | self.list_control = self.getControl(2000) 12 | self.list_control.reset() 13 | 14 | quality_item = xbmcgui.ListItem(label="Filter by Quality") 15 | quality_item.setProperty("type", "quality") 16 | self.list_control.addItem(quality_item) 17 | 18 | provider_item = xbmcgui.ListItem(label="Filter by Provider") 19 | provider_item.setProperty("type", "provider") 20 | self.list_control.addItem(provider_item) 21 | 22 | source_item = xbmcgui.ListItem(label="Filter by Source") 23 | source_item.setProperty("type", "indexer") 24 | self.list_control.addItem(source_item) 25 | 26 | language_item = xbmcgui.ListItem(label="Filter by Language") 27 | language_item.setProperty("type", "language") 28 | self.list_control.addItem(language_item) 29 | 30 | reset_item = xbmcgui.ListItem(label="Reset Filter") 31 | reset_item.setProperty("type", "reset") 32 | self.list_control.addItem(reset_item) 33 | 34 | self.set_default_focus(self.list_control, 2000, control_list_reset=True) 35 | 36 | def handle_action(self, action_id, control_id=None): 37 | if action_id == 7: # Select 38 | pos = self.list_control.getSelectedPosition() 39 | if pos == 0: 40 | self.selected_type = "quality" 41 | elif pos == 1: 42 | self.selected_type = "provider" 43 | elif pos == 2: 44 | self.selected_type = "indexer" 45 | elif pos == 3: 46 | self.selected_type = "language" 47 | else: 48 | self.selected_type = None # Reset 49 | self.close() 50 | elif action_id in (2, 9, 10, 13, 92): # Back/Escape 51 | self.close() -------------------------------------------------------------------------------- /lib/clients/peerflix.py: -------------------------------------------------------------------------------- 1 | import re 2 | from lib.clients.base import BaseClient 3 | from lib.utils.general.utils import USER_AGENT_HEADER 4 | from lib.utils.kodi.utils import translation 5 | 6 | 7 | class Peerflix(BaseClient): 8 | def __init__(self, host, notification, language): 9 | super().__init__(host, notification) 10 | self.language = language.lower() 11 | 12 | def search(self, imdb_id, mode, media_type, season, episode): 13 | try: 14 | if mode == "tv" or media_type == "tv": 15 | url = f"{self.host}/language={self.language}/stream/series/{imdb_id}:{season}:{episode}.json" 16 | elif mode == "movies" or media_type == "movies": 17 | url = f"{self.host}/language={self.language}/stream/movie/{imdb_id}.json" 18 | res = self.session.get(url, headers=USER_AGENT_HEADER, timeout=10) 19 | if res.status_code != 200: 20 | return 21 | return self.parse_response(res) 22 | except Exception as e: 23 | self.handle_exception(f"{translation(30234)}: {str(e)}") 24 | 25 | def parse_response(self, res): 26 | res = res.json() 27 | results = [] 28 | for item in res["streams"]: 29 | results.append( 30 | { 31 | "title": item["title"].splitlines()[0], 32 | "type": "Torrent", 33 | "indexer": "Peerflix", 34 | "guid": item["infoHash"], 35 | "infoHash": item["infoHash"], 36 | "size":item["sizebytes"] or 0, 37 | "seeders": item.get("seed", 0) or 0, 38 | "languages": [item["language"]], 39 | "fullLanguages": [item["language"]], 40 | "provider": self.extract_provider(item["title"]), 41 | "publishDate": "", 42 | "peers": 0, 43 | } 44 | ) 45 | return results 46 | 47 | def extract_provider(self, title): 48 | match = re.search(r"🌐.* ([^ \n]+)", title) 49 | return match.group(1) if match else "" 50 | -------------------------------------------------------------------------------- /lib/gui/qr_progress_dialog.py: -------------------------------------------------------------------------------- 1 | import xbmcgui 2 | 3 | 4 | class QRProgressDialog(xbmcgui.WindowXMLDialog): 5 | def __init__( 6 | self, 7 | xml_file: str, 8 | location: str, 9 | ): 10 | super().__init__(xml_file, location) 11 | self.title = "QR Code Authentication" 12 | self.message = "" 13 | self.progress = 0 14 | self.iscanceled = False 15 | self.qr_image_path = "" 16 | 17 | def setup(self, title, qr_code, url, user_code="", debrid_type="", is_debrid=True): 18 | self.title = title 19 | self.qr_image_path = qr_code 20 | if is_debrid: 21 | if debrid_type == "RealDebrid": 22 | self.message = f"Navigate to: https://real-debrid.com/device\n\nEnter the following code: [COLOR seagreen][B]{user_code}[/B][/COLOR]" 23 | else: 24 | self.message = f"Go to:\n[COLOR cyan]{url}[/COLOR]\nEnter code: [COLOR seagreen][B]{user_code}[/B][/COLOR]" 25 | else: 26 | self.message = f"Pastebin Link:\n[COLOR cyan]{url}[/COLOR]" 27 | 28 | def show_dialog(self): 29 | self.show() 30 | self.onInit() 31 | 32 | def close_dialog(self): 33 | self.close() 34 | 35 | def onInit(self): 36 | self.getControl(12001).setLabel(self.title) # Title label 37 | self.getControl(12002).setLabel(self.message) # Message / instructions 38 | self.getControl(12004).setPercent(self.progress) # Progress bar 39 | if self.qr_image_path: 40 | self.getControl(12006).setImage(self.qr_image_path) # QR image 41 | self.setFocusId(12003) # Focus on Close button 42 | 43 | def update_progress(self, percent, message=None): 44 | self.progress = percent 45 | self.getControl(12004).setPercent(percent) 46 | if message: 47 | self.getControl(12002).setLabel(message) 48 | 49 | def onClick(self, controlId): 50 | if controlId == 12003: # Close button 51 | self.iscanceled = True 52 | self.close_dialog() 53 | 54 | def onAction(self, action): 55 | if action.getId() in (10, 92): 56 | self.close_dialog() 57 | -------------------------------------------------------------------------------- /lib/utils/plex/utils.py: -------------------------------------------------------------------------------- 1 | from lib.api.plex.media_server import check_server_connection, get_servers 2 | from lib.api.plex.plex import PlexApi 3 | from lib.utils.kodi.utils import get_setting, kodilog, set_setting 4 | from xbmcgui import Dialog 5 | 6 | 7 | plex = PlexApi() 8 | 9 | server_config = {"discoveryUrl": [], "streamingUrl": []} 10 | 11 | def plex_login(): 12 | success = plex.login() 13 | if success: 14 | user = plex.get_plex_user() 15 | if user: 16 | set_setting("plex_user", user.username) 17 | 18 | 19 | def validate_server(): 20 | servers = get_servers("https://plex.tv/api/v2", get_setting("plex_token")) 21 | if servers: 22 | server_names = [s.name for s in servers] 23 | chosen_server = Dialog().select("Select server", server_names) 24 | if chosen_server < 0: 25 | return 26 | for s in servers: 27 | if s.name == server_names[chosen_server]: 28 | set_setting("plex_server_name", s.name) 29 | set_setting("plex_server_token", s.access_token) 30 | for c in s.connections: 31 | server_config["streamingUrl"].append(c["uri"]) 32 | if c["local"] is not True: 33 | server_config["discoveryUrl"].append(c["uri"]) 34 | break 35 | discovery_test(server_config["discoveryUrl"], get_setting("plex_server_token")) 36 | streaming_test(server_config["streamingUrl"], get_setting("plex_server_token")) 37 | 38 | 39 | def discovery_test(urls, access_token): 40 | kodilog("Making discovery test...") 41 | kodilog(urls) 42 | for url in urls: 43 | success = check_server_connection(url, access_token) 44 | if success: 45 | set_setting("plex_discovery_url", url) 46 | break 47 | 48 | 49 | def streaming_test(urls, access_token): 50 | kodilog("Making streaming test...") 51 | kodilog(urls) 52 | for url in urls: 53 | success = check_server_connection(url, access_token) 54 | if success: 55 | set_setting("plex_streaming_url", url) 56 | break 57 | 58 | def plex_logout(): 59 | plex.logout() -------------------------------------------------------------------------------- /lib/api/tvdbapi/tvdbapi.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from datetime import timedelta 3 | from lib.db.cached import cache 4 | from lib.utils.kodi.settings import is_cache_enabled, get_cache_expiration 5 | 6 | class TVDBAPI: 7 | def __init__(self): 8 | self.headers = {"User-Agent": "TheTVDB v.4 TV Scraper for Kodi"} 9 | self.apiKey = {"apikey": "edae60dc-1b44-4bac-8db7-65c0aaf5258b"} 10 | self.baseUrl = "https://api4.thetvdb.com/v4/" 11 | self.art = {} 12 | self.request_response = None 13 | self.threads = [] 14 | 15 | def get_token(self): 16 | identifier = "tvdb_token" 17 | token = cache.get(identifier) 18 | if token: 19 | return token 20 | else: 21 | res = requests.post(self.baseUrl + "login", json=self.apiKey, headers=self.headers) 22 | data = res.json() 23 | token = data["data"].get("token") 24 | cache.set( 25 | identifier, 26 | token, 27 | timedelta(hours=get_cache_expiration() if is_cache_enabled() else 0), 28 | ) 29 | return token 30 | 31 | def get_request(self, url): 32 | token = self.get_token() 33 | self.headers.update( 34 | { 35 | "Authorization": "Bearer {0}".format(token), 36 | "Accept": "application/json" 37 | } 38 | ) 39 | url = self.baseUrl + url 40 | response = requests.get(url, headers=self.headers) 41 | if response: 42 | response = response.json().get("data") 43 | self.request_response = response 44 | return response 45 | else: 46 | return None 47 | 48 | def get_imdb_id(self, tvdb_id): 49 | imdb_id = None 50 | url = "series/{}/extended".format(tvdb_id) 51 | data = self.get_request(url) 52 | if data: 53 | imdb_id = [x.get("id") for x in data["remoteIds"] if x.get("type") == 2] 54 | return imdb_id[0] if imdb_id else None 55 | 56 | def get_seasons(self, tvdb_id): 57 | url = "seasons/{}/extended".format(tvdb_id) 58 | data = self.get_request(url) 59 | return data 60 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/change.py: -------------------------------------------------------------------------------- 1 | from lib.api.tmdbv3api.tmdb import TMDb 2 | 3 | 4 | class Change(TMDb): 5 | _urls = { 6 | "movie": "/movie/changes", 7 | "tv": "/tv/changes", 8 | "person": "/person/changes" 9 | } 10 | 11 | def _change_list(self, change_type, start_date="", end_date="", page=1): 12 | params = "page=%s" % page 13 | if start_date: 14 | params += "&start_date=%s" % start_date 15 | if end_date: 16 | params += "&end_date=%s" % end_date 17 | return self._request_obj( 18 | self._urls[change_type], 19 | params=params, 20 | key="results" 21 | ) 22 | 23 | def movie_change_list(self, start_date="", end_date="", page=1): 24 | """ 25 | Get the changes for a movie. By default only the last 24 hours are returned. 26 | You can query up to 14 days in a single query by using the start_date and end_date query parameters. 27 | :param start_date: str 28 | :param end_date: str 29 | :param page: int 30 | :return: 31 | """ 32 | return self._change_list("movie", start_date=start_date, end_date=end_date, page=page) 33 | 34 | def tv_change_list(self, start_date="", end_date="", page=1): 35 | """ 36 | Get a list of all of the TV show ids that have been changed in the past 24 hours. 37 | You can query up to 14 days in a single query by using the start_date and end_date query parameters. 38 | :param start_date: str 39 | :param end_date: str 40 | :param page: int 41 | :return: 42 | """ 43 | return self._change_list("tv", start_date=start_date, end_date=end_date, page=page) 44 | 45 | def person_change_list(self, start_date="", end_date="", page=1): 46 | """ 47 | Get a list of all of the person ids that have been changed in the past 24 hours. 48 | You can query up to 14 days in a single query by using the start_date and end_date query parameters. 49 | :param start_date: str 50 | :param end_date: str 51 | :param page: int 52 | :return: 53 | """ 54 | return self._change_list("person", start_date=start_date, end_date=end_date, page=page) 55 | -------------------------------------------------------------------------------- /lib/jacktook/client.py: -------------------------------------------------------------------------------- 1 | from lib.clients.base import BaseClient, TorrentStream 2 | from .providers import ( 3 | burst_search, 4 | burst_search_episode, 5 | burst_search_movie, 6 | burst_search_season, 7 | ) 8 | from lib.utils.kodi.utils import convert_size_to_bytes, get_setting 9 | from typing import List, Optional, Dict, Any, Callable 10 | 11 | 12 | class Burst(BaseClient): 13 | def __init__(self, notification: Callable) -> None: 14 | super().__init__("", notification) 15 | 16 | def search( 17 | self, 18 | tmdb_id: str, 19 | query: str, 20 | mode: str, 21 | media_type: str, 22 | season: Optional[int], 23 | episode: Optional[int], 24 | ) -> Optional[List[TorrentStream]]: 25 | try: 26 | if mode == "tv" or media_type == "tv": 27 | if get_setting("include_season_packs"): 28 | results = burst_search_season(tmdb_id, query, season) 29 | else: 30 | results = burst_search_episode(tmdb_id, query, season, episode) 31 | elif mode == "movies" or media_type == "movies": 32 | results = burst_search_movie(tmdb_id, query) 33 | else: 34 | results = burst_search(query) 35 | if results: 36 | results = self.parse_response(results) 37 | return results 38 | except Exception as e: 39 | self.handle_exception(f"Burst error: {str(e)}") 40 | 41 | def parse_response(self, res: List[Dict[str, Any]]) -> List[TorrentStream]: 42 | results = [] 43 | for r in res: 44 | results.append( 45 | TorrentStream( 46 | title=r.title, 47 | type="Torrent", 48 | indexer="Burst", 49 | guid=r.guid, 50 | infoHash="", 51 | size=convert_size_to_bytes(str(r.size)), 52 | seeders=int(r.seeders), 53 | peers=int(r.peers), 54 | languages=[], 55 | fullLanguages="", 56 | provider=r.indexer, 57 | publishDate="", 58 | ) 59 | ) 60 | return results 61 | -------------------------------------------------------------------------------- /lib/api/trakt/main_cache.py: -------------------------------------------------------------------------------- 1 | from lib.api.trakt.base_cache import BaseCache, get_timestamp 2 | 3 | 4 | GET_ALL = "SELECT id FROM maincache" 5 | DELETE_ALL = "DELETE FROM maincache" 6 | LIKE_SELECT = "SELECT id from maincache where id LIKE %s" 7 | LIKE_DELETE = "DELETE FROM maincache WHERE id LIKE %s" 8 | CLEAN = "DELETE from maincache WHERE CAST(expires AS INT) <= ?" 9 | 10 | 11 | class MainCache(BaseCache): 12 | def __init__(self): 13 | BaseCache.__init__(self, "maincache_db", "maincache") 14 | 15 | def delete_all(self): 16 | try: 17 | dbcon = self.manual_connect("maincache_db") 18 | for i in dbcon.execute(GET_ALL): 19 | self.delete_memory_cache(str(i[0])) 20 | dbcon.execute(DELETE_ALL) 21 | dbcon.execute("VACUUM") 22 | return True 23 | except: 24 | return False 25 | 26 | def delete_all_folderscrapers(self): 27 | dbcon = self.manual_connect("maincache_db") 28 | remove_list = [ 29 | str(i[0]) 30 | for i in dbcon.execute(LIKE_SELECT % "'FOLDERSCRAPER_%'").fetchall() 31 | ] 32 | if not remove_list: 33 | return True 34 | try: 35 | dbcon.execute(LIKE_DELETE % "'FOLDERSCRAPER_%'") 36 | dbcon.execute("VACUUM") 37 | for item in remove_list: 38 | self.delete_memory_cache(str(item)) 39 | return True 40 | except: 41 | return False 42 | 43 | def clean_database(self): 44 | try: 45 | dbcon = self.manual_connect("maincache_db") 46 | dbcon.execute(CLEAN, (get_timestamp(),)) 47 | dbcon.execute("VACUUM") 48 | return True 49 | except: 50 | return False 51 | 52 | 53 | main_cache = MainCache() 54 | 55 | 56 | def cache_object(function, string, args, json=True, expiration=24): 57 | cache = main_cache.get(string) 58 | if cache is not None: 59 | return cache 60 | if isinstance(args, list): 61 | args = tuple(args) 62 | else: 63 | args = (args,) 64 | if json: 65 | result = function(*args).json() 66 | else: 67 | result = function(*args) 68 | main_cache.set(string, result, expiration=expiration) 69 | return result 70 | -------------------------------------------------------------------------------- /lib/utils/torrentio/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from lib.db.cached import cache 3 | from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled 4 | import xbmcgui 5 | 6 | 7 | items = [ 8 | "YTS", 9 | "EZTV", 10 | "RARBG", 11 | "1337x", 12 | "ThePirateBay", 13 | "KickassTorrents", 14 | "TorrentGalaxy", 15 | "MagnetDL", 16 | "HorribleSubs", 17 | "NyaaSi", 18 | "TokyoTosho", 19 | "AniDex", 20 | "Rutor", 21 | "Rutracker", 22 | "Comando", 23 | "BluDV", 24 | "Torrent9", 25 | "MejorTorrent", 26 | "Wolfmax4k", 27 | "Cinecalidad", 28 | ] 29 | 30 | 31 | def open_providers_selection(identifier="torrentio_providers"): 32 | cached_providers = cache.get(identifier) 33 | if cached_providers: 34 | choice = xbmcgui.Dialog().yesno( 35 | "Providers Selection Dialog", 36 | f"Your current Providers are: \n{','.join(cached_providers)}\n\nDo you want to change?", 37 | yeslabel="Ok", 38 | nolabel="No", 39 | ) 40 | if not choice: 41 | return 42 | providers_selection() 43 | else: 44 | providers_selection() 45 | 46 | 47 | def providers_selection(identifier="torrentio_providers"): 48 | selected = xbmcgui.Dialog().multiselect("Select Providers", items) 49 | if selected: 50 | providers = [items[i] for i in selected] 51 | cache.set( 52 | identifier, 53 | providers, 54 | timedelta(hours=get_cache_expiration() if is_cache_enabled() else 0), 55 | ) 56 | xbmcgui.Dialog().ok( 57 | "Selection Dialog", f"Successfully selected: {',' .join(providers)}" 58 | ) 59 | else: 60 | xbmcgui.Dialog().notification( 61 | "Selection", "No providers selected", xbmcgui.NOTIFICATION_INFO 62 | ) 63 | 64 | 65 | def filter_torrentio_provider(results, identifier="torrentio_providers"): 66 | selected_providers = cache.get(identifier) 67 | if not selected_providers: 68 | return results 69 | 70 | filtered_results = [ 71 | res 72 | for res in results 73 | if res["indexer"] != "Torrentio" 74 | or (res["indexer"] == "Torrentio" and res["provider"] in selected_providers) 75 | ] 76 | return filtered_results 77 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/trending.py: -------------------------------------------------------------------------------- 1 | from lib.api.tmdbv3api.tmdb import TMDb 2 | 3 | 4 | class Trending(TMDb): 5 | _urls = {"trending": "/trending/%s/%s"} 6 | 7 | def _trending(self, media_type="all", time_window="day", page=1): 8 | return self._request_obj( 9 | self._urls["trending"] % (media_type, time_window), 10 | params="page=%s" % page 11 | ) 12 | 13 | def all_day(self, page=1): 14 | """ 15 | Get all daily trending 16 | :param page: int 17 | :return: 18 | """ 19 | return self._trending(media_type="all", time_window="day", page=page) 20 | 21 | def all_week(self, page=1): 22 | """ 23 | Get all weekly trending 24 | :param page: int 25 | :return: 26 | """ 27 | return self._trending(media_type="all", time_window="week", page=page) 28 | 29 | def movie_day(self, page=1): 30 | """ 31 | Get movie daily trending 32 | :param page: int 33 | :return: 34 | """ 35 | return self._trending(media_type="movie", time_window="day", page=page) 36 | 37 | def movie_week(self, page=1): 38 | """ 39 | Get movie weekly trending 40 | :param page: int 41 | :return: 42 | """ 43 | return self._trending(media_type="movie", time_window="week", page=page) 44 | 45 | def tv_day(self, page=1): 46 | """ 47 | Get tv daily trending 48 | :param page: int 49 | :return: 50 | """ 51 | return self._trending(media_type="tv", time_window="day", page=page) 52 | 53 | def tv_week(self, page=1): 54 | """ 55 | Get tv weekly trending 56 | :param page: int 57 | :return: 58 | """ 59 | return self._trending(media_type="tv", time_window="week", page=page) 60 | 61 | def person_day(self, page=1): 62 | """ 63 | Get person daily trending 64 | :param page: int 65 | :return: 66 | """ 67 | return self._trending(media_type="person", time_window="day", page=page) 68 | 69 | def person_week(self, page=1): 70 | """ 71 | Get person weekly trending 72 | :param page: int 73 | :return: 74 | """ 75 | return self._trending(media_type="person", time_window="week", page=page) 76 | -------------------------------------------------------------------------------- /lib/db/pickle_db.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pickle 3 | import xbmcvfs 4 | 5 | from lib.jacktook.utils import kodilog 6 | from lib.utils.kodi.utils import ADDON_ID 7 | 8 | 9 | class PickleDatabase: 10 | def __init__(self): 11 | BASE_DATABASE = { 12 | "jt:watch": {}, 13 | "jt:lth": {}, 14 | "jt:lfh": {}, 15 | } 16 | 17 | data_dir = xbmcvfs.translatePath( 18 | os.path.join("special://profile/addon_data/", ADDON_ID) 19 | ) 20 | self._database_path = os.path.join(data_dir, "database.pickle") 21 | xbmcvfs.mkdirs(data_dir) 22 | 23 | try: 24 | if os.path.exists(self._database_path): 25 | with open(self._database_path, "rb") as f: 26 | database = pickle.load(f) 27 | else: 28 | database = {} 29 | except Exception as e: 30 | kodilog(f"Failed to load database: {e}") 31 | database = {} 32 | 33 | self._database = {**BASE_DATABASE, **database} 34 | self.addon_xml_path = xbmcvfs.translatePath( 35 | os.path.join("special://home/addons/", ADDON_ID, "addon.xml") 36 | ) 37 | 38 | def set_item(self, key: str, subkey: str, value, commit: bool = True): 39 | self._database[key][subkey] = value 40 | if commit: 41 | self.commit() 42 | 43 | def get_item(self, key: str, subkey: str): 44 | return self._database[key].get(subkey, None) 45 | 46 | def delete_item(self, key: str, subkey: str, commit: bool = True): 47 | try: 48 | if subkey in self._database[key]: 49 | del self._database[key][subkey] 50 | if commit: 51 | self.commit() 52 | except KeyError: 53 | pass 54 | 55 | def set_key(self, key: str, value, commit: bool = True): 56 | self._database[key] = value 57 | if commit: 58 | self.commit() 59 | 60 | def get_key(self, key: str): 61 | return self._database[key] 62 | 63 | def commit(self): 64 | try: 65 | with open(self._database_path, "wb") as f: 66 | pickle.dump(self._database, f) 67 | except Exception as e: 68 | kodilog(f"Failed to save database: {e}") 69 | 70 | @property 71 | def database(self): 72 | """Expose internal database dict as read-only property.""" 73 | return self._database 74 | 75 | 76 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/auth.py: -------------------------------------------------------------------------------- 1 | from lib.api.tmdbv3api.tmdb import TMDb 2 | 3 | class Authentication(TMDb): 4 | _urls = { 5 | "create_request_token": "/authentication/token/new", 6 | "validate_with_login": "/authentication/token/validate_with_login", 7 | "create_session": "/authentication/session/new", 8 | "delete_session": "/authentication/session", 9 | } 10 | 11 | def __init__(self, username, password): 12 | super().__init__() 13 | self.username = username 14 | self.password = password 15 | self.expires_at = None 16 | self.request_token = self._create_request_token() 17 | self._authorise_request_token_with_login() 18 | self._create_session() 19 | 20 | def _create_request_token(self): 21 | """ 22 | Create a temporary request token that can be used to validate a TMDb user login. 23 | """ 24 | response = self._request_obj(self._urls["create_request_token"]) 25 | self.expires_at = response.expires_at 26 | return response.request_token 27 | 28 | def _create_session(self): 29 | """ 30 | You can use this method to create a fully valid session ID once a user has validated the request token. 31 | """ 32 | response = self._request_obj( 33 | self._urls["create_session"], 34 | method="POST", 35 | json={"request_token": self.request_token} 36 | ) 37 | self.session_id = response.session_id 38 | 39 | def _authorise_request_token_with_login(self): 40 | """ 41 | This method allows an application to validate a request token by entering a username and password. 42 | """ 43 | self._request_obj( 44 | self._urls["validate_with_login"], 45 | method="POST", 46 | json={ 47 | "username": self.username, 48 | "password": self.password, 49 | "request_token": self.request_token, 50 | } 51 | ) 52 | 53 | def delete_session(self): 54 | """ 55 | If you would like to delete (or "logout") from a session, call this method with a valid session ID. 56 | """ 57 | if self.has_session: 58 | self._request_obj( 59 | self._urls["delete_session"], 60 | method="DELETE", 61 | json={"session_id": self.session_id} 62 | ) 63 | self.session_id = "" 64 | -------------------------------------------------------------------------------- /lib/clients/elfhosted.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List, Dict, Any, Optional, Callable 3 | from lib.clients.base import BaseClient, TorrentStream 4 | from lib.utils.kodi.utils import convert_size_to_bytes, translation 5 | 6 | 7 | class Elfhosted(BaseClient): 8 | def __init__(self, host: str, notification: Callable) -> None: 9 | super().__init__(host, notification) 10 | 11 | def search( 12 | self, 13 | imdb_id: str, 14 | mode: str, 15 | media_type: str, 16 | season: Optional[int], 17 | episode: Optional[int], 18 | ) -> Optional[List[TorrentStream]]: 19 | try: 20 | if mode == "tv" or media_type == "tv": 21 | url = f"{self.host}/stream/series/{imdb_id}:{season}:{episode}.json" 22 | elif mode == "movies" or media_type == "movies": 23 | url = f"{self.host}/stream/{mode}/{imdb_id}.json" 24 | else: 25 | self.handle_exception(translation(30231)) 26 | return None 27 | 28 | res = self.session.get(url, timeout=10) 29 | if res.status_code != 200: 30 | return 31 | response = self.parse_response(res) 32 | return response 33 | except Exception as e: 34 | self.handle_exception(f"{translation(30231)}: {str(e)}") 35 | 36 | def parse_response(self, res: Any) -> List[TorrentStream]: 37 | res = res.json() 38 | results = [] 39 | for item in res["streams"]: 40 | parsed_item = self.parse_stream_title(item["title"]) 41 | results.append( 42 | TorrentStream( 43 | title=parsed_item["title"], 44 | type="Torrent", 45 | indexer="Elfhosted", 46 | guid=item["infoHash"], 47 | infoHash=item["infoHash"], 48 | size=parsed_item["size"], 49 | publishDate="", 50 | seeders=0, 51 | peers=0, 52 | languages=[], 53 | fullLanguages="", 54 | provider="", 55 | ) 56 | ) 57 | return results 58 | 59 | def parse_stream_title(self, title: str) -> Dict[str, Any]: 60 | name = title.splitlines()[0] 61 | 62 | size_match = re.search(r"💾 (\d+(?:\.\d+)?\s*(GB|MB))", title, re.IGNORECASE) 63 | size = size_match.group(1) if size_match else "" 64 | size = convert_size_to_bytes(size) 65 | 66 | return { 67 | "title": name, 68 | "size": size, 69 | } 70 | -------------------------------------------------------------------------------- /lib/utils/debrid/qrcode_utils.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | from lib.jacktook.utils import ADDON_PATH, kodilog 4 | from lib.utils.kodi.utils import ADDON_PROFILE_PATH 5 | from lib.vendor.segno import make as segnomake 6 | 7 | import xbmcgui 8 | 9 | 10 | def make_qrcode(url): 11 | if not url: 12 | return 13 | try: 14 | art_path = path.join(ADDON_PROFILE_PATH, "qr.png") 15 | kodilog(f"Creating QR code for URL: {art_path}") 16 | qrcode = segnomake(url, micro=False) 17 | qrcode.save(art_path, scale=20) 18 | return art_path 19 | except Exception as e: 20 | kodilog("Error creating QR code: %s", e) 21 | 22 | 23 | class QRProgressDialogLib(xbmcgui.WindowDialog): 24 | def __init__(self, title, qr_path, auth_url, user_code): 25 | super().__init__() 26 | # Use skin base res (Kodi scales this to actual screen) 27 | screen_w, screen_h = 1280, 720 28 | 29 | box_w, box_h = 600, 400 30 | box_x = (screen_w - box_w) // 2 # 340 31 | box_y = (screen_h - box_h) // 2 # 160 32 | 33 | # Background 34 | self.bg = xbmcgui.ControlImage( 35 | box_x, 36 | box_y, 37 | box_w, 38 | box_h, 39 | path.join(ADDON_PATH, "resources", "img", "texture.png"), 40 | ) 41 | self.addControl(self.bg) 42 | 43 | # Title 44 | self.title_lbl = xbmcgui.ControlLabel( 45 | box_x + 20, box_y + 20, box_w - 40, 30, f"[B]{title}[/B]" 46 | ) 47 | self.addControl(self.title_lbl) 48 | 49 | # QR 50 | self.qr = xbmcgui.ControlImage(box_x + 30, box_y + 70, 200, 200, qr_path) 51 | self.addControl(self.qr) 52 | 53 | # Instructions 54 | self.text_lbl = xbmcgui.ControlLabel( 55 | box_x + 250, 56 | box_y + 70, 57 | box_w - 280, 58 | 120, 59 | f"Go to:\n[COLOR cyan]{auth_url}[/COLOR]\n\n" 60 | f"Enter code:\n[COLOR seagreen][B]{user_code}[/B][/COLOR]", 61 | ) 62 | self.addControl(self.text_lbl) 63 | 64 | # Progress bar 65 | self.progress = xbmcgui.ControlProgress(box_x + 20, box_y + 300, box_w - 40, 30) 66 | self.addControl(self.progress) 67 | 68 | # Cancel button 69 | self.cancel_btn = xbmcgui.ControlButton( 70 | box_x + box_w - 140, box_y + box_h - 40, 120, 30, "Cancel" 71 | ) 72 | self.addControl(self.cancel_btn) 73 | 74 | def setProgress(self, percent, message=""): 75 | self.progress.setPercent(percent) 76 | if message: 77 | self.text_lbl.setLabel(message) 78 | 79 | def isCanceled(self): 80 | return self.getFocusId() == self.cancel_btn.getId() 81 | -------------------------------------------------------------------------------- /lib/clients/trakt/paginator.py: -------------------------------------------------------------------------------- 1 | import json 2 | from lib.api.trakt.base_cache import connect_database 3 | 4 | 5 | class Paginator: 6 | def __init__(self, page_size=10): 7 | self.page_size = page_size 8 | self.total_pages = 0 9 | self.current_page = 0 10 | 11 | def _connect(self): 12 | return connect_database(database_name="paginator_db") 13 | 14 | def _store_page(self, page_number, total_pages, data): 15 | conn = self._connect() 16 | cursor = conn.cursor() 17 | cursor.execute(''' 18 | INSERT OR REPLACE INTO paginated_data (id, page_number, data, total_pages) 19 | VALUES (?, ?, ?, ?) 20 | ''', (f'page_{page_number}', page_number, json.dumps(data), total_pages)) 21 | conn.commit() 22 | conn.close() 23 | 24 | def _retrieve_page(self, page_number): 25 | conn = self._connect() 26 | cursor = conn.cursor() 27 | cursor.execute(''' 28 | SELECT data FROM paginated_data WHERE page_number = ? 29 | ''', (page_number,)) 30 | result = cursor.fetchone() 31 | conn.close() 32 | if result and result[0] is not None: 33 | return json.loads(result[0]) 34 | return None 35 | 36 | def _clear_table(self): 37 | conn = self._connect() 38 | cursor = conn.cursor() 39 | cursor.execute('DELETE FROM paginated_data') 40 | conn.commit() 41 | conn.close() 42 | 43 | def initialize(self, data): 44 | self._clear_table() 45 | self.total_pages = (len(data) + self.page_size - 1) // self.page_size 46 | for i in range(self.total_pages): 47 | start = i * self.page_size 48 | end = start + self.page_size 49 | page_data = data[start:end] 50 | self._store_page(i + 1, self.total_pages, page_data) 51 | 52 | def get_page(self, page_number): 53 | self.current_page = page_number - 1 54 | data = self._retrieve_page(page_number) 55 | if data is None: 56 | raise IndexError("Page number not found in database: Requested page {}".format(page_number)) 57 | return data 58 | 59 | def next_page(self): 60 | """Move to the next page.""" 61 | if self.current_page < self.total_pages - 1: 62 | self.current_page += 1 63 | return self.get_page(self.current_page + 1) 64 | 65 | def previous_page(self): 66 | """Move to the previous page.""" 67 | if self.current_page > 0: 68 | self.current_page -= 1 69 | return self.get_page(self.current_page + 1) 70 | 71 | def current_page_data(self): 72 | """Get the data for the current page.""" 73 | return self.get_page(self.current_page + 1) 74 | 75 | 76 | 77 | paginator_db = Paginator() 78 | -------------------------------------------------------------------------------- /lib/utils/clients/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from lib.clients.jackett import Jackett 3 | from lib.clients.jackgram.client import Jackgram 4 | from lib.clients.prowlarr import Prowlarr 5 | from lib.clients.zilean import Zilean 6 | from lib.jacktook.client import Burst 7 | from lib.utils.kodi.utils import get_setting, notification, translation 8 | from lib.utils.general.utils import Indexer 9 | from lib.utils.kodi.settings import get_int_setting 10 | 11 | 12 | def validate_host(host: Optional[str], indexer: str) -> Optional[bool]: 13 | if not host: 14 | notification(f"{translation(30223)}: {indexer}") 15 | return None 16 | return True 17 | 18 | 19 | def validate_key(api_key: Optional[str], indexer: str) -> Optional[bool]: 20 | if not api_key: 21 | notification(f"{translation(30221)}: {indexer}") 22 | return None 23 | return True 24 | 25 | 26 | def update_dialog(title: str, message: str, dialog: object) -> None: 27 | dialog.update(0, f"Jacktook [COLOR FFFF6B00]{title}[/COLOR]", message) 28 | 29 | 30 | def validate_credentials( 31 | indexer: str, host: Optional[str], api_key: Optional[str] = None 32 | ) -> bool: 33 | """ 34 | Validates the host and API key for a given indexer. 35 | """ 36 | if not validate_host(host, indexer): 37 | return False 38 | if api_key is not None and not validate_key(api_key, indexer): 39 | return False 40 | return True 41 | 42 | 43 | def get_client(indexer: str) -> Optional[object]: 44 | if indexer == Indexer.JACKETT: 45 | host = str(get_setting("jackett_host", "")) 46 | api_key = str(get_setting("jackett_apikey", "")) 47 | port = str(get_setting("jackett_port", "9117")) 48 | 49 | if not validate_credentials(indexer, host, api_key): 50 | return 51 | return Jackett(host, api_key, port, notification) 52 | 53 | elif indexer == Indexer.PROWLARR: 54 | host = str(get_setting("prowlarr_host")) 55 | api_key = str(get_setting("prowlarr_apikey")) 56 | port = str(get_setting("prowlarr_port", "9696")) 57 | 58 | if not validate_credentials(indexer, host, api_key): 59 | return 60 | return Prowlarr(host, api_key, port, notification) 61 | 62 | elif indexer == Indexer.JACKGRAM: 63 | host = str(get_setting("jackgram_host")) 64 | if not validate_credentials(indexer, host): 65 | return 66 | return Jackgram(host, notification) 67 | 68 | elif indexer == Indexer.ZILEAN: 69 | timeout = get_int_setting("zilean_timeout") 70 | host = str(get_setting("zilean_host")) 71 | if not validate_credentials(indexer, host): 72 | return 73 | return Zilean(host, timeout, notification) 74 | 75 | elif indexer == Indexer.BURST: 76 | return Burst(notification) 77 | -------------------------------------------------------------------------------- /lib/clients/stremio/stream.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | class Stream: 4 | def __init__(self, json_string): 5 | if isinstance(json_string, str): 6 | try: 7 | data = json.loads(json_string) 8 | except json.JSONDecodeError as e: 9 | raise ValueError(f"Invalid JSON string: {e}") 10 | elif isinstance(json_string, dict): 11 | data = json_string 12 | else: 13 | raise ValueError("Input must be a JSON string or a dictionary.") 14 | 15 | # Initialize required attributes 16 | self.url = data.get("url", "") 17 | self.ytId = data.get("ytId") 18 | self.infoHash = data.get("infoHash", "") 19 | self.fileIdx = data.get("fileIdx") 20 | self.externalUrl = data.get("externalUrl") 21 | self.meta = data.get("meta", {}) 22 | 23 | # Initialize optional attributes 24 | self.name = data.get("name") 25 | self.title = data.get("title", "") # deprecated 26 | self.description = data.get( 27 | "description", self.title 28 | ) # Use `title` as fallback 29 | self.subtitles = data.get("subtitles", []) 30 | self.sources = data.get("sources", []) 31 | 32 | # Initialize behavior hints 33 | behavior_hints = data.get("behaviorHints", {}) 34 | self.countryWhitelist = behavior_hints.get("countryWhitelist", []) 35 | self.notWebReady = behavior_hints.get("notWebReady", False) 36 | self.bingeGroup = behavior_hints.get("bingeGroup") 37 | self.proxyHeaders = behavior_hints.get("proxyHeaders", {}) 38 | self.videoHash = behavior_hints.get("videoHash") 39 | self.videoSize = behavior_hints.get("videoSize") 40 | self.filename = behavior_hints.get("filename") 41 | 42 | # Validation for at least one stream identifier 43 | if not (self.url or self.ytId or self.infoHash or self.externalUrl): 44 | raise ValueError( 45 | "At least one of 'url', 'ytId', 'infoHash', or 'externalUrl' must be specified." 46 | ) 47 | 48 | def get_parsed_title(self) -> str: 49 | title = self.filename or self.description or self.title 50 | return title.splitlines()[0] if title else "" 51 | 52 | def get_sub_indexer(self, addon) -> str: 53 | if addon.manifest.name.split(" ")[0] == "AIOStreams": 54 | return self.name.split()[1] if self.name else "" 55 | return "" 56 | 57 | def get_parsed_size(self) -> int: 58 | return self.videoSize or self.meta.get("size") or 0 59 | 60 | def get_provider(self): 61 | return self.meta.get("indexer") or "" 62 | 63 | def __repr__(self): 64 | return f"Stream(name={self.name}, url={self.url}, ytId={self.ytId}, infoHash={self.infoHash}, externalUrl={self.externalUrl})" 65 | -------------------------------------------------------------------------------- /lib/gui/play_window.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from lib.gui.base_window import BaseWindow 3 | from lib.utils.kodi.utils import kodilog 4 | import xbmc 5 | 6 | 7 | class PlayWindow(BaseWindow): 8 | def __init__(self, xml_file, xml_location, item_information=None): 9 | try: 10 | super().__init__(xml_file, xml_location, item_information=item_information) 11 | self.player = xbmc.Player() 12 | self.playing_file = self.getPlayingFile() 13 | self.duration = self.getTotalTime() - self.getTime() 14 | self.closed = False 15 | except Exception as e: 16 | kodilog(f"Error PlayWindow: {e}") 17 | self.player = None 18 | 19 | def __del__(self): 20 | self.player = None 21 | del self.player 22 | 23 | def getTotalTime(self): 24 | return self.player.getTotalTime() if self.isPlaying() else 0 25 | 26 | def getTime(self): 27 | return self.player.getTime() if self.isPlaying() else 0 28 | 29 | def isPlaying(self): 30 | return self.player.isPlaying() 31 | 32 | def getPlayingFile(self): 33 | return self.player.getPlayingFile() 34 | 35 | def seekTime(self, seekTime): 36 | self.player.seekTime(seekTime) 37 | 38 | def pause(self): 39 | self.player.pause() 40 | 41 | def onInit(self): 42 | self.background_tasks() 43 | super().onInit() 44 | 45 | def calculate_percent(self): 46 | return ((int(self.getTotalTime()) - int(self.getTime())) / float(self.duration)) * 100 47 | 48 | def background_tasks(self): 49 | try: 50 | try: 51 | progress_bar = self.getControlProgress(3014) 52 | except RuntimeError: 53 | progress_bar = None 54 | 55 | while ( 56 | int(self.getTotalTime()) - int(self.getTime()) > 2 57 | and not self.closed 58 | and self.playing_file == self.getPlayingFile() 59 | ): 60 | xbmc.sleep(500) 61 | if progress_bar is not None: 62 | progress_bar.setPercent(self.calculate_percent()) 63 | 64 | self.smart_play_action() 65 | except Exception as e: 66 | kodilog(f"Error: {e}") 67 | 68 | self.close() 69 | 70 | @abc.abstractmethod 71 | def smart_play_action(self): 72 | """ 73 | Perform the default smartplay action at window timeout 74 | :return: 75 | """ 76 | 77 | def close(self): 78 | self.closed = True 79 | super().close() 80 | 81 | def handle_action(self, action, control_id=None): 82 | if action == 7: 83 | if control_id == 3001: 84 | xbmc.executebuiltin('PlayerControl(BigSkipForward)') 85 | self.close() 86 | if control_id == 3002: 87 | self.close() 88 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/find.py: -------------------------------------------------------------------------------- 1 | from lib.api.tmdbv3api.tmdb import TMDb 2 | 3 | 4 | class Find(TMDb): 5 | _urls = { 6 | "find": "/find/%s" 7 | } 8 | 9 | def find(self, external_id, external_source): 10 | """ 11 | The find method makes it easy to search for objects in our database by an external id. For example, an IMDB ID. 12 | :param external_id: str 13 | :param external_source str 14 | :return: 15 | """ 16 | return self._request_obj( 17 | self._urls["find"] % external_id.replace("/", "%2F"), 18 | params="external_source=" + external_source 19 | ) 20 | 21 | def find_by_imdb_id(self, imdb_id): 22 | """ 23 | The find method makes it easy to search for objects in our database by an IMDB ID. 24 | :param imdb_id: str 25 | :return: 26 | """ 27 | return self.find(imdb_id, "imdb_id") 28 | 29 | def find_by_tvdb_id(self, tvdb_id): 30 | """ 31 | The find method makes it easy to search for objects in our database by a TVDB ID. 32 | :param tvdb_id: int 33 | :return: 34 | """ 35 | return self.find(tvdb_id, "tvdb_id") 36 | 37 | def find_by_freebase_mid(self, freebase_mid): 38 | """ 39 | The find method makes it easy to search for objects in our database by a Freebase MID. 40 | :param freebase_mid: str 41 | :return: 42 | """ 43 | return self.find(freebase_mid, "freebase_mid") 44 | 45 | def find_by_freebase_id(self, freebase_id): 46 | """ 47 | The find method makes it easy to search for objects in our database by a Freebase ID. 48 | :param freebase_id: str 49 | :return: 50 | """ 51 | return self.find(freebase_id, "freebase_id") 52 | 53 | def find_by_tvrage_id(self, tvrage_id): 54 | """ 55 | The find method makes it easy to search for objects in our database by a TVRage ID. 56 | :param tvrage_id: str 57 | :return: 58 | """ 59 | return self.find(tvrage_id, "tvrage_id") 60 | 61 | def find_by_facebook_id(self, facebook_id): 62 | """ 63 | The find method makes it easy to search for objects in our database by a Facebook ID. 64 | :param facebook_id: str 65 | :return: 66 | """ 67 | return self.find(facebook_id, "facebook_id") 68 | 69 | def find_by_instagram_id(self, instagram_id): 70 | """ 71 | The find method makes it easy to search for objects in our database by a Instagram ID. 72 | :param instagram_id: str 73 | :return: 74 | """ 75 | return self.find(instagram_id, "instagram_id") 76 | 77 | def find_by_twitter_id(self, twitter_id): 78 | """ 79 | The find method makes it easy to search for objects in our database by a Twitter ID. 80 | :param twitter_id: str 81 | :return: 82 | """ 83 | return self.find(twitter_id, "twitter_id") 84 | -------------------------------------------------------------------------------- /lib/clients/fma.py: -------------------------------------------------------------------------------- 1 | import re 2 | import requests 3 | 4 | 5 | class FindMyAnime: 6 | base_url = "https://find-my-anime.dtimur.de/api" 7 | 8 | def get_anime_data(self, anime_id, anime_id_provider): 9 | params = {"id": anime_id, "provider": anime_id_provider, "includeAdult": "true"} 10 | return self.make_request(params=params) 11 | 12 | def make_request(self, params): 13 | res = requests.get( 14 | self.base_url, 15 | params=params, 16 | ) 17 | if res.status_code == 200: 18 | return res.json() 19 | else: 20 | raise Exception(f"FMA error::{res.text}") 21 | 22 | 23 | def extract_season(res): 24 | regexes = [ 25 | r"season\s(\d+)", 26 | r"\s(\d+)st\sseason(?:\s|$)", 27 | r"\s(\d+)nd\sseason(?:\s|$)", 28 | r"\s(\d+)rd\sseason(?:\s|$)", 29 | r"\s(\d+)th\sseason(?:\s|$)", 30 | ] 31 | s_ids = [] 32 | for regex in regexes: 33 | if isinstance(res.get("title"), dict): 34 | s_ids += [ 35 | re.findall(regex, name, re.IGNORECASE) 36 | for lang, name in res.get("title").items() 37 | if name is not None 38 | ] 39 | else: 40 | s_ids += [ 41 | re.findall(regex, name, re.IGNORECASE) for name in res.get("title") 42 | ] 43 | 44 | s_ids += [ 45 | re.findall(regex, name, re.IGNORECASE) for name in res.get("synonyms") 46 | ] 47 | 48 | s_ids = [s[0] for s in s_ids if s] 49 | 50 | if not s_ids: 51 | regex = r"\s(\d+)$" 52 | cour = False 53 | if isinstance(res.get("title"), dict): 54 | for lang, name in res.get("title").items(): 55 | if name is not None and ( 56 | " part " in name.lower() or " cour " in name.lower() 57 | ): 58 | cour = True 59 | break 60 | if not cour: 61 | s_ids += [ 62 | re.findall(regex, name, re.IGNORECASE) 63 | for lang, name in res.get("title").items() 64 | if name is not None 65 | ] 66 | s_ids += [ 67 | re.findall(regex, name, re.IGNORECASE) 68 | for name in res.get("synonyms") 69 | ] 70 | else: 71 | for name in res.get("title"): 72 | if " part " in name.lower() or " cour " in name.lower(): 73 | cour = True 74 | break 75 | if not cour: 76 | s_ids += [ 77 | re.findall(regex, name, re.IGNORECASE) for name in res.get("title") 78 | ] 79 | s_ids += [ 80 | re.findall(regex, name, re.IGNORECASE) 81 | for name in res.get("synonyms") 82 | ] 83 | s_ids = [s[0] for s in s_ids if s and int(s[0]) < 20] 84 | 85 | return s_ids 86 | -------------------------------------------------------------------------------- /lib/clients/stremio/client.py: -------------------------------------------------------------------------------- 1 | from json import JSONDecodeError 2 | from requests.exceptions import RequestException, Timeout, TooManyRedirects 3 | from requests import Session 4 | from lib.utils.general.utils import USER_AGENT_HEADER 5 | from lib.utils.kodi.utils import kodilog 6 | 7 | 8 | class Stremio: 9 | def __init__(self, authKey=None): 10 | self.authKey = authKey 11 | self.session = Session() 12 | self.session.headers.update(USER_AGENT_HEADER) 13 | 14 | def _request(self, method, url, data=None): 15 | try: 16 | if method == "GET": 17 | resp = self.session.get(url, timeout=10) 18 | elif method == "POST": 19 | resp = self.session.post(url, json=data, timeout=10) 20 | else: 21 | raise ValueError(f"Unsupported HTTP method: {method}") 22 | 23 | if resp.status_code != 200: 24 | kodilog( 25 | f"Status code {resp.status_code} received for URL: {url}. Response: {resp.text}" 26 | ) 27 | resp.raise_for_status() 28 | 29 | try: 30 | return resp.json() 31 | except JSONDecodeError: 32 | kodilog( 33 | f"Failed to decode JSON response for URL: {url}. Response: {resp.text}" 34 | ) 35 | raise 36 | except Timeout: 37 | kodilog(f"Request timed out for URL: {url}") 38 | raise 39 | except TooManyRedirects: 40 | kodilog(f"Too many redirects encountered for URL: {url}") 41 | raise 42 | except RequestException as e: 43 | kodilog(f"Failed to fetch data from {url}: {e}") 44 | raise 45 | 46 | def _get(self, url): 47 | return self._request("GET", url) 48 | 49 | def _post(self, url, data): 50 | return self._request("POST", url, data) 51 | 52 | def login(self, email, password): 53 | """Login to Stremio account.""" 54 | 55 | data = { 56 | "authKey": self.authKey, 57 | "email": email, 58 | "password": password, 59 | } 60 | 61 | res = self._post("https://api.strem.io/api/login", data) 62 | self.authKey = res.get("result", {}).get("authKey", None) 63 | 64 | def dataExport(self): 65 | """Export user data.""" 66 | assert self.authKey, "Login first" 67 | data = {"authKey": self.authKey} 68 | res = self._post("https://api.strem.io/api/dataExport", data) 69 | exportId = res.get("result", {}).get("exportId", None) 70 | 71 | dataExport = self._get( 72 | f"https://api.strem.io/data-export/{exportId}/export.json" 73 | ) 74 | return dataExport 75 | 76 | def get_community_addons(self): 77 | """Get community addons.""" 78 | response = self._get( 79 | "https://stremio-addons.net/api/addon_catalog/all/stremio-addons.net.json" 80 | ) 81 | return response 82 | 83 | def get_my_addons(self): 84 | """Get user addons.""" 85 | response = self.dataExport() 86 | return response.get("addons", {}).get("addons", []) 87 | -------------------------------------------------------------------------------- /lib/gui/source_pack_select.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Dict, Any 2 | from lib.gui.source_pack_window import SourcePackWindow 3 | from lib.utils.general.utils import DebridType 4 | from lib.domain.torrent import TorrentStream 5 | from lib.utils.player.utils import resolve_playback_url 6 | 7 | 8 | class SourcePackSelect(SourcePackWindow): 9 | def __init__( 10 | self, 11 | xml_file: str, 12 | location: str, 13 | source: Optional[TorrentStream] = None, 14 | pack_info: Optional[Dict[str, Any]] = None, 15 | item_information: Optional[Dict[str, Any]] = None, 16 | ) -> None: 17 | super().__init__( 18 | xml_file, location, pack_info=pack_info, item_information=item_information 19 | ) 20 | self.source: Optional[TorrentStream] = source 21 | self.pack_info: Optional[Dict[str, Any]] = pack_info 22 | self.position: int = -1 23 | self.playback_info: Optional[Dict[str, Any]] = None 24 | self.setProperty("instant_close", "false") 25 | self.setProperty("resolving", "false") 26 | 27 | def doModal(self) -> Optional[Dict[str, Any]]: 28 | super().doModal() 29 | return self.playback_info 30 | 31 | def handle_action(self, action_id: int, control_id: Optional[int] = None) -> None: 32 | self.position = self.display_list.getSelectedPosition() 33 | if action_id == 7: 34 | if control_id == 1000: 35 | self._resolve_item() 36 | 37 | def _resolve_item(self) -> None: 38 | self.setProperty("resolving", "true") 39 | 40 | if self.source and self.pack_info: 41 | common_data = { 42 | "type": self.source.type, 43 | "debrid_type": self.source.debridType, 44 | "is_torrent": False, 45 | "is_pack": True, 46 | "mode": self.item_information.get("mode"), 47 | "ids": self.item_information.get("ids"), 48 | "tv_data": self.item_information.get("tv_data"), 49 | } 50 | if self.source.debridType in [DebridType.RD, DebridType.TB]: 51 | _, title = self.pack_info["files"][self.position] 52 | data = { 53 | **common_data, 54 | "title": title, 55 | "pack_info": { 56 | "file_position": self.position, 57 | "torrent_id": self.pack_info["torrent_id"], 58 | }, 59 | } 60 | else: 61 | url, title = self.pack_info["files"][self.position] 62 | data = { 63 | **common_data, 64 | "title": title, 65 | "pack_info": { 66 | "file_position": self.position, 67 | "url": url, 68 | }, 69 | } 70 | self.playback_info = resolve_playback_url(data) 71 | 72 | if not self.playback_info: 73 | self.setProperty("resolving", "false") 74 | self.close() 75 | return 76 | 77 | self.setProperty("instant_close", "true") 78 | self.close() 79 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/list.py: -------------------------------------------------------------------------------- 1 | from lib.api.tmdbv3api.tmdb import TMDb 2 | 3 | class List(TMDb): 4 | _urls = { 5 | "details": "/list/%s", 6 | "check_status": "/list/%s/item_status", 7 | "create": "/list", 8 | "add_movie": "/list/%s/add_item", 9 | "remove_movie": "/list/%s/remove_item", 10 | "clear_list": "/list/%s/clear", 11 | "delete_list": "/list/%s", 12 | } 13 | 14 | def details(self, list_id): 15 | """ 16 | Get list details by id. 17 | :param list_id: int 18 | :return: 19 | """ 20 | return self._request_obj(self._urls["details"] % list_id, key="items") 21 | 22 | def check_item_status(self, list_id, movie_id): 23 | """ 24 | You can use this method to check if a movie has already been added to the list. 25 | :param list_id: int 26 | :param movie_id: int 27 | :return: 28 | """ 29 | return self._request_obj(self._urls["check_status"] % list_id, params="movie_id=%s" % movie_id)["item_present"] 30 | 31 | def create_list(self, name, description): 32 | """ 33 | You can use this method to check if a movie has already been added to the list. 34 | :param name: str 35 | :param description: str 36 | :return: 37 | """ 38 | return self._request_obj( 39 | self._urls["create"], 40 | params="session_id=%s" % self.session_id, 41 | method="POST", 42 | json={ 43 | "name": name, 44 | "description": description, 45 | "language": self.language 46 | } 47 | ).list_id 48 | 49 | def add_movie(self, list_id, movie_id): 50 | """ 51 | Add a movie to a list. 52 | :param list_id: int 53 | :param movie_id: int 54 | """ 55 | self._request_obj( 56 | self._urls["add_movie"] % list_id, 57 | params="session_id=%s" % self.session_id, 58 | method="POST", 59 | json={"media_id": movie_id} 60 | ) 61 | 62 | def remove_movie(self, list_id, movie_id): 63 | """ 64 | Remove a movie from a list. 65 | :param list_id: int 66 | :param movie_id: int 67 | """ 68 | self._request_obj( 69 | self._urls["remove_movie"] % list_id, 70 | params="session_id=%s" % self.session_id, 71 | method="POST", 72 | json={"media_id": movie_id} 73 | ) 74 | 75 | def clear_list(self, list_id): 76 | """ 77 | Clear all of the items from a list. 78 | :param list_id: int 79 | """ 80 | self._request_obj( 81 | self._urls["clear_list"] % list_id, 82 | params="session_id=%s&confirm=true" % self.session_id, 83 | method="POST" 84 | ) 85 | 86 | def delete_list(self, list_id): 87 | """ 88 | Delete a list. 89 | :param list_id: int 90 | """ 91 | self._request_obj( 92 | self._urls["delete_list"] % list_id, 93 | params="session_id=%s" % self.session_id, 94 | method="DELETE" 95 | ) 96 | -------------------------------------------------------------------------------- /resources/skins/Default/1080i/customdialog.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 600 4 | 300 5 | 800 6 | 400 7 | 8 | 9 | 32503 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 0.40 19 | 20 | 21 | 22 | 800 23 | 400 24 | 25 | 26 | 27 | 800 28 | 400 29 | circle.png 30 | CC000000 31 | 32 | 33 | 34 | 35 | font14 36 | center 37 | white 38 | AA000000 39 | 40 | 41 | 42 | 43 | font14 44 | 90 45 | 780 46 | 150 47 | center 48 | white 49 | AA000000 50 | true 51 | 52 | 53 | 54 | 55 | 745 56 | 50 57 | 50 58 | Button/close.png 59 | Button/close.png 60 | 61 | 62 | 63 | 64 | 600 65 | 100 66 | 280 67 | 90 68 | 69 | 70 | 71 | -6 72 | -2 73 | circle.png 74 | CCFF0000 75 | 76 | 77 | 78 | 79 | font14 80 | center 81 | center 82 | 100 83 | white 84 | FF000000 85 | true 86 | 87 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /resources/skins/Default/1080i/qr_dialog.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 480 4 | 300 5 | 900 6 | 450 7 | 8 | 12005 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 0.40 18 | 19 | 20 | 21 | 22 | 900 23 | 450 24 | circle.png 25 | CC000000 26 | 27 | 28 | 29 | 30 | 20 31 | 880 32 | 40 33 | center 34 | font16 35 | white 36 | AA000000 37 | 38 | 39 | 40 | 41 | 80 42 | 60 43 | 220 44 | 220 45 | qr_placeholder.png 46 | 47 | 48 | 49 | 50 | 80 51 | 300 52 | 580 53 | 220 54 | left 55 | font14 56 | white 57 | AA000000 58 | true 59 | 60 | 61 | 62 | 63 | 320 64 | 60 65 | 780 66 | 30 67 | percentage 68 | 69 | 70 | 71 | 72 | horizontal 73 | 370 74 | 360 75 | 120 76 | 77 | 78 | 79 | 140 80 | 50 81 | 82 | center 83 | font14 84 | DDFFFFFF 85 | EEFFFFFF 86 | DDFFFFFF 87 | circle.png 88 | circle.png 89 | no 90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /resources/skins/Default/1080i/filter_type.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 710 4 | 290 5 | 500 6 | 500 7 | 8 | 2000 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 0.40 18 | 19 | 20 | 21 | 22 | 0 23 | 0 24 | 500 25 | 500 26 | circle.png 27 | CC000000 28 | 29 | 30 | 31 | 32 | 0 33 | 50 34 | 500 35 | 60 36 | font16_title 37 | white 38 | center 39 | 40 | 41 | 42 | 43 | 44 | 50 45 | 120 46 | 400 47 | 400 48 | 2000 49 | 2000 50 | 2000 51 | 2000 52 | 30 53 | 54 | 55 | 56 | 57 | 400 58 | 60 59 | 60 | 0 61 | 10 62 | 60 63 | 40 64 | circle.png 65 | CC222222 66 | 67 | 68 | 80 69 | 10 70 | 320 71 | 40 72 | font14 73 | white 74 | left 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 400 84 | 60 85 | 86 | 0 87 | 10 88 | 60 89 | 40 90 | circle.png 91 | FF12A0C7 92 | 93 | 94 | 80 95 | 10 96 | 320 97 | 40 98 | font14 99 | white 100 | left 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /scripts/check_py37_compat.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import os 3 | import sys 4 | 5 | # Disallowed base names used as generics in Python < 3.9 6 | PEP585_TYPES = {"list", "dict", "set", "tuple", "frozenset", "type", "callable"} 7 | 8 | # PEP 593, 604, 647, 612, 673 keywords 9 | UNSUPPORTED_NAMES = { 10 | "Annotated", "Self", "TypeGuard", "ParamSpec", "Concatenate", "Required", "NotRequired" 11 | } 12 | 13 | def find_incompatible_syntax(file_path): 14 | with open(file_path, "r", encoding="utf-8") as f: 15 | try: 16 | tree = ast.parse(f.read(), filename=file_path) 17 | except SyntaxError as e: 18 | return [("SYNTAX ERROR", e.lineno, str(e))] 19 | 20 | issues = [] 21 | 22 | class Visitor(ast.NodeVisitor): 23 | def visit_BinOp(self, node): 24 | # Check for union types (e.g., int | None) 25 | if isinstance(node.op, ast.BitOr): 26 | issues.append(("Union | syntax", node.lineno, "Use Union[...] instead of |")) 27 | self.generic_visit(node) 28 | 29 | def visit_AnnAssign(self, node): 30 | if isinstance(node.annotation, ast.Subscript): 31 | base = node.annotation.value 32 | if isinstance(base, ast.Name) and base.id.lower() in PEP585_TYPES: 33 | issues.append(("PEP 585 generic", node.lineno, f"Use typing.{base.id.capitalize()} instead")) 34 | self.generic_visit(node) 35 | 36 | def visit_FunctionDef(self, node): 37 | for arg in node.args.args + node.args.kwonlyargs: 38 | if isinstance(arg.annotation, ast.Subscript): 39 | base = arg.annotation.value 40 | if isinstance(base, ast.Name) and base.id.lower() in PEP585_TYPES: 41 | issues.append(("PEP 585 generic", node.lineno, f"Use typing.{base.id.capitalize()} instead")) 42 | if isinstance(node.returns, ast.Subscript): 43 | base = node.returns.value 44 | if isinstance(base, ast.Name) and base.id.lower() in PEP585_TYPES: 45 | issues.append(("PEP 585 generic", node.lineno, f"Use typing.{base.id.capitalize()} instead")) 46 | self.generic_visit(node) 47 | 48 | def visit_Match(self, node): 49 | issues.append(("match-case", node.lineno, "Structural pattern matching not supported in Python 3.7")) 50 | self.generic_visit(node) 51 | 52 | def visit_Name(self, node): 53 | if node.id in UNSUPPORTED_NAMES: 54 | issues.append((f"Unsupported typing: {node.id}", node.lineno, f"Requires typing_extensions or Python >= 3.8/3.10")) 55 | self.generic_visit(node) 56 | 57 | Visitor().visit(tree) 58 | return issues 59 | 60 | def scan_project(root_path): 61 | results = [] 62 | for dirpath, _, filenames in os.walk(root_path): 63 | for file in filenames: 64 | if file.endswith(".py"): 65 | filepath = os.path.join(dirpath, file) 66 | issues = find_incompatible_syntax(filepath) 67 | for kind, lineno, message in issues: 68 | results.append((filepath, lineno, kind, message)) 69 | return results 70 | 71 | if __name__ == "__main__": 72 | if len(sys.argv) < 2: 73 | print("Usage: python check_py37_compat.py /path/to/project") 74 | sys.exit(1) 75 | 76 | root = sys.argv[1] 77 | findings = scan_project(root) 78 | 79 | if not findings: 80 | print("✅ No Python 3.7 incompatibilities found.") 81 | else: 82 | print("❌ Found potential Python 3.7 incompatibilities:\n") 83 | for path, line, kind, msg in findings: 84 | print(f"{path}:{line} [{kind}] {msg}") 85 | -------------------------------------------------------------------------------- /resources/skins/Default/1080i/filter_items.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 635 4 | 215 5 | 650 6 | 650 7 | 8 | 2000 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 0.40 18 | 19 | 20 | 21 | 22 | 0 23 | 0 24 | 650 25 | 650 26 | circle.png 27 | CC000000 28 | 29 | 30 | 31 | 32 | 0 33 | 30 34 | 650 35 | 60 36 | font16_title 37 | white 38 | center 39 | 40 | 41 | 42 | 43 | 44 | 25 45 | 110 46 | 600 47 | 510 48 | 2000 49 | 2000 50 | 2000 51 | 2000 52 | 20 53 | 54 | 55 | 56 | 57 | 600 58 | 70 59 | 60 | 0 61 | 10 62 | 600 63 | 50 64 | circle.png 65 | CC222222 66 | 67 | 68 | 0 69 | 10 70 | 600 71 | 50 72 | font14 73 | white 74 | center 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 600 84 | 70 85 | 86 | 0 87 | 10 88 | 600 89 | 50 90 | circle.png 91 | FF12A0C7 92 | 93 | 94 | 0 95 | 10 96 | 600 97 | 50 98 | font14 99 | white 100 | center 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /resources/skins/Default/1080i/playing_next.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog.Close(fullscreeninfo,true) 4 | Dialog.Close(videoosd,true) 5 | 6 | 3001 7 | 8 | 9 | 10 | 20 11 | 150 12 | 520 13 | 120 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -6 27 | -6 28 | circle.png 29 | 00000000 30 | 31 | 32 | 33 | 34 | true 35 | 36 | 37 | 38 | circle.png 39 | circle.png 40 | 41 | 42 | 43 | 44 | circle.png 45 | 46 | 47 | 48 | 49 | 25 50 | font12 51 | center 52 | ffffffff 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 20 61 | 50 62 | 520 63 | 120 64 | 65 | 66 | horizontal 67 | 56 68 | 20 69 | center 70 | 71 | 72 | 73 | 74 | auto 75 | font12 76 | 20 77 | ddffffff 78 | eeffffff 79 | ddffffff 80 | center 81 | circle.png 82 | circle.png 83 | no 84 | 85 | 86 | 87 | 88 | 89 | auto 90 | font12 91 | 20 92 | DDFFFFFF 93 | EEFFFFFF 94 | DDFFFFFF 95 | center 96 | circle.png 97 | circle.png 98 | no 99 | 100 | 101 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /lib/clients/jackgram/client.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Optional, Any, Callable 2 | from lib.clients.base import BaseClient, TorrentStream 3 | from lib.utils.kodi.utils import kodilog, translation 4 | 5 | 6 | class Jackgram(BaseClient): 7 | def __init__(self, host: str, notification: Callable) -> None: 8 | super().__init__(host, notification) 9 | 10 | def search( 11 | self, 12 | tmdb_id: str, 13 | query: str, 14 | mode: str, 15 | media_type: str, 16 | season: Optional[int], 17 | episode: Optional[int], 18 | ) -> Optional[List[TorrentStream]]: 19 | try: 20 | if mode == "tv" or media_type == "tv": 21 | url = f"{self.host}/stream/series/{tmdb_id}:{season}:{episode}.json" 22 | elif mode == "movies" or media_type == "movies": 23 | url = f"{self.host}/stream/movie/{tmdb_id}.json" 24 | else: 25 | url = f"{self.host}/search?query={query}" 26 | 27 | kodilog(f"URL: {url}") 28 | 29 | res = self.session.get(url, timeout=10) 30 | if res.status_code != 200: 31 | return 32 | 33 | if mode in ["tv", "movies"]: 34 | return self.parse_response(res) 35 | else: 36 | return self.parse_response_search(res) 37 | except Exception as e: 38 | self.handle_exception(f"{translation(30232)}: {e}") 39 | 40 | def get_latest(self, page: int) -> Optional[Dict[str, Any]]: 41 | url = f"{self.host}/stream/latest?page={page}" 42 | res = self.session.get(url, timeout=10) 43 | if res.status_code != 200: 44 | return 45 | return res.json() 46 | 47 | def get_files(self, page: int) -> Optional[Dict[str, Any]]: 48 | url = f"{self.host}/stream/files?page={page}" 49 | res = self.session.get(url, timeout=10) 50 | if res.status_code != 200: 51 | return 52 | return res.json() 53 | 54 | def parse_response(self, res: Any) -> List[TorrentStream]: 55 | res = res.json() 56 | results = [] 57 | for item in res["streams"]: 58 | results.append( 59 | TorrentStream( 60 | title=item["title"], 61 | type="Direct", 62 | indexer=item["name"], 63 | size=item["size"], 64 | publishDate=item["date"], 65 | url=item["url"], 66 | guid=item.get("guid", ""), 67 | infoHash=item.get("infoHash", ""), 68 | seeders=item.get("seeders", 0), 69 | languages=item.get("languages", []), 70 | fullLanguages=item.get("fullLanguages", ""), 71 | provider=item.get("provider", ""), 72 | peers=item.get("peers", 0), 73 | ) 74 | ) 75 | return results 76 | 77 | def parse_response_search(self, res: Any) -> List[TorrentStream]: 78 | res = res.json() 79 | results = [] 80 | for item in res["results"]: 81 | if item.get("type") == "file": 82 | file_info = self._extract_file_info(item) 83 | results.append(TorrentStream(**file_info)) 84 | else: 85 | for file in item.get("files", []): 86 | file_info = self._extract_file_info(file) 87 | results.append(TorrentStream(**file_info)) 88 | return results 89 | 90 | def _extract_file_info(self, file): 91 | return { 92 | "title": file.get("title", ""), 93 | "type": "Direct", 94 | "indexer": file.get("name", ""), 95 | "size": file.get("size", ""), 96 | "publishDate": file.get("date", ""), 97 | "url": file.get("url", ""), 98 | } 99 | -------------------------------------------------------------------------------- /lib/utils/views/last_titles.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from lib.clients.tmdb.utils.utils import tmdb_get 5 | from lib.db.pickle_db import PickleDatabase 6 | from lib.jacktook.utils import kodilog 7 | from lib.utils.general.utils import parse_time, set_media_infoTag, set_pluging_category 8 | from lib.utils.kodi.utils import ( 9 | ADDON_HANDLE, 10 | ADDON_PATH, 11 | build_url, 12 | container_refresh, 13 | end_of_directory, 14 | translation, 15 | ) 16 | 17 | from xbmcgui import ListItem 18 | from xbmcplugin import addDirectoryItem 19 | 20 | 21 | pickle_db = PickleDatabase() 22 | 23 | 24 | def delete_last_title_entry(params): 25 | pickle_db.delete_item(key="jt:lth", subkey=params.get("title")) 26 | container_refresh() 27 | 28 | 29 | def show_last_titles(params): 30 | if params is None: 31 | params = {} 32 | 33 | set_pluging_category(translation(90070)) 34 | 35 | per_page = 10 36 | page = int(params.get("page", 1)) 37 | 38 | all_items = list(reversed(pickle_db.get_key("jt:lth").items())) 39 | total = len(all_items) 40 | 41 | start = (page - 1) * per_page 42 | end = start + per_page 43 | items = all_items[start:end] 44 | 45 | items = sorted(items, key=parse_time, reverse=True) 46 | 47 | # Add "Clear Titles" button 48 | list_item = ListItem(label="Clear Titles") 49 | list_item.setArt( 50 | {"icon": os.path.join(ADDON_PATH, "resources", "img", "clear.png")} 51 | ) 52 | addDirectoryItem(ADDON_HANDLE, build_url("clear_history", type="lth"), list_item) 53 | 54 | for title, data in items: 55 | formatted_time = data["timestamp"] 56 | mode = data["mode"] 57 | ids = data.get("ids") 58 | 59 | if mode == "tv": 60 | details = tmdb_get("tv_details", ids.get("tmdb_id")) 61 | else: 62 | details = tmdb_get("movie_details", ids.get("tmdb_id")) 63 | 64 | if not details: 65 | kodilog(f"Failed to get details for {mode} with ID {ids.get('tmdb_id')}") 66 | continue 67 | 68 | list_item = ListItem(label=f"{title} — {formatted_time}") 69 | set_media_infoTag(list_item, data=details, mode=mode) 70 | list_item.setArt( 71 | {"icon": os.path.join(ADDON_PATH, "resources", "img", "trending.png")} 72 | ) 73 | list_item.addContextMenuItems( 74 | [ 75 | ( 76 | "Delete from history", 77 | f'RunPlugin({build_url("delete_last_title_entry", title=title)})', 78 | ) 79 | ] 80 | ) 81 | 82 | if mode == "tv": 83 | addDirectoryItem( 84 | ADDON_HANDLE, 85 | build_url("show_seasons_details", ids=ids, mode=mode), 86 | list_item, 87 | isFolder=True, 88 | ) 89 | elif mode == "movies": 90 | list_item.setProperty("IsPlayable", "true") 91 | addDirectoryItem( 92 | ADDON_HANDLE, 93 | build_url("search", mode=mode, query=title, ids=ids), 94 | list_item, 95 | isFolder=False, 96 | ) 97 | elif mode == "tg_latest": 98 | addDirectoryItem( 99 | ADDON_HANDLE, 100 | build_url( 101 | "list_telegram_latest_files", data=json.dumps(data.get("tg_data")) 102 | ), 103 | list_item, 104 | isFolder=True, 105 | ) 106 | 107 | # "Next Page" 108 | if end < total: 109 | list_item = ListItem(label=f"Next Page") 110 | list_item.setArt( 111 | {"icon": os.path.join(ADDON_PATH, "resources", "img", "nextpage.png")} 112 | ) 113 | addDirectoryItem( 114 | ADDON_HANDLE, 115 | build_url("titles_history", page=page + 1), 116 | list_item, 117 | isFolder=True, 118 | ) 119 | 120 | end_of_directory() 121 | -------------------------------------------------------------------------------- /lib/clients/tmdb/anime.py: -------------------------------------------------------------------------------- 1 | from lib.clients.tmdb.base import BaseTmdbClient 2 | from lib.clients.tmdb.utils.utils import ( 3 | get_tmdb_movie_details, 4 | get_tmdb_show_details, 5 | ) 6 | 7 | from lib.db.pickle_db import PickleDatabase 8 | from lib.utils.general.utils import ( 9 | Animation, 10 | Anime, 11 | Cartoons, 12 | add_next_button, 13 | execute_thread_pool, 14 | set_media_infoTag, 15 | set_pluging_category, 16 | ) 17 | 18 | from lib.utils.kodi.utils import ( 19 | end_of_directory, 20 | kodilog, 21 | show_keyboard, 22 | notification, 23 | translation, 24 | ) 25 | 26 | 27 | from xbmcgui import ListItem 28 | 29 | 30 | class TmdbAnimeClient(BaseTmdbClient): 31 | def handle_anime_search_query(self, page): 32 | if page == 1: 33 | query = show_keyboard(id=30241) 34 | if query: 35 | PickleDatabase().set_key("anime_query", query) 36 | return query 37 | return None 38 | return PickleDatabase().get_key("anime_query") 39 | 40 | def handle_anime_category_query(self, client, category, submode, page): 41 | if category == Anime.AIRING: 42 | set_pluging_category(translation(90039)) 43 | return client.anime_on_the_air(submode, page) 44 | elif category == Anime.POPULAR: 45 | set_pluging_category(translation(90064)) 46 | return client.anime_popular(submode, page) 47 | elif category == Anime.POPULAR_RECENT: 48 | set_pluging_category(translation(90038)) 49 | return client.anime_popular_recent(submode, page) 50 | else: 51 | kodilog(f"Invalid category: {category}") 52 | return None 53 | 54 | def process_anime_results(self, data, submode, page, mode, category): 55 | if data.total_results == 0: 56 | notification("No results found") 57 | return 58 | execute_thread_pool(data.results, TmdbAnimeClient.show_anime_results, submode) 59 | add_next_button( 60 | "next_page_anime", page=page, mode=mode, submode=submode, category=category 61 | ) 62 | end_of_directory() 63 | 64 | @staticmethod 65 | def handle_anime_years_or_genres(category, mode, page, submode): 66 | if category == Anime.YEARS: 67 | TmdbAnimeClient.show_years_items(mode, page, submode) 68 | elif category == Anime.GENRES: 69 | TmdbAnimeClient.show_genres_items(mode, page, submode) 70 | else: 71 | kodilog(f"Invalid category: {category}") 72 | return None 73 | 74 | @staticmethod 75 | def handle_animation_or_cartoons_query(client, category, submode, page): 76 | if category == Animation().POPULAR: 77 | return client.animation_popular(submode, page) 78 | elif category == Cartoons.POPULAR: 79 | return client.cartoons_popular(submode, page) 80 | else: 81 | kodilog(f"Invalid category: {category}") 82 | return None 83 | 84 | @staticmethod 85 | def show_anime_results(res, mode): 86 | tmdb_id = res.get("id", "") 87 | if mode == "movies": 88 | title = res.title 89 | movie_details = get_tmdb_movie_details(tmdb_id) 90 | imdb_id = getattr(movie_details, "external_ids").get("imdb_id", "") 91 | tvdb_id = "" 92 | elif mode == "tv": 93 | title = res.name 94 | show_details = get_tmdb_show_details(tmdb_id) 95 | external_ids = getattr(show_details, "external_ids") 96 | imdb_id = external_ids.get("imdb_id", "") 97 | tvdb_id = external_ids.get("tvdb_id", "") 98 | else: 99 | kodilog(f"Invalid mode: {mode}") 100 | return None 101 | 102 | ids = {"tmdb_id": tmdb_id, "tvdb_id": tvdb_id, "imdb_id": imdb_id} 103 | list_item = ListItem(label=title) 104 | set_media_infoTag(list_item, data=res, mode=mode) 105 | TmdbAnimeClient.add_media_directory_item(list_item, mode, title, ids) 106 | -------------------------------------------------------------------------------- /lib/clients/torrentio.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List, Dict, Tuple, Optional, Any, Callable 3 | 4 | from lib.clients.base import BaseClient, TorrentStream 5 | from lib.utils.localization.countries import find_language_by_unicode 6 | from lib.utils.kodi.utils import convert_size_to_bytes, kodilog, translation 7 | from lib.utils.general.utils import USER_AGENT_HEADER, unicode_flag_to_country_code 8 | 9 | 10 | class Torrentio(BaseClient): 11 | def __init__(self, host: str, notification: Callable) -> None: 12 | super().__init__(host, notification) 13 | 14 | def search( 15 | self, 16 | imdb_id: str, 17 | mode: str, 18 | media_type: str, 19 | season: Optional[int], 20 | episode: Optional[int], 21 | ) -> Optional[List[TorrentStream]]: 22 | try: 23 | if mode == "tv" or media_type == "tv": 24 | url = f"{self.host}/stream/series/{imdb_id}:{season}:{episode}.json" 25 | elif mode == "movies" or media_type == "movies": 26 | url = f"{self.host}/stream/{mode}/{imdb_id}.json" 27 | else: 28 | self.handle_exception(translation(30228)) 29 | return None 30 | 31 | res = self.session.get(url, headers=USER_AGENT_HEADER, timeout=10) 32 | if res.status_code != 200: 33 | return 34 | return self.parse_response(res) 35 | except Exception as e: 36 | self.handle_exception(f"{translation(30228)}: {str(e)}") 37 | 38 | def parse_response(self, res: Any) -> List[TorrentStream]: 39 | res = res.json() 40 | results = [] 41 | for item in res["streams"]: 42 | parsed_item = self.parse_stream_title(item["title"]) 43 | results.append( 44 | TorrentStream( 45 | title=parsed_item["title"], 46 | type="Torrent", 47 | indexer="Torrentio", 48 | guid=item["infoHash"], 49 | infoHash=item["infoHash"], 50 | size=parsed_item["size"], 51 | seeders=parsed_item["seeders"], 52 | languages=parsed_item["languages"], 53 | fullLanguages=parsed_item["full_languages"], 54 | provider=parsed_item["provider"], 55 | publishDate="", 56 | peers=0, 57 | ) 58 | ) 59 | return results 60 | 61 | def parse_stream_title(self, title: str) -> Dict[str, Any]: 62 | name = title.splitlines()[0] 63 | 64 | size_match = re.search(r"💾 (\d+(?:\.\d+)?\s*(GB|MB))", title, re.IGNORECASE) 65 | size = size_match.group(1) if size_match else "" 66 | size = convert_size_to_bytes(size) 67 | 68 | seeders_match = re.search(r"👤 (\d+)", title) 69 | seeders = int(seeders_match.group(1)) if seeders_match else None 70 | 71 | languages, full_languages = self.extract_languages(title) 72 | 73 | provider = self.extract_provider(title) 74 | 75 | return { 76 | "title": name, 77 | "size": size, 78 | "seeders": seeders, 79 | "languages": languages, 80 | "full_languages": full_languages, 81 | "provider": provider, 82 | } 83 | 84 | def extract_languages(self, title: str) -> Tuple[List[str], List[str]]: 85 | languages = [] 86 | full_languages = [] 87 | # Regex to match unicode country flag emojis 88 | flag_emojis = re.findall(r"[\U0001F1E6-\U0001F1FF]{2}", title) 89 | if flag_emojis: 90 | for flag in flag_emojis: 91 | languages.append(unicode_flag_to_country_code(flag).upper()) 92 | full_lang = find_language_by_unicode(flag) 93 | if (full_lang != None) and (full_lang not in full_languages): 94 | full_languages.append(full_lang) 95 | return languages, full_languages 96 | 97 | def extract_provider(self, title: str) -> str: 98 | match = re.search(r"⚙.* ([^ \n]+)", title) 99 | return match.group(1) if match else "" 100 | -------------------------------------------------------------------------------- /lib/clients/debrid/premiumize.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from typing import Dict, List, Any, Optional 3 | from lib.api.debrid.premiumize import Premiumize 4 | from lib.utils.kodi.utils import get_setting, kodilog, notification 5 | from lib.utils.general.utils import ( 6 | DebridType, 7 | IndexerType, 8 | debrid_dialog_update, 9 | filter_debrid_episode, 10 | get_cached, 11 | info_hash_to_magnet, 12 | set_cached, 13 | supported_video_extensions, 14 | ) 15 | from lib.domain.torrent import TorrentStream 16 | 17 | 18 | class PremiumizeHelper: 19 | def __init__(self): 20 | self.client = Premiumize(token=get_setting("premiumize_token")) 21 | 22 | def check_cached( 23 | self, 24 | results: List[TorrentStream], 25 | cached_results: List[TorrentStream], 26 | uncached_results: List[TorrentStream], 27 | total: int, 28 | dialog: Any, 29 | lock: Any, 30 | ) -> None: 31 | """Checks if torrents are cached in Premiumize.""" 32 | hashes = [res.infoHash for res in results] 33 | torrents_info = self.client.get_torrent_instant_availability(hashes) 34 | cached_response = torrents_info.get("response", []) 35 | 36 | for index, res in enumerate(copy.deepcopy(results)): 37 | debrid_dialog_update("PM", total, dialog, lock) 38 | res.type = IndexerType.DEBRID 39 | res.debridType = DebridType.PM 40 | 41 | if index < len(cached_response) and cached_response[index] is True: 42 | res.isCached = True 43 | cached_results.append(res) 44 | else: 45 | res.isCached = False 46 | uncached_results.append(res) 47 | 48 | def get_link(self, info_hash, data) -> Optional[Dict[str, Any]]: 49 | """Gets a direct download link for a Premiumize torrent.""" 50 | magnet = info_hash_to_magnet(info_hash) 51 | response_data = self.client.create_download_link(magnet) 52 | 53 | if response_data.get("status") == "error": 54 | kodilog( 55 | f"Failed to get link from Premiumize: {response_data.get('message')}" 56 | ) 57 | return None 58 | 59 | content = response_data.get("content", []) 60 | if len(content) > 1: 61 | if data["tv_data"]: 62 | season = data["tv_data"].get("season", "") 63 | episode = data["tv_data"].get("episode", "") 64 | content = filter_debrid_episode( 65 | content, episode_num=episode, season_num=season 66 | ) 67 | if not content: 68 | return 69 | data["url"] = content[0].get("stream_link") 70 | return data 71 | else: 72 | data["is_pack"] = True 73 | return data 74 | else: 75 | data["url"] = content[0].get("stream_link") 76 | return data 77 | 78 | def get_pack_info(self, info_hash): 79 | """Retrieves information about a torrent pack, including file names.""" 80 | info = get_cached(info_hash) 81 | if info: 82 | return info 83 | 84 | extensions = supported_video_extensions()[:-1] 85 | magnet = info_hash_to_magnet(info_hash) 86 | response_data = self.client.create_download_link(magnet) 87 | 88 | if response_data.get("status") == "error": 89 | notification( 90 | f"Failed to get link from Premiumize: {response_data.get('message')}" 91 | ) 92 | return None 93 | 94 | torrent_content = response_data.get("content", []) 95 | if len(torrent_content) <= 1: 96 | notification("No files on the current source") 97 | return None 98 | 99 | files = [ 100 | (item.get("link"), item.get("path").rsplit("/", 1)[-1]) 101 | for item in torrent_content 102 | if any(item.get("path", "").lower().endswith(ext) for ext in extensions) 103 | and item.get("link") 104 | ] 105 | 106 | info = {"files": files} 107 | set_cached(info, info_hash) 108 | return info 109 | -------------------------------------------------------------------------------- /lib/api/debrid/torbox.py: -------------------------------------------------------------------------------- 1 | from lib.api.debrid.base import DebridClient, ProviderException 2 | from lib.utils.kodi.utils import notification 3 | 4 | 5 | class Torbox(DebridClient): 6 | BASE_URL = "https://api.torbox.app/v1/api" 7 | 8 | def initialize_headers(self): 9 | self.headers = { 10 | "Authorization": f"Bearer {self.token}", 11 | "User-Agent": "Jacktook/1.0", 12 | "Accept": "application/json", 13 | } 14 | 15 | def disable_access_token(self): 16 | pass 17 | 18 | def _handle_service_specific_errors(self, error_data: dict, status_code: int): 19 | error_code = error_data.get("error") 20 | if error_code in {"BAD_TOKEN", "AUTH_ERROR", "OAUTH_VERIFICATION_ERROR"}: 21 | raise ProviderException("Invalid Torbox token") 22 | elif error_code == "DOWNLOAD_TOO_LARGE": 23 | raise ProviderException("Download size too large for the user plan") 24 | elif error_code in {"ACTIVE_LIMIT", "MONTHLY_LIMIT"}: 25 | raise ProviderException("Download limit exceeded") 26 | elif error_code in {"DOWNLOAD_SERVER_ERROR", "DATABASE_ERROR"}: 27 | raise ProviderException("Torbox server error") 28 | 29 | def _make_request( 30 | self, 31 | method, 32 | url, 33 | data=None, 34 | params=None, 35 | json=None, 36 | is_return_none=False, 37 | is_expected_to_fail=False, 38 | ): 39 | params = params or {} 40 | url = self.BASE_URL + url 41 | return super()._make_request( 42 | method, 43 | url, 44 | data=data, 45 | params=params, 46 | json=json, 47 | is_return_none=is_return_none, 48 | is_expected_to_fail=is_expected_to_fail, 49 | ) 50 | 51 | def add_magnet_link(self, magnet_link): 52 | return self._make_request( 53 | "POST", 54 | "/torrents/createtorrent", 55 | data={"magnet": magnet_link}, 56 | is_expected_to_fail=False, 57 | ) 58 | 59 | def get_user_torrent_list(self): 60 | return self._make_request( 61 | "GET", 62 | "/torrents/mylist", 63 | params={"bypass_cache": "true"}, 64 | ) 65 | 66 | def get_torrent_info(self, magnet_id): 67 | response = self.get_user_torrent_list() 68 | torrent_list = response.get("data", {}) 69 | for torrent in torrent_list: 70 | if torrent.get("magnet", "") == magnet_id: 71 | return torrent 72 | 73 | def get_available_torrent(self, info_hash): 74 | response = self.get_user_torrent_list() 75 | torrent_list = response.get("data", {}) 76 | for torrent in torrent_list: 77 | if torrent.get("hash", "") == info_hash: 78 | return torrent 79 | 80 | def get_torrent_instant_availability(self, torrent_hashes): 81 | return self._make_request( 82 | "GET", 83 | "/torrents/checkcached", 84 | params={"hash": torrent_hashes, "format": "object"}, 85 | ) 86 | 87 | def create_download_link(self, torrent_id, filename, user_ip=None): 88 | params = { 89 | "token": self.token, 90 | "torrent_id": torrent_id, 91 | "file_id": filename, 92 | } 93 | if user_ip: 94 | params["user_ip"] = user_ip 95 | 96 | response = self._make_request( 97 | "GET", 98 | "/torrents/requestdl", 99 | params=params, 100 | is_expected_to_fail=True, 101 | ) 102 | detail = response.get("detail", "") 103 | if "successfully" in detail: 104 | return response 105 | else: 106 | notification(f"Failed to create download link: {detail}") 107 | return None 108 | 109 | def delete_torrent(self, torrent_id): 110 | self._make_request( 111 | "POST", 112 | "/torrents/controltorrent", 113 | data={"torrent_id": torrent_id, "operation": "Delete"}, 114 | is_expected_to_fail=False, 115 | ) 116 | 117 | def download(self): 118 | pass -------------------------------------------------------------------------------- /lib/api/tmdbv3api/objs/anime.py: -------------------------------------------------------------------------------- 1 | from lib.api.tmdbv3api.tmdb import TMDb 2 | from lib.api.tmdbv3api.utils import get_current_date, get_dates, years_tvshows 3 | 4 | 5 | class TmdbAnime(TMDb): 6 | _urls = { 7 | "discover_tv": "/discover/tv", 8 | "discover_movie": "/discover/movie", 9 | "search_tv": "/search/tv", 10 | "search_movie": "/search/movie", 11 | "tv_keywords": "/tv/%s/keywords", 12 | "movie_keywords": "/movie/%s/keywords", 13 | } 14 | 15 | def cartoons_popular(self, mode, page_no): 16 | return self._request_obj( 17 | ( 18 | self._urls["discover_tv"] 19 | if mode == "tv" 20 | else self._urls["discover_movie"] 21 | ), 22 | params="with_keywords=6513-cartoon&page=%s" % page_no, 23 | ) 24 | 25 | def animation_popular(self, mode, page_no): 26 | return self._request_obj( 27 | ( 28 | self._urls["discover_tv"] 29 | if mode == "tv" 30 | else self._urls["discover_movie"] 31 | ), 32 | params="with_keywords=297442&page=%s" % page_no, 33 | ) 34 | 35 | def anime_popular(self, mode, page_no): 36 | return self._request_obj( 37 | ( 38 | self._urls["discover_tv"] 39 | if mode == "tv" 40 | else self._urls["discover_movie"] 41 | ), 42 | params="with_keywords=210024&page=%s" % page_no, 43 | ) 44 | 45 | def anime_popular_recent(self, mode, page_no): 46 | return self._request_obj( 47 | ( 48 | self._urls["discover_tv"] 49 | if mode == "tv" 50 | else self._urls["discover_movie"] 51 | ), 52 | params=( 53 | "with_keywords=210024&sort_by=first_air_date.desc&include_null_first_air_dates=false&first_air_date_year=%s&page=%s" 54 | % (years_tvshows[0]["id"], page_no) 55 | ), 56 | ) 57 | 58 | def anime_year(self, params): 59 | return self._request_obj( 60 | ( 61 | self._urls["discover_tv"] 62 | if params["mode"] == "tv" 63 | else self._urls["discover_movie"] 64 | ), 65 | params="with_keywords=210024&include_null_first_air_dates=false&first_air_date_year=%s&page=%s" 66 | % (params["year"], params["page"]), 67 | ) 68 | 69 | def anime_genres(self, params): 70 | return self._request_obj( 71 | ( 72 | self._urls["discover_tv"] 73 | if params["mode"] == "tv" 74 | else self._urls["discover_movie"] 75 | ), 76 | params="&with_keywords=210024&with_genres=%s&include_null_first_air_dates=false&first_air_date.lte=%s&page=%s" 77 | % (params["genre_id"], get_current_date(), params["page"]), 78 | ) 79 | 80 | def anime_on_the_air(self, mode, page_no): 81 | current_date, future_date = get_dates(7, reverse=False) 82 | return self._request_obj( 83 | ( 84 | self._urls["discover_tv"] 85 | if mode == "tv" 86 | else self._urls["discover_movie"] 87 | ), 88 | params="with_keywords=210024&air_date.gte=%s&air_date.lte=%s&page=%s" 89 | % (current_date, future_date, page_no), 90 | ) 91 | 92 | def anime_search(self, query, mode, page_no, adult=False): 93 | params = "query=%s&page=%s" % (query, page_no) 94 | 95 | if adult is not None: 96 | params += "&include_adult=%s" % "true" if adult else "false" 97 | 98 | return self._request_obj( 99 | self._urls["search_tv"] if mode == "tv" else self._urls["search_movie"], 100 | params=params, 101 | key="results", 102 | ) 103 | 104 | def tmdb_keywords(self, mode, tmdb_id): 105 | return self._request_obj( 106 | ( 107 | self._urls["tv_keywords"] 108 | if mode == "tv" 109 | else self._urls["movie_keywords"] 110 | ) 111 | % tmdb_id, 112 | ) 113 | -------------------------------------------------------------------------------- /lib/clients/tmdb/base.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from lib.api.tmdbv3api.as_obj import AsObj 4 | from lib.utils.general.utils import set_pluging_category 5 | from lib.utils.kodi.utils import ( 6 | build_url, 7 | end_of_directory, 8 | notification, 9 | set_view, 10 | translation, 11 | ) 12 | from lib.clients.tmdb.utils.utils import ( 13 | add_kodi_dir_item, 14 | add_tmdb_movie_context_menu, 15 | add_tmdb_show_context_menu, 16 | tmdb_get, 17 | ) 18 | 19 | from xbmcgui import ListItem 20 | 21 | 22 | class BaseTmdbClient: 23 | @staticmethod 24 | def add_media_directory_item(list_item, mode, title, ids, media_type=""): 25 | if mode == "movies" or (mode == "multi" and media_type == "movie"): 26 | context_menu = add_tmdb_movie_context_menu( 27 | mode, media_type, title=title, ids=ids 28 | ) 29 | # if is_trakt_auth(): 30 | # context_menu += add_trakt_watchlist_context_menu( 31 | # "movies", ids 32 | # ) + add_trakt_watched_context_menu("movies", ids=ids) 33 | list_item.addContextMenuItems(context_menu) 34 | list_item.setProperty("IsPlayable", "true") 35 | is_folder = False 36 | elif mode == "tv" or (mode == "multi" and media_type == "tv"): 37 | context_menu = add_tmdb_show_context_menu(mode, ids=ids) 38 | # if is_trakt_auth(): 39 | # context_menu += add_trakt_watchlist_context_menu( 40 | # "shows", ids 41 | # ) + add_trakt_watched_context_menu("shows", ids=ids) 42 | list_item.addContextMenuItems(context_menu) 43 | is_folder = True 44 | else: 45 | is_folder = True 46 | 47 | add_kodi_dir_item( 48 | list_item=list_item, 49 | url=build_url( 50 | "show_tmdb_item", 51 | mode=mode, 52 | submode="", 53 | id=ids.get("tmdb_id"), 54 | title=title, 55 | media_type=media_type, 56 | ), 57 | is_folder=is_folder, 58 | ) 59 | 60 | @staticmethod 61 | def show_years_items(mode, page, submode=None): 62 | set_pluging_category(translation(90027)) 63 | current_year = datetime.now().year 64 | for year in range(current_year, 1899, -1): 65 | list_item = ListItem(label=str(year)) 66 | add_kodi_dir_item( 67 | list_item=list_item, 68 | url=build_url( 69 | "search_tmdb_year", 70 | mode=mode, 71 | submode=submode, 72 | year=year, 73 | page=page, 74 | ), 75 | is_folder=True, 76 | icon_path="status.png", 77 | ) 78 | end_of_directory() 79 | set_view("widelist") 80 | 81 | @staticmethod 82 | def show_genres_items(mode, page, submode=None): 83 | set_pluging_category(translation(90025)) 84 | path = ( 85 | "show_genres" 86 | if mode == "tv" or (mode == "anime" and submode == "tv") 87 | else "movie_genres" 88 | ) 89 | genres = tmdb_get(path=path) 90 | if genres is None or len(genres) == 0: 91 | notification("No genres found") 92 | end_of_directory() 93 | return 94 | 95 | for genre in genres: 96 | if isinstance(genre, AsObj): 97 | if genre.get("name") == "TV Movie": 98 | continue 99 | list_item = ListItem(label=genre["name"]) 100 | add_kodi_dir_item( 101 | list_item=list_item, 102 | url=build_url( 103 | "search_tmdb_genres", 104 | mode=mode, 105 | submode=submode, 106 | genre_id=genre["id"], 107 | page=page, 108 | ), 109 | is_folder=True, 110 | icon_path=None, 111 | ) 112 | end_of_directory() 113 | set_view("widelist") 114 | -------------------------------------------------------------------------------- /resources/skins/Default/1080i/custom_progress_dialog.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 600 4 | 400 5 | 800 6 | 300 7 | 8 | 12005 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 0.40 18 | 19 | 20 | 800 21 | 300 22 | 23 | 800 24 | 300 25 | circle.png 26 | CC000000 27 | 28 | 29 | 30 30 | 780 31 | 40 32 | center 33 | font14 34 | white 35 | AA000000 36 | 37 | 38 | 80 39 | 780 40 | 60 41 | center 42 | font12 43 | white 44 | AA000000 45 | true 46 | 47 | 48 | 180 49 | 40 50 | 720 51 | 20 52 | percentage 53 | 54 | 55 | horizontal 56 | 220 57 | 200 58 | 100 59 | 60 | 61 | auto 62 | 56 63 | 64 | center 65 | 20 66 | font14 67 | DDFFFFFF 68 | EEFFFFFF 69 | DDFFFFFF 70 | circle.png 71 | circle.png 72 | no 73 | 74 | 75 | 76 | auto 77 | 56 78 | 79 | center 80 | 20 81 | font14 82 | DDFFFFFF 83 | EEFFFFFF 84 | DDFFFFFF 85 | circle.png 86 | circle.png 87 | no 88 | 89 | 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /lib/clients/subtitle/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import unicodedata 3 | 4 | 5 | def slugify_title(title): 6 | title = re.sub(r"\.[^.]+$", "", title) # Remove extension 7 | title = re.sub(r"[^\w\s.-]", "", title) # Remove unwanted characters 8 | return title.strip() 9 | 10 | 11 | def slugify(value): 12 | # 1. Normaliza el texto para separar letras acentuadas (ñ → n, á → a, etc.) 13 | value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode() 14 | 15 | # 3. Reemplaza espacios y guiones múltiples con un solo guion bajo 16 | return re.sub(r"[-\s]+", "_", value) 17 | 18 | 19 | def get_deepl_language_code(language_name): 20 | language_mapping = { 21 | "None": "None", 22 | "العربية": "ar", 23 | "Български": "bg", 24 | "中文": "zh", 25 | "繁體中文": "zh-Hant", 26 | "简体中文": "zh-Hans", 27 | "Čeština": "cs", 28 | "Dansk": "da", 29 | "Nederlands": "nl", 30 | "English": "en", 31 | "English (British)": "en-GB", 32 | "English (American)": "en-US", 33 | "Eesti": "et", 34 | "Suomi": "fi", 35 | "Français": "fr", 36 | "Deutsch": "de", 37 | "Ελληνικά": "el", 38 | "Magyar": "hu", 39 | "Indonesia": "id", 40 | "Italiano": "it", 41 | "日本語": "ja", 42 | "한국어": "ko", 43 | "Latviešu": "lv", 44 | "Lietuvių": "lt", 45 | "Norsk (Bokmål)": "nb", 46 | "Polski": "pl", 47 | "Português": "pt", 48 | "Português (Brasil)": "pt-BR", 49 | "Română": "ro", 50 | "Русский": "ru", 51 | "Slovenčina": "sk", 52 | "Slovenščina": "sl", 53 | "Español": "es", 54 | "Svenska": "sv", 55 | "Türkçe": "tr", 56 | "Українська": "uk", 57 | } 58 | return language_mapping.get(language_name, None) 59 | 60 | 61 | def get_language_code(language_name): 62 | """ 63 | Convert full language names to ISO 639-1 or 639-2 language codes. 64 | """ 65 | language_map = { 66 | "None": "None", 67 | "Arabic": "ara", 68 | "Bulgarian": "bul", 69 | "Czech": "ces", 70 | "Danish": "dan", 71 | "German": "deu", 72 | "Greek": "ell", 73 | "English": "eng", 74 | "Spanish": "spa", 75 | "Estonian": "est", 76 | "Finnish": "fin", 77 | "French": "fra", 78 | "Hungarian": "hun", 79 | "Indonesian": "ind", 80 | "Italian": "ita", 81 | "Japanese": "jpn", 82 | "Korean": "kor", 83 | "Lithuanian": "lit", 84 | "Latvian": "lav", 85 | "Dutch": "nld", 86 | "Polish": "pol", 87 | "Portuguese": "por", 88 | "Romanian": "ron", 89 | "Russian": "rus", 90 | "Slovak": "slk", 91 | "Slovenian": "slv", 92 | "Swedish": "swe", 93 | "Turkish": "tur", 94 | "Ukrainian": "ukr", 95 | "Chinese": "zho", 96 | } 97 | 98 | return language_map.get(language_name, None) 99 | 100 | 101 | def language_code_to_name(code): 102 | """ 103 | Convert ISO 639-1 or 639-2 language codes to full language names. 104 | """ 105 | lang_map = { 106 | "eng": "English", 107 | "fre": "French", 108 | "fra": "French", 109 | "ger": "German", 110 | "deu": "German", 111 | "spa": "Spanish", 112 | "spl": "Spanish (Latin)", 113 | "srp": "Serbian", 114 | "hrv": "Croatian", 115 | "slo": "Slovak", 116 | "slk": "Slovak", 117 | "slv": "Slovenian", 118 | "ell": "Greek", 119 | "gre": "Greek", 120 | "ara": "Arabic", 121 | "jpn": "Japanese", 122 | "vie": "Vietnamese", 123 | "ron": "Romanian", 124 | "rum": "Romanian", 125 | "kur": "Kurdish", 126 | "ind": "Indonesian", 127 | "tur": "Turkish", 128 | "mal": "Malayalam", 129 | "per": "Persian", 130 | "fas": "Persian", 131 | "ita": "Italian", 132 | "nld": "Dutch", 133 | "dut": "Dutch", 134 | "por": "Portuguese", 135 | "zho": "Chinese", 136 | "chi": "Chinese", 137 | # Add more as needed 138 | } 139 | 140 | return lang_map.get(code.lower(), f"Unknown ({code})") 141 | -------------------------------------------------------------------------------- /service.py: -------------------------------------------------------------------------------- 1 | from threading import Thread 2 | from lib.api.trakt.base_cache import setup_databases 3 | from lib.utils.kodi.utils import ( 4 | get_kodi_version, 5 | get_property_no_fallback, 6 | get_setting, 7 | kodilog, 8 | clear_property, 9 | dialog_ok, 10 | set_property_no_fallback, 11 | translatePath, 12 | ) 13 | from time import time 14 | from lib.utils.kodi.settings import update_delay 15 | from lib.updater import updates_check_addon 16 | 17 | import xbmcaddon 18 | import xbmcvfs 19 | import xbmc 20 | 21 | first_run_update_prop = "jacktook.first_run_update" 22 | pause_services_prop = "jacktook.pause_services" 23 | 24 | 25 | class CheckKodiVersion: 26 | def run(self): 27 | kodilog("Checking Kodi version...") 28 | if get_kodi_version() < 20: 29 | dialog_ok( 30 | heading="Jacktook", 31 | line1="Kodi 20 or above required. Please update Kodi to use thid addon-", 32 | ) 33 | 34 | 35 | class DatabaseSetup: 36 | def run(self): 37 | setup_databases() 38 | 39 | 40 | class UpdateCheck: 41 | def run(self): 42 | kodilog("Update Check Service Started...") 43 | if get_property_no_fallback(first_run_update_prop) == "true": 44 | kodilog("Update check already performed, skipping...") 45 | return 46 | 47 | try: 48 | end_pause = time() + update_delay() 49 | monitor = xbmc.Monitor() 50 | player = xbmc.Player() 51 | 52 | while not monitor.abortRequested(): 53 | while time() < end_pause: 54 | monitor.waitForAbort(1) 55 | while player.isPlayingVideo(): 56 | monitor.waitForAbort(1) 57 | updates_check_addon() 58 | break 59 | 60 | set_property_no_fallback(first_run_update_prop, "true") 61 | kodilog("Update Check Service Finished") 62 | except Exception as e: 63 | kodilog(e) 64 | 65 | 66 | def TMDBHelperAutoInstall(): 67 | try: 68 | _ = xbmcaddon.Addon("plugin.video.themoviedb.helper") 69 | except RuntimeError: 70 | return 71 | 72 | tmdb_helper_path = "special://home/addons/plugin.video.themoviedb.helper/resources/players/jacktook.select.json" 73 | if xbmcvfs.exists(tmdb_helper_path): 74 | return 75 | jacktook_select_path = ( 76 | "special://home/addons/plugin.video.jacktook/jacktook.select.json" 77 | ) 78 | if not xbmcvfs.exists(jacktook_select_path): 79 | kodilog("jacktook.select.json file not found!") 80 | return 81 | 82 | ok = xbmcvfs.copy(jacktook_select_path, tmdb_helper_path) 83 | if not ok: 84 | kodilog("Error installing jacktook.select.json file!") 85 | return 86 | 87 | 88 | class DownloaderSetup: 89 | def run(self): 90 | download_dir = get_setting("download_dir") 91 | translated_path = translatePath(download_dir) 92 | if not xbmcvfs.exists(translated_path): 93 | xbmcvfs.mkdir(translated_path) 94 | 95 | 96 | class JacktookMOnitor(xbmc.Monitor): 97 | def __init__(self): 98 | xbmc.Monitor.__init__(self) 99 | self.startServices() 100 | 101 | def startServices(self): 102 | CheckKodiVersion().run() 103 | DatabaseSetup().run() 104 | Thread(target=UpdateCheck().run).start() 105 | DownloaderSetup().run() 106 | TMDBHelperAutoInstall() 107 | 108 | def onScreensaverActivated(self): 109 | set_property_no_fallback(pause_services_prop, 'true') 110 | kodilog("PAUSING Jacktook Services Due to Device Sleep") 111 | 112 | def onScreensaverDeactivated(self): 113 | clear_property(pause_services_prop) 114 | kodilog("UNPAUSING Jacktook Services Due to Device Awake") 115 | 116 | def onNotification(self, sender, method, data): 117 | if method == 'System.OnSleep': 118 | set_property_no_fallback(pause_services_prop, "true") 119 | kodilog("PAUSING Jacktook Services Due to Device Sleep") 120 | elif method == 'System.OnWake': 121 | clear_property(pause_services_prop) 122 | kodilog("UNPAUSING Jacktook Services Due to Device Awake") 123 | 124 | 125 | JacktookMOnitor().waitForAbort() 126 | -------------------------------------------------------------------------------- /lib/clients/debrid/torbox.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from typing import Dict, List, Any, Optional 3 | from lib.api.debrid.torbox import Torbox 4 | from lib.utils.kodi.utils import get_setting, notification 5 | from lib.utils.general.utils import ( 6 | DebridType, 7 | IndexerType, 8 | debrid_dialog_update, 9 | get_cached, 10 | get_public_ip, 11 | info_hash_to_magnet, 12 | set_cached, 13 | supported_video_extensions, 14 | ) 15 | from lib.domain.torrent import TorrentStream 16 | 17 | EXTENSIONS = supported_video_extensions()[:-1] 18 | 19 | 20 | class TorboxException(Exception): 21 | def __init__(self, message): 22 | self.message = message 23 | super().__init__(self.message) 24 | 25 | 26 | class TorboxHelper: 27 | def __init__(self): 28 | self.client = Torbox(token=get_setting("torbox_token")) 29 | 30 | def check_cached( 31 | self, 32 | results: List[TorrentStream], 33 | cached_results: List[TorrentStream], 34 | uncached_results: List[TorrentStream], 35 | total: int, 36 | dialog: Any, 37 | lock: Any, 38 | ) -> None: 39 | hashes = [res.infoHash for res in results] 40 | response = self.client.get_torrent_instant_availability(hashes) 41 | cached_response = response.get("data", []) 42 | 43 | for res in copy.deepcopy(results): 44 | debrid_dialog_update("TB", total, dialog, lock) 45 | res.type = IndexerType.DEBRID 46 | res.debridType = DebridType.TB 47 | 48 | with lock: 49 | if res.infoHash in cached_response: 50 | res.isCached = True 51 | cached_results.append(res) 52 | else: 53 | res.isCached = False 54 | uncached_results.append(res) 55 | 56 | def add_torbox_torrent(self, info_hash): 57 | torrent_info = self.client.get_available_torrent(info_hash) 58 | if ( 59 | torrent_info 60 | and torrent_info.get("download_finished") 61 | and torrent_info.get("download_present") 62 | ): 63 | return torrent_info 64 | 65 | magnet = info_hash_to_magnet(info_hash) 66 | response = self.client.add_magnet_link(magnet) 67 | 68 | if not response.get("success"): 69 | raise TorboxException(f"Failed to add magnet link to Torbox: {response}") 70 | 71 | if "Found Cached" in response.get("detail", ""): 72 | return self.client.get_available_torrent(info_hash) 73 | 74 | def get_link(self, info_hash, data) -> Optional[Dict[str, Any]]: 75 | torrent_info = self.add_torbox_torrent(info_hash) 76 | if not torrent_info: 77 | return None 78 | 79 | file = max(torrent_info["files"], key=lambda x: x.get("size", 0)) 80 | response_data = self.client.create_download_link( 81 | torrent_info.get("id"), file.get("id"), get_public_ip() 82 | ) 83 | if response_data: 84 | data["url"] = response_data.get("data", {}) 85 | return data 86 | 87 | def get_pack_link(self, data) -> Optional[Dict[str, Any]]: 88 | pack_info = data.get("pack_info", {}) 89 | file_id = pack_info.get("file_id", "") 90 | torrent_id = pack_info.get("torrent_id", "") 91 | 92 | response_data = self.client.create_download_link(torrent_id, file_id) 93 | if response_data: 94 | data["url"] = response_data.get("data", {}) 95 | return data 96 | 97 | def get_pack_info(self, info_hash): 98 | info = get_cached(info_hash) 99 | if info: 100 | return info 101 | 102 | torrent_info = self.add_torbox_torrent(info_hash) 103 | if not torrent_info: 104 | return None 105 | 106 | info = {"id": torrent_info["id"], "files": []} 107 | torrent_files = torrent_info.get("files", []) 108 | 109 | if not torrent_files: 110 | notification("No files on the current source") 111 | return None 112 | 113 | files = [ 114 | (id, item["name"]) 115 | for id, item in enumerate(torrent_files) 116 | if any(item["short_name"].lower().endswith(ext) for ext in EXTENSIONS) 117 | ] 118 | 119 | info["files"] = files 120 | set_cached(info, info_hash) 121 | return info 122 | -------------------------------------------------------------------------------- /lib/api/tmdbv3api/as_obj.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import sys 3 | from .exceptions import TMDbException 4 | 5 | 6 | class AsObj: 7 | def __init__(self, json=None, key=None, dict_key=False, dict_key_name=None): 8 | self._json = json if json else {} 9 | self._key = key 10 | self._dict_key = dict_key 11 | self._dict_key_name = dict_key_name 12 | self._obj_list = [] 13 | self._list_only = False 14 | if isinstance(self._json, list): 15 | self._obj_list = [AsObj(o) if isinstance(o, (dict, list)) else o for o in self._json] 16 | self._list_only = True 17 | elif dict_key: 18 | self._obj_list = [ 19 | AsObj({k: v}, key=k, dict_key_name=dict_key_name) if isinstance(v, (dict, list)) else v 20 | for k, v in self._json.items() 21 | ] 22 | self._list_only = True 23 | else: 24 | for key, value in self._json.items(): 25 | if isinstance(value, (dict, list)): 26 | if self._key and key == self._key: 27 | final = AsObj(value, dict_key=isinstance(value, dict), dict_key_name=key) 28 | self._obj_list = final 29 | else: 30 | final = AsObj(value) 31 | else: 32 | final = value 33 | if dict_key_name: 34 | setattr(self, dict_key_name, key) 35 | setattr(self, key, final) 36 | 37 | def _dict(self): 38 | return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} 39 | 40 | def __delitem__(self, key): 41 | return delattr(self, key) 42 | 43 | def __getitem__(self, key): 44 | if isinstance(key, int) and self._obj_list: 45 | return self._obj_list[key] 46 | else: 47 | return getattr(self, key) 48 | 49 | def __iter__(self): 50 | return (o for o in self._obj_list) if self._obj_list else iter(self._dict()) 51 | 52 | def __len__(self): 53 | return len(self._obj_list) if self._obj_list else len(self._dict()) 54 | 55 | def __repr__(self): 56 | return str(self._obj_list) if self._list_only else str(self._dict()) 57 | 58 | def __setitem__(self, key, value): 59 | return setattr(self, key, value) 60 | 61 | def __str__(self): 62 | return str(self._obj_list) if self._list_only else str(self._dict()) 63 | 64 | if sys.version_info >= (3, 8): 65 | def __reversed__(self): 66 | return reversed(self._dict()) 67 | 68 | if sys.version_info >= (3, 9): 69 | def __class_getitem__(self, key): 70 | return self.__dict__.__class_getitem__(key) 71 | 72 | def __ior__(self, value): 73 | return self._dict().__ior__(value) 74 | 75 | def __or__(self, value): 76 | return self._dict().__or__(value) 77 | 78 | def copy(self): 79 | return AsObj(self._json.copy(), key=self._key, dict_key=self._dict_key, dict_key_name=self._dict_key_name) 80 | 81 | def get(self, key, value=None): 82 | return self._dict().get(key, value) 83 | 84 | def items(self): 85 | return self._dict().items() 86 | 87 | def keys(self): 88 | return self._dict().keys() 89 | 90 | def pop(self, key, value=None): 91 | return self.__dict__.pop(key, value) 92 | 93 | def popitem(self): 94 | return self.__dict__.popitem() 95 | 96 | def setdefault(self, key, value=None): 97 | return self.__dict__.setdefault(key, value) 98 | 99 | def update(self, entries): 100 | return self.__dict__.update(entries) 101 | 102 | def values(self): 103 | return self._dict().values() 104 | 105 | def to_dict(self): 106 | if self._list_only: 107 | return [ 108 | o.to_dict() if isinstance(o, AsObj) else o 109 | for o in self._obj_list 110 | ] 111 | else: 112 | result = {} 113 | for k, v in self._dict().items(): 114 | if isinstance(v, AsObj): 115 | result[k] = v.to_dict() 116 | elif isinstance(v, list): 117 | result[k] = [ 118 | i.to_dict() if isinstance(i, AsObj) else i 119 | for i in v 120 | ] 121 | else: 122 | result[k] = v 123 | return result 124 | -------------------------------------------------------------------------------- /lib/clients/zilean.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from types import SimpleNamespace 4 | from requests import ConnectTimeout, ReadTimeout 5 | from requests.exceptions import RequestException 6 | from lib.clients.base import BaseClient, TorrentStream 7 | from lib.utils.general.utils import USER_AGENT_HEADER, info_hash_to_magnet 8 | from typing import List, Optional, Dict, Any, Callable 9 | from lib.utils.kodi.utils import notification 10 | 11 | 12 | class Zilean(BaseClient): 13 | def __init__(self, host: str, timeout: int, notification: Callable) -> None: 14 | super().__init__(host, notification) 15 | self.timeout = timeout 16 | self.initialized = self.validate() 17 | if not self.initialized: 18 | return 19 | 20 | def validate(self) -> bool: 21 | try: 22 | response = self.ping() 23 | return response.ok 24 | except Exception as e: 25 | notification(f"Zilean failed to initialize: {e}") 26 | return False 27 | 28 | def search( 29 | self, 30 | query: str, 31 | mode: str, 32 | media_type: str, 33 | season: Optional[int], 34 | episode: Optional[int], 35 | ) -> Optional[List[TorrentStream]]: 36 | try: 37 | data = self.api_scrape(query, mode, media_type, season, episode) 38 | if not data: 39 | return None 40 | return self.parse_response(data) 41 | except RateLimitExceeded: 42 | logging.warning(f"Zilean ratelimit exceeded for query: {query}") 43 | except ConnectTimeout: 44 | logging.warning(f"Zilean connection timeout for query: {query}") 45 | except ReadTimeout: 46 | logging.warning(f"Zilean read timeout for query: {query}") 47 | except RequestException as e: 48 | logging.error(f"Zilean request exception: {e}") 49 | except Exception as e: 50 | logging.error(f"Zilean exception thrown: {e}") 51 | 52 | def api_scrape( 53 | self, 54 | query: str, 55 | mode: str, 56 | media_type: str, 57 | season: Optional[int], 58 | episode: Optional[int], 59 | ) -> Optional[List[Dict[str, Any]]]: 60 | filtered_url = f"{self.host}/dmm/filtered" 61 | search_url = f"{self.host}/dmm/search" 62 | 63 | if mode in {"tv", "movies"} or media_type in {"tv", "movies"}: 64 | params: Dict[str, Any] = {"Query": query} 65 | if mode == "tv" or media_type == "tv": 66 | params.update({"Season": season, "Episode": episode}) 67 | 68 | res = self.session.get( 69 | filtered_url, params=params, headers=USER_AGENT_HEADER, timeout=10 70 | ) 71 | else: 72 | payload = {"queryText": query} 73 | res = self.session.post( 74 | search_url, 75 | json=payload, 76 | headers=USER_AGENT_HEADER, 77 | timeout=self.timeout, 78 | ) 79 | if res.status_code != 200: 80 | return 81 | response = json.loads( 82 | res.content, object_hook=lambda item: SimpleNamespace(**item) 83 | ) 84 | torrents = [] 85 | for result in response: 86 | torrents.append( 87 | { 88 | "infoHash": result.info_hash, 89 | "filename": result.raw_title, 90 | "filesize": result.size, 91 | "languages": result.languages, 92 | } 93 | ) 94 | return torrents 95 | 96 | def parse_response(self, data: List[Dict[str, Any]]) -> List[TorrentStream]: 97 | results = [] 98 | for item in data: 99 | results.append( 100 | TorrentStream( 101 | title=item["filename"], 102 | type="Torrent", 103 | indexer="Zilean", 104 | guid=info_hash_to_magnet(item["infoHash"]), 105 | infoHash=item["infoHash"], 106 | size=item["filesize"], 107 | seeders=0, 108 | languages=item["languages"], 109 | fullLanguages="", 110 | publishDate="", 111 | peers=0, 112 | provider="", 113 | ) 114 | ) 115 | return results 116 | 117 | def ping(self, additional_headers: Optional[Dict[str, str]] = None) -> Any: 118 | return self.session.get( 119 | f"{self.host}/healthchecks/ping", 120 | headers=additional_headers, 121 | timeout=self.timeout, 122 | ) 123 | 124 | 125 | class RateLimitExceeded(Exception): 126 | pass 127 | -------------------------------------------------------------------------------- /lib/api/plex/plex.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | import json 3 | from time import sleep, time 4 | import requests 5 | 6 | from lib.api.plex.settings import settings 7 | from lib.api.plex.models.plex_models import AuthPin, PlexUser 8 | from lib.api.plex.utils import HTTPException, PlexUnauthorizedError 9 | from lib.utils.kodi.utils import copy2clip, dialog_ok, kodilog, set_setting 10 | 11 | import xbmcgui 12 | 13 | class PlexApi: 14 | def __init__(self) -> None: 15 | self.PLEX_AUTH_URL = "https://app.plex.tv/auth#?" 16 | self.PLEX_API_URL = "https://plex.tv/api/v2" 17 | self.client = requests.Session() 18 | self.headers = {"accept": "application/json"} 19 | self.auth_token = None 20 | 21 | def login(self): 22 | auth_pin = self.create_auth_pin() 23 | copy2clip(auth_pin.code) 24 | content = "%s[CR]%s" % ( 25 | f"Navigate to: [B]https://www.plex.tv/link[/B]", 26 | f"and enter the code: [COLOR seagreen][B]{auth_pin.code}[/B][/COLOR] " 27 | ) 28 | progressDialog = xbmcgui.DialogProgress() 29 | progressDialog.create("Plex Auth") 30 | progressDialog.update(-1, content) 31 | kodilog("Start polling plex.tv for token") 32 | start_time = time() 33 | while time() - start_time < 300: 34 | auth_token = self.get_auth_token(auth_pin) 35 | if auth_token is not None: 36 | self.auth_token = auth_token 37 | set_setting("plex_token", self.auth_token) 38 | progressDialog.close() 39 | dialog_ok("Success", "Authentication completed.") 40 | return True 41 | if progressDialog.iscanceled(): 42 | progressDialog.close() 43 | return False 44 | sleep(1) 45 | else: 46 | progressDialog.close() 47 | dialog_ok("Error:", "Pin timed out.") 48 | return False 49 | 50 | def create_auth_pin(self) -> AuthPin: 51 | response = self.client.post( 52 | f"{self.PLEX_API_URL}/pins", 53 | data={ 54 | "strong": "false", 55 | "X-Plex-Product": settings.product_name, 56 | "X-Plex-Client-Identifier": settings.identifier, 57 | }, 58 | headers=self.headers, 59 | timeout=settings.plex_requests_timeout, 60 | ) 61 | json = response.json() 62 | return AuthPin(**json) 63 | 64 | def get_auth_token(self, auth_pin): 65 | json = self.get_json( 66 | url=f"{self.PLEX_API_URL}/pins/{auth_pin.id}", 67 | params={ 68 | "code": auth_pin.code, 69 | "X-Plex-Client-Identifier": settings.identifier, 70 | }, 71 | ) 72 | return json["authToken"] 73 | 74 | def get_json(self, url, params=None): 75 | if params is None: 76 | params = {} 77 | try: 78 | response = self.client.get( 79 | url, 80 | params=params, 81 | headers=self.headers, 82 | timeout=settings.plex_requests_timeout, 83 | ) 84 | if response.status_code in (401, 403): 85 | raise PlexUnauthorizedError() 86 | if response.status_code >= 400: 87 | raise HTTPException( 88 | status_code=502, 89 | detail="Received error from plex server", 90 | ) 91 | return json.loads(response.content) 92 | except TimeoutError: 93 | raise HTTPException( 94 | status_code=504, 95 | detail="Plex server timeout error", 96 | ) 97 | 98 | def get_plex_user(self): 99 | response = self.client.get( 100 | f"{self.PLEX_API_URL}/user", 101 | params={ 102 | "X-Plex-Product": settings.product_name, 103 | "X-Plex-Client-Identifier": settings.identifier, 104 | "X-Plex-Token": self.auth_token, 105 | }, 106 | headers=self.headers, 107 | timeout=settings.plex_requests_timeout, 108 | ) 109 | if response.status_code != HTTPStatus.OK: 110 | return 111 | json = response.json() 112 | return PlexUser(**json) 113 | 114 | def logout(self): 115 | self.auth_token = "" 116 | set_setting("plex_token", "") 117 | try: 118 | self.client.close() 119 | except: 120 | pass 121 | set_setting("plex_user", "") 122 | set_setting("plex_server_name", "") 123 | set_setting("plex_discovery_url", "") 124 | set_setting("plex_streaming_url", "") 125 | set_setting("plex_token", "") 126 | set_setting("plex_server_token", "") 127 | 128 | -------------------------------------------------------------------------------- /lib/api/fanart/fanart.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | API_KEY = "a7ad21743fd710fccb738232f2fbdcfc" 4 | CLIENT_KEY = "fe073550acf157bdb8a4217f215c0882" 5 | BASE_URL = "https://webservice.fanart.tv/v3/{media_type}/{media_id}" 6 | TIMEOUT = 3.0 7 | 8 | DEFAULT_FANART = { 9 | "poster2": "", 10 | "fanart2": "", 11 | "banner": "", 12 | "clearart": "", 13 | "clearlogo": "", 14 | "landscape": "", 15 | "discart": "", 16 | "fanart_added": False, 17 | } 18 | 19 | # Shared HTTP session (persistent, connection pooled) 20 | session = requests.Session() 21 | session.mount( 22 | "https://webservice.fanart.tv", requests.adapters.HTTPAdapter(pool_maxsize=100) 23 | ) 24 | 25 | 26 | def get_fanart(media_type: str, language: str, media_id: str) -> dict: 27 | """ 28 | Fetch localized artwork (poster, fanart, clearlogo, etc.) from fanart.tv. 29 | Supports movies and TV shows, with language fallback: local → English → universal. 30 | """ 31 | if not media_id: 32 | return DEFAULT_FANART.copy() 33 | 34 | url = BASE_URL.format(media_type=media_type, media_id=media_id) 35 | headers = {"client-key": CLIENT_KEY, "api-key": API_KEY} 36 | 37 | try: 38 | response = session.get(url, headers=headers, timeout=TIMEOUT) 39 | data = response.json() 40 | except Exception: 41 | return DEFAULT_FANART.copy() 42 | 43 | # Handle API errors 44 | if not data or "error_message" in data: 45 | return DEFAULT_FANART.copy() 46 | 47 | # Convenience alias 48 | get = data.get 49 | 50 | if media_type == "movies": 51 | return { 52 | "poster": select_art(get("movieposter"), language), 53 | "fanart": select_art(get("moviebackground"), language), 54 | "banner": select_art(get("moviebanner"), language), 55 | "clearart": select_art( 56 | get("movieart", []) + get("hdmovieclearart", []), language 57 | ), 58 | "clearlogo": select_art( 59 | get("movielogo", []) + get("hdmovielogo", []), language 60 | ), 61 | "landscape": select_art(get("moviethumb"), language), 62 | "discart": select_art(get("moviedisc"), language), 63 | "fanart_added": True, 64 | } 65 | 66 | # Default to TV shows 67 | return { 68 | "poster": select_art(get("tvposter"), language), 69 | "fanart": select_art(get("showbackground"), language), 70 | "banner": select_art(get("tvbanner"), language), 71 | "clearart": select_art(get("clearart", []) + get("hdclearart", []), language), 72 | "clearlogo": select_art(get("hdtvlogo", []) + get("clearlogo", []), language), 73 | "landscape": select_art(get("tvthumb"), language), 74 | "discart": "", 75 | "fanart_added": True, 76 | } 77 | 78 | 79 | # --- Artwork Selector --- 80 | def select_art(art_list: list, language: str) -> str: 81 | """ 82 | Return the best matching artwork URL from a list of art objects. 83 | Preference order: user's language → English → universal. 84 | Sorts by number of likes (descending). 85 | """ 86 | if not art_list: 87 | return "" 88 | 89 | try: 90 | # 1. User's language (e.g. 'es') 91 | matches = [ 92 | (x["url"], int(x.get("likes", 0))) 93 | for x in art_list 94 | if x.get("lang") == language 95 | ] 96 | 97 | # 2. English fallback 98 | if not matches and language != "en": 99 | matches = [ 100 | (x["url"], int(x.get("likes", 0))) 101 | for x in art_list 102 | if x.get("lang") == "en" 103 | ] 104 | 105 | # 3. Universal fallback ('00' or no lang) 106 | if not matches: 107 | matches = [ 108 | (x["url"], int(x.get("likes", 0))) 109 | for x in art_list 110 | if x.get("lang") in ("00", "", None) 111 | ] 112 | 113 | if matches: 114 | # Sort by likes descending, return top URL 115 | matches.sort(key=lambda x: x[1], reverse=True) 116 | return matches[0][0] 117 | 118 | except Exception: 119 | pass 120 | 121 | return "" 122 | 123 | 124 | # --- Helper: Merge Results into Existing Metadata --- 125 | def add_fanart( 126 | media_type: str, language: str, media_id: str, meta: dict 127 | ) -> dict: 128 | """ 129 | Update an existing metadata dictionary with fanart data. 130 | Always returns a complete dict (never None). 131 | """ 132 | try: 133 | meta.update(get_fanart(media_type, language, media_id)) 134 | except Exception: 135 | meta.update(DEFAULT_FANART) 136 | return meta 137 | -------------------------------------------------------------------------------- /lib/utils/torrent/flatbencode.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import io 3 | import itertools 4 | 5 | ONE_CHAR = 1 6 | INTEGER_START = b'i' 7 | LIST_START = b'l' 8 | DICT_START = b'd' 9 | END = b'e' 10 | NEGATIVE_SIGN = b'-' 11 | STRING_LENGTH_SEPARATOR = b':' 12 | 13 | __all__ = ['decode', 'DecodingError', 'encode'] 14 | 15 | 16 | class DecodingError(ValueError): 17 | pass 18 | 19 | 20 | def byte_is_integer(b): 21 | return b'0' <= b <= b'9' 22 | 23 | 24 | def group_by(it, n): 25 | """ 26 | >>> list(group_by([1, 2, 3, 4], 2)) 27 | [(1, 2), (3, 4)] 28 | """ 29 | return zip(*[itertools.islice(it2, i, None, n) for i, it2 in enumerate(itertools.tee(it))]) 30 | 31 | 32 | def list_to_dict(l): 33 | if not all(isinstance(k, bytes) for k, v in group_by(reversed(l), 2)): 34 | raise DecodingError 35 | return collections.OrderedDict(group_by(reversed(l), 2)) 36 | 37 | 38 | def _read_integer(buf): 39 | c = buf.read(ONE_CHAR) 40 | if c == NEGATIVE_SIGN: 41 | negative = True 42 | c = buf.read(ONE_CHAR) 43 | else: 44 | negative = False 45 | 46 | acc = io.BytesIO() 47 | while c != END: 48 | if len(c) == 0: 49 | raise DecodingError 50 | if not byte_is_integer(c): 51 | raise DecodingError 52 | acc.write(c) 53 | c = buf.read(ONE_CHAR) 54 | 55 | n = acc.getvalue() 56 | if n.startswith(b'0') and len(n) > 1: # '03' is illegal 57 | raise DecodingError 58 | n = int(n) 59 | if n == 0 and negative: # '-0' is illegal 60 | raise DecodingError 61 | if negative: 62 | n = -n 63 | return n 64 | 65 | 66 | def _read_length(c, buf): 67 | acc = io.BytesIO() 68 | while c != STRING_LENGTH_SEPARATOR: 69 | if not byte_is_integer(c): 70 | raise DecodingError 71 | acc.write(c) 72 | c = buf.read(ONE_CHAR) 73 | return int(acc.getvalue()) 74 | 75 | 76 | def _read_string(firstchar, buf): 77 | length = _read_length(firstchar, buf) 78 | string = buf.read(length) 79 | if len(string) != length: 80 | raise DecodingError 81 | return string 82 | 83 | 84 | list_starter = object() 85 | dict_starter = object() 86 | 87 | 88 | def decode(s): 89 | buf = io.BufferedReader(io.BytesIO(s)) 90 | buf.seek(0) 91 | 92 | stack = [] 93 | 94 | while True: 95 | c = buf.read(ONE_CHAR) 96 | if not c: 97 | raise DecodingError 98 | if c == END: 99 | acc = [] 100 | while True: 101 | if not stack: 102 | raise DecodingError 103 | x = stack.pop() 104 | if x == list_starter: 105 | elem = list(reversed(acc)) 106 | break 107 | elif x == dict_starter: 108 | elem = list_to_dict(acc) 109 | break 110 | else: 111 | acc.append(x) 112 | elif c == INTEGER_START: 113 | elem = _read_integer(buf) 114 | elif c == DICT_START: 115 | stack.append(dict_starter) 116 | continue 117 | elif c == LIST_START: 118 | stack.append(list_starter) 119 | continue 120 | else: 121 | elem = _read_string(c, buf) 122 | 123 | if not stack: 124 | end_of_string = not buf.read(ONE_CHAR) 125 | if not end_of_string: 126 | raise DecodingError 127 | return elem 128 | else: 129 | stack.append(elem) 130 | 131 | 132 | def encode(obj): 133 | def generator(obj): 134 | if isinstance(obj, dict): 135 | if not all(isinstance(k, bytes) for k in obj.keys()): 136 | raise ValueError("Dictionary keys must be strings") 137 | yield DICT_START 138 | # Dictionary keys should be sorted according to the BEP-0003: 139 | # "Keys must be strings and appear in sorted order (sorted as 140 | # raw strings, not alphanumerics)." 141 | for k in sorted(obj.keys()): 142 | yield from generator(k) 143 | yield from generator(obj[k]) 144 | yield END 145 | elif isinstance(obj, list): 146 | yield LIST_START 147 | for elem in obj: 148 | yield from generator(elem) 149 | yield END 150 | elif isinstance(obj, bytes): 151 | yield str(len(obj)).encode('ascii') 152 | yield STRING_LENGTH_SEPARATOR 153 | yield obj 154 | elif isinstance(obj, int): 155 | yield INTEGER_START 156 | yield str(obj).encode('ascii') 157 | yield END 158 | else: 159 | raise ValueError("type {} not supported".format(type(obj))) 160 | 161 | return b''.join(generator(obj)) 162 | --------------------------------------------------------------------------------