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