import json
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
from typing import List, Optional
import xbmc
from xbmcgui import Dialog
from lib.clients.stremio.addon_client import StremioAddonClient
from lib.clients.stremio.constants import STREMIO_ADDON_ALIASES_KEY, STREMIO_ADDONS_KEY
from lib.clients.stremio.helpers import (
get_addon_by_base_url,
get_addon_display_name,
get_selected_stream_addons,
)
from lib.db.cached import cache
from lib.domain.torrent import TorrentStream
from lib.gui.custom_dialogs import source_select
from lib.gui.search_status_window import SearchStatusWindow, SearchTaskManager
from lib.player import JacktookPLayer
from lib.utils.clients.utils import get_client, update_dialog
from lib.utils.debrid.debrid_utils import check_debrid_cached
from lib.utils.general.utils import (
DialogListener,
Indexer,
SearchVariant,
build_media_metadata,
cache_results,
clean_auto_play_undesired,
get_cached_results,
normalize_tv_data,
post_process,
pre_process,
safe_json_loads,
set_content_type,
set_watched_title,
)
from lib.utils.kodi.settings import auto_play_enabled, get_setting
from lib.utils.kodi.utils import (
ADDON_PATH,
cancel_playback,
close_busy_dialog,
kodilog,
notification,
translation,
)
from lib.utils.player.utils import resolve_playback_url
TITLE_LANGUAGE_LOCALIZED_FIRST = "localized_first"
TITLE_LANGUAGE_ENGLISH_FIRST = "english_first"
TITLE_LANGUAGE_ENGLISH_ONLY = "english_only"
class SearchCancelled(Exception):
"""Raised when the user cancels an interactive search flow."""
def __init__(self, results: Optional[List[TorrentStream]] = None):
super().__init__("Search cancelled")
self.results = results or []
class SilentProgressDialog:
"""No-op progress dialog for flows that should not show Kodi progress UI."""
def create(self, *args, **kwargs):
pass
def update(self, *args, **kwargs):
pass
def close(self):
pass
def _build_search_cache_scope(scoped_addon_url: str = "") -> str:
flags = {
"torrent": bool(get_setting("torrent_enable")),
"external_scraper": bool(get_setting("external_scraper_enabled")),
"stremio": bool(get_setting("stremio_enabled")),
"rd": bool(get_setting("real_debrid_enabled")),
"ad": bool(get_setting("alldebrid_enabled")),
"tb": bool(get_setting("torbox_enabled")),
"oc": bool(get_setting("offcloud_enabled")),
"pm": bool(get_setting("premiumize_enabled")),
"db": bool(get_setting("debrider_enabled")),
"ed": bool(get_setting("easydebrid_enabled")),
}
selected_stream_addons = str(cache.get(STREMIO_ADDONS_KEY) or "")
stremio_addon_aliases = json.dumps(
cache.get(STREMIO_ADDON_ALIASES_KEY) or {}, sort_keys=True
)
source_manager_selection = str(cache.get("source_manager_selection") or "")
external_scraper_module = str(get_setting("external_scraper_module") or "")
normalized_scoped_url = str(scoped_addon_url or "")
return "{}|{}|{}|{}|{}|{}".format(
"|".join([f"{key}:{int(value)}" for key, value in sorted(flags.items())]),
selected_stream_addons,
stremio_addon_aliases,
source_manager_selection,
external_scraper_module,
normalized_scoped_url,
)
BUILTIN_INDEXER_SETTINGS = {
Indexer.JACKETT: "jackett_enabled",
Indexer.PROWLARR: "prowlarr_enabled",
Indexer.BURST: "jacktookburst_enabled",
Indexer.JACKGRAM: "jackgram_enabled",
Indexer.EASYNEWS: "easynews_enabled",
Indexer.STREMIO: "stremio_enabled",
Indexer.EXTERNAL_SCRAPER: "external_scraper_enabled",
}
def _get_source_manager_selection() -> set:
"""Return the cached source-manager selection as a set of strings.
If the cache is empty, missing, or corrupt, returns an empty set so
that callers treat every source as enabled (open fallback).
"""
raw = cache.get("source_manager_selection")
if not raw:
return set()
try:
parsed = json.loads(raw) if isinstance(raw, str) else list(raw)
except (ValueError, TypeError):
return set()
return set(parsed) if parsed else set()
def _is_source_enabled(indexer_key, stremio_addon_key=None):
selected = _get_source_manager_selection()
# Empty cache → everything is enabled (no whitelist restriction)
if not selected:
return True
# Stremio addons are keyed with a "Stremio:" prefix
if stremio_addon_key:
return f"Stremio:{stremio_addon_key}" in selected
# External scraper uses its configured module name as the cache key.
# The fallback literal "External Scraper" supports older caches.
if indexer_key == Indexer.EXTERNAL_SCRAPER:
if not get_setting("external_scraper_enabled"):
return False
module_name = get_setting("external_scraper_module_name") or "External Scraper"
return module_name in selected or "External Scraper" in selected
# Managed builtin sources are enabled only when explicitly selected.
return str(indexer_key) in selected
def _clean_title_candidate(value) -> str:
if value is None:
return ""
return str(value).strip()
def _unique_title_candidates(candidates: List[str]) -> List[str]:
unique = []
seen = set()
for candidate in candidates:
cleaned = _clean_title_candidate(candidate)
if not cleaned:
continue
key = cleaned.casefold()
if key in seen:
continue
seen.add(key)
unique.append(cleaned)
return unique
def _normalize_search_variant(variant) -> str:
allowed = {
SearchVariant.DEFAULT,
SearchVariant.TITLE_YEAR,
SearchVariant.ORIGINAL_TITLE,
SearchVariant.ORIGINAL_TITLE_YEAR,
}
return variant if variant in allowed else SearchVariant.DEFAULT
def _normalize_title_language_mode(mode) -> str:
allowed = {
TITLE_LANGUAGE_LOCALIZED_FIRST,
TITLE_LANGUAGE_ENGLISH_FIRST,
TITLE_LANGUAGE_ENGLISH_ONLY,
}
return mode if mode in allowed else TITLE_LANGUAGE_LOCALIZED_FIRST
def _extract_english_tmdb_title(details, mode: str) -> str:
translations = getattr(details, "translations", None)
entries = getattr(translations, "translations", translations)
if not entries:
return ""
title_key = "title" if mode == "movies" else "name"
for entry in entries:
if getattr(entry, "iso_639_1", "") != "en":
continue
data = getattr(entry, "data", None)
if not data:
continue
return _clean_title_candidate(getattr(data, title_key, ""))
return ""
def _infer_tmdb_year(ids: dict, mode: str) -> Optional[int]:
if not ids or mode not in ("movies", "tv"):
return None
tmdb_id = ids.get("tmdb_id")
if not tmdb_id:
return None
try:
from lib.clients.tmdb.utils.utils import get_tmdb_media_details
details = get_tmdb_media_details(tmdb_id, mode)
date_value = (
getattr(details, "release_date", None)
if mode == "movies"
else getattr(details, "first_air_date", None)
)
return int(str(date_value)[:4]) if date_value else None
except Exception as e:
kodilog(f"Failed to infer TMDB year: {e}")
return None
def _build_title_fallback_queries(
query: str,
ids: dict,
mode: str,
variant: str = SearchVariant.DEFAULT,
year: Optional[int] = None,
title_language_mode: Optional[str] = None,
) -> List[str]:
variant = _normalize_search_variant(variant)
title_language_mode = _normalize_title_language_mode(
title_language_mode
if title_language_mode is not None
else get_setting("search_title_language_mode", TITLE_LANGUAGE_LOCALIZED_FIRST)
)
candidates = []
cleaned_query = _clean_title_candidate(query)
cleaned_year = _clean_title_candidate(year)
localized_candidate = ""
if cleaned_query and variant == SearchVariant.DEFAULT:
localized_candidate = cleaned_query
elif cleaned_query and cleaned_year and variant == SearchVariant.TITLE_YEAR:
localized_candidate = f"{cleaned_query} {cleaned_year}"
if localized_candidate:
candidates.append(localized_candidate)
if not ids:
return _unique_title_candidates(candidates)
tmdb_id = ids.get("tmdb_id")
if not tmdb_id or mode not in ("movies", "tv"):
return _unique_title_candidates(candidates)
try:
from lib.clients.tmdb.utils.utils import get_tmdb_media_details
details = get_tmdb_media_details(tmdb_id, mode)
except Exception as e:
kodilog(f"Failed to load TMDB titles for fallback: {e}")
details = None
if not details:
return _unique_title_candidates(candidates)
english_title = _extract_english_tmdb_title(details, mode)
original_title = getattr(details, "original_title", "") or getattr(details, "original_name", "")
english_candidate = ""
original_candidate = ""
if variant == SearchVariant.DEFAULT:
english_candidate = english_title
original_candidate = original_title
elif variant == SearchVariant.TITLE_YEAR and cleaned_year:
if english_title:
english_candidate = f"{english_title} {cleaned_year}"
if original_title:
original_candidate = f"{original_title} {cleaned_year}"
if variant == SearchVariant.DEFAULT or variant == SearchVariant.TITLE_YEAR:
if title_language_mode == TITLE_LANGUAGE_ENGLISH_ONLY:
candidates = [english_candidate, original_candidate]
if not any(candidates) and localized_candidate:
candidates.append(localized_candidate)
elif title_language_mode == TITLE_LANGUAGE_ENGLISH_FIRST:
candidates = [english_candidate, original_candidate, localized_candidate]
else:
candidates = [localized_candidate, english_candidate, original_candidate]
elif variant == SearchVariant.ORIGINAL_TITLE:
if original_title:
candidates.append(original_title)
elif cleaned_query:
candidates.append(cleaned_query)
elif variant == SearchVariant.ORIGINAL_TITLE_YEAR:
if original_title and cleaned_year:
candidates.append(f"{original_title} {cleaned_year}")
elif original_title:
candidates.append(original_title)
elif cleaned_query and cleaned_year:
candidates.append(f"{cleaned_query} {cleaned_year}")
elif cleaned_query:
candidates.append(cleaned_query)
return _unique_title_candidates(candidates)
def _perform_search_with_title_fallback(
indexer_key,
dialog,
query: str,
ids: dict,
mode: str,
*args,
variant: str = SearchVariant.DEFAULT,
year: Optional[int] = None,
title_language_mode: str = TITLE_LANGUAGE_LOCALIZED_FIRST,
**kwargs,
):
variant = _normalize_search_variant(variant)
title_language_mode = _normalize_title_language_mode(title_language_mode)
queries = _build_title_fallback_queries(
query,
ids,
mode,
variant,
year,
title_language_mode=title_language_mode,
)
for attempt, candidate in enumerate(queries, start=1):
if attempt > 1:
kodilog(
f"Retrying {indexer_key} search with fallback title {candidate!r}",
level=xbmc.LOGINFO,
)
results = _perform_search(
indexer_key,
dialog,
candidate,
mode,
*args,
variant=variant,
year=year,
**kwargs,
)
if results:
return results
return []
def _handle_super_quick_play(params: dict) -> bool:
if not get_setting("super_quick_play", False):
kodilog("Super quick play disabled")
return False
ids = safe_json_loads(params.get("ids"))
key = str(ids.get("original_id") or ids.get("imdb_id") or ids.get("tmdb_id") or "")
if key:
tv_data = safe_json_loads(params.get("tv_data") or "{}")
if tv_data and "season" in tv_data and "episode" in tv_data:
key += f"_{tv_data['season']}_{tv_data['episode']}"
if not key:
kodilog("Super quick play: No valid key found from ids")
return False
cached_torrent = cache.get(key)
if not cached_torrent:
kodilog("No cached media found")
return False
kodilog(f"Found cached media: {cached_torrent['title']}")
if get_setting("silent_resume", False):
playback_info = resolve_playback_url(cached_torrent)
if not playback_info:
notification(translation(90144))
return True
player = JacktookPLayer()
player.run(data=playback_info)
return True
dialog = Dialog()
if dialog.yesno(
translation(90142),
translation(90143),
):
playback_info = resolve_playback_url(cached_torrent)
if not playback_info:
notification(translation(90144))
return True
player = JacktookPLayer()
player.run(data=playback_info)
return True
kodilog("User chose not to play cached torrent")
return False
def _process_search_results(
results,
mode,
ep_name,
episode,
season,
scoped_addon_url,
query,
media_type,
rescrape,
suppress_debrid_dialog=False,
):
bypassed_streams = []
other_results = results
if get_setting("stremio_bypass_addons", True):
bypass_list_str = str(get_setting("stremio_bypass_addon_list", "") or "")
bypass_addons = [value.strip() for value in bypass_list_str.split(",") if value.strip()]
def is_bypassed(result):
if result.addonKey and result.addonKey in bypass_addons:
return True
legacy_name = (result.addonSourceName or result.indexer or "").lower()
return any(addon == legacy_name for addon in bypass_addons)
bypassed_streams = [res for res in results if is_bypassed(res)]
other_results = [res for res in results if not is_bypassed(res)]
pre_results = []
if other_results:
pre_results = pre_process_results(
other_results,
mode,
ep_name,
episode,
season,
skip_episode_filter=bool(scoped_addon_url),
)
post_results = []
if pre_results:
post_results = process_results(
pre_results,
query,
mode,
media_type,
rescrape,
episode,
suppress_dialog=suppress_debrid_dialog,
)
# Combine results, prioritizing bypassed streams exact native sorting
return bypassed_streams + post_results
def run_search_entry(params: dict):
if _handle_super_quick_play(params):
return
query = params.get("query", "")
mode = params.get("mode", "")
media_type = params.get("media_type", "")
ids = safe_json_loads(params.get("ids"))
tv_data = normalize_tv_data(safe_json_loads(params.get("tv_data")))
direct = params.get("direct", False)
rescrape = params.get("rescrape", False)
skip_cancel = params.get("skip_cancel_on_back", False)
variant = _normalize_search_variant(params.get("search_variant", SearchVariant.DEFAULT))
title_language_mode = _normalize_title_language_mode(
params.get(
"search_title_language_mode",
get_setting("search_title_language_mode", TITLE_LANGUAGE_LOCALIZED_FIRST),
)
)
year = params.get("year")
if year is not None:
try:
year = int(year)
except (ValueError, TypeError):
year = None
kodilog(
f"run_search_entry received: query={query}, mode={mode}, media_type={media_type}, "
f"variant={variant}, title_language_mode={title_language_mode}, year={year}, "
f"rescrape={rescrape}, force_select={params.get('force_select', False)}"
)
kodilog(f"[PLAYNEXT] run_search_entry: tv_data from params={tv_data}, ids={ids}")
library_data = None
if params.get("stremio_addon_url") and params.get("stremio_catalog_type"):
library_data = {
"source": "stremio_catalog",
"addon_url": params.get("stremio_addon_url"),
"catalog_type": params.get("stremio_catalog_type"),
"meta_id": params.get("stremio_meta_id") or ids.get("original_id", ""),
}
set_content_type(mode)
set_watched_title(query, ids, mode, media_type=media_type, library_data=library_data)
ep_name = tv_data.get("name", "")
episode = tv_data.get("episode", 1)
season = tv_data.get("season", 1)
scoped_addon_url = params.get("scoped_addon_url", "")
suppress_debrid_dialog = False
try:
results = search_client(
query,
ids,
mode,
media_type,
rescrape,
season,
episode,
scoped_addon_url=scoped_addon_url,
variant=variant,
title_language_mode=title_language_mode,
year=year,
)
except SearchCancelled as exc:
kodilog("Search cancelled from detailed status window")
results = exc.results
suppress_debrid_dialog = True
if not results:
notification("No results found")
if not skip_cancel:
cancel_playback()
return
final_results = _process_search_results(
results,
mode,
ep_name,
episode,
season,
scoped_addon_url,
query,
media_type,
rescrape,
suppress_debrid_dialog=suppress_debrid_dialog,
)
if not final_results:
notification(translation(90358))
if not skip_cancel:
cancel_playback()
return
preferred_group = params.get("preferred_group")
force_select = params.get("force_select", False)
autoplay_context = params.get("autoplay_context")
if auto_play_enabled() and not force_select:
if not auto_play(
final_results,
ids,
tv_data,
mode,
preferred_group,
autoplay_context=autoplay_context,
) and not skip_cancel:
cancel_playback()
return
if not show_source_select(
final_results,
mode,
ids,
tv_data,
query,
media_type,
rescrape,
direct,
autoplay_context=autoplay_context,
) and not skip_cancel:
cancel_playback()
def _perform_search(indexer_key, dialog, *args, **kwargs):
show_dialog = kwargs.pop("show_dialog", True)
scoped_addon_url = kwargs.pop("scoped_addon_url", "")
if indexer_key == Indexer.STREMIO:
if scoped_addon_url:
addon = get_addon_by_base_url(scoped_addon_url)
stremio_addons = [addon] if addon else []
else:
stremio_addons = get_selected_stream_addons()
if not stremio_addons:
notification("No Stremio addons selected")
return []
ids_dict = args[0]
rest_args = args[1:]
results = []
for addon in stremio_addons:
if show_dialog:
update_dialog(addon.manifest.name, "Searching...", dialog)
video_id = None
original_id = ids_dict.get("original_id")
media_kind = "series" if args[1] == "tv" or args[2] == "tv" else "movie"
if original_id:
prefix = original_id.split(":")[0]
if addon.isSupported("stream", media_kind, prefix):
video_id = original_id
# Try IMDb ID for addons that declare tt: prefix
if not video_id and ids_dict.get("imdb_id"):
if addon.isSupported("stream", media_kind, "tt"):
video_id = ids_dict.get("imdb_id")
# Try TMDB ID for addons that declare tmdb: prefix
if not video_id and ids_dict.get("tmdb_id"):
if addon.isSupported("stream", media_kind, "tmdb:") or addon.isSupported(
"stream", media_kind, "tmdb"
):
video_id = f"tmdb:{ids_dict['tmdb_id']}"
if video_id:
try:
client = StremioAddonClient(addon)
results.extend(client.search(video_id, *rest_args))
except Exception as e:
kodilog(f"Error searching {addon.manifest.name}: {e}")
return results
kodilog(f"[ExternalScraper] _perform_search for {indexer_key}, getting client...")
client = get_client(indexer_key)
kodilog(f"[ExternalScraper] client={client}")
if not client:
return []
if indexer_key == Indexer.BURST:
kwargs["silent"] = True
return client.search(*args, **kwargs)
def _submit_search_tasks(
executor,
tasks,
dialog,
query,
mode,
media_type,
season,
episode,
ids,
scoped_addon_url,
tmdb_id,
imdb_id,
show_dialog,
variant: str = SearchVariant.DEFAULT,
title_language_mode: str = TITLE_LANGUAGE_LOCALIZED_FIRST,
year: Optional[int] = None,
title_aliases: Optional[List[str]] = None,
):
def submit_performer(*args, **kwargs):
if "show_dialog" not in kwargs:
kwargs["show_dialog"] = show_dialog
if "scoped_addon_url" not in kwargs:
kwargs["scoped_addon_url"] = scoped_addon_url
if "variant" not in kwargs:
kwargs["variant"] = variant
if "title_language_mode" not in kwargs:
kwargs["title_language_mode"] = title_language_mode
if "year" not in kwargs:
kwargs["year"] = year
return executor.submit(
_perform_search,
*args,
**kwargs,
)
if scoped_addon_url:
addon = get_addon_by_base_url(scoped_addon_url)
if (
addon
and (ids.get("imdb_id") or ids.get("original_id") or ids.get("tmdb_id"))
and _is_source_enabled(Indexer.STREMIO, addon.key())
):
tasks.append(
submit_performer(Indexer.STREMIO, dialog, ids, mode, media_type, season, episode)
)
else:
if _is_source_enabled(Indexer.EASYNEWS):
add_task_if_enabled(
executor,
tasks,
"easynews_enabled",
Indexer.EASYNEWS,
_perform_search_with_title_fallback,
dialog,
query,
ids,
mode,
media_type,
season,
episode,
show_dialog=show_dialog,
scoped_addon_url=scoped_addon_url,
variant=variant,
title_language_mode=title_language_mode,
year=year,
)
if _is_source_enabled(Indexer.BURST):
add_task_if_enabled(
executor,
tasks,
"jacktookburst_enabled",
Indexer.BURST,
_perform_search,
dialog,
imdb_id or "",
query,
mode,
media_type,
season,
episode,
show_dialog=show_dialog,
scoped_addon_url=scoped_addon_url,
)
if get_setting("prowlarr_enabled") and _is_source_enabled(Indexer.PROWLARR):
indexers_ids = get_setting("prowlarr_indexer_ids")
tasks.append(
executor.submit(
_perform_search_with_title_fallback,
Indexer.PROWLARR,
dialog,
query,
ids,
mode,
season,
episode,
indexers_ids,
show_dialog=show_dialog,
scoped_addon_url=scoped_addon_url,
variant=variant,
title_language_mode=title_language_mode,
year=year,
)
)
if _is_source_enabled(Indexer.JACKETT):
add_task_if_enabled(
executor,
tasks,
"jackett_enabled",
Indexer.JACKETT,
_perform_search_with_title_fallback,
dialog,
query,
ids,
mode,
season,
episode,
show_dialog=show_dialog,
scoped_addon_url=scoped_addon_url,
variant=variant,
title_language_mode=title_language_mode,
year=year,
)
if _is_source_enabled(Indexer.JACKGRAM):
add_task_if_enabled(
executor,
tasks,
"jackgram_enabled",
Indexer.JACKGRAM,
_perform_search,
dialog,
tmdb_id,
query,
mode,
media_type,
season,
episode,
show_dialog=show_dialog,
scoped_addon_url=scoped_addon_url,
)
if _is_source_enabled(Indexer.EXTERNAL_SCRAPER):
add_task_if_enabled(
executor,
tasks,
"external_scraper_enabled",
Indexer.EXTERNAL_SCRAPER,
_perform_search,
dialog,
tmdb_id,
query,
mode,
media_type,
season,
episode,
show_dialog=show_dialog,
scoped_addon_url=scoped_addon_url,
imdb_id=imdb_id or "",
tvdb_id=(ids or {}).get("tvdb_id", ""),
year=str(year) if year else "",
aliases=title_aliases,
)
if (
get_setting("stremio_enabled")
and (ids.get("imdb_id") or ids.get("original_id"))
and _is_source_enabled(Indexer.STREMIO)
):
tasks.append(
submit_performer(Indexer.STREMIO, dialog, ids, mode, media_type, season, episode)
)
def _collect_search_results(tasks, listener, show_dialog) -> List[TorrentStream]:
total_results = []
total_tasks = len(tasks)
completed_tasks = 0
for future in as_completed(tasks):
try:
completed_tasks += 1
if show_dialog and total_tasks > 0:
percent = int(completed_tasks / total_tasks * 100)
update_dialog("Searching", f"Searching... {percent}%", listener.dialog, percent)
results = future.result()
kodilog(f"Results from {future}: {results}", level=xbmc.LOGDEBUG)
if results:
total_results.extend(results)
except Exception as e:
import traceback
error_details = traceback.format_exc()
kodilog(f"Error resolving future result in thread pool: {e}\n{error_details}")
return total_results
def _submit_search_tasks_managed(
manager,
dialog,
query,
mode,
media_type,
season,
episode,
ids,
scoped_addon_url,
tmdb_id,
imdb_id,
variant: str = SearchVariant.DEFAULT,
title_language_mode: str = TITLE_LANGUAGE_LOCALIZED_FIRST,
year: Optional[int] = None,
title_aliases: Optional[List[str]] = None,
):
def submit_performer_managed(name, indexer_key, *args, **kwargs):
kwargs["show_dialog"] = False
if "scoped_addon_url" not in kwargs:
kwargs["scoped_addon_url"] = scoped_addon_url
if "variant" not in kwargs:
kwargs["variant"] = variant
if "title_language_mode" not in kwargs:
kwargs["title_language_mode"] = title_language_mode
if "year" not in kwargs:
kwargs["year"] = year
return manager.submit_task(
name,
indexer_key,
_perform_search,
indexer_key,
*args,
**kwargs,
)
if scoped_addon_url:
addon = get_addon_by_base_url(scoped_addon_url)
if (
addon
and (ids.get("imdb_id") or ids.get("original_id") or ids.get("tmdb_id"))
and _is_source_enabled(Indexer.STREMIO, addon.key())
):
submit_performer_managed(
get_addon_display_name(addon),
Indexer.STREMIO,
dialog,
ids,
mode,
media_type,
season,
episode,
scoped_addon_url=scoped_addon_url,
)
else:
if _is_source_enabled(Indexer.EASYNEWS):
add_task_if_enabled_managed(
manager,
"easynews_enabled",
Indexer.EASYNEWS,
_perform_search_with_title_fallback,
dialog,
query,
ids,
mode,
media_type,
season,
episode,
show_dialog=False,
scoped_addon_url=scoped_addon_url,
variant=variant,
title_language_mode=title_language_mode,
year=year,
)
if _is_source_enabled(Indexer.BURST):
add_task_if_enabled_managed(
manager,
"jacktookburst_enabled",
Indexer.BURST,
_perform_search,
dialog,
imdb_id or "",
query,
mode,
media_type,
season,
episode,
show_dialog=False,
scoped_addon_url=scoped_addon_url,
)
if get_setting("prowlarr_enabled") and _is_source_enabled(Indexer.PROWLARR):
indexers_ids = get_setting("prowlarr_indexer_ids")
manager.submit_task(
"Prowlarr",
Indexer.PROWLARR,
_perform_search_with_title_fallback,
Indexer.PROWLARR,
dialog,
query,
ids,
mode,
season,
episode,
indexers_ids,
show_dialog=False,
scoped_addon_url=scoped_addon_url,
variant=variant,
title_language_mode=title_language_mode,
year=year,
)
if _is_source_enabled(Indexer.JACKETT):
add_task_if_enabled_managed(
manager,
"jackett_enabled",
Indexer.JACKETT,
_perform_search_with_title_fallback,
dialog,
query,
ids,
mode,
season,
episode,
show_dialog=False,
scoped_addon_url=scoped_addon_url,
variant=variant,
title_language_mode=title_language_mode,
year=year,
)
if _is_source_enabled(Indexer.JACKGRAM):
add_task_if_enabled_managed(
manager,
"jackgram_enabled",
Indexer.JACKGRAM,
_perform_search,
dialog,
tmdb_id,
query,
mode,
media_type,
season,
episode,
show_dialog=False,
scoped_addon_url=scoped_addon_url,
)
if _is_source_enabled(Indexer.EXTERNAL_SCRAPER):
module_display_name = get_setting("external_scraper_module_name") or "External Scraper"
manager.submit_task(
module_display_name,
Indexer.EXTERNAL_SCRAPER,
_perform_search,
Indexer.EXTERNAL_SCRAPER,
dialog,
tmdb_id,
query,
mode,
media_type,
season,
episode,
show_dialog=False,
scoped_addon_url=scoped_addon_url,
imdb_id=imdb_id or "",
tvdb_id=(ids or {}).get("tvdb_id", ""),
year=str(year) if year else "",
aliases=title_aliases,
)
if get_setting("stremio_enabled") and (ids.get("imdb_id") or ids.get("original_id")):
stremio_addons = get_selected_stream_addons()
filtered_addons = [
addon
for addon in stremio_addons
if _is_source_enabled(Indexer.STREMIO, addon.key())
]
for addon in filtered_addons:
submit_performer_managed(
get_addon_display_name(addon),
Indexer.STREMIO,
dialog,
ids,
mode,
media_type,
season,
episode,
scoped_addon_url=addon.url(),
)
def _check_search_caches(
query: str,
ids: dict,
mode: str,
media_type: str,
episode: int,
season: int,
cache_scope: str,
) -> Optional[List[TorrentStream]]:
"""Check standard cache then autoscrape fallback cache.
Returns cached results if found (even an empty list), or None if
no cache entry exists (caller should perform a fresh search).
"""
cached = get_cached_results(query, mode, media_type, episode, cache_scope=cache_scope)
if cached is not None:
return cached
# Fallback: autoscrape cache (PlayNext background scrape) under
# ``as_results:{id}_{season}_{episode}``.
if mode != "tv" and media_type != "tv":
return None
id_value = (
(ids or {}).get("original_id")
or (ids or {}).get("imdb_id")
or (ids or {}).get("tmdb_id")
)
if id_value is None or season is None or episode is None:
return None
from lib.db.cached import cache as _cache
from lib.utils.player.utils import get_autoscrape_results_cache_key
as_key = get_autoscrape_results_cache_key(id_value, season, episode)
as_results = _cache.get(as_key)
if as_results is None:
return None
kodilog(f"[CACHE] Autoscrape cache HIT for {as_key}, reusing {len(as_results)} results")
cache_results(as_results, query, mode, media_type, episode, cache_scope=cache_scope)
return as_results
def _run_detailed_search(
query: str,
ids: dict,
mode: str,
media_type: str,
season: int,
episode: int,
scoped_addon_url: str,
tmdb_id: Optional[int],
imdb_id: Optional[str],
variant: str,
title_language_mode: str,
year: Optional[int],
title_aliases: List[str],
) -> List[TorrentStream]:
"""Search with SearchStatusWindow (``search_dialog_style=1``)."""
executor = ThreadPoolExecutor(max_workers=int(get_setting("thread_number", 6)))
manager = SearchTaskManager(executor)
try:
_submit_search_tasks_managed(
manager, None, query, mode, media_type, season, episode, ids,
scoped_addon_url, tmdb_id, imdb_id,
variant=variant, title_language_mode=title_language_mode,
year=year, title_aliases=title_aliases,
)
item_info = {"ids": ids, "mode": mode}
if ids:
item_info.update(build_media_metadata(ids, mode))
window = SearchStatusWindow(
"search_status.xml", ADDON_PATH,
task_manager=manager, item_information=item_info,
)
try:
window.show()
window.run_until_complete()
finally:
del window
if manager.is_cancelled:
manager.cancel_pending()
raise SearchCancelled(manager.collect_results())
return manager.collect_results()
except Exception:
manager.cancel_pending()
raise
finally:
executor.shutdown(wait=False)
def _run_simple_search(
query: str,
ids: dict,
mode: str,
media_type: str,
season: int,
episode: int,
scoped_addon_url: str,
tmdb_id: Optional[int],
imdb_id: Optional[str],
show_dialog: bool,
variant: str,
title_language_mode: str,
year: Optional[int],
title_aliases: List[str],
) -> List[TorrentStream]:
"""Search with a simple progress dialog (``search_dialog_style=0``)."""
tasks: List[Future] = []
with DialogListener() as listener:
if show_dialog:
listener.dialog.create("")
with ThreadPoolExecutor(max_workers=int(get_setting("thread_number", 6))) as executor:
_submit_search_tasks(
executor, tasks, listener.dialog,
query, mode, media_type, season, episode, ids,
scoped_addon_url, tmdb_id, imdb_id, show_dialog,
variant=variant, title_language_mode=title_language_mode,
year=year, title_aliases=title_aliases,
)
total_results = _collect_search_results(tasks, listener, show_dialog)
return total_results
def search_client(
query: str,
ids: dict,
mode: str,
media_type: str,
rescrape: bool,
season: int,
episode: int,
show_dialog: bool = True,
scoped_addon_url: str = "",
variant: str = SearchVariant.DEFAULT,
title_language_mode: str = TITLE_LANGUAGE_LOCALIZED_FIRST,
year: Optional[int] = None,
) -> List[TorrentStream]:
close_busy_dialog()
if year is None:
year = _infer_tmdb_year(ids, mode)
title_aliases = _build_title_fallback_queries(
query, ids, mode, SearchVariant.DEFAULT, year,
title_language_mode=title_language_mode,
)
cache_scope = _build_search_cache_scope(scoped_addon_url)
if not rescrape:
cached = _check_search_caches(query, ids, mode, media_type, episode, season, cache_scope)
if cached is not None:
return cached
tmdb_id, imdb_id = (ids.get("tmdb_id"), ids.get("imdb_id")) if ids else (None, None)
if show_dialog and get_setting("search_dialog_style", "0") == "1":
try:
total_results = _run_detailed_search(
query, ids, mode, media_type, season, episode, scoped_addon_url,
tmdb_id, imdb_id, variant, title_language_mode, year, title_aliases,
)
except SearchCancelled:
raise
except Exception as e:
kodilog(f"Detailed search status failed, falling back to simple search: {e}")
total_results = _run_simple_search(
query, ids, mode, media_type, season, episode, scoped_addon_url,
tmdb_id, imdb_id, True, variant, title_language_mode, year,
title_aliases,
)
else:
total_results = _run_simple_search(
query, ids, mode, media_type, season, episode, scoped_addon_url,
tmdb_id, imdb_id, show_dialog, variant, title_language_mode, year,
title_aliases,
)
cache_results(total_results, query, mode, media_type, episode, cache_scope=cache_scope)
return total_results
def pre_process_results(
results: List[TorrentStream],
mode: str,
ep_name: str,
episode: int,
season: int,
skip_episode_filter: bool = False,
) -> List[TorrentStream]:
return pre_process(results, mode, ep_name, episode, season, skip_episode_filter)
def process_results(
pre_results: List[TorrentStream],
query: str,
mode: str,
media_type: str,
rescrape: bool,
episode: int,
suppress_dialog: bool = False,
) -> List[TorrentStream]:
torrent_results = []
if get_setting("torrent_enable"):
torrent_results = post_process(pre_results)
close_busy_dialog()
if suppress_dialog:
debrid_results = check_debrid_cached(
query, pre_results, mode, media_type, SilentProgressDialog(), rescrape, episode
)
else:
with DialogListener() as listener:
debrid_results = check_debrid_cached(
query, pre_results, mode, media_type, listener.dialog, rescrape, episode
)
return debrid_results + torrent_results
def show_source_select(
results: List[TorrentStream],
mode: str,
ids: dict,
tv_data: dict,
query: str,
media_type: str,
rescrape: bool,
direct: bool = False,
autoplay_context: Optional[str] = None,
) -> bool:
item_info = {
"tv_data": tv_data,
"ids": ids,
"mode": mode,
"query": query,
"media_type": media_type,
"rescrape": rescrape,
}
if autoplay_context:
item_info["playnext_context"] = True
item_info["direct_play"] = True
if not direct and ids:
item_info.update(build_media_metadata(ids, mode))
kodilog(
"show_source_select context: "
f"query={query}, mode={mode}, media_type={media_type}, "
f"year={item_info.get('year')}, ids={ids}"
)
xml_file_string = "source_select_direct.xml" if mode == "direct" else "source_select.xml"
return source_select(item_info, xml_file=xml_file_string, sources=results)
def auto_play(
results: List[TorrentStream],
ids,
tv_data,
mode,
preferred_group=None,
force_select=False,
autoplay_context: Optional[str] = None,
) -> bool:
if force_select:
return False
filtered_results = clean_auto_play_undesired(results)
if not filtered_results:
notification("No suitable source found for auto play.")
return False
preferred_quality = str(get_setting("auto_play_quality"))
quality_matches = [
r for r in filtered_results if preferred_quality.lower() in r.quality.lower()
]
if not quality_matches:
notification("No sources found with the preferred quality.")
return False
selected_result = quality_matches[0]
if preferred_group:
group_matches = [r for r in quality_matches if preferred_group.lower() in r.title.lower()]
if group_matches:
selected_result = group_matches[0]
playback_info = resolve_playback_url(
data={
"title": selected_result.title,
"mode": mode,
"indexer": selected_result.indexer,
"type": selected_result.type,
"debrid_type": selected_result.debridType,
"ids": ids,
"info_hash": selected_result.infoHash,
"url": selected_result.url,
# getattr -- see prepare_source_data: unpickled cached results predate
# this field and pickle does not apply dataclass defaults.
"proxyHeaders": getattr(selected_result, "proxyHeaders", None) or {},
"tv_data": tv_data,
"is_torrent": False,
},
)
if not playback_info:
return False
playback_info["autoplay"] = True
if autoplay_context:
playback_info["playnext_context"] = True
playback_info["direct_play"] = True
player = JacktookPLayer()
player.run(data=playback_info)
del player
return True
def stremio_addon_generator(stremio_addons, dialog, show_dialog):
for addon in stremio_addons:
if show_dialog:
update_dialog(addon.manifest.name, "Searching...", dialog)
yield StremioAddonClient(addon)
def add_task_if_enabled(
executor, tasks, setting_key, indexer_key, perform_search, dialog, *args, **kwargs
):
"""Add a search task to the task list if the corresponding setting is enabled."""
if get_setting(setting_key):
tasks.append(executor.submit(perform_search, indexer_key, dialog, *args, **kwargs))
def add_task_if_enabled_managed(
manager, setting_key, indexer_key, perform_search, dialog, *args, **kwargs
):
"""Add a managed search task if the corresponding setting is enabled."""
if get_setting(setting_key):
name = str(indexer_key).title()
manager.submit_task(name, indexer_key, perform_search, indexer_key, dialog, *args, **kwargs)