From c60f3a23d7bd168628b06136c6f3f1d33a7393ae Mon Sep 17 00:00:00 2001 From: Gabriel Ortega Date: Thu, 30 Jan 2025 23:52:36 +0100 Subject: [PATCH 01/10] experimenting with sections in source select window --- lib/clients/stremio_addon.py | 6 +- lib/gui/custom_dialogs.py | 3 +- lib/gui/source_select_new.py | 267 ++++++++++ lib/navigation.py | 3 +- .../skins/Default/1080i/source_select_new.xml | 485 ++++++++++++++++++ 5 files changed, 759 insertions(+), 5 deletions(-) create mode 100644 lib/gui/source_select_new.py create mode 100644 resources/skins/Default/1080i/source_select_new.xml diff --git a/lib/clients/stremio_addon.py b/lib/clients/stremio_addon.py index 87d11ed3..20ff035d 100644 --- a/lib/clients/stremio_addon.py +++ b/lib/clients/stremio_addon.py @@ -85,10 +85,10 @@ def parse_torrent_description(self, desc: str) -> dict: provider_match = re.findall(provider_pattern, desc) words = [match[1].strip() for match in provider_match] - if words: - words = words[-1].splitlines()[0] - provider = words + provider = "N/A" + if words: + provider = words[-1].splitlines()[0] return { "size": size or 0, diff --git a/lib/gui/custom_dialogs.py b/lib/gui/custom_dialogs.py index 9d71b080..cd63e9cc 100644 --- a/lib/gui/custom_dialogs.py +++ b/lib/gui/custom_dialogs.py @@ -7,6 +7,7 @@ from lib.gui.resume_window import ResumeDialog from lib.utils.kodi_utils import ADDON_PATH, PLAYLIST from lib.gui.source_select import SourceSelect +from lib.gui.source_select_new import SourceSelectNew class CustomWindow(WindowXML): @@ -84,7 +85,7 @@ def onClick(self, controlId): def source_select(item_info, xml_file, sources): - window = SourceSelect( + window = SourceSelectNew( xml_file, ADDON_PATH, item_information=item_info, diff --git a/lib/gui/source_select_new.py b/lib/gui/source_select_new.py new file mode 100644 index 00000000..76b3ab20 --- /dev/null +++ b/lib/gui/source_select_new.py @@ -0,0 +1,267 @@ +import xbmcgui +from lib.gui.base_window import BaseWindow +from lib.gui.resolver_window import ResolverWindow +from lib.gui.resume_window import ResumeDialog +from lib.utils.kodi_utils import ADDON_PATH +from lib.utils.debrid_utils import get_debrid_status +from lib.utils.kodi_utils import bytes_to_human_readable +from lib.utils.utils import ( + extract_publish_date, + get_colored_languages, + get_random_color, +) +from lib.api.jacktook.kodi import kodilog +from typing import List + + +class SourceItem(xbmcgui.ListItem): + @staticmethod + def fromSource(source: dict): + item = SourceItem(label=f"{source['title']}") + for info in source: + value = source[info] + if info == "peers": + value = value if value else "" + if info == "publishDate": + value = extract_publish_date(value) + if info == "size": + value = bytes_to_human_readable(int(value)) if value else "" + if info in ["indexer", "provider", "type"]: + color = get_random_color(value) + value = f"[B][COLOR {color}]{value}[/COLOR][/B]" + if info == "fullLanguages": + value = get_colored_languages(value) + if len(value) <= 0: + value = "" + if info == "isCached": + info = "status" + value = get_debrid_status(source) + item.setProperty(info, str(value)) + return item + +class Section: + def __init__(self, title: str, description: str, sources: List[SourceItem]): + self.title = title + self.description = description + self.sources = sources + self.position = 0 + + def set_position(self, position: int): + self.position = position + + def get_source(self): + return self.sources[self.position] + +class SectionCollection: + def __init__(self, current_index: int, sections: List[Section]): + self.sections = sections + self.current_index = current_index + + def get_current_section(self): + return self.sections[self.current_index] + + def get_current_description(self): + return self.get_current_section().description + + def get_current_sources(self): + return self.get_current_section().sources + + def get_current_source(self): + return self.get_current_section().get_current_source() + + def get_current_position(self): + return self.get_current_section().position + + def set_position(self, position: int): + self.get_current_section().set_position(position) + + def get_next_section(self): + self.current_index += 1 + if self.current_index > len(self.sections) - 1: + self.current_index = len(self.sections) - 1 + return self.sections[self.current_index] + + def get_previous_section(self): + self.current_index -= 1 + if self.current_index < 0: + self.current_index = 0 + return self.sections[self.current_index] + + def get_section_by_index(self, index): + return self.sections[index] + + def get_section_index(self): + return self.current_index + + def set_section_index(self, index): + self.current_index = index + + def get_section_count(self): + return len(self.sections) + + def get_sections(self): + return self.sections + + def get_title(self): + return self.get_current_section().title + + def get_titles(self): + return [section.title for section in self.sections] + + + + +class SourceSelectNew(BaseWindow): + def __init__( + self, xml_file, location, item_information=None, sources=None, uncached=None + ): + super().__init__(xml_file, location, item_information=item_information) + + # get a list different providers that appears in the sources + providersAndSeeds = [ + [source["provider"], source["seeders"]] + for source in sources + ] + + # reduce the list to providers and the sum of their seeds + providers = {} + for provider, seeds in providersAndSeeds: + if provider in providers: + providers[provider] += seeds + else: + providers[provider] = seeds + + # get a list of providers sorted by the sum of their seeds + sortedProviders = [x[0] for x in sorted(providers.items(), key=lambda x: x[1], reverse=True)] + + sectionList = [] + sectionList.append(Section( + "Priority Language", + "Sources with Spanish audio", + [SourceItem.fromSource(source) for source in sources if 'es' in source["fullLanguages"]])) + sectionList.append(Section( + "Top Seeders", + "Results with the most seeders", + [SourceItem.fromSource(source) for source in sources if not 'es' in source["fullLanguages"]]) + ) + for provider in sortedProviders: + sectionList.append(Section( + provider, + f"Filtered sources from {provider} provider", + [SourceItem.fromSource(source) for source in sources if source["provider"] == provider])) + + + self.sections = SectionCollection(0, sectionList) + + self.uncached_sources = uncached or [] + self.position = -1 + self.sources = sources + self.item_information = item_information + self.playback_info = None + self.resume = None + self.CACHE_KEY = ( + self.item_information["tv_data"] or self.item_information["ids"] + ) + self.setProperty("instant_close", "false") + self.setProperty("resolving", "false") + + def onInit(self): + self.display_list = self.getControlList(1000) + self.title = self.getControl(1001) + self.description = self.getControl(1002) + self.populate_sources_list() + self.set_default_focus(self.display_list, 1000, control_list_reset=True) + super().onInit() + + def doModal(self): + super().doModal() + return self.playback_info + + def populate_sources_list(self): + + # nav bar + titles = self.sections.get_titles() + current_index = self.sections.get_section_index() + + navitems = titles[:current_index] + navitems = ["...", navitems[-2], navitems[-1]] if len(navitems) > 2 else navitems + + navitems.append(f"[B][COLOR white]{titles[current_index]}[/COLOR][/B]") + navitems.extend(titles[current_index + 1:]) + + self.title.setLabel(" | ".join(navitems)) + + # description + self.description.setLabel(self.sections.get_current_description()) + + # list + self.display_list.reset() + sources = self.sections.get_current_sources() + self.display_list.addItems(sources) + self.display_list.selectItem(self.sections.get_current_position()) + + def handle_action(self, action_id, control_id=None): + self.sections.set_position(self.display_list.getSelectedPosition()) + kodilog(f"action_id: {action_id}, control_id: {control_id}") + if action_id == xbmcgui.ACTION_CONTEXT_MENU: + selected_source = self.sections.get_current_source() + type = selected_source["type"] + if type == "Torrent": + response = xbmcgui.Dialog().contextmenu(["Download to Debrid"]) + if response == 0: + self._download_into() + elif type == "Direct": + pass + else: + response = xbmcgui.Dialog().contextmenu(["Browse into"]) + if response == 0: + self._resolve_pack() + if control_id == 1000: + if action_id == xbmcgui.ACTION_SELECT_ITEM: + if control_id == 1000: + control_list = self.getControl(control_id) + self.set_cached_focus(control_id, control_list.getSelectedPosition()) + self._resolve_item(pack_select=False) + if action_id == xbmcgui.ACTION_MOVE_LEFT: + self.sections.get_previous_section() + self.populate_sources_list() + if action_id == xbmcgui.ACTION_MOVE_RIGHT: + self.sections.get_next_section() + self.populate_sources_list() + + def _download_into(self): + pass + + def _resolve_pack(self): + pass + + def _resolve_item(self, pack_select): + self.setProperty("resolving", "true") + + selected_source = self.sections.get_current_source() + + resolver_window = ResolverWindow( + "resolver.xml", + ADDON_PATH, + source=selected_source, + previous_window=self, + item_information=self.item_information, + ) + resolver_window.doModal(pack_select) + self.playback_info = resolver_window.playback_info + + del resolver_window + self.setProperty("instant_close", "true") + self.close() + + def show_resume_dialog(self, playback_percent): + try: + resume_window = ResumeDialog( + "resume_dialog.xml", + ADDON_PATH, + resume_percent=playback_percent, + ) + resume_window.doModal() + return resume_window.resume + finally: + del resume_window diff --git a/lib/navigation.py b/lib/navigation.py index a5fa2f0b..a7c63837 100644 --- a/lib/navigation.py +++ b/lib/navigation.py @@ -610,7 +610,8 @@ def handle_results(results, mode, ids, tv_data, direct=False): if mode == "direct": xml_file_string = "source_select_direct.xml" else: - xml_file_string = "source_select.xml" + # TODO: Expecting to have a way to select between skins + xml_file_string = "source_select_new.xml" return source_select( item_info, diff --git a/resources/skins/Default/1080i/source_select_new.xml b/resources/skins/Default/1080i/source_select_new.xml new file mode 100644 index 00000000..9254def6 --- /dev/null +++ b/resources/skins/Default/1080i/source_select_new.xml @@ -0,0 +1,485 @@ + + + 0 + 0 + 1920 + 1080 + + + + + + + 0 + 0 + 1920 + 1080 + + + + white.png + 8000000 + Conditional + !String.IsEqual(Window().Property(instant_close),true) + + + + + $INFO[Window().Property(info.fanart)] + FFFFFFFF + Conditional + !String.IsEqual(Window().Property(instant_close),true) + + + + + white.png + CC000000 + Conditional + !String.IsEqual(Window().Property(instant_close),true) + + + + + + + + + + WindowClose + Conditional + + + + + 0 + 20 + 200 + 150 + keep + jtk_clearlogo.png + CCFFFFFF + !String.IsEmpty(Window().Property(info.clearlogo)) + !String.IsEqual(Window().Property(instant_close),true) + + + + + + + font20 + 28 + 300 + 1620 + 50 + + + + + + font12 + 65 + 300 + 1620 + 50 + + + + + WindowOpen + WindowClose + Conditional + Conditional + 1366 + 120 + 20 + 890 + false + white.png + white.png + white.png + String.IsEqual(Window().Property(instant_close),false) + + + + + WindowOpen + WindowClose + Conditional + Conditional + list + 1111 + 100 + 110 + 1340 + 900 + 20 + vertical + !String.IsEqual(Window().Property(instant_close),true) + + + + + 1250 + + + 10 + 200 + left-circle.png + FF00559D + + + + + 10 + circle.png + FF362e33 + + + + + font30 + 70 + center + 200 + 66FFFFFF + + + + + + 120 + center + 200 + font20 + 66FFFFFF + left + + + + + + 30 + 220 + 1000 + 20 + font12 + FFFFFFFF + left + + + + + 40 + 20 + + + 30 + + + + 20 + font12 + CCFFFFFF + left + 200 + + !String.IsEmpty(ListItem.Property(size)) + + + + + + 20 + font12 + left + 550 + CCFFFFFF + + !String.IsEmpty(ListItem.Property(peers)) + + + + + 20 + 20 + font12 + left + 900 + CCFFFFFF + + !String.IsEmpty(ListItem.Property(seeders)) + + + + + + 70 + + + + 600 + 20 + font12 + 200 + CCFFFFFF + left + + !String.IsEmpty(ListItem.Property(indexer)) + + + + + 20 + font12 + left + 550 + CCFFFFFF + + !String.IsEmpty(ListItem.Property(provider)) + + + + + 20 + font12 + left + 900 + CCFFFFFF + + !String.IsEmpty(ListItem.Property(publishDate)) + + + + + 110 + + + + 20 + font12 + left + 200 + FFFFFFFF + + !String.IsEmpty(ListItem.Property(status)) + + + + + 20 + font12 + left + 550 + FFFFFFFF + + !String.IsEmpty(ListItem.Property(fullLanguages)) + + + + + + + + + + + 1250 + + + 10 + 200 + left-circle.png + FF00559D + + + + + 10 + circle.png + 992A3E5C + + + + + font30 + 70 + center + 200 + FFFFFFFF + + + + + + 120 + center + 200 + font20 + FFFFFFFF + left + + + + + + 30 + 220 + 1000 + 20 + font12 + FFFFFFFF + left + + + + + 40 + 20 + + + 30 + + + + 20 + font12 + FFFFFFFF + left + 200 + + !String.IsEmpty(ListItem.Property(size)) + + + + + 20 + font12 + left + 550 + FFFFFFFF + + !String.IsEmpty(ListItem.Property(peers)) + + + + + 20 + 20 + font12 + left + 900 + FFFFFFFF + + !String.IsEmpty(ListItem.Property(seeders)) + + + + + + 70 + + + + 600 + 20 + font12 + 200 + FFFFFFFF + left + + !String.IsEmpty(ListItem.Property(indexer)) + + + + + 20 + font12 + left + 550 + FFFFFFFF + + !String.IsEmpty(ListItem.Property(provider)) + + + + + 20 + font12 + left + 900 + CCFFFFFF + + !String.IsEmpty(ListItem.Property(publishDate)) + + + + 110 + + + + 20 + font12 + left + 200 + FFFFFFFF + + !String.IsEmpty(ListItem.Property(status)) + + + + + 20 + font12 + left + 550 + FFFFFFFF + + !String.IsEmpty(ListItem.Property(fullLanguages)) + + + + + + + + + + WindowOpen + WindowClose + Conditional + Conditional + 100 + 70 + 500 + !String.IsEqual(Window().Property(instant_close),true) + + + + keep + 20 + 20 + 600 + 500 + center + top + $INFO[Window.Property(info.poster)] + !String.IsEmpty(Window.Property(info.poster)) + + + + + true + 650 + 60 + 200 + 460 + font12 + FFFFFFFF + left + top + + !String.IsEmpty(Window.Property(info.plot)) + + + + From c7be5714251b36a15ac6f6d71a2ac2fc8c371517 Mon Sep 17 00:00:00 2001 From: Gabriel Ortega Date: Fri, 31 Jan 2025 00:28:19 +0100 Subject: [PATCH 02/10] fix bug selecting source --- lib/gui/source_select_new.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gui/source_select_new.py b/lib/gui/source_select_new.py index 76b3ab20..8dc33c31 100644 --- a/lib/gui/source_select_new.py +++ b/lib/gui/source_select_new.py @@ -67,7 +67,7 @@ def get_current_sources(self): return self.get_current_section().sources def get_current_source(self): - return self.get_current_section().get_current_source() + return self.get_current_section().get_source() def get_current_position(self): return self.get_current_section().position From c5c27d88e915743fb053c1b6528e7afb544b4edd Mon Sep 17 00:00:00 2001 From: Gabriel Ortega Date: Fri, 31 Jan 2025 00:47:20 +0100 Subject: [PATCH 03/10] fix exception when selecting source --- lib/gui/source_select_new.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/gui/source_select_new.py b/lib/gui/source_select_new.py index 8dc33c31..969ace1c 100644 --- a/lib/gui/source_select_new.py +++ b/lib/gui/source_select_new.py @@ -117,6 +117,12 @@ def __init__( ): super().__init__(xml_file, location, item_information=item_information) + # add a correlative id field to sources + for i, source in enumerate(sources): + source["id"] = i + + self.sources = sources + # get a list different providers that appears in the sources providersAndSeeds = [ [source["provider"], source["seeders"]] @@ -202,9 +208,9 @@ def populate_sources_list(self): def handle_action(self, action_id, control_id=None): self.sections.set_position(self.display_list.getSelectedPosition()) - kodilog(f"action_id: {action_id}, control_id: {control_id}") if action_id == xbmcgui.ACTION_CONTEXT_MENU: - selected_source = self.sections.get_current_source() + selected_source = self.getSourceFromSourceItem(self.sections.get_current_source()) + type = selected_source["type"] if type == "Torrent": response = xbmcgui.Dialog().contextmenu(["Download to Debrid"]) @@ -235,11 +241,15 @@ def _download_into(self): def _resolve_pack(self): pass + def _get_source_from_source_item(self, source_item: SourceItem): + index = int(source_item.getProperty("id")) + return self.sources[index] + def _resolve_item(self, pack_select): self.setProperty("resolving", "true") - selected_source = self.sections.get_current_source() - + selected_source = self.getSourceFromSourceItem(self.sections.get_current_source()) + resolver_window = ResolverWindow( "resolver.xml", ADDON_PATH, From 6e12551455903a0a714b63b874846e18e597a976 Mon Sep 17 00:00:00 2001 From: Gabriel Ortega Date: Fri, 31 Jan 2025 19:45:57 +0100 Subject: [PATCH 04/10] refactoring new source_select --- lib/gui/source_section_manager.py | 102 +++++++ lib/gui/source_select_new.py | 463 ++++++++++++++---------------- 2 files changed, 325 insertions(+), 240 deletions(-) create mode 100644 lib/gui/source_section_manager.py diff --git a/lib/gui/source_section_manager.py b/lib/gui/source_section_manager.py new file mode 100644 index 00000000..50f53b5a --- /dev/null +++ b/lib/gui/source_section_manager.py @@ -0,0 +1,102 @@ +from lib.utils.kodi_utils import bytes_to_human_readable +from lib.utils.debrid_utils import get_debrid_status +from lib.utils.utils import ( + extract_publish_date, + get_colored_languages, + get_random_color, +) +import xbmcgui +from typing import Dict, List + + +class SourceItem(xbmcgui.ListItem): + """A custom ListItem representing a media source with formatted properties.""" + + @staticmethod + def from_source(source: Dict) -> "SourceItem": + """ + Creates a SourceItem from a source dictionary. + + Args: + source: Dictionary containing source metadata + + Returns: + Configured SourceItem with formatted properties + """ + processors = { + "peers": lambda v, s: v or "", + "publishDate": lambda v, s: extract_publish_date(v), + "size": lambda v, s: bytes_to_human_readable(int(v)) if v else "", + "indexer": lambda v, s: SourceItem._format_colored_text(v), + "provider": lambda v, s: SourceItem._format_colored_text(v), + "type": lambda v, s: SourceItem._format_colored_text(v), + "fullLanguages": lambda v, s: get_colored_languages(v) or "", + "status": lambda v, s: get_debrid_status(s) if s.get("isCached") else "", + } + + item = SourceItem(label=source["title"]) + + item_properties = { + key: processors.get(key, lambda v, s: v)(value, source) + for key, value in source.items() + } + + for key, value in item_properties.items(): + item.setProperty(key, str(value)) + + return item + + @staticmethod + def _format_colored_text(text: str) -> str: + """Formats text with random color using Kodi markup.""" + color = get_random_color(text) + return f"[B][COLOR {color}]{text}[/COLOR][/B]" + + +class SourceSection: + """Represents a group of media sources with common characteristics.""" + + def __init__(self, title: str, description: str, sources: List[SourceItem]): + self.title = title + self.description = description + self.sources = sources + self.selection_position = 0 + + @property + def current_source(self) -> SourceItem: + """Get currently selected source in this section.""" + return self.sources[self.selection_position] + + def update_selection_position(self, new_position: int) -> None: + """Update the selected position in this section's source list.""" + self.selection_position = max(0, min(new_position, len(self.sources) - 1)) + + +class SourceSectionManager: + """Manages navigation between multiple SourceSections.""" + + def __init__(self, sections: List[SourceSection], initial_index: int = 0): + self._sections = sections + self._current_index = initial_index + + @property + def current_section(self) -> SourceSection: + """Get the currently active section.""" + return self._sections[self._current_index] + + @property + def section_titles(self) -> List[str]: + """Get list of all section titles.""" + return [section.title for section in self._sections] + + def move_to_next_section(self) -> None: + """Advance to the next section in the list.""" + self._current_index = min(self._current_index + 1, len(self._sections) - 1) + + def move_to_previous_section(self) -> None: + """Return to the previous section in the list.""" + self._current_index = max(self._current_index - 1, 0) + + def jump_to_section(self, section_index: int) -> None: + """Jump directly to a specific section by index.""" + self._current_index = max(0, min(section_index, len(self._sections) - 1)) diff --git a/lib/gui/source_select_new.py b/lib/gui/source_select_new.py index 969ace1c..0970d417 100644 --- a/lib/gui/source_select_new.py +++ b/lib/gui/source_select_new.py @@ -1,277 +1,260 @@ import xbmcgui +from typing import List, Dict, Optional from lib.gui.base_window import BaseWindow from lib.gui.resolver_window import ResolverWindow +from lib.gui.source_section_manager import ( + SourceSectionManager, + SourceSection, + SourceItem, +) from lib.gui.resume_window import ResumeDialog from lib.utils.kodi_utils import ADDON_PATH -from lib.utils.debrid_utils import get_debrid_status -from lib.utils.kodi_utils import bytes_to_human_readable -from lib.utils.utils import ( - extract_publish_date, - get_colored_languages, - get_random_color, -) from lib.api.jacktook.kodi import kodilog -from typing import List - - -class SourceItem(xbmcgui.ListItem): - @staticmethod - def fromSource(source: dict): - item = SourceItem(label=f"{source['title']}") - for info in source: - value = source[info] - if info == "peers": - value = value if value else "" - if info == "publishDate": - value = extract_publish_date(value) - if info == "size": - value = bytes_to_human_readable(int(value)) if value else "" - if info in ["indexer", "provider", "type"]: - color = get_random_color(value) - value = f"[B][COLOR {color}]{value}[/COLOR][/B]" - if info == "fullLanguages": - value = get_colored_languages(value) - if len(value) <= 0: - value = "" - if info == "isCached": - info = "status" - value = get_debrid_status(source) - item.setProperty(info, str(value)) - return item - -class Section: - def __init__(self, title: str, description: str, sources: List[SourceItem]): - self.title = title - self.description = description - self.sources = sources - self.position = 0 - - def set_position(self, position: int): - self.position = position - - def get_source(self): - return self.sources[self.position] - -class SectionCollection: - def __init__(self, current_index: int, sections: List[Section]): - self.sections = sections - self.current_index = current_index - - def get_current_section(self): - return self.sections[self.current_index] - - def get_current_description(self): - return self.get_current_section().description - - def get_current_sources(self): - return self.get_current_section().sources - - def get_current_source(self): - return self.get_current_section().get_source() - - def get_current_position(self): - return self.get_current_section().position - - def set_position(self, position: int): - self.get_current_section().set_position(position) - - def get_next_section(self): - self.current_index += 1 - if self.current_index > len(self.sections) - 1: - self.current_index = len(self.sections) - 1 - return self.sections[self.current_index] - - def get_previous_section(self): - self.current_index -= 1 - if self.current_index < 0: - self.current_index = 0 - return self.sections[self.current_index] - - def get_section_by_index(self, index): - return self.sections[index] - - def get_section_index(self): - return self.current_index - - def set_section_index(self, index): - self.current_index = index - - def get_section_count(self): - return len(self.sections) - - def get_sections(self): - return self.sections - - def get_title(self): - return self.get_current_section().title - - def get_titles(self): - return [section.title for section in self.sections] - - - + class SourceSelectNew(BaseWindow): + """Main window for selecting media sources from organized sections.""" + + CACHE_KEY_FIELD = "tv_data" # Fallback to "ids" if not available + def __init__( - self, xml_file, location, item_information=None, sources=None, uncached=None + self, + xml_layout: str, + window_location: str, + item_information: Optional[Dict] = None, + sources: Optional[List[Dict]] = None, + uncached: Optional[List[Dict]] = None, ): - super().__init__(xml_file, location, item_information=item_information) - - # add a correlative id field to sources - for i, source in enumerate(sources): - source["id"] = i - - self.sources = sources - - # get a list different providers that appears in the sources - providersAndSeeds = [ - [source["provider"], source["seeders"]] - for source in sources - ] - - # reduce the list to providers and the sum of their seeds - providers = {} - for provider, seeds in providersAndSeeds: - if provider in providers: - providers[provider] += seeds - else: - providers[provider] = seeds - - # get a list of providers sorted by the sum of their seeds - sortedProviders = [x[0] for x in sorted(providers.items(), key=lambda x: x[1], reverse=True)] - - sectionList = [] - sectionList.append(Section( - "Priority Language", - "Sources with Spanish audio", - [SourceItem.fromSource(source) for source in sources if 'es' in source["fullLanguages"]])) - sectionList.append(Section( - "Top Seeders", - "Results with the most seeders", - [SourceItem.fromSource(source) for source in sources if not 'es' in source["fullLanguages"]]) - ) - for provider in sortedProviders: - sectionList.append(Section( - provider, - f"Filtered sources from {provider} provider", - [SourceItem.fromSource(source) for source in sources if source["provider"] == provider])) - - - self.sections = SectionCollection(0, sectionList) - - self.uncached_sources = uncached or [] - self.position = -1 - self.sources = sources - self.item_information = item_information - self.playback_info = None - self.resume = None - self.CACHE_KEY = ( - self.item_information["tv_data"] or self.item_information["ids"] - ) + super().__init__(xml_layout, window_location, item_information=item_information) + + self._sources = self._preprocess_sources(sources or []) + self._uncached_sources = uncached or [] + self._item_metadata = item_information or {} + self._playback_info = None + self._resume_flag = None + + self._init_ui_properties() + self._section_manager = self._create_section_manager() + + def _preprocess_sources(self, raw_sources: List[Dict]) -> List[Dict]: + """Add unique identifiers to sources for tracking.""" + return [dict(source, id=i) for i, source in enumerate(raw_sources)] + + def _init_ui_properties(self) -> None: + """Initialize default UI state properties.""" self.setProperty("instant_close", "false") self.setProperty("resolving", "false") - def onInit(self): - self.display_list = self.getControlList(1000) - self.title = self.getControl(1001) - self.description = self.getControl(1002) - self.populate_sources_list() - self.set_default_focus(self.display_list, 1000, control_list_reset=True) + def _create_section_manager(self) -> SourceSectionManager: + """Organize sources into categorized sections.""" + sections = [ + self._create_priority_language_section(), + self._create_top_seeders_section(), + *self._create_provider_sections(), + ] + return SourceSectionManager(sections) + + def _create_priority_language_section(self) -> SourceSection: + """Create section for priority language (Spanish) sources.""" + spanish_sources = [ + s for s in self._sources if "es" in s.get("fullLanguages", []) + ] + return SourceSection( + title="Priority Language", + description="Sources with Spanish audio", + sources=[SourceItem.from_source(s) for s in spanish_sources], + ) + + def _create_top_seeders_section(self) -> SourceSection: + """Create section for sources with highest combined seeders.""" + non_spanish_sources = [ + s for s in self._sources if "es" not in s.get("fullLanguages", []) + ] + return SourceSection( + title="Top Seeders", + description="Results with the most seeders", + sources=[SourceItem.from_source(s) for s in non_spanish_sources], + ) + + def _create_provider_sections(self) -> List[SourceSection]: + """Create sections organized by provider, sorted by total seeders.""" + provider_rankings = self._calculate_provider_seed_rankings() + return [ + SourceSection( + title=provider, + description=f"Sources from {provider} provider", + sources=[ + SourceItem.from_source(s) + for s in self._sources + if s["provider"] == provider + ], + ) + for provider in provider_rankings + ] + + def _calculate_provider_seed_rankings(self) -> List[str]: + """Calculate provider rankings based on total seeders.""" + seed_sums: Dict[str, int] = {} + for source in self._sources: + provider = source["provider"] + seed_sums[provider] = seed_sums.get(provider, 0) + source.get("seeders", 0) + return sorted(seed_sums.keys(), key=lambda k: seed_sums[k], reverse=True) + + def onInit(self) -> None: + """Initialize window controls and populate initial data.""" + self._source_list = self.getControlList(1000) + self._navigation_label = self.getControl(1001) + self._description_label = self.getControl(1002) + self._refresh_ui() + self.set_default_focus(self._source_list, 1000, control_list_reset=True) super().onInit() - def doModal(self): - super().doModal() - return self.playback_info - - def populate_sources_list(self): - - # nav bar - titles = self.sections.get_titles() - current_index = self.sections.get_section_index() - - navitems = titles[:current_index] - navitems = ["...", navitems[-2], navitems[-1]] if len(navitems) > 2 else navitems - - navitems.append(f"[B][COLOR white]{titles[current_index]}[/COLOR][/B]") - navitems.extend(titles[current_index + 1:]) - - self.title.setLabel(" | ".join(navitems)) - - # description - self.description.setLabel(self.sections.get_current_description()) - - # list - self.display_list.reset() - sources = self.sections.get_current_sources() - self.display_list.addItems(sources) - self.display_list.selectItem(self.sections.get_current_position()) - - def handle_action(self, action_id, control_id=None): - self.sections.set_position(self.display_list.getSelectedPosition()) - if action_id == xbmcgui.ACTION_CONTEXT_MENU: - selected_source = self.getSourceFromSourceItem(self.sections.get_current_source()) - - type = selected_source["type"] - if type == "Torrent": - response = xbmcgui.Dialog().contextmenu(["Download to Debrid"]) - if response == 0: - self._download_into() - elif type == "Direct": - pass - else: - response = xbmcgui.Dialog().contextmenu(["Browse into"]) - if response == 0: - self._resolve_pack() + def _refresh_ui(self) -> None: + """Update all UI elements with current state.""" + self._update_navigation_header() + self._update_description() + self._populate_source_list() + + def _update_navigation_header(self) -> None: + """Update the navigation breadcrumb display.""" + current_index = self._section_manager._current_index + all_titles = self._section_manager.section_titles + + # Build truncated navigation path + preceding_titles = all_titles[:current_index] + if len(preceding_titles) > 2: + preceding_titles = ["...", *preceding_titles[-2:]] + + navigation_path = [ + *preceding_titles, + f"[B][COLOR white]{all_titles[current_index]}[/COLOR][/B]", + *all_titles[current_index + 1 :], + ] + + self._navigation_label.setLabel(" | ".join(navigation_path)) + + def _update_description(self) -> None: + """Update the section description label.""" + self._description_label.setLabel( + self._section_manager.current_section.description + ) + + def _populate_source_list(self) -> None: + """Populate the source list with current section's items.""" + self._source_list.reset() + current_sources = self._section_manager.current_section.sources + self._source_list.addItems(current_sources) + self._source_list.selectItem( + self._section_manager.current_section.selection_position + ) + + def handle_action(self, action_id: int, control_id: Optional[int] = None) -> None: + """Handle user input actions.""" + kodilog(f"Action ID: {action_id}, Control ID: {control_id}") if control_id == 1000: - if action_id == xbmcgui.ACTION_SELECT_ITEM: - if control_id == 1000: - control_list = self.getControl(control_id) - self.set_cached_focus(control_id, control_list.getSelectedPosition()) - self._resolve_item(pack_select=False) - if action_id == xbmcgui.ACTION_MOVE_LEFT: - self.sections.get_previous_section() - self.populate_sources_list() - if action_id == xbmcgui.ACTION_MOVE_RIGHT: - self.sections.get_next_section() - self.populate_sources_list() - - def _download_into(self): - pass + self._handle_source_list_action(action_id) + + def _handle_source_list_action(self, action_id: int) -> None: + """Process actions specific to the source list control.""" + current_section = self._section_manager.current_section + current_section.update_selection_position( + self._source_list.getSelectedPosition() + ) + + action_handlers = { + xbmcgui.ACTION_SELECT_ITEM: self._resolve_selected_source, + xbmcgui.ACTION_MOVE_LEFT: self._section_manager.move_to_previous_section, + xbmcgui.ACTION_MOVE_RIGHT: self._section_manager.move_to_next_section, + xbmcgui.ACTION_CONTEXT_MENU: self._show_context_menu, + } + + handler = action_handlers.get(action_id) + if handler: + handler() + self._refresh_ui() - def _resolve_pack(self): + def _show_context_menu(self) -> None: + """Display context menu for selected source.""" + source = self._get_source_from_item( + self._section_manager.current_section.current_source + ) + menu_options = self._get_context_menu_options(source["type"]) + + choice = xbmcgui.Dialog().contextmenu(menu_options) + if choice == 0: + self._handle_context_choice(source["type"]) + + def _get_context_menu_options(self, source_type: str) -> List[str]: + """Get available context menu options based on source type.""" + return { + "Torrent": ["Download to Debrid"], + "Direct": [], + }.get(source_type, ["Browse into"]) + + def _handle_context_choice(self, source_type: str) -> None: + """Handle context menu selection.""" + handlers = { + "Torrent": self._download_to_debrid, + "Direct": lambda: None, + "default": self._browse_source_pack, + } + handler = handlers.get(source_type, handlers["default"]) + handler() + + def _download_to_debrid(self) -> None: + """Handle Debrid download request.""" + # Implementation placeholder pass - def _get_source_from_source_item(self, source_item: SourceItem): - index = int(source_item.getProperty("id")) - return self.sources[index] + def _browse_source_pack(self) -> None: + """Handle pack browsing request.""" + # Implementation placeholder + pass - def _resolve_item(self, pack_select): + def _resolve_selected_source(self) -> None: + """Initiate resolution of the selected source.""" self.setProperty("resolving", "true") + selected_source = self._get_source_from_item( + self._section_manager.current_section.current_source + ) - selected_source = self.getSourceFromSourceItem(self.sections.get_current_source()) - - resolver_window = ResolverWindow( + resolver = ResolverWindow( "resolver.xml", ADDON_PATH, source=selected_source, previous_window=self, - item_information=self.item_information, + item_information=self._item_metadata, ) - resolver_window.doModal(pack_select) - self.playback_info = resolver_window.playback_info + resolver.doModal(pack_select=False) + self._playback_info = resolver.playback_info + + del resolver + self._close_window() + + def _get_source_from_item(self, source_item: SourceItem) -> Dict: + """Retrieve original source data from ListItem.""" + source_id = int(source_item.getProperty("id")) + return next(s for s in self._sources if s["id"] == source_id) - del resolver_window + def _close_window(self) -> None: + """Close the window and clean up resources.""" self.setProperty("instant_close", "true") self.close() - def show_resume_dialog(self, playback_percent): + def doModal(self) -> Optional[Dict]: + """Display the window and return playback info when closed.""" + super().doModal() + return self._playback_info + + def show_resume_dialog(self, playback_percent: float) -> bool: + """Display resume playback dialog.""" try: - resume_window = ResumeDialog( + resume_dialog = ResumeDialog( "resume_dialog.xml", ADDON_PATH, resume_percent=playback_percent, ) - resume_window.doModal() - return resume_window.resume + resume_dialog.doModal() + return resume_dialog.resume finally: - del resume_window + del resume_dialog From c351dda6735feed4584949bfd02b5e69a07b77f8 Mon Sep 17 00:00:00 2001 From: Gabriel Ortega Date: Fri, 31 Jan 2025 23:38:14 +0100 Subject: [PATCH 05/10] refactor enrichment / filtering / sorting --- lib/clients/stremio_addon.py | 52 +------ lib/gui/source_select_new.py | 35 +---- lib/sources_tools/__init__.py | 17 +++ lib/sources_tools/enricher.py | 9 ++ lib/sources_tools/enricher_builder.py | 20 +++ lib/sources_tools/filter_builder.py | 128 ++++++++++++++++ lib/sources_tools/is_pack_enricher.py | 40 +++++ lib/sources_tools/language_enricher.py | 27 ++++ lib/sources_tools/quality_enricher.py | 39 +++++ lib/sources_tools/stats_enricher.py | 29 ++++ lib/utils/utils.py | 197 +++++-------------------- 11 files changed, 362 insertions(+), 231 deletions(-) create mode 100644 lib/sources_tools/__init__.py create mode 100644 lib/sources_tools/enricher.py create mode 100644 lib/sources_tools/enricher_builder.py create mode 100644 lib/sources_tools/filter_builder.py create mode 100644 lib/sources_tools/is_pack_enricher.py create mode 100644 lib/sources_tools/language_enricher.py create mode 100644 lib/sources_tools/quality_enricher.py create mode 100644 lib/sources_tools/stats_enricher.py diff --git a/lib/clients/stremio_addon.py b/lib/clients/stremio_addon.py index 20ff035d..2ea05191 100644 --- a/lib/clients/stremio_addon.py +++ b/lib/clients/stremio_addon.py @@ -4,12 +4,6 @@ from lib.stremio.addons_manager import Addon from lib.stremio.stream import Stream -from lib.api.jacktook.kodi import kodilog -from lib.utils.kodi_utils import convert_size_to_bytes -from lib.utils.language_detection import find_languages_in_string - -import re - class StremioAddonClient(BaseClient): def __init__(self, addon: Addon): @@ -40,7 +34,6 @@ def parse_response(self, res): results = [] for item in res["streams"]: stream = Stream(item) - parsed = self.parse_torrent_description(stream.description) results.append( { "title": stream.get_parsed_title(), @@ -49,50 +42,19 @@ def parse_response(self, res): if stream.url else IndexerType.TORRENT ), + "description": stream.description, "url": stream.url, "indexer": self.addon.manifest.name.split(" ")[0], "guid": stream.infoHash, "magnet": info_hash_to_magnet(stream.infoHash), "infoHash": stream.infoHash, - "size": stream.get_parsed_size() - or item.get("sizebytes") - or parsed["size"], - "seeders": item.get("seed", 0) or parsed["seeders"], - "languages": parsed["languages"], # [item.get("language", "")], - "fullLanguages": parsed["languages"], # [item.get("language", "")], - "provider": parsed["provider"], + "size": stream.get_parsed_size() or item.get("sizebytes"), + "seeders": item.get("seed", 0), + "languages": [item.get("language")] if item.get("language") else [], + "fullLanguages": [item.get("language")] if item.get("language") else [], + "provider": "", "publishDate": "", "peers": 0, } ) - return results - - def parse_torrent_description(self, desc: str) -> dict: - # Extract size - size_pattern = r"💾 ([\d.]+ (?:GB|MB))" - size_match = re.search(size_pattern, desc) - size = size_match.group(1) if size_match else None - if size: - size = convert_size_to_bytes(size) - - # Extract seeders - seeders_pattern = r"👤 (\d+)" - seeders_match = re.search(seeders_pattern, desc) - seeders = int(seeders_match.group(1)) if seeders_match else None - - # Extract provider - provider_pattern = r"([🌐🔗⚙️])\s*([^🌐🔗⚙️]+)" - provider_match = re.findall(provider_pattern, desc) - - words = [match[1].strip() for match in provider_match] - - provider = "N/A" - if words: - provider = words[-1].splitlines()[0] - - return { - "size": size or 0, - "seeders": seeders or 0, - "provider": provider or "", - "languages": find_languages_in_string(desc), - } + return results \ No newline at end of file diff --git a/lib/gui/source_select_new.py b/lib/gui/source_select_new.py index 0970d417..1111831e 100644 --- a/lib/gui/source_select_new.py +++ b/lib/gui/source_select_new.py @@ -50,15 +50,18 @@ def _create_section_manager(self) -> SourceSectionManager: sections = [ self._create_priority_language_section(), self._create_top_seeders_section(), - *self._create_provider_sections(), ] - return SourceSectionManager(sections) - def _create_priority_language_section(self) -> SourceSection: + return SourceSectionManager([section for section in sections if section]) + + def _create_priority_language_section(self) -> Optional[SourceSection]: """Create section for priority language (Spanish) sources.""" spanish_sources = [ s for s in self._sources if "es" in s.get("fullLanguages", []) ] + if not spanish_sources: + return None + return SourceSection( title="Priority Language", description="Sources with Spanish audio", @@ -76,30 +79,6 @@ def _create_top_seeders_section(self) -> SourceSection: sources=[SourceItem.from_source(s) for s in non_spanish_sources], ) - def _create_provider_sections(self) -> List[SourceSection]: - """Create sections organized by provider, sorted by total seeders.""" - provider_rankings = self._calculate_provider_seed_rankings() - return [ - SourceSection( - title=provider, - description=f"Sources from {provider} provider", - sources=[ - SourceItem.from_source(s) - for s in self._sources - if s["provider"] == provider - ], - ) - for provider in provider_rankings - ] - - def _calculate_provider_seed_rankings(self) -> List[str]: - """Calculate provider rankings based on total seeders.""" - seed_sums: Dict[str, int] = {} - for source in self._sources: - provider = source["provider"] - seed_sums[provider] = seed_sums.get(provider, 0) + source.get("seeders", 0) - return sorted(seed_sums.keys(), key=lambda k: seed_sums[k], reverse=True) - def onInit(self) -> None: """Initialize window controls and populate initial data.""" self._source_list = self.getControlList(1000) @@ -142,6 +121,8 @@ def _update_description(self) -> None: def _populate_source_list(self) -> None: """Populate the source list with current section's items.""" self._source_list.reset() + kodilog(f"Current Section: {self._section_manager.current_section.title}") + kodilog(f"Current Sources: {self._section_manager.current_section.sources}") current_sources = self._section_manager.current_section.sources self._source_list.addItems(current_sources) self._source_list.selectItem( diff --git a/lib/sources_tools/__init__.py b/lib/sources_tools/__init__.py new file mode 100644 index 00000000..a32e5c80 --- /dev/null +++ b/lib/sources_tools/__init__.py @@ -0,0 +1,17 @@ +from .enricher_builder import EnricherBuilder +from .enricher import Enricher +from .language_enricher import LanguageEnricher +from .stats_enricher import StatsEnricher +from .filter_builder import FilterBuilder +from .is_pack_enricher import IsPackEnricher +from .quality_enricher import QualityEnricher + +__all__ = [ + "EnricherBuilder", + "Enricher", + "LanguageEnricher", + "StatsEnricher", + "FilterBuilder", + "IsPackEnricher", + "QualityEnricher", +] diff --git a/lib/sources_tools/enricher.py b/lib/sources_tools/enricher.py new file mode 100644 index 00000000..f7deab03 --- /dev/null +++ b/lib/sources_tools/enricher.py @@ -0,0 +1,9 @@ +import abc +from typing import Dict + + +class Enricher(abc.ABC): + @abc.abstractmethod + def enrich(self, item: Dict) -> None: + """Enrich an item with additional metadata""" + pass diff --git a/lib/sources_tools/enricher_builder.py b/lib/sources_tools/enricher_builder.py new file mode 100644 index 00000000..21ad1260 --- /dev/null +++ b/lib/sources_tools/enricher_builder.py @@ -0,0 +1,20 @@ +from typing import Dict, List +from .enricher import Enricher + + +class EnricherBuilder: + def __init__(self, items: List[Dict]): + self.items = [item.copy() for item in items] + self._enrichers: List[Enricher] = [] + + def add(self, enricher: Enricher) -> "EnricherBuilder": + self._enrichers.append(enricher) + return self + + def build(self) -> List[Dict]: + processed = [] + for item in self.items: + for enricher in self._enrichers: + enricher.enrich(item) + processed.append(item) + return processed diff --git a/lib/sources_tools/filter_builder.py b/lib/sources_tools/filter_builder.py new file mode 100644 index 00000000..b33ed19b --- /dev/null +++ b/lib/sources_tools/filter_builder.py @@ -0,0 +1,128 @@ +import re +from typing import List, Dict, Any, Union + + +class FilterBuilder: + def __init__(self, items: List[Dict[str, Any]]): + self.items = items + self._sort_criteria: List[tuple] = [] + self._limit: int = 0 + self._language_filters: List[str] = [] + self._episode_name: Union[str, None] = None + self._episode_num: Union[int, None] = None + self._season_num: Union[int, None] = None + self._filter_sources: bool = False # New flag for source filtering + + def sort_by(self, field: str, ascending: bool = True) -> "FilterBuilder": + self._sort_criteria.append((field, ascending)) + return self + + def limit(self, n: int) -> "FilterBuilder": + self._limit = n + return self + + def filter_by_language(self, language_code: str) -> "FilterBuilder": + self._language_filters.append(language_code) + return self + + def filter_by_episode( + self, + episode_name: str, + episode_num: Union[int, str], + season_num: Union[int, str], + ) -> "FilterBuilder": + self._episode_name = episode_name + self._episode_num = int(episode_num) + self._season_num = int(season_num) + return self + + # New method for source filtering + def filter_by_source(self) -> "FilterBuilder": + self._filter_sources = True + return self + + def build(self) -> List[Dict[str, Any]]: + filtered = self._apply_filters() + sorted_items = self._apply_sorting(filtered) + return self._apply_limit(sorted_items) + + def _apply_filters(self) -> List[Dict[str, Any]]: + # Remove duplicates first (order-preserving) + seen = [] + filtered = [] + for item in self.items: + if item not in seen: + filtered.append(item) + seen.append(item) + + # Apply source filter if enabled + if self._filter_sources: + filtered = [ + item for item in filtered if item.get("infoHash") or item.get("guid") + ] + + # Apply language filters (OR logic) + if self._language_filters: + filtered = [ + item + for item in filtered + if any( + lang in item.get("languages", []) for lang in self._language_filters + ) + ] + + # Apply episode filter + if self._episode_num is not None and self._season_num is not None: + episode_fill = f"{self._episode_num:02d}" + season_fill = f"{self._season_num:02d}" + + patterns = [ + rf"S{season_fill}E{episode_fill}", # SXXEXX + rf"{season_fill}x{episode_fill}", # XXxXX + rf"\s{season_fill}\s", # Space-padded season + rf"\.S{season_fill}", # .SXX + rf"\.S{season_fill}E{episode_fill}", # .SXXEXX + rf"\sS{season_fill}E{episode_fill}\s", # Space-padded SXXEXX + r"Cap\.", # Cap. prefix + ] + + if self._episode_name: + patterns.append(re.escape(self._episode_name)) + + combined_pattern = re.compile("|".join(patterns), flags=re.IGNORECASE) + filtered = [ + item + for item in filtered + if combined_pattern.search(item.get("title", "")) + ] + + return filtered + + def _apply_sorting(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + if not self._sort_criteria: + return items + + def sort_key(item): + key = [] + for field, ascending in self._sort_criteria: + value = item.get(field) + # Handle numeric fields with descending support + if isinstance(value, (int, float)): + key.append(-value if not ascending else value) + # Handle string fields (lexicographic sorting) + elif isinstance(value, str): + key.append(value.lower() if ascending else value.lower()[::-1]) + else: + key.append(value) + return tuple(key) + + try: + result = sorted(items, key=sort_key) + return result + except TypeError: + pass + + return items + + def _apply_limit(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return items[: self._limit] if self._limit else items diff --git a/lib/sources_tools/is_pack_enricher.py b/lib/sources_tools/is_pack_enricher.py new file mode 100644 index 00000000..6263d0cc --- /dev/null +++ b/lib/sources_tools/is_pack_enricher.py @@ -0,0 +1,40 @@ +from .enricher import Enricher +import re +from re import Pattern +from typing import Dict + + +class IsPackEnricher(Enricher): + def __init__(self, season_number: int): + self.season_number = season_number + self.season_fill = f"{season_number:02d}" + self.pattern = self._build_pattern() + + def _build_pattern(self) -> Pattern: + base_patterns = [ + # Season number variations + rf"\.S({self.season_number}|{self.season_fill})\.", + rf"\sS({self.season_number}|{self.season_fill})\s", + rf"\.({self.season_number}|{self.season_fill})\.season", + # Complete season indicators + r"total\.season", + r"(^|\s)season(\s|$)", + r"the\.complete", + r"(^|\s)complete(\s|$)", + # Episode range detection + rf"S{self.season_fill}E\d{{2}}-\d{{2}}", + # Season directory patterns + rf"\.season\.({self.season_number}|{self.season_fill})\.", + rf"\.season({self.season_number}|{self.season_fill})\.", + # Season range patterns + rf"s01 (to|thru) ({self.season_number}|s{self.season_fill})", + rf"s1 (to|thru) ({self.season_number}|s{self.season_fill})", + ] + + return re.compile( + "|".join(f"({p})" for p in base_patterns), flags=re.IGNORECASE + ) + + def enrich(self, item: Dict) -> None: + title = item.get("title", "") + item["isPack"] = bool(self.pattern.search(title)) diff --git a/lib/sources_tools/language_enricher.py b/lib/sources_tools/language_enricher.py new file mode 100644 index 00000000..c9025672 --- /dev/null +++ b/lib/sources_tools/language_enricher.py @@ -0,0 +1,27 @@ +from .enricher import Enricher +import re +from typing import Dict, Set + + +class LanguageEnricher(Enricher): + def __init__(self, language_map: Dict[str, str], keywords: Set[str]): + self.flag_regex = re.compile(r"[\U0001F1E6-\U0001F1FF]{2}") + self.keyword_regex = re.compile( + r"\b(?:" + "|".join(re.escape(k) for k in keywords) + r")\b", re.IGNORECASE + ) + self.language_map = language_map + + def enrich(self, item: Dict) -> None: + desc = item.get("description", "") + + # Flag-based detection + flags = self.flag_regex.findall(desc) + flag_langs = {self.language_map.get(f, "") for f in flags} + + # Keyword-based detection + keywords = self.keyword_regex.findall(desc.lower()) + keyword_langs = {self.language_map.get(k, "") for k in keywords} + + combined = flag_langs | keyword_langs + item["languages"] = list(combined - {""}) + item["fullLanguages"] = item["languages"].copy() diff --git a/lib/sources_tools/quality_enricher.py b/lib/sources_tools/quality_enricher.py new file mode 100644 index 00000000..85e69d40 --- /dev/null +++ b/lib/sources_tools/quality_enricher.py @@ -0,0 +1,39 @@ +from .enricher import Enricher +import re +from typing import Dict + + +class QualityEnricher(Enricher): + class ResolutionTier: + def __init__(self, pattern: str, label: str, priority: int): + self.regex = re.compile(pattern, re.IGNORECASE) + self.label = label + self.priority = priority + + def __init__(self, field_name: str = "quality"): + self.field_name = field_name + self.tiers = [ + self.ResolutionTier( + r"(?i)\b(2160p?|4k|uhd)\b", "[B][COLOR yellow]4k[/COLOR][/B]", 4 + ), + self.ResolutionTier( + r"(?i)\b(1080p?|fullhd)\b", "[B][COLOR blue]1080p[/COLOR][/B]", 3 + ), + self.ResolutionTier( + r"(?i)\b720p?\b", "[B][COLOR orange]720p[/COLOR][/B]", 2 + ), + self.ResolutionTier( + r"(?i)\b480p?\b", "[B][COLOR orange]480p[/COLOR][/B]", 1 + ), + ] + + def enrich(self, item: Dict) -> None: + title = item.get("title", "") + for tier in sorted(self.tiers, key=lambda t: -t.priority): + if tier.regex.search(title): + item["quality"] = tier.label + item["quality_sort"] = tier.priority + return + + item["quality"] = "[B][COLOR yellow]N/A[/COLOR][/B]" + item["quality_sort"] = 0 diff --git a/lib/sources_tools/stats_enricher.py b/lib/sources_tools/stats_enricher.py new file mode 100644 index 00000000..4a4a07eb --- /dev/null +++ b/lib/sources_tools/stats_enricher.py @@ -0,0 +1,29 @@ +from .enricher import Enricher +from typing import Dict, Callable +import re + + +class StatsEnricher(Enricher): + def __init__(self, size_converter: Callable): + self.size_pattern = re.compile(r"💾 ([\d.]+ (?:GB|MB))") + self.seeders_pattern = re.compile(r"👤 (\d+)") + self.provider_pattern = re.compile(r"([🌐🔗⚙️])\s*([^🌐🔗⚙️]+)") + self.convert_size = size_converter + + def enrich(self, item: Dict) -> None: + desc = item.get("description", "") + if not desc: + return + + # Size extraction + if size_match := self.size_pattern.search(desc): + item["size"] = self.convert_size(size_match.group(1)) + + # Seeders extraction + if seeders_match := self.seeders_pattern.search(desc): + item["seeders"] = int(seeders_match.group(1)) + + # Provider detection + if provider_matches := self.provider_pattern.findall(desc): + cleaned = [m[1].strip().splitlines()[0] for m in provider_matches] + item["provider"] = cleaned[-1] if cleaned else "N/A" diff --git a/lib/utils/utils.py b/lib/utils/utils.py index 4dec6cf9..bbc56737 100644 --- a/lib/utils/utils.py +++ b/lib/utils/utils.py @@ -14,8 +14,16 @@ from lib.api.tvdbapi.tvdbapi import TVDBAPI from lib.db.cached import cache from lib.db.main_db import main_db - - +from lib.sources_tools import FilterBuilder +from lib.sources_tools import ( + EnricherBuilder, + StatsEnricher, + QualityEnricher, + LanguageEnricher, + IsPackEnricher, +) +from lib.utils.kodi_utils import convert_size_to_bytes +from lib.utils.language_detection import language_codes, langsSet from lib.torf._magnet import Magnet from lib.utils.kodi_utils import ( ADDON_HANDLE, @@ -611,25 +619,10 @@ def clear(type="", update=False): container_refresh() -def limit_results(results): - limit = int(get_setting("indexers_total_results")) - return results[:limit] - - def get_description_length(): return int(get_setting("indexers_desc_length")) -def remove_duplicate(results): - seen_values = [] - result_dict = [] - for res in results: - if res not in seen_values: - result_dict.append(res) - seen_values.append(res) - return result_dict - - def unzip(zip_location, destination_location, destination_check): try: zipfile = ZipFile(zip_location) @@ -643,161 +636,47 @@ def unzip(zip_location, destination_location, destination_check): return status -def check_season_pack(results, season_num): - season_fill = f"{int(season_num):02}" - - patterns = [ - # Season as ".S{season_num}." or ".S{season_fill}." - r"\.S%s\." % season_num, - r"\.S%s\." % season_fill, - # Season as " S{season_num} " or " S{season_fill} " - r"\sS%s\s" % season_num, - r"\sS%s\s" % season_fill, - # Season as ".{season_num}.season" (like .1.season, .01.season) - r"\.%s\.season" % season_num, - # "total.season" or "season" or "the.complete" - r"total\.season", - r"season", - r"the\.complete", - r"complete", - # Pattern to detect episode ranges like S02E01-02 - r"S(\d{2})E(\d{2})-(\d{2})", - # Season as ".season.{season_num}." or ".season.{season_fill}." - r"\.season\.%s\." % season_num, - r"\.season%s\." % season_num, - r"\.season\.%s\." % season_fill, - # Handle cases "s1 to {season_num}", "s1 thru {season_num}", etc. - r"s1 to %s" % season_num, - r"s1 to s%s" % season_num, - r"s01 to %s" % season_fill, - r"s01 to s%s" % season_fill, - r"s1 thru %s" % season_num, - r"s1 thru s%s" % season_num, - r"s01 thru %s" % season_fill, - r"s01 thru s%s" % season_fill, - ] - - combined_pattern = "|".join(patterns) - - for res in results: - match = re.search(combined_pattern, res["title"]) - if match: - res["isPack"] = True - else: - res["isPack"] = False - +def pre_process(results, mode, ep_name, episode, season): + results = ( + EnricherBuilder(results) + .add(StatsEnricher(size_converter=convert_size_to_bytes)) + .add(QualityEnricher()) + .add(LanguageEnricher(language_codes, langsSet)) + .build() + ) -def pre_process(results, mode, episode_name, episode, season): - results = remove_duplicate(results) + filters = FilterBuilder(results) if get_setting("stremio_enabled") and get_setting("torrent_enable"): - results = filter_torrent_sources(results) + filters.filter_by_source() if mode == "tv" and get_setting("filter_by_episode"): - results = filter_by_episode(results, episode_name, episode, season) - - results = filter_by_quality(results) + filters.filter_by_episode(ep_name, episode, season) - return results + return filters.build() def post_process(results, season=0): - if int(season) > 0: - check_season_pack(results, season) - - results = sort_results(results) - - results = limit_results(results) - - return results - - -def filter_torrent_sources(results): - filtered_results = [] - for res in results: - if res["infoHash"] or res["guid"]: - filtered_results.append(res) - return filtered_results - - -def sort_results(res): sort_by = get_setting("indexers_sort_by") + limit = int(get_setting("indexers_total_results")) - field_to_sort = { - "Seeds": "seeders", - "Size": "size", - "Date": "publishDate", - "Quality": "quality", - "Cached": "isCached", - } - - if sort_by in field_to_sort: - res = sorted(res, key=lambda r: r.get(field_to_sort[sort_by], 0), reverse=True) - - priority_language = get_setting("priority_language").lower() - if priority_language and priority_language != "None": - res = sorted( - res, key=lambda r: priority_language in r.get("languages", []), reverse=True - ) + results = EnricherBuilder(results).add(IsPackEnricher(season)).build() - return res - - -def filter_by_episode(results, episode_name, episode_num, season_num): - episode_fill = f"{int(episode_num):02}" - season_fill = f"{int(season_num):02}" - - patterns = [ - r"S%sE%s" % (season_fill, episode_fill), # SXXEXX format - r"%sx%s" % (season_fill, episode_fill), # XXxXX format - r"\s%s\s" % season_fill, # season surrounded by spaces - r"\.S%s" % season_fill, # .SXX format - r"\.S%sE%s" % (season_fill, episode_fill), # .SXXEXX format - r"\sS%sE%s\s" - % (season_fill, episode_fill), # season and episode surrounded by spaces - r"Cap\.", # match "Cap." - ] - - if episode_name: - patterns.append(episode_name) - - combined_pattern = "|".join(patterns) - - filtered_episodes = [] - for res in results: - match = re.search(combined_pattern, res["title"]) - if match: - filtered_episodes.append(res) - - return filtered_episodes - - -def filter_by_quality(results): - quality_720p = [] - quality_1080p = [] - quality_4k = [] - no_quarlity = [] - - for res in results: - title = res["title"] - if "480p" in title: - res["quality"] = "[B][COLOR orange]480p[/COLOR][/B]" - quality_720p.append(res) - elif "720p" in title: - res["quality"] = "[B][COLOR orange]720p[/COLOR][/B]" - quality_720p.append(res) - elif "1080p" in title: - res["quality"] = "[B][COLOR blue]1080p[/COLOR][/B]" - quality_1080p.append(res) - elif "2160" in title: - res["quality"] = "[B][COLOR yellow]4k[/COLOR][/B]" - quality_4k.append(res) - else: - res["quality"] = "[B][COLOR yellow]N/A[/COLOR][/B]" - no_quarlity.append(res) + filters = FilterBuilder(results).limit(limit) + + if sort_by == "Seeds": + filters.sort_by("seeders", ascending=False) + elif sort_by == "Size": + filters.sort_by("size", ascending=False) + elif sort_by == "Date": + filters.sort_by("publishDate", ascending=False) + elif sort_by == "Quality": + filters.sort_by("quality_sort", ascending=False) + filters.sort_by("seeders", ascending=False) + elif sort_by == "Cached": + filters.sort_by("isCached", ascending=False) - combined_list = quality_4k + quality_1080p + quality_720p + no_quarlity - return combined_list + return filters.build() def clean_auto_play_undesired(results): From 405571ae4402f0f70990bb260abc1868e3820b4c Mon Sep 17 00:00:00 2001 From: Gabriel Ortega Date: Sat, 1 Feb 2025 23:14:39 +0100 Subject: [PATCH 06/10] several fixes. fix in torbox client. added settings side panel in source select --- lib/clients/debrid/torbox.py | 4 +- lib/gui/source_section_manager.py | 1 - lib/gui/source_select_new.py | 65 ++- lib/sources_tools/__init__.py | 2 + lib/sources_tools/cache_enricher.py | 32 ++ lib/sources_tools/enricher.py | 17 +- lib/sources_tools/enricher_builder.py | 10 +- lib/sources_tools/filter_builder.py | 182 ++++--- lib/sources_tools/is_pack_enricher.py | 11 +- lib/sources_tools/language_enricher.py | 11 +- lib/sources_tools/quality_enricher.py | 18 +- lib/sources_tools/stats_enricher.py | 11 +- lib/utils/utils.py | 22 +- .../skins/Default/1080i/source_select_new.xml | 500 +++++++----------- 14 files changed, 483 insertions(+), 403 deletions(-) create mode 100644 lib/sources_tools/cache_enricher.py diff --git a/lib/clients/debrid/torbox.py b/lib/clients/debrid/torbox.py index a36e9ecb..ab547152 100644 --- a/lib/clients/debrid/torbox.py +++ b/lib/clients/debrid/torbox.py @@ -77,9 +77,9 @@ def get_available_torrent(self, info_hash): def get_torrent_instant_availability(self, torrent_hashes): return self._make_request( - "GET", + "POST", "/torrents/checkcached", - params={"hash": torrent_hashes, "format": "object"}, + json={"hashes": torrent_hashes, "format": "object"}, ) def create_download_link(self, torrent_id, filename, user_ip): diff --git a/lib/gui/source_section_manager.py b/lib/gui/source_section_manager.py index 50f53b5a..f08ffe94 100644 --- a/lib/gui/source_section_manager.py +++ b/lib/gui/source_section_manager.py @@ -31,7 +31,6 @@ def from_source(source: Dict) -> "SourceItem": "provider": lambda v, s: SourceItem._format_colored_text(v), "type": lambda v, s: SourceItem._format_colored_text(v), "fullLanguages": lambda v, s: get_colored_languages(v) or "", - "status": lambda v, s: get_debrid_status(s) if s.get("isCached") else "", } item = SourceItem(label=source["title"]) diff --git a/lib/gui/source_select_new.py b/lib/gui/source_select_new.py index 1111831e..46780219 100644 --- a/lib/gui/source_select_new.py +++ b/lib/gui/source_select_new.py @@ -50,6 +50,7 @@ def _create_section_manager(self) -> SourceSectionManager: sections = [ self._create_priority_language_section(), self._create_top_seeders_section(), + *self._create_quality_sections(), ] return SourceSectionManager([section for section in sections if section]) @@ -62,10 +63,11 @@ def _create_priority_language_section(self) -> Optional[SourceSection]: if not spanish_sources: return None + sources = [SourceItem.from_source(s) for s in spanish_sources] return SourceSection( - title="Priority Language", + title=f"Priority Language ({len(sources)})", description="Sources with Spanish audio", - sources=[SourceItem.from_source(s) for s in spanish_sources], + sources=sources, ) def _create_top_seeders_section(self) -> SourceSection: @@ -73,17 +75,49 @@ def _create_top_seeders_section(self) -> SourceSection: non_spanish_sources = [ s for s in self._sources if "es" not in s.get("fullLanguages", []) ] + sources = [SourceItem.from_source(s) for s in non_spanish_sources] + return SourceSection( - title="Top Seeders", + title=f"All results ({len(non_spanish_sources)})", description="Results with the most seeders", - sources=[SourceItem.from_source(s) for s in non_spanish_sources], + sources=sources, ) + def _create_quality_sections(self) -> List[SourceSection]: + """Create sections for sources grouped by quality.""" + quality_groups = { + "4K": ["4K", "UHD", "2160p"], + "1080p": ["1080p", "Full HD"], + "720p": ["720p", "HD"], + "SD": ["480p", "360p", "240p"], + } + + quality_sections = [] + for quality, keywords in quality_groups.items(): + quality_sources = [ + s + for s in self._sources + if any(kw in s.get("title", "") for kw in keywords) + ] + + if quality_sources: + quality_sections.append( + SourceSection( + title=f"{quality} ({len(quality_sources)})", + description=f"Sources with {quality} resolution", + sources=[SourceItem.from_source(s) for s in quality_sources], + ) + ) + + return quality_sections + def onInit(self) -> None: """Initialize window controls and populate initial data.""" self._source_list = self.getControlList(1000) self._navigation_label = self.getControl(1001) self._description_label = self.getControl(1002) + self._settings_group = self.getControl(2000) + self._refresh_ui() self.set_default_focus(self._source_list, 1000, control_list_reset=True) super().onInit() @@ -121,8 +155,6 @@ def _update_description(self) -> None: def _populate_source_list(self) -> None: """Populate the source list with current section's items.""" self._source_list.reset() - kodilog(f"Current Section: {self._section_manager.current_section.title}") - kodilog(f"Current Sources: {self._section_manager.current_section.sources}") current_sources = self._section_manager.current_section.sources self._source_list.addItems(current_sources) self._source_list.selectItem( @@ -134,6 +166,8 @@ def handle_action(self, action_id: int, control_id: Optional[int] = None) -> Non kodilog(f"Action ID: {action_id}, Control ID: {control_id}") if control_id == 1000: self._handle_source_list_action(action_id) + if control_id >= 2000: + self._handle_settings_action(action_id) def _handle_source_list_action(self, action_id: int) -> None: """Process actions specific to the source list control.""" @@ -144,7 +178,7 @@ def _handle_source_list_action(self, action_id: int) -> None: action_handlers = { xbmcgui.ACTION_SELECT_ITEM: self._resolve_selected_source, - xbmcgui.ACTION_MOVE_LEFT: self._section_manager.move_to_previous_section, + xbmcgui.ACTION_MOVE_LEFT: self._section_manager.move_to_previous_section if self._section_manager._current_index >0 else self._settings_open, xbmcgui.ACTION_MOVE_RIGHT: self._section_manager.move_to_next_section, xbmcgui.ACTION_CONTEXT_MENU: self._show_context_menu, } @@ -154,6 +188,23 @@ def _handle_source_list_action(self, action_id: int) -> None: handler() self._refresh_ui() + def _handle_settings_action(self, action_id): + if action_id == xbmcgui.ACTION_MOVE_RIGHT: + self._settings_close() + + def _settings_open(self) -> None: + """Open settings sidebar.""" + self.setProperty("settings_open", "true") + #self._settings_group.setVisible(True) + self._settings_group_first = self.getControl(2003) + self.setFocus(self._settings_group_first) + + def _settings_close(self) -> None: + """Close settings sidebar.""" + self.setProperty("settings_open", "") + #self._settings_group.setVisible(False) + self.setFocus(self._source_list) + def _show_context_menu(self) -> None: """Display context menu for selected source.""" source = self._get_source_from_item( diff --git a/lib/sources_tools/__init__.py b/lib/sources_tools/__init__.py index a32e5c80..a801462e 100644 --- a/lib/sources_tools/__init__.py +++ b/lib/sources_tools/__init__.py @@ -5,6 +5,7 @@ from .filter_builder import FilterBuilder from .is_pack_enricher import IsPackEnricher from .quality_enricher import QualityEnricher +from .cache_enricher import CacheEnricher __all__ = [ "EnricherBuilder", @@ -14,4 +15,5 @@ "FilterBuilder", "IsPackEnricher", "QualityEnricher", + "CacheEnricher" ] diff --git a/lib/sources_tools/cache_enricher.py b/lib/sources_tools/cache_enricher.py new file mode 100644 index 00000000..ae446bf7 --- /dev/null +++ b/lib/sources_tools/cache_enricher.py @@ -0,0 +1,32 @@ +from .enricher import Enricher +from typing import Dict, Callable, List +import re +from lib.clients.debrid.torbox import Torbox +from lib.api.jacktook.kodi import kodilog + +class CacheEnricher(Enricher): + def __init__(self): + pass + + def initialize(self, items: List[Dict]) -> None: + infoHashes: List[str] = list(set(filter(None, [item.get("infoHash") for item in items]))) + + torbox = Torbox("782153a0-dd26-4865-8f77-91f1dc9b78be") + + response = torbox.get_torrent_instant_availability(infoHashes) + response.get('data', []) + + self.cachedHashes = set(response.get('data', [])) + kodilog(f"CacheEnricher: Cached hashes: {self.cachedHashes}") + + def needs(self): + return ["infoHash"] + + def provides(self): + return ["isCached", "status"] + + def enrich(self, item: Dict) -> None: + if item.get("infoHash") in self.cachedHashes: + item["isCached"] = True + item["status"] = "Cached in Torbox" + item["cachedIn"] = list(set(item.get("cachedIn", []) + ["Torbox"])) \ No newline at end of file diff --git a/lib/sources_tools/enricher.py b/lib/sources_tools/enricher.py index f7deab03..28d23a3a 100644 --- a/lib/sources_tools/enricher.py +++ b/lib/sources_tools/enricher.py @@ -1,5 +1,5 @@ import abc -from typing import Dict +from typing import Dict, List class Enricher(abc.ABC): @@ -7,3 +7,18 @@ class Enricher(abc.ABC): def enrich(self, item: Dict) -> None: """Enrich an item with additional metadata""" pass + + @abc.abstractmethod + def initialize(self, items: List[Dict]) -> None: + """Initialize the enricher with a list of items""" + pass + + @abc.abstractmethod + def needs(self) -> List[str]: + """Returns the fields that the enricher needs to function""" + pass + + @abc.abstractmethod + def provides(self) -> List[str]: + """Returns the fields that the enricher will provide""" + pass \ No newline at end of file diff --git a/lib/sources_tools/enricher_builder.py b/lib/sources_tools/enricher_builder.py index 21ad1260..afc2018d 100644 --- a/lib/sources_tools/enricher_builder.py +++ b/lib/sources_tools/enricher_builder.py @@ -3,17 +3,19 @@ class EnricherBuilder: - def __init__(self, items: List[Dict]): - self.items = [item.copy() for item in items] + def __init__(self): self._enrichers: List[Enricher] = [] def add(self, enricher: Enricher) -> "EnricherBuilder": self._enrichers.append(enricher) return self - def build(self) -> List[Dict]: + def build(self, items: List[Dict]) -> List[Dict]: processed = [] - for item in self.items: + for enricher in self._enrichers: + enricher.initialize(items) + + for item in [item.copy() for item in items]: for enricher in self._enrichers: enricher.enrich(item) processed.append(item) diff --git a/lib/sources_tools/filter_builder.py b/lib/sources_tools/filter_builder.py index b33ed19b..a57f7ffc 100644 --- a/lib/sources_tools/filter_builder.py +++ b/lib/sources_tools/filter_builder.py @@ -1,17 +1,88 @@ import re +from abc import ABC, abstractmethod from typing import List, Dict, Any, Union +class Filter(ABC): + @abstractmethod + def apply(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + pass + + +class DedupeFilter(Filter): + def apply(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + seen = set() + filtered = [] + + for item in items: + info_hash = item.get("infoHash") + if info_hash not in seen: + if info_hash is not None: + seen.add(info_hash) + filtered.append(item) + + return filtered + + +class SourceFilter(Filter): + def apply(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return [item for item in items if item.get("infoHash") or item.get("guid")] + + +class LanguageFilter(Filter): + def __init__(self, languages: List[str]): + self.languages = languages + + def apply(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + if not self.languages: + return items + return [ + item for item in items + if any(lang in item.get("languages", []) for lang in self.languages) + ] + + +class EpisodeFilter(Filter): + def __init__(self, episode_name: str, episode_num: int, season_num: int): + self.episode_name = episode_name + self.episode_num = episode_num + self.season_num = season_num + + def apply(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + episode_num = self.episode_num + season_num = self.season_num + + if episode_num is None or season_num is None: + return items + + episode_fill = f"{episode_num:02d}" + season_fill = f"{season_num:02d}" + + patterns = [ + rf"S{season_fill}E{episode_fill}", + rf"{season_fill}x{episode_fill}", + rf"\s{season_fill}\s", + rf"\.S{season_fill}", + rf"\.S{season_fill}E{episode_fill}", + rf"\sS{season_fill}E{episode_fill}\s", + r"Cap\.", + ] + + if self.episode_name: + patterns.append(re.escape(self.episode_name)) + + combined_pattern = re.compile("|".join(patterns), flags=re.IGNORECASE) + return [ + item for item in items + if combined_pattern.search(item.get("title", "")) + ] + + class FilterBuilder: - def __init__(self, items: List[Dict[str, Any]]): - self.items = items + def __init__(self): + self._filters: List[Filter] = [] self._sort_criteria: List[tuple] = [] self._limit: int = 0 - self._language_filters: List[str] = [] - self._episode_name: Union[str, None] = None - self._episode_num: Union[int, None] = None - self._season_num: Union[int, None] = None - self._filter_sources: bool = False # New flag for source filtering def sort_by(self, field: str, ascending: bool = True) -> "FilterBuilder": self._sort_criteria.append((field, ascending)) @@ -22,7 +93,15 @@ def limit(self, n: int) -> "FilterBuilder": return self def filter_by_language(self, language_code: str) -> "FilterBuilder": - self._language_filters.append(language_code) + existing = next((f for f in self._filters if isinstance(f, LanguageFilter)), None) + if existing: + existing.languages.append(language_code) + else: + self._filters.append(LanguageFilter([language_code])) + return self + + def deduple_by_infoHash(self) -> "FilterBuilder": + self.add_filter(DedupeFilter()) return self def filter_by_episode( @@ -31,72 +110,30 @@ def filter_by_episode( episode_num: Union[int, str], season_num: Union[int, str], ) -> "FilterBuilder": - self._episode_name = episode_name - self._episode_num = int(episode_num) - self._season_num = int(season_num) + episode_num = int(episode_num) + season_num = int(season_num) + # Remove any existing EpisodeFilter + self._filters = [f for f in self._filters if not isinstance(f, EpisodeFilter)] + self._filters.append(EpisodeFilter(episode_name, episode_num, season_num)) return self - # New method for source filtering def filter_by_source(self) -> "FilterBuilder": - self._filter_sources = True + if not any(isinstance(f, SourceFilter) for f in self._filters): + self._filters.append(SourceFilter()) return self - def build(self) -> List[Dict[str, Any]]: - filtered = self._apply_filters() - sorted_items = self._apply_sorting(filtered) - return self._apply_limit(sorted_items) + def add_filter(self, filter: Filter) -> "FilterBuilder": + self._filters.append(filter) + return self - def _apply_filters(self) -> List[Dict[str, Any]]: - # Remove duplicates first (order-preserving) - seen = [] - filtered = [] - for item in self.items: - if item not in seen: - filtered.append(item) - seen.append(item) - - # Apply source filter if enabled - if self._filter_sources: - filtered = [ - item for item in filtered if item.get("infoHash") or item.get("guid") - ] - - # Apply language filters (OR logic) - if self._language_filters: - filtered = [ - item - for item in filtered - if any( - lang in item.get("languages", []) for lang in self._language_filters - ) - ] - - # Apply episode filter - if self._episode_num is not None and self._season_num is not None: - episode_fill = f"{self._episode_num:02d}" - season_fill = f"{self._season_num:02d}" - - patterns = [ - rf"S{season_fill}E{episode_fill}", # SXXEXX - rf"{season_fill}x{episode_fill}", # XXxXX - rf"\s{season_fill}\s", # Space-padded season - rf"\.S{season_fill}", # .SXX - rf"\.S{season_fill}E{episode_fill}", # .SXXEXX - rf"\sS{season_fill}E{episode_fill}\s", # Space-padded SXXEXX - r"Cap\.", # Cap. prefix - ] - - if self._episode_name: - patterns.append(re.escape(self._episode_name)) - - combined_pattern = re.compile("|".join(patterns), flags=re.IGNORECASE) - filtered = [ - item - for item in filtered - if combined_pattern.search(item.get("title", "")) - ] + def build(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + filtered_items = items.copy() + for filter in self._filters: + filtered_items = filter.apply(filtered_items) - return filtered + sorted_items = self._apply_sorting(filtered_items) + limited_items = self._apply_limit(sorted_items) + return limited_items def _apply_sorting(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: if not self._sort_criteria: @@ -106,10 +143,8 @@ def sort_key(item): key = [] for field, ascending in self._sort_criteria: value = item.get(field) - # Handle numeric fields with descending support if isinstance(value, (int, float)): key.append(-value if not ascending else value) - # Handle string fields (lexicographic sorting) elif isinstance(value, str): key.append(value.lower() if ascending else value.lower()[::-1]) else: @@ -117,12 +152,9 @@ def sort_key(item): return tuple(key) try: - result = sorted(items, key=sort_key) - return result + return sorted(items, key=sort_key) except TypeError: - pass - - return items + return items def _apply_limit(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - return items[: self._limit] if self._limit else items + return items[: self._limit] if self._limit else items \ No newline at end of file diff --git a/lib/sources_tools/is_pack_enricher.py b/lib/sources_tools/is_pack_enricher.py index 6263d0cc..1b4c6834 100644 --- a/lib/sources_tools/is_pack_enricher.py +++ b/lib/sources_tools/is_pack_enricher.py @@ -1,7 +1,7 @@ from .enricher import Enricher import re from re import Pattern -from typing import Dict +from typing import Dict, List class IsPackEnricher(Enricher): @@ -10,6 +10,15 @@ def __init__(self, season_number: int): self.season_fill = f"{season_number:02d}" self.pattern = self._build_pattern() + def initialize(self, items: List[Dict]) -> None: + return + + def needs(self): + return ["title"] + + def provides(self): + return ["isPack"] + def _build_pattern(self) -> Pattern: base_patterns = [ # Season number variations diff --git a/lib/sources_tools/language_enricher.py b/lib/sources_tools/language_enricher.py index c9025672..fbcc7f58 100644 --- a/lib/sources_tools/language_enricher.py +++ b/lib/sources_tools/language_enricher.py @@ -1,6 +1,6 @@ from .enricher import Enricher import re -from typing import Dict, Set +from typing import Dict, Set, List class LanguageEnricher(Enricher): @@ -11,6 +11,15 @@ def __init__(self, language_map: Dict[str, str], keywords: Set[str]): ) self.language_map = language_map + def initialize(self, items: List[Dict]) -> None: + return + + def needs(self): + return ["description", "languages"] + + def provides(self): + return ["languages", "fullLanguages"] + def enrich(self, item: Dict) -> None: desc = item.get("description", "") diff --git a/lib/sources_tools/quality_enricher.py b/lib/sources_tools/quality_enricher.py index 85e69d40..1c4d8eb6 100644 --- a/lib/sources_tools/quality_enricher.py +++ b/lib/sources_tools/quality_enricher.py @@ -1,6 +1,6 @@ from .enricher import Enricher import re -from typing import Dict +from typing import Dict, List class QualityEnricher(Enricher): @@ -10,14 +10,13 @@ def __init__(self, pattern: str, label: str, priority: int): self.label = label self.priority = priority - def __init__(self, field_name: str = "quality"): - self.field_name = field_name + def __init__(self): self.tiers = [ self.ResolutionTier( - r"(?i)\b(2160p?|4k|uhd)\b", "[B][COLOR yellow]4k[/COLOR][/B]", 4 + r"(?i)\b(2160p?|4k)\b", "[B][COLOR yellow]4k[/COLOR][/B]", 4 ), self.ResolutionTier( - r"(?i)\b(1080p?|fullhd)\b", "[B][COLOR blue]1080p[/COLOR][/B]", 3 + r"(?i)\b(1080p?)\b", "[B][COLOR blue]1080p[/COLOR][/B]", 3 ), self.ResolutionTier( r"(?i)\b720p?\b", "[B][COLOR orange]720p[/COLOR][/B]", 2 @@ -27,6 +26,15 @@ def __init__(self, field_name: str = "quality"): ), ] + def initialize(self, items: List[Dict]) -> None: + return + + def needs(self): + return ["title"] + + def provides(self): + return ["quality", "quality_sort"] + def enrich(self, item: Dict) -> None: title = item.get("title", "") for tier in sorted(self.tiers, key=lambda t: -t.priority): diff --git a/lib/sources_tools/stats_enricher.py b/lib/sources_tools/stats_enricher.py index 4a4a07eb..1b9609a3 100644 --- a/lib/sources_tools/stats_enricher.py +++ b/lib/sources_tools/stats_enricher.py @@ -1,5 +1,5 @@ from .enricher import Enricher -from typing import Dict, Callable +from typing import Dict, Callable, List import re @@ -9,6 +9,15 @@ def __init__(self, size_converter: Callable): self.seeders_pattern = re.compile(r"👤 (\d+)") self.provider_pattern = re.compile(r"([🌐🔗⚙️])\s*([^🌐🔗⚙️]+)") self.convert_size = size_converter + + def initialize(self, items: List[Dict]) -> None: + return + + def needs(self): + return ["description"] + + def provides(self): + return ["size", "seeders", "provider"] def enrich(self, item: Dict) -> None: desc = item.get("description", "") diff --git a/lib/utils/utils.py b/lib/utils/utils.py index bbc56737..2cbbbdeb 100644 --- a/lib/utils/utils.py +++ b/lib/utils/utils.py @@ -21,6 +21,7 @@ QualityEnricher, LanguageEnricher, IsPackEnricher, + CacheEnricher ) from lib.utils.kodi_utils import convert_size_to_bytes from lib.utils.language_detection import language_codes, langsSet @@ -638,14 +639,15 @@ def unzip(zip_location, destination_location, destination_check): def pre_process(results, mode, ep_name, episode, season): results = ( - EnricherBuilder(results) - .add(StatsEnricher(size_converter=convert_size_to_bytes)) - .add(QualityEnricher()) - .add(LanguageEnricher(language_codes, langsSet)) - .build() + EnricherBuilder() + .add(StatsEnricher(size_converter=convert_size_to_bytes)) + .add(QualityEnricher()) + .add(LanguageEnricher(language_codes, langsSet)) + .add(CacheEnricher()) + .build(results) ) - filters = FilterBuilder(results) + filters = FilterBuilder() if get_setting("stremio_enabled") and get_setting("torrent_enable"): filters.filter_by_source() @@ -653,16 +655,16 @@ def pre_process(results, mode, ep_name, episode, season): if mode == "tv" and get_setting("filter_by_episode"): filters.filter_by_episode(ep_name, episode, season) - return filters.build() + return filters.build(results) def post_process(results, season=0): sort_by = get_setting("indexers_sort_by") limit = int(get_setting("indexers_total_results")) - results = EnricherBuilder(results).add(IsPackEnricher(season)).build() + results = EnricherBuilder().add(IsPackEnricher(season)).build(results) - filters = FilterBuilder(results).limit(limit) + filters = FilterBuilder().limit(limit).deduple_by_infoHash() if sort_by == "Seeds": filters.sort_by("seeders", ascending=False) @@ -676,7 +678,7 @@ def post_process(results, season=0): elif sort_by == "Cached": filters.sort_by("isCached", ascending=False) - return filters.build() + return filters.build(results) def clean_auto_play_undesired(results): diff --git a/resources/skins/Default/1080i/source_select_new.xml b/resources/skins/Default/1080i/source_select_new.xml index 9254def6..ccf9bfd5 100644 --- a/resources/skins/Default/1080i/source_select_new.xml +++ b/resources/skins/Default/1080i/source_select_new.xml @@ -53,7 +53,7 @@ 0 - 20 + 70 200 150 keep @@ -68,7 +68,7 @@ font20 28 - 300 + 560 1620 50 @@ -78,7 +78,7 @@ font12 65 - 300 + 560 1620 50 @@ -89,7 +89,7 @@ WindowClose Conditional Conditional - 1366 + 1820 120 20 890 @@ -100,27 +100,26 @@ String.IsEqual(Window().Property(instant_close),false) - - - WindowOpen - WindowClose - Conditional - Conditional - list + + WindowOpen + WindowClose + Conditional + Conditional + list 1111 - 100 - 110 - 1340 - 900 - 20 - vertical - !String.IsEqual(Window().Property(instant_close),true) + 560 + 110 + 1340 + 900 + 20 + vertical + !String.IsEqual(Window().Property(instant_close),true) - - + + - 1250 - + 1250 + 10 200 @@ -128,163 +127,86 @@ FF00559D - - - 10 + + 10 circle.png - FF362e33 - + 992A3E5C + + + + + font12 + 30 + 20 + center + 200 + FFFFFFFF + + - - font30 + font12 70 + 20 center 200 - 66FFFFFF - + FFFFFFFF + + - 120 + 110 + 20 center 200 - font20 + font12 66FFFFFF left - + - - 30 - 220 - 1000 - 20 - font12 - FFFFFFFF - left - - - - - 40 - 20 - - - 30 - - - - 20 - font12 - CCFFFFFF - left - 200 - - !String.IsEmpty(ListItem.Property(size)) - - - - - - 20 - font12 - left - 550 - CCFFFFFF - - !String.IsEmpty(ListItem.Property(peers)) - - - - - 20 - 20 - font12 - left - 900 - CCFFFFFF - - !String.IsEmpty(ListItem.Property(seeders)) - - - - - - 70 - - - - 600 - 20 - font12 - 200 - CCFFFFFF - left - - !String.IsEmpty(ListItem.Property(indexer)) - - - - - 20 - font12 - left - 550 - CCFFFFFF - - !String.IsEmpty(ListItem.Property(provider)) - - - - - 20 - font12 - left - 900 - CCFFFFFF - - !String.IsEmpty(ListItem.Property(publishDate)) - - - - - 110 - - - - 20 - font12 - left - 200 - FFFFFFFF - - !String.IsEmpty(ListItem.Property(status)) - - - - - 20 - font12 - left - 550 - FFFFFFFF - - !String.IsEmpty(ListItem.Property(fullLanguages)) - - + + 30 + 220 + 1000 + 20 + font12 + FFFFFFFF + left + - - - - - - - 1250 + + 70 + 220 + 1000 + 20 + font12 + FFFFFFFF + left + + + + 110 + 220 + 1000 + 20 + font12 + CCFFFFFF + left + + + + + + + + 1250 + 10 200 @@ -293,163 +215,93 @@ - - 10 - circle.png - 992A3E5C - + + 10 + circle.png + FF362e33 + - - font30 + font12 + 30 + 20 + center + 200 + FFFFFFFF + + + + + font12 70 + 20 center 200 FFFFFFFF - + + - 120 + 110 + 20 center 200 - font20 - FFFFFFFF + font12 + 66FFFFFF left - + - - 30 - 220 - 1000 - 20 - font12 - FFFFFFFF - left - - - - - 40 - 20 - - - 30 - - - - 20 - font12 - FFFFFFFF - left - 200 - - !String.IsEmpty(ListItem.Property(size)) - - - - - 20 - font12 - left - 550 - FFFFFFFF - - !String.IsEmpty(ListItem.Property(peers)) - - - - - 20 - 20 - font12 - left - 900 - FFFFFFFF - - !String.IsEmpty(ListItem.Property(seeders)) - - - - - - 70 - - - - 600 - 20 - font12 - 200 - FFFFFFFF - left - - !String.IsEmpty(ListItem.Property(indexer)) - - - - - 20 - font12 - left - 550 - FFFFFFFF - - !String.IsEmpty(ListItem.Property(provider)) - - - - - 20 - font12 - left - 900 - CCFFFFFF - - !String.IsEmpty(ListItem.Property(publishDate)) - - - - 110 - - - - 20 - font12 - left - 200 - FFFFFFFF - - !String.IsEmpty(ListItem.Property(status)) - - - - - 20 - font12 - left - 550 - FFFFFFFF - - !String.IsEmpty(ListItem.Property(fullLanguages)) - - + + 30 + 220 + 1000 + 20 + font12 + FFFFFFFF + left + - - - + + + 70 + 220 + 1000 + 20 + font12 + FFFFFFFF + left + + + + + 110 + 220 + 1000 + 20 + font12 + CCFFFFFF + left + + + + + - WindowOpen - WindowClose - Conditional - Conditional + WindowOpen + WindowClose + Conditional + Conditional + Conditional + Conditional + 100 - 70 + 20 500 !String.IsEqual(Window().Property(instant_close),true) @@ -481,5 +333,63 @@ !String.IsEmpty(Window.Property(info.plot)) + + + + Conditional + Conditional + 140 + 60 + 450 + 980 + + + + 20 + + 0 + 20 + 40 + left + + SetProperty(dedupe,True,1000) + SetProperty(dedupe,False,1000) + 2002 + + + white.png + white.png + + + + + left + + + + + left + + + + left + + + + left + + + + + + left + center + + Enter search string + + SetProperty(languageFilter,$INFO[Control.GetLabel(3)],1000) + + + From 8ea68cd5b65d8a9c2e94a34f7d5dcdf774604c77 Mon Sep 17 00:00:00 2001 From: Gabriel Ortega Date: Thu, 6 Feb 2025 16:41:49 +0100 Subject: [PATCH 07/10] deep refactor in progress --- lib/clients/base.py | 5 +- lib/clients/debrid/torrserve.py | 187 +++++++++ lib/clients/debrid/transmission.py | 120 ++++++ lib/clients/elfhosted.py | 2 +- lib/clients/jackett.py | 2 +- lib/clients/jacktook_burst.py | 2 +- lib/clients/medifusion.py | 4 +- lib/clients/peerflix.py | 4 +- lib/clients/stremio_addon.py | 2 +- lib/clients/torrentio.py | 4 +- lib/clients/zilean.py | 8 +- lib/domain/cached_source.py | 11 + .../interface/cache_provider_interface.py | 10 + .../interface/enricher_interface.py} | 15 +- lib/domain/interface/filter_interface.py | 12 + lib/domain/quality_tier.py | 28 ++ lib/domain/source.py | 42 ++ lib/gui/base_window.py | 6 +- lib/gui/custom_dialogs.py | 15 - lib/gui/resolver_window.py | 4 +- lib/gui/source_section_manager.py | 44 +- lib/gui/source_select_new.py | 384 +++++++----------- lib/gui/source_window.py | 1 - lib/navigation.py | 159 +------- lib/router.py | 2 +- lib/search.py | 252 ++++++++++++ .../enrich}/__init__.py | 14 +- lib/services/enrich/cache_enricher.py | 42 ++ lib/services/enrich/enricher_builder.py | 116 ++++++ lib/services/enrich/file_enricher.py | 29 ++ lib/services/enrich/format_enricher.py | 105 +++++ .../enrich}/is_pack_enricher.py | 12 +- .../enrich}/language_enricher.py | 19 +- lib/services/enrich/magnet_enricher.py | 26 ++ lib/services/enrich/quality_enricher.py | 26 ++ .../enrich}/stats_enricher.py | 10 +- .../filters/__init__.py} | 136 ++++--- lib/sources_tools/cache_enricher.py | 32 -- lib/sources_tools/enricher_builder.py | 22 - lib/sources_tools/quality_enricher.py | 47 --- lib/utils/debrid_utils.py | 17 +- lib/utils/ed_utils.py | 4 +- lib/utils/kodi_utils.py | 10 - lib/utils/language_detection.py | 2 + lib/utils/pm_utils.py | 2 +- lib/utils/rd_utils.py | 2 +- lib/utils/torbox_utils.py | 4 +- lib/utils/utils.py | 85 +--- resources/language/English/strings.po | 32 ++ resources/settings.xml | 58 +++ .../skins/Default/1080i/source_select_new.xml | 118 +++--- 51 files changed, 1464 insertions(+), 831 deletions(-) create mode 100644 lib/clients/debrid/torrserve.py create mode 100644 lib/clients/debrid/transmission.py create mode 100644 lib/domain/cached_source.py create mode 100644 lib/domain/interface/cache_provider_interface.py rename lib/{sources_tools/enricher.py => domain/interface/enricher_interface.py} (69%) create mode 100644 lib/domain/interface/filter_interface.py create mode 100644 lib/domain/quality_tier.py create mode 100644 lib/domain/source.py create mode 100644 lib/search.py rename lib/{sources_tools => services/enrich}/__init__.py (58%) create mode 100644 lib/services/enrich/cache_enricher.py create mode 100644 lib/services/enrich/enricher_builder.py create mode 100644 lib/services/enrich/file_enricher.py create mode 100644 lib/services/enrich/format_enricher.py rename lib/{sources_tools => services/enrich}/is_pack_enricher.py (82%) rename lib/{sources_tools => services/enrich}/language_enricher.py (67%) create mode 100644 lib/services/enrich/magnet_enricher.py create mode 100644 lib/services/enrich/quality_enricher.py rename lib/{sources_tools => services/enrich}/stats_enricher.py (82%) rename lib/{sources_tools/filter_builder.py => services/filters/__init__.py} (51%) delete mode 100644 lib/sources_tools/cache_enricher.py delete mode 100644 lib/sources_tools/enricher_builder.py delete mode 100644 lib/sources_tools/quality_enricher.py diff --git a/lib/clients/base.py b/lib/clients/base.py index 3f6c0974..d65c7179 100644 --- a/lib/clients/base.py +++ b/lib/clients/base.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from requests import Session - +from typing import List +from lib.domain.source import Source class BaseClient(ABC): def __init__(self, host, notification): @@ -9,7 +10,7 @@ def __init__(self, host, notification): self.session = Session() @abstractmethod - def search(self, tmdb_id, query, mode, media_type, season, episode): + def search(self, tmdb_id, query, mode, media_type, season, episode) -> List[Source]: pass @abstractmethod diff --git a/lib/clients/debrid/torrserve.py b/lib/clients/debrid/torrserve.py new file mode 100644 index 00000000..f666fe89 --- /dev/null +++ b/lib/clients/debrid/torrserve.py @@ -0,0 +1,187 @@ +import requests +from requests.auth import HTTPBasicAuth +from requests.exceptions import RequestException +from urllib.parse import urlparse +from lib.api.jacktook.kodi import kodilog + +class TorrServeException(Exception): + pass + +class TorrServeClient: + def __init__(self, base_url="http://localhost:8090", username=None, password=None): + # Validate and format base URL + parsed = urlparse(base_url) + if not parsed.scheme: + base_url = f"http://{base_url}" + elif parsed.scheme not in ("http", "https"): + raise TorrServeException("Invalid URL scheme. Use http:// or https://") + + self.base_url = base_url.rstrip("/") + self.session = requests.Session() + + if username and password: + self.session.auth = HTTPBasicAuth(username, password) + + self.session.headers.update({ + "Accept": "application/json", + "Content-Type": "application/json" + }) + + def _request(self, method, endpoint, data=None): + """Improved URL handling with better error messages""" + try: + # Construct safe URL + endpoint = endpoint.lstrip("/") + url = f"{self.base_url}/{endpoint}" + + # Validate URL format + parsed = urlparse(url) + if not parsed.scheme or not parsed.netloc: + raise TorrServeException(f"Invalid URL format: {url}") + + response = self.session.request(method, url, json=data) + response.raise_for_status() + return response.json() + except RequestException as e: + raise TorrServeException(f"Request to {url} failed: {str(e)}") + except ValueError: + raise TorrServeException(f"Invalid JSON response from {url}") + + def get_torrent_instant_availability(self, info_hashes): + """ + Check availability of torrents by their info hashes + + Args: + info_hashes (list): List of torrent info hashes (strings) + + Returns: + dict: Dictionary with info_hash as key and availability percentage as value + """ + kodilog(f"Checking availability for {info_hashes} torrents") + try: + # Get list of all torrents + response = self._request("POST", "/torrents", { + "action": "list" + }) + + available = {} + hash_map = {} + + # Parse response (assuming array of TorrentStatus) + for torrent in response: + if "hash" in torrent: + t_hash = torrent["hash"].lower() + if not t_hash in info_hashes: + continue + status = torrent.get("stat", 0) + + # Calculate completion percentage + size = torrent.get("torrent_size", 0) + loaded = torrent.get("preloaded_bytes", 0) + percentage = (loaded / size * 100) if size > 0 else 0 + + hash_map[t_hash] = round(percentage, 2) if status == 3 else 0 # Only consider working torrents + + # Match requested hashes + for ih in info_hashes: + completion = hash_map.get(ih.lower(), 0) + if not completion: + continue + available[ih.lower()] = hash_map.get(ih.lower(), 0) + + return available + + except TorrServeException as e: + raise TorrServeException(f"Availability check failed: {str(e)}") + + def add_magnet(self, magnet_uri, save_to_db=True, title=None, category=None): + """ + Add a torrent by magnet URI + + Args: + magnet_uri (str): Magnet URI to add + save_to_db (bool): Save torrent to database + title (str): Custom title for the torrent + category (str): Torrent category (movie/tv/music/other) + + Returns: + dict: Dictionary containing: + - status: "added", "duplicate", or "error" + - info_hash: Torrent info hash (lowercase) + - percentage: Current download completion percentage + """ + try: + # Add torrent request + payload = { + "action": "add", + "link": magnet_uri, + "save_to_db": save_to_db + } + kodilog(f"Payload: {payload}") + if title: + payload["title"] = title + if category: + payload["category"] = category + + response = self._request("POST", "/torrents", payload) + + # Check response status + status_code = response.get("stat", 0) + info_hash = response.get("hash", "").lower() + + if not info_hash: + raise TorrServeException("Missing info hash in response") + + # Determine status + if status_code == 5: # TorrentInDB + status = "duplicate" + elif status_code == 3: # TorrentWorking + status = "added" + else: + status = "unknown" + + # Calculate percentage + size = response.get("torrent_size", 0) + loaded = response.get("preloaded_bytes", 0) + percentage = (loaded / size * 100) if size > 0 else 0 + + return { + "status": status, + "info_hash": info_hash, + "percentage": round(percentage, 2) + } + + except TorrServeException as e: + return { + "status": "error", + "info_hash": "", + "percentage": 0, + "error": str(e) + } + + def get_torrent_status(self, info_hash): + """ + Get detailed status of a specific torrent + """ + try: + response = self._request("POST", "/torrents", { + "action": "get", + "hash": info_hash + }) + return response + except TorrServeException as e: + raise TorrServeException(f"Status check failed: {str(e)}") + + def remove_torrent(self, info_hash, remove_data=False): + """ + Remove a torrent from the server + """ + try: + action = "rem" if not remove_data else "drop" + self._request("POST", "/torrents", { + "action": action, + "hash": info_hash + }) + return True + except TorrServeException as e: + raise TorrServeException(f"Removal failed: {str(e)}") \ No newline at end of file diff --git a/lib/clients/debrid/transmission.py b/lib/clients/debrid/transmission.py new file mode 100644 index 00000000..05c1d779 --- /dev/null +++ b/lib/clients/debrid/transmission.py @@ -0,0 +1,120 @@ +import requests +from lib.utils.kodi_utils import notification +from requests.exceptions import RequestException +from lib.domain.interface.cache_provider_interface import CacheProviderInterface +from lib.domain.cached_source import CachedSource +from typing import Dict, List +from lib.api.jacktook.kodi import kodilog +from lib.domain.source import Source +from lib.utils.kodi_formats import is_video + +class TransmissionException(Exception): + pass + +class TransmissionClient(CacheProviderInterface): + def __init__(self, base_url: str = "http://192.168.1.130:9091", downloads_url: str = "", username: str = "", password: str = ""): + self.base_url = base_url.rstrip("/") + self.session = requests.Session() + self.session_id = None + self.downloads_url = downloads_url.rstrip("/") + self.session.headers.update({"Content-Type": "application/json", "Accept": "application/json"}) + if username and password: + self.session.auth = (username, password) + + def _rpc_request(self, method, arguments=None): + url = f"{self.base_url}/transmission/rpc" + payload = {"method": method, "arguments": arguments or {}} + + for _ in range(2): # Allow one retry after 409 + try: + response = self.session.post(url, json=payload) + if response.status_code == 409: + self.session_id = response.headers.get("X-Transmission-Session-Id") + if self.session_id: + self.session.headers["X-Transmission-Session-Id"] = self.session_id + continue # Retry with new session ID + raise TransmissionException("Missing session ID in 409 response") + + response.raise_for_status() + data = response.json() + if data.get("result") != "success": + raise TransmissionException(f"RPC error: {data.get('result')}") + + return data.get("arguments", {}) + + except (RequestException, ValueError) as e: + raise TransmissionException(f"Request failed: {str(e)}") + + raise TransmissionException("Failed after session ID retry") + + def get_torrent_instant_availability(self, info_hashes): + try: + torrents = self._rpc_request("torrent-get", {"fields": ["hashString", "percentDone"]}).get("torrents", []) + hash_map = {t["hashString"].lower(): t["percentDone"] for t in torrents} + return {ih: round(hash_map[ih.lower()] * 100, 2) for ih in info_hashes if ih.lower() in hash_map} + except TransmissionException as e: + notification(f"Transmission error: {str(e)}") + raise + + def add_magnet(self, magnet_uri: str): + try: + response = self._rpc_request("torrent-add", {"filename": magnet_uri, "paused": False}) + if "torrent-added" in response: + torrent = response["torrent-added"] + return {"status": "added", "info_hash": torrent["hashString"].lower()} + elif "torrent-duplicate" in response: + torrent = response["torrent-duplicate"] + return { + "status": "duplicate", + "info_hash": torrent["hashString"].lower(), + "percentage": round(torrent["percentDone"] * 100, 2), + } + raise TransmissionException("Unexpected response structure") + except TransmissionException as e: + notification(f"Failed to add magnet: {str(e)}") + raise + + def get_cached_hashes(self, sources: List[Source]) -> Dict[str, CachedSource]: + info_hashes = {source["info_hash"]: source.get("filename", "") for source in sources if source.get("info_hash")} + cached_sources = {} + + try: + torrents = self._rpc_request("torrent-get", {"fields": ["hashString", "percentDone", "files"]}).get("torrents", []) + kodilog(f"TransmissionClient: {len(torrents)} torrents found") + + for t in torrents: + t_hash = t["hashString"] + if t_hash not in info_hashes: + continue + + filename = info_hashes[t_hash] + + t_files = [f"{self.downloads_url}/{file['name']}" for file in t.get("files", [])] + + first_video = "" + playable_url = "" + for file in t_files: + if filename and file.endswith(filename): + playable_url = file + break + if not first_video and is_video(file): + first_video = file + + if not playable_url: + playable_url = first_video + + cached_sources[t["hashString"]] = CachedSource( + hash=t["hashString"].lower(), + cache_provider=self, + cache_provider_name="Transmission", + ratio=t["percentDone"], + instant_availability=t["percentDone"] == 1, + urls=t_files, + playable_url=playable_url, + ) + + return cached_sources + + except TransmissionException as e: + notification(f"Transmission error: {str(e)}") + raise \ No newline at end of file diff --git a/lib/clients/elfhosted.py b/lib/clients/elfhosted.py index c209e2b5..2d7457f5 100644 --- a/lib/clients/elfhosted.py +++ b/lib/clients/elfhosted.py @@ -33,7 +33,7 @@ def parse_response(self, res): "type": "Torrent", "indexer": "Elfhosted", "guid": item["infoHash"], - "infoHash": item["infoHash"], + "info_hash": item["infoHash"], "size": parsed_item["size"], "publishDate": "", "seeders": 0, diff --git a/lib/clients/jackett.py b/lib/clients/jackett.py index fdbcdc0e..2dc81395 100644 --- a/lib/clients/jackett.py +++ b/lib/clients/jackett.py @@ -58,6 +58,6 @@ def extract_result(results, item): "magnetUrl": attributes.get("magneturl", ""), "seeders": int(attributes.get("seeders", 0)), "peers": int(attributes.get("peers", 0)), - "infoHash": attributes.get("infohash", ""), + "info_hash": attributes.get("infohash", ""), } ) diff --git a/lib/clients/jacktook_burst.py b/lib/clients/jacktook_burst.py index 3c9e6f14..807cffd0 100644 --- a/lib/clients/jacktook_burst.py +++ b/lib/clients/jacktook_burst.py @@ -31,7 +31,7 @@ def parse_response(self, res): "indexer": "Burst", "provider": r.indexer, "guid": r.guid, - "infoHash": None, + "info_hash": None, "size": convert_size_to_bytes(r.size), "seeders": int(r.seeders), "peers": int(r.peers), diff --git a/lib/clients/medifusion.py b/lib/clients/medifusion.py index 4e65d1c9..e13df613 100644 --- a/lib/clients/medifusion.py +++ b/lib/clients/medifusion.py @@ -75,7 +75,7 @@ def parse_response(self, res): "type": "Torrent", "indexer": "MediaFusion", "guid": info_hash, - "infoHash": info_hash, + "info_hash": info_hash, "size": parsed_item["size"], "seeders": parsed_item["seeders"], "languages": parsed_item["languages"], @@ -92,7 +92,7 @@ def extract_info_hash(self, item): path = urlparse(item["url"]).path.split("/") info_hash = path[path.index("stream") + 1] else: - info_hash = item["infoHash"] + info_hash = item["info_hash"] return info_hash def parse_stream_title(self, item): diff --git a/lib/clients/peerflix.py b/lib/clients/peerflix.py index 26b3a1d3..0d9d66b7 100644 --- a/lib/clients/peerflix.py +++ b/lib/clients/peerflix.py @@ -31,8 +31,8 @@ def parse_response(self, res): "title": item["title"].splitlines()[0], "type": "Torrent", "indexer": "Peerflix", - "guid": item["infoHash"], - "infoHash": item["infoHash"], + "guid": item["info_hash"], + "info_hash": item["infoHash"], "size":item["sizebytes"] or 0, "seeders": item.get("seed", 0) or 0, "languages": [item["language"]], diff --git a/lib/clients/stremio_addon.py b/lib/clients/stremio_addon.py index 2ea05191..597efda0 100644 --- a/lib/clients/stremio_addon.py +++ b/lib/clients/stremio_addon.py @@ -47,7 +47,7 @@ def parse_response(self, res): "indexer": self.addon.manifest.name.split(" ")[0], "guid": stream.infoHash, "magnet": info_hash_to_magnet(stream.infoHash), - "infoHash": stream.infoHash, + "info_hash": stream.infoHash, "size": stream.get_parsed_size() or item.get("sizebytes"), "seeders": item.get("seed", 0), "languages": [item.get("language")] if item.get("language") else [], diff --git a/lib/clients/torrentio.py b/lib/clients/torrentio.py index 53b01a20..58619d1e 100644 --- a/lib/clients/torrentio.py +++ b/lib/clients/torrentio.py @@ -34,8 +34,8 @@ def parse_response(self, res): "title": parsed_item["title"], "type": "Torrent", "indexer": "Torrentio", - "guid": item["infoHash"], - "infoHash": item["infoHash"], + "guid": item["info_hash"], + "info_hash": item["infoHash"], "size": parsed_item["size"], "seeders": parsed_item["seeders"], "languages": parsed_item["languages"], diff --git a/lib/clients/zilean.py b/lib/clients/zilean.py index e7dbb922..d649633c 100644 --- a/lib/clients/zilean.py +++ b/lib/clients/zilean.py @@ -70,7 +70,7 @@ def api_scrape(self, query, mode, media_type, season, episode): for result in response: torrents.append( { - "infoHash": result.info_hash, + "info_hash": result.info_hash, "filename": result.raw_title, "filesize": result.size, "languages": result.languages, @@ -87,9 +87,9 @@ def parse_response(self, data): "title": item["filename"], "type": "Torrent", "indexer": "Zilean", - "guid": item["infoHash"], - "magnet": info_hash_to_magnet(item["infoHash"]), - "infoHash": item["infoHash"], + "guid": item["info_hash"], + "magnet": info_hash_to_magnet(item["info_hash"]), + "info_hash": item["info_hash"], "size": item["filesize"], "seeders": 0, "languages": item["languages"], diff --git a/lib/domain/cached_source.py b/lib/domain/cached_source.py new file mode 100644 index 00000000..cf885585 --- /dev/null +++ b/lib/domain/cached_source.py @@ -0,0 +1,11 @@ +from typing import TypedDict, Any, List + + +class CachedSource(TypedDict): + hash: str + cache_provider: Any + cache_provider_name: str + ratio: float + instant_availability: bool + urls: List[str] + playable_url: str diff --git a/lib/domain/interface/cache_provider_interface.py b/lib/domain/interface/cache_provider_interface.py new file mode 100644 index 00000000..77f2079c --- /dev/null +++ b/lib/domain/interface/cache_provider_interface.py @@ -0,0 +1,10 @@ +from abc import ABC, abstractmethod +from typing import List, Dict +from lib.domain.cached_source import CachedSource +from lib.domain.source import Source + + +class CacheProviderInterface(ABC): + @abstractmethod + def get_cached_hashes(self, infoHashes: List[Source]) -> Dict[str, CachedSource]: + pass diff --git a/lib/sources_tools/enricher.py b/lib/domain/interface/enricher_interface.py similarity index 69% rename from lib/sources_tools/enricher.py rename to lib/domain/interface/enricher_interface.py index 28d23a3a..a3b6975b 100644 --- a/lib/sources_tools/enricher.py +++ b/lib/domain/interface/enricher_interface.py @@ -1,24 +1,25 @@ import abc -from typing import Dict, List +from typing import List +from lib.domain.source import Source -class Enricher(abc.ABC): +class EnricherInterface(abc.ABC): @abc.abstractmethod - def enrich(self, item: Dict) -> None: + def enrich(self, item: Source) -> None: """Enrich an item with additional metadata""" pass @abc.abstractmethod - def initialize(self, items: List[Dict]) -> None: + def initialize(self, items: List[Source]) -> None: """Initialize the enricher with a list of items""" pass - + @abc.abstractmethod def needs(self) -> List[str]: """Returns the fields that the enricher needs to function""" pass - + @abc.abstractmethod def provides(self) -> List[str]: """Returns the fields that the enricher will provide""" - pass \ No newline at end of file + pass diff --git a/lib/domain/interface/filter_interface.py b/lib/domain/interface/filter_interface.py new file mode 100644 index 00000000..1291ffb0 --- /dev/null +++ b/lib/domain/interface/filter_interface.py @@ -0,0 +1,12 @@ +from abc import ABC, abstractmethod +from typing import Dict, Any +from lib.domain.source import Source + + +class FilterInterface(ABC): + @abstractmethod + def matches(self, item: Source) -> bool: + pass + + def reset(self): + pass diff --git a/lib/domain/quality_tier.py b/lib/domain/quality_tier.py new file mode 100644 index 00000000..96070c98 --- /dev/null +++ b/lib/domain/quality_tier.py @@ -0,0 +1,28 @@ +import re + + +class QualityTier: + + def __init__(self, pattern: str, label: str, label_formatted: str, priority: int): + self.regex = re.compile(pattern, re.IGNORECASE) if pattern else None + self.label = label + self.label_formatted = label_formatted + self.priority = priority + + @staticmethod + def default_quality_tiers(): + return [ + QualityTier( + r"(?i)\b(2160p?|4k)\b", "4k", "[B][COLOR yellow]4k[/COLOR][/B]", 4 + ), + QualityTier( + r"(?i)\b(1080p?)\b", "1080p", "[B][COLOR cyan]1080p[/COLOR][/B]", 3 + ), + QualityTier( + r"(?i)\b720p?\b", "720p", "[B][COLOR orange]720p[/COLOR][/B]", 2 + ), + QualityTier( + r"(?i)\b480p?\b", "480p", "[B][COLOR orange]480p[/COLOR][/B]", 1 + ), + QualityTier(None, "Other qualities", "[B][COLOR red]N/A[/COLOR][/B]", 0), + ] diff --git a/lib/domain/source.py b/lib/domain/source.py new file mode 100644 index 00000000..0eb07ba7 --- /dev/null +++ b/lib/domain/source.py @@ -0,0 +1,42 @@ +from typing import TypedDict, List, Any +from .cached_source import CachedSource + + +class Source(TypedDict, total=False): + title: str + description: str + type: Any # IndexerType + url: str + indexer: str + guid: str + magnet: str + info_hash: str + size: int + languages: List[str] + full_languages: List[str] + provider: str + publishDate: str + seeders: int + peers: int + + quality: str + quality_sort: int + + status: str + + is_pack: bool + is_cached: bool + cache_sources: List[CachedSource] + + file: str + folder: str + + correlative_id: int + + quality_formatted: str + a1: str + a2: str + a3: str + b1: str + b2: str + b3: str diff --git a/lib/gui/base_window.py b/lib/gui/base_window.py index d819f69d..0ee936fc 100644 --- a/lib/gui/base_window.py +++ b/lib/gui/base_window.py @@ -57,8 +57,8 @@ def set_default_focus( self.setFocus(control) return - if control_list and control_list.size() > 0: - if control_list_reset: + if control_list: + if control_list.size() > 0 and control_list_reset: control_list.selectItem(0) self.setFocus(control_list) elif control_id: @@ -67,7 +67,7 @@ def set_default_focus( else: raise ValueError("Neither valid control list nor control ID provided.") except (RuntimeError, ValueError) as e: - kodilog(f"Could not set focus: {e}", "debug") + kodilog(f"Could not set focus: {e}") if control_id: self.setFocusId(control_id) diff --git a/lib/gui/custom_dialogs.py b/lib/gui/custom_dialogs.py index cd63e9cc..960a43ca 100644 --- a/lib/gui/custom_dialogs.py +++ b/lib/gui/custom_dialogs.py @@ -7,7 +7,6 @@ from lib.gui.resume_window import ResumeDialog from lib.utils.kodi_utils import ADDON_PATH, PLAYLIST from lib.gui.source_select import SourceSelect -from lib.gui.source_select_new import SourceSelectNew class CustomWindow(WindowXML): @@ -83,20 +82,6 @@ def onClick(self, controlId): "plot": "Silo is the story of the last ten thousand people on earth, their mile-deep home protecting them from the toxic and deadly world outside. However, no one knows when or why the silo was built and any who try to find out face fatal consequences.", } - -def source_select(item_info, xml_file, sources): - window = SourceSelectNew( - xml_file, - ADDON_PATH, - item_information=item_info, - sources=sources, - uncached=sources, - ) - data = window.doModal() - del window - return data - - def run_next_dialog(params): kodilog("run_next_dialog") if PLAYLIST.size() > 0 and PLAYLIST.getposition() != (PLAYLIST.size() - 1): diff --git a/lib/gui/resolver_window.py b/lib/gui/resolver_window.py index c40fb312..88d33497 100644 --- a/lib/gui/resolver_window.py +++ b/lib/gui/resolver_window.py @@ -100,7 +100,7 @@ def resolve_single_source(self, url, magnet, is_torrent): "indexer": self.source["indexer"], "url": url, "magnet": magnet, - "info_hash": self.source.get("infoHash", ""), + "info_hash": self.source.get("info_hash", ""), "is_torrent": is_torrent, "is_pack": self.pack_select, "mode": self.item_information["mode"], @@ -112,7 +112,7 @@ def resolve_single_source(self, url, magnet, is_torrent): def resolve_pack(self): self.pack_data = get_pack_info( type=self.source.get("type"), - info_hash=self.source.get("infoHash"), + info_hash=self.source.get("info_hash"), ) self.window = SourcePackSelect( diff --git a/lib/gui/source_section_manager.py b/lib/gui/source_section_manager.py index f08ffe94..b037a671 100644 --- a/lib/gui/source_section_manager.py +++ b/lib/gui/source_section_manager.py @@ -1,56 +1,20 @@ -from lib.utils.kodi_utils import bytes_to_human_readable -from lib.utils.debrid_utils import get_debrid_status -from lib.utils.utils import ( - extract_publish_date, - get_colored_languages, - get_random_color, -) import xbmcgui -from typing import Dict, List +from typing import List +from lib.domain.source import Source class SourceItem(xbmcgui.ListItem): """A custom ListItem representing a media source with formatted properties.""" @staticmethod - def from_source(source: Dict) -> "SourceItem": - """ - Creates a SourceItem from a source dictionary. - - Args: - source: Dictionary containing source metadata - - Returns: - Configured SourceItem with formatted properties - """ - processors = { - "peers": lambda v, s: v or "", - "publishDate": lambda v, s: extract_publish_date(v), - "size": lambda v, s: bytes_to_human_readable(int(v)) if v else "", - "indexer": lambda v, s: SourceItem._format_colored_text(v), - "provider": lambda v, s: SourceItem._format_colored_text(v), - "type": lambda v, s: SourceItem._format_colored_text(v), - "fullLanguages": lambda v, s: get_colored_languages(v) or "", - } - + def from_source(source: Source) -> "SourceItem": item = SourceItem(label=source["title"]) - item_properties = { - key: processors.get(key, lambda v, s: v)(value, source) - for key, value in source.items() - } - - for key, value in item_properties.items(): + for key, value in source.items(): item.setProperty(key, str(value)) return item - @staticmethod - def _format_colored_text(text: str) -> str: - """Formats text with random color using Kodi markup.""" - color = get_random_color(text) - return f"[B][COLOR {color}]{text}[/COLOR][/B]" - class SourceSection: """Represents a group of media sources with common characteristics.""" diff --git a/lib/gui/source_select_new.py b/lib/gui/source_select_new.py index 46780219..e6a2f9b5 100644 --- a/lib/gui/source_select_new.py +++ b/lib/gui/source_select_new.py @@ -1,292 +1,212 @@ import xbmcgui from typing import List, Dict, Optional from lib.gui.base_window import BaseWindow -from lib.gui.resolver_window import ResolverWindow from lib.gui.source_section_manager import ( SourceSectionManager, SourceSection, SourceItem, ) -from lib.gui.resume_window import ResumeDialog -from lib.utils.kodi_utils import ADDON_PATH from lib.api.jacktook.kodi import kodilog +from lib.domain.quality_tier import QualityTier +from lib.services.filters import FilterBuilder +from lib.clients.debrid.transmission import TransmissionClient +from lib.clients.debrid.torrserve import TorrServeClient +from lib.utils.kodi_utils import get_setting +from lib.domain.source import Source -class SourceSelectNew(BaseWindow): - """Main window for selecting media sources from organized sections.""" +class SourceSelectWindow(BaseWindow): + """Media source selection window with categorized sections.""" - CACHE_KEY_FIELD = "tv_data" # Fallback to "ids" if not available + CACHE_KEY_FIELD = "tv_data" + SOURCE_ITEM_ID = 1000 + NAVIGATION_LABEL_ID = 1001 + DESCRIPTION_LABEL_ID = 1002 + SETTINGS_GROUP_ID = 2000 + SETTINGS_FIRST_ID = 2002 def __init__( self, xml_layout: str, window_location: str, item_information: Optional[Dict] = None, - sources: Optional[List[Dict]] = None, - uncached: Optional[List[Dict]] = None, + get_sources=None, ): super().__init__(xml_layout, window_location, item_information=item_information) - - self._sources = self._preprocess_sources(sources or []) - self._uncached_sources = uncached or [] self._item_metadata = item_information or {} - self._playback_info = None - self._resume_flag = None - - self._init_ui_properties() - self._section_manager = self._create_section_manager() - - def _preprocess_sources(self, raw_sources: List[Dict]) -> List[Dict]: - """Add unique identifiers to sources for tracking.""" - return [dict(source, id=i) for i, source in enumerate(raw_sources)] - - def _init_ui_properties(self) -> None: - """Initialize default UI state properties.""" - self.setProperty("instant_close", "false") - self.setProperty("resolving", "false") + self._get_sources = get_sources + self._source: Optional[Source] = None + self._sources: Optional[List[Source]] = [] + self._navigation_label: Optional[xbmcgui.ControlLabel] = None + self.setProperty("instant_close", "true") - def _create_section_manager(self) -> SourceSectionManager: - """Organize sources into categorized sections.""" + def _create_sections(self) -> SourceSectionManager: + """Create organized source sections.""" sections = [ - self._create_priority_language_section(), - self._create_top_seeders_section(), + self._create_language_section("Spanish", "es"), *self._create_quality_sections(), ] + return SourceSectionManager([s for s in sections if s]) - return SourceSectionManager([section for section in sections if section]) - - def _create_priority_language_section(self) -> Optional[SourceSection]: - """Create section for priority language (Spanish) sources.""" - spanish_sources = [ - s for s in self._sources if "es" in s.get("fullLanguages", []) - ] - if not spanish_sources: + def _create_language_section(self, lang: str, code: str) -> Optional[SourceSection]: + """Create section for specific language sources.""" + sources = [s for s in self._sources if code in s.get("languages", [])] + if not sources: return None - sources = [SourceItem.from_source(s) for s in spanish_sources] return SourceSection( title=f"Priority Language ({len(sources)})", - description="Sources with Spanish audio", - sources=sources, - ) - - def _create_top_seeders_section(self) -> SourceSection: - """Create section for sources with highest combined seeders.""" - non_spanish_sources = [ - s for s in self._sources if "es" not in s.get("fullLanguages", []) - ] - sources = [SourceItem.from_source(s) for s in non_spanish_sources] - - return SourceSection( - title=f"All results ({len(non_spanish_sources)})", - description="Results with the most seeders", - sources=sources, + description=f"Sources with {lang} audio", + sources=[SourceItem.from_source(s) for s in sources], ) def _create_quality_sections(self) -> List[SourceSection]: - """Create sections for sources grouped by quality.""" - quality_groups = { - "4K": ["4K", "UHD", "2160p"], - "1080p": ["1080p", "Full HD"], - "720p": ["720p", "HD"], - "SD": ["480p", "360p", "240p"], - } + """Generate quality tier sections.""" + return [ + SourceSection( + title=f"{tier.label} ({len(sources)})", + description=f"{tier.label_formatted} resolution sources", + sources=[SourceItem.from_source(s) for s in sources], + ) + for tier in QualityTier.default_quality_tiers() + if ( + sources := FilterBuilder() + .filter_by_quality(tier.priority) + .build(self._sources) + ) + ] - quality_sections = [] - for quality, keywords in quality_groups.items(): - quality_sources = [ - s - for s in self._sources - if any(kw in s.get("title", "") for kw in keywords) - ] + def onInit(self) -> None: + """Initialize window and populate data.""" + self.setProperty("instant_close", "true") - if quality_sources: - quality_sections.append( - SourceSection( - title=f"{quality} ({len(quality_sources)})", - description=f"Sources with {quality} resolution", - sources=[SourceItem.from_source(s) for s in quality_sources], - ) - ) + self._source_list = self.getControlList(self.SOURCE_ITEM_ID) + self._navigation_label = self.getControl(self.NAVIGATION_LABEL_ID) + self._description_label = self.getControl(self.DESCRIPTION_LABEL_ID) + self._settings_first = self.getControl(self.SETTINGS_FIRST_ID) - return quality_sections + super().onInit() - def onInit(self) -> None: - """Initialize window controls and populate initial data.""" - self._source_list = self.getControlList(1000) - self._navigation_label = self.getControl(1001) - self._description_label = self.getControl(1002) - self._settings_group = self.getControl(2000) + sources = self._get_sources() + for i, source in enumerate(sources): + source["correlative_id"] = i + self._sources = sources self._refresh_ui() - self.set_default_focus(self._source_list, 1000, control_list_reset=True) - super().onInit() def _refresh_ui(self) -> None: - """Update all UI elements with current state.""" - self._update_navigation_header() - self._update_description() - self._populate_source_list() - - def _update_navigation_header(self) -> None: - """Update the navigation breadcrumb display.""" - current_index = self._section_manager._current_index - all_titles = self._section_manager.section_titles - - # Build truncated navigation path - preceding_titles = all_titles[:current_index] - if len(preceding_titles) > 2: - preceding_titles = ["...", *preceding_titles[-2:]] - - navigation_path = [ - *preceding_titles, - f"[B][COLOR white]{all_titles[current_index]}[/COLOR][/B]", - *all_titles[current_index + 1 :], - ] - - self._navigation_label.setLabel(" | ".join(navigation_path)) - - def _update_description(self) -> None: - """Update the section description label.""" + """Update all UI components.""" + self.section_manager = self._create_sections() + self._navigation_label.setLabel(self._build_navigation_path()) self._description_label.setLabel( - self._section_manager.current_section.description + self.section_manager.current_section.description ) - - def _populate_source_list(self) -> None: - """Populate the source list with current section's items.""" self._source_list.reset() - current_sources = self._section_manager.current_section.sources - self._source_list.addItems(current_sources) - self._source_list.selectItem( - self._section_manager.current_section.selection_position + self._source_list.addItems(self.section_manager.current_section.sources) + self.set_default_focus(self._source_list) + self.setProperty("instant_close", "false") + + def _build_navigation_path(self) -> str: + """Create truncated navigation breadcrumb.""" + titles = self.section_manager.section_titles + current_idx = self.section_manager._current_index + + preceding = titles[:current_idx] + if len(preceding) > 2: + preceding = ["...", *preceding[-2:]] + + return " | ".join( + [ + *preceding, + f"[B][COLOR white]{titles[current_idx]}[/COLOR][/B]", + *titles[current_idx + 1 :], + ] ) def handle_action(self, action_id: int, control_id: Optional[int] = None) -> None: - """Handle user input actions.""" - kodilog(f"Action ID: {action_id}, Control ID: {control_id}") - if control_id == 1000: - self._handle_source_list_action(action_id) - if control_id >= 2000: + """Route user actions to appropriate handlers.""" + if control_id is None: + return + elif control_id == self.SOURCE_ITEM_ID: + self._handle_list_action(action_id) + elif control_id >= self.SETTINGS_GROUP_ID: self._handle_settings_action(action_id) - def _handle_source_list_action(self, action_id: int) -> None: - """Process actions specific to the source list control.""" - current_section = self._section_manager.current_section - current_section.update_selection_position( - self._source_list.getSelectedPosition() - ) - - action_handlers = { - xbmcgui.ACTION_SELECT_ITEM: self._resolve_selected_source, - xbmcgui.ACTION_MOVE_LEFT: self._section_manager.move_to_previous_section if self._section_manager._current_index >0 else self._settings_open, - xbmcgui.ACTION_MOVE_RIGHT: self._section_manager.move_to_next_section, + def _handle_list_action(self, action_id: int) -> None: + """Process source list interactions.""" + actions = { + xbmcgui.ACTION_SELECT_ITEM: self._resolve_source, + xbmcgui.ACTION_MOVE_LEFT: self._handle_navigation_left, + xbmcgui.ACTION_MOVE_RIGHT: self.section_manager.move_to_next_section, xbmcgui.ACTION_CONTEXT_MENU: self._show_context_menu, } - - handler = action_handlers.get(action_id) - if handler: + if handler := actions.get(action_id): handler() self._refresh_ui() - def _handle_settings_action(self, action_id): + def _handle_settings_action(self, action_id: int) -> None: + """Process settings interactions.""" if action_id == xbmcgui.ACTION_MOVE_RIGHT: - self._settings_close() - - def _settings_open(self) -> None: - """Open settings sidebar.""" - self.setProperty("settings_open", "true") - #self._settings_group.setVisible(True) - self._settings_group_first = self.getControl(2003) - self.setFocus(self._settings_group_first) - - def _settings_close(self) -> None: - """Close settings sidebar.""" - self.setProperty("settings_open", "") - #self._settings_group.setVisible(False) - self.setFocus(self._source_list) - - def _show_context_menu(self) -> None: - """Display context menu for selected source.""" - source = self._get_source_from_item( - self._section_manager.current_section.current_source - ) - menu_options = self._get_context_menu_options(source["type"]) + self.setProperty("settings_open", "false") + self.setFocus(self._source_list) + + def _handle_navigation_left(self) -> None: + """Handle left navigation between sections/settings.""" + if self.section_manager._current_index > 0: + self.section_manager.move_to_previous_section() + else: + self.setProperty("settings_open", "true") + self.setFocus(self._settings_first) + + def _resolve_source(self) -> None: + """Initiate playback resolution for selected source.""" + source = self._get_selected_source() + self._source = source + self.close() - choice = xbmcgui.Dialog().contextmenu(menu_options) - if choice == 0: - self._handle_context_choice(source["type"]) + # resolver = ResolverWindow( + # "resolver.xml", ADDON_PATH, + # source=source, + # item_information=self._item_metadata, + # previous_window=self + # ) + # resolver.doModal() + # self._playback_info = resolver.playback_info + # self.close() - def _get_context_menu_options(self, source_type: str) -> List[str]: - """Get available context menu options based on source type.""" - return { - "Torrent": ["Download to Debrid"], + def _show_context_menu(self) -> None: + """Display context options for selected source.""" + source = self._get_selected_source() + + options = { + "Torrent": [ + "Download to Debrid", + "Download to Transmission", + "Download to TorrServer", + ], "Direct": [], - }.get(source_type, ["Browse into"]) - - def _handle_context_choice(self, source_type: str) -> None: - """Handle context menu selection.""" - handlers = { - "Torrent": self._download_to_debrid, - "Direct": lambda: None, - "default": self._browse_source_pack, - } - handler = handlers.get(source_type, handlers["default"]) - handler() - - def _download_to_debrid(self) -> None: - """Handle Debrid download request.""" - # Implementation placeholder - pass - - def _browse_source_pack(self) -> None: - """Handle pack browsing request.""" - # Implementation placeholder - pass - - def _resolve_selected_source(self) -> None: - """Initiate resolution of the selected source.""" - self.setProperty("resolving", "true") - selected_source = self._get_source_from_item( - self._section_manager.current_section.current_source - ) - - resolver = ResolverWindow( - "resolver.xml", - ADDON_PATH, - source=selected_source, - previous_window=self, - item_information=self._item_metadata, - ) - resolver.doModal(pack_select=False) - self._playback_info = resolver.playback_info - - del resolver - self._close_window() - - def _get_source_from_item(self, source_item: SourceItem) -> Dict: - """Retrieve original source data from ListItem.""" - source_id = int(source_item.getProperty("id")) - return next(s for s in self._sources if s["id"] == source_id) - - def _close_window(self) -> None: - """Close the window and clean up resources.""" - self.setProperty("instant_close", "true") - self.close() + }.get(source["type"], ["Browse into"]) + + choice = xbmcgui.Dialog().contextmenu(options) + if choice == 1: + TransmissionClient( + get_setting("transmission_host"), + get_setting("transmission_folder"), + get_setting("transmission_user"), + get_setting("transmission_pass"), + ).add_magnet(source["magnet"]) + elif choice == 2: + kodilog("TorrServeClient().add_magnet") + TorrServeClient().add_magnet(source["magnet"]) + + def _get_selected_source(self) -> Source: + """Retrieve currently selected source data.""" + + return self._sources[ + int(self._source_list.getSelectedItem().getProperty("correlative_id")) + ] - def doModal(self) -> Optional[Dict]: - """Display the window and return playback info when closed.""" + def doModal(self) -> Optional[Source]: + """Show window and return playback info on close.""" super().doModal() - return self._playback_info - - def show_resume_dialog(self, playback_percent: float) -> bool: - """Display resume playback dialog.""" - try: - resume_dialog = ResumeDialog( - "resume_dialog.xml", - ADDON_PATH, - resume_percent=playback_percent, - ) - resume_dialog.doModal() - return resume_dialog.resume - finally: - del resume_dialog + return self._source diff --git a/lib/gui/source_window.py b/lib/gui/source_window.py index 87dc1174..993048c0 100644 --- a/lib/gui/source_window.py +++ b/lib/gui/source_window.py @@ -2,7 +2,6 @@ import xbmcgui from lib.gui.base_window import BaseWindow from lib.utils.debrid_utils import get_debrid_status -from lib.utils.kodi_utils import bytes_to_human_readable from lib.utils.utils import extract_publish_date, get_colored_languages, get_random_color diff --git a/lib/navigation.py b/lib/navigation.py index a7c63837..ad98f108 100644 --- a/lib/navigation.py +++ b/lib/navigation.py @@ -15,19 +15,16 @@ CustomDialog, resume_dialog_mock, run_next_mock, - source_select, source_select_mock, ) from lib.player import JacktookPLayer from lib.utils.seasons import show_episode_info, show_season_info -from lib.utils.tmdb_utils import get_tmdb_media_details from lib.utils.torrentio_utils import open_providers_selection from lib.api.trakt.trakt_api import ( trakt_authenticate, trakt_revoke_authentication, ) -from lib.clients.search import search_client from lib.files_history import last_files from lib.play import get_playback_info from lib.titles_history import last_titles @@ -40,7 +37,6 @@ from lib.utils.rd_utils import get_rd_info from lib.utils.items_menus import tv_items, movie_items, anime_items, animation_items -from lib.utils.debrid_utils import check_debrid_cached from lib.tmdb import ( handle_tmdb_anime_query, @@ -53,23 +49,16 @@ from lib.db.cached import cache from lib.utils.utils import ( - TMDB_POSTER_URL, - DialogListener, - clean_auto_play_undesired, clear, clear_all_cache, - get_fanart_details, get_password, get_random_color, get_service_host, get_username, make_listing, - post_process, - pre_process, get_port, list_item, set_content_type, - set_watched_title, ssl_enabled, check_debrid_enabled, Debrids, @@ -95,7 +84,7 @@ translation, ) -from lib.utils.settings import get_cache_expiration, is_auto_play +from lib.utils.settings import get_cache_expiration from lib.utils.settings import addon_settings from lib.updater import updates_check_addon @@ -513,133 +502,6 @@ def search_direct(params): endOfDirectory(ADDON_HANDLE, updateListing=update_listing) -def search(params): - query = params["query"] - mode = params["mode"] - media_type = params.get("media_type", "") - ids = params.get("ids", "") - tv_data = params.get("tv_data", "") - direct = params.get("direct", False) - rescrape = params.get("rescrape", False) - - set_content_type(mode, media_type) - set_watched_title(query, ids, mode, media_type) - - episode, season, ep_name = (0, 0, "") - if tv_data: - try: - ep_name, episode, season = tv_data.split("(^)") - except ValueError: - pass - - with DialogListener() as listener: - results = search_client( - query, ids, mode, media_type, listener.dialog, rescrape, season, episode - ) - if not results: - notification("No results found") - return - - pre_results = pre_process( - results, - mode, - ep_name, - episode, - season, - ) - if not pre_results: - notification("No results found for episode") - return - - if get_setting("torrent_enable"): - post_results = post_process(pre_results) - else: - with DialogListener() as listener: - cached_results = handle_debrid_client( - query, - pre_results, - mode, - media_type, - listener.dialog, - rescrape, - episode, - ) - if not cached_results: - notification("No cached results found") - return - - if is_auto_play(): - auto_play(cached_results, ids, tv_data, mode) - return - - post_results = post_process(cached_results, season) - - data = handle_results(post_results, mode, ids, tv_data, direct) - - if not data: - cancel_playback() - return - - player = JacktookPLayer(db=bookmark_db) - player.run(data=data) - del player - - -def handle_results(results, mode, ids, tv_data, direct=False): - if direct: - item_info = {"tv_data": tv_data, "ids": ids, "mode": mode} - else: - tmdb_id, tvdb_id, _ = [id.strip() for id in ids.split(",")] - - details = get_tmdb_media_details(tmdb_id, mode) - poster = f"{TMDB_POSTER_URL}{details.poster_path or ''}" - overview = details.overview or "" - - fanart_data = get_fanart_details(tvdb_id=tvdb_id, tmdb_id=tmdb_id, mode=mode) - - item_info = { - "poster": poster, - "fanart": fanart_data["fanart"] or poster, - "clearlogo": fanart_data["clearlogo"], - "plot": overview, - "tv_data": tv_data, - "ids": ids, - "mode": mode, - } - - if mode == "direct": - xml_file_string = "source_select_direct.xml" - else: - # TODO: Expecting to have a way to select between skins - xml_file_string = "source_select_new.xml" - - return source_select( - item_info, - xml_file=xml_file_string, - sources=results, - ) - - -def handle_debrid_client( - query, - proc_results, - mode, - media_type, - p_dialog, - rescrape, - episode, -): - return check_debrid_cached( - query, - proc_results, - mode, - media_type, - p_dialog, - rescrape, - episode, - ) - - def play_torrent(params): data = literal_eval(params["data"]) player = JacktookPLayer(db=bookmark_db) @@ -647,25 +509,6 @@ def play_torrent(params): del player -def auto_play(results, ids, tv_data, mode): - result = clean_auto_play_undesired(results) - playback_info = get_playback_info( - data={ - "title": result.get("title"), - "mode": mode, - "indexer": result.get("indexer"), - "type": result.get("type"), - "ids": ids, - "info_hash": result.get("infoHash"), - "tv_data": tv_data, - "is_torrent": False, - }, - ) - player = JacktookPLayer(db=bookmark_db) - player.run(data=playback_info) - del player - - def cloud_details(params): type = params.get("type") diff --git a/lib/router.py b/lib/router.py index b120909e..ff8ea0d0 100644 --- a/lib/router.py +++ b/lib/router.py @@ -36,7 +36,6 @@ rd_auth, rd_remove_auth, root_menu, - search, search_direct, search_item, settings, @@ -49,6 +48,7 @@ tv_seasons_details, tv_shows_items, ) +from lib.search import search from lib.telegram import ( get_telegram_files, get_telegram_latest, diff --git a/lib/search.py b/lib/search.py new file mode 100644 index 00000000..5f3654f6 --- /dev/null +++ b/lib/search.py @@ -0,0 +1,252 @@ +from lib.utils.tmdb_utils import get_tmdb_media_details +from lib.utils.utils import ( + TMDB_POSTER_URL, + get_fanart_details, + clean_auto_play_undesired, + set_content_type, + set_watched_title, + IndexerType, +) +from lib.utils.language_detection import language_codes, langsSet +from lib.clients.debrid.transmission import TransmissionClient +from lib.db.bookmark_db import bookmark_db +from lib.clients.search import search_client +from lib.utils.kodi_utils import ( + cancel_playback, + get_setting, + convert_size_to_bytes, + ADDON_PATH, +) +from lib.player import JacktookPLayer +from lib.play import get_playback_info +from lib.services.filters import FilterBuilder +from lib.services.enrich import ( + EnricherBuilder, + StatsEnricher, + QualityEnricher, + LanguageEnricher, + IsPackEnricher, + CacheEnricher, + FormatEnricher, + MagnetEnricher, + FileEnricher, +) +from lib.gui.source_select_new import SourceSelectWindow + + +def search(params): + """ + Handles media search and playback. + """ + query = params["query"] + mode = params["mode"] + media_type = params.get("media_type", "") + ids = params.get("ids", "") + tv_data = params.get("tv_data", "") + direct = params.get("direct", False) + rescrape = params.get("rescrape", False) + + # Parse TV data if available + episode, season, ep_name = parse_tv_data(tv_data) + + # Extract TMDb and TVDb IDs + tmdb_id, tvdb_id = extract_ids(ids) + + # Fetch media details from TMDb + details = get_tmdb_media_details(tmdb_id, mode) + poster = f"{TMDB_POSTER_URL}{details.poster_path or ''}" + overview = details.overview or "" + + # Fetch fanart details + fanart_data = get_fanart_details(tvdb_id=tvdb_id, tmdb_id=tmdb_id, mode=mode) + + # Prepare item information + item_info = { + "episode": episode, + "season": season, + "ep_name": ep_name, + "tvdb_id": tvdb_id, + "tmdb_id": tmdb_id, + "tv_data": tv_data, + "ids": ids, + "mode": mode, + "poster": poster, + "fanart": fanart_data["fanart"] or poster, + "clearlogo": fanart_data["clearlogo"], + "plot": overview, + } + + # Set content type and watched title + set_content_type(mode, media_type) + set_watched_title(query, ids, mode, media_type) + + # Search for sources + source = select_source( + item_info, query, ids, mode, media_type, rescrape, season, episode, direct + ) + if not source: + return + + # Handle selected source + playback_info = handle_results(source, item_info) + if not playback_info: + cancel_playback() + return + + # Start playback + player = JacktookPLayer(db=bookmark_db) + player.run(data=playback_info) + del player + + +def parse_tv_data(tv_data): + """ + Parses TV data into episode, season, and episode name. + """ + episode, season, ep_name = 0, 0, "" + if tv_data: + try: + ep_name, episode, season = tv_data.split("(^)") + except ValueError: + pass + return int(episode), int(season), ep_name + + +def extract_ids(ids): + """ + Extracts TMDb and TVDb IDs from the input string. + """ + tmdb_id, tvdb_id, _ = [id.strip() for id in ids.split(",")] + return tmdb_id, tvdb_id + + +def select_source( + item_info, query, ids, mode, media_type, rescrape, season, episode, direct +): + """ + Searches for and selects a source. + """ + + def get_sources(): + results = search_client( + query, ids, mode, media_type, FakeDialog(), rescrape, season, episode + ) + if not results: + notification("No results found") + return None + return process(results, mode, item_info["ep_name"], episode, season) + + source_select_window = SourceSelectWindow( + "source_select_new.xml", + ADDON_PATH, + item_information=item_info, + get_sources=get_sources, + ) + source = source_select_window.doModal() + del source_select_window + return source + + +def process(results, mode, ep_name, episode, season): + """ + Processes and filters search results. + """ + sort_by = get_setting("indexers_sort_by") + limit = int(get_setting("indexers_total_results")) + + enricher = ( + EnricherBuilder() + .add(FileEnricher()) + .add(StatsEnricher(size_converter=convert_size_to_bytes)) + .add(IsPackEnricher(season) if season else None) + .add(MagnetEnricher()) + .add(QualityEnricher()) + .add(LanguageEnricher(language_codes, langsSet)) + .add( + CacheEnricher( + [ + ( + TransmissionClient( + get_setting("transmission_host"), + get_setting("transmission_folder"), + get_setting("transmission_user"), + get_setting("transmission_pass"), + ) + if get_setting("transmission_enabled") + else None + ), + ] + ) + ) + .add(FormatEnricher()) + ) + results = enricher.build(results) + + filters = FilterBuilder().dedupe_by_infoHash().limit(limit) + if get_setting("stremio_enabled") and get_setting("torrent_enable"): + filters.filter_by_source() + if mode == "tv" and get_setting("filter_by_episode"): + filters.filter_by_episode(ep_name, episode, season) + if sort_by == "Seeds": + filters.sort_by("seeders", ascending=False) + elif sort_by == "Size": + filters.sort_by("size", ascending=False) + elif sort_by == "Date": + filters.sort_by("publishDate", ascending=False) + elif sort_by == "Quality": + filters.sort_by("quality_sort", ascending=False) + filters.sort_by("seeders", ascending=False) + elif sort_by == "Cached": + filters.sort_by("isCached", ascending=False) + return filters.build(results) + + +def handle_results(source, info_item): + """ + Handles the selected source and prepares playback information. + """ + if not source: + return None + + cache_sources = source.get("cache_sources", []) + for cache_source in cache_sources: + if cache_source.get("instant_availability"): + playable_url = cache_source.get("playable_url") + return { + **info_item, + "title": source["title"], + "type": source["type"], + "indexer": source["indexer"], + "url": playable_url, + "info_hash": source.get("info_hash", ""), + "is_torrent": False, + "is_pack": False, + } + + return get_playback_info( + { + "title": source["title"], + "type": IndexerType.TORRENT, + "indexer": source["indexer"], + "info_hash": source.get("info_hash", ""), + "magnet": source.get("magnet", ""), + "is_pack": False, + "mode": info_item["mode"], + "ids": info_item["ids"], + "tv_data": info_item["tv_data"], + "is_torrent": True, + "url": source["magnet"], + } + ) + + +class FakeDialog: + """ + A placeholder dialog class for mocking progress updates. + """ + + def create(self, message: str): + pass + + def update(self, percent: int, title: str, message: str): + pass diff --git a/lib/sources_tools/__init__.py b/lib/services/enrich/__init__.py similarity index 58% rename from lib/sources_tools/__init__.py rename to lib/services/enrich/__init__.py index a801462e..f95bdbf4 100644 --- a/lib/sources_tools/__init__.py +++ b/lib/services/enrich/__init__.py @@ -1,19 +1,23 @@ from .enricher_builder import EnricherBuilder -from .enricher import Enricher from .language_enricher import LanguageEnricher from .stats_enricher import StatsEnricher -from .filter_builder import FilterBuilder from .is_pack_enricher import IsPackEnricher from .quality_enricher import QualityEnricher from .cache_enricher import CacheEnricher +from lib.domain.quality_tier import QualityTier +from .format_enricher import FormatEnricher +from .magnet_enricher import MagnetEnricher +from .file_enricher import FileEnricher __all__ = [ "EnricherBuilder", - "Enricher", "LanguageEnricher", "StatsEnricher", - "FilterBuilder", "IsPackEnricher", "QualityEnricher", - "CacheEnricher" + "CacheEnricher", + "QualityTier", + "FormatEnricher", + "MagnetEnricher", + "FileEnricher", ] diff --git a/lib/services/enrich/cache_enricher.py b/lib/services/enrich/cache_enricher.py new file mode 100644 index 00000000..9c181d94 --- /dev/null +++ b/lib/services/enrich/cache_enricher.py @@ -0,0 +1,42 @@ +from lib.domain.interface.enricher_interface import EnricherInterface +from typing import Dict, List +from lib.clients.debrid.debrid_client import ProviderException +from lib.domain.interface.cache_provider_interface import CacheProviderInterface +from lib.domain.cached_source import CachedSource +from lib.api.jacktook.kodi import kodilog +from lib.domain.source import Source + + +class CacheEnricher(EnricherInterface): + def __init__(self, cache_providers: List[CacheProviderInterface]): + self.cache_providers = cache_providers + + def initialize(self, items: List[Source]) -> None: + self.provider_results: List[Dict[str, CachedSource]] = [] + + for cache_provider in self.cache_providers: + cached_hashes = cache_provider.get_cached_hashes(items) + self.provider_results.append(cached_hashes) + + return + + def needs(self): + return ["info_hash"] + + def provides(self): + return ["is_cached", "cache_sources"] + + def enrich(self, item: Source) -> None: + info_hash = item.get("info_hash") + if not info_hash: + return + + for provider_result in self.provider_results: + cached_source = provider_result.get(info_hash) + if not cached_source: + continue + + item["is_cached"] = True + item["cache_sources"] = list( + item.get("cache_sources", []) + [cached_source] + ) diff --git a/lib/services/enrich/enricher_builder.py b/lib/services/enrich/enricher_builder.py new file mode 100644 index 00000000..88ff184b --- /dev/null +++ b/lib/services/enrich/enricher_builder.py @@ -0,0 +1,116 @@ +from typing import List, Optional +from lib.domain.interface.enricher_interface import EnricherInterface +from collections import defaultdict +from lib.domain.source import Source + + +class EnricherBuilder: + def __init__(self): + self._enrichers: List[EnricherInterface] = [] + + def add(self, enricher: Optional[EnricherInterface]) -> "EnricherBuilder": + if enricher is None: + return self + + self._enrichers.append(enricher) + return self + + def build(self, items: List[Source]) -> List[Source]: + processed = [] + for enricher in self._enrichers: + enricher.initialize(items) + + for item in [item.copy() for item in items]: + for enricher in self._enrichers: + enricher.enrich(item) + processed.append(item) + return processed + + def generate_report(self) -> str: + report = [] + enrichers_info = [] + provided_fields = defaultdict(list) + all_needs = set() + + for enricher in self._enrichers: + name = type(enricher).__name__ + needs = enricher.needs() + provides = enricher.provides() + enrichers_info.append((name, needs, provides)) + all_needs.update(needs) + for field in provides: + provided_fields[field].append(name) + + # Enrichers Details Section + report.append("Enrichers Report:") + report.append("=================") + report.append("\nEnrichers Details:") + for name, needs, provides in enrichers_info: + report.append(f"- {name}:") + report.append(f" Needs: {', '.join(needs) if needs else 'None'}") + report.append(f" Provides: {', '.join(provides) if provides else 'None'}") + report.append("") + + # Insights Section + report.append("\nInsights:") + report.append("=========") + + # Insight 1: Check unmet dependencies + cumulative_provided = set() + insights_unmet = [] + for index, (name, needs, _) in enumerate(enrichers_info): + unmet = [field for field in needs if field not in cumulative_provided] + if unmet: + insights_unmet.append((name, unmet)) + # Update cumulative_provided with current enricher's provides + cumulative_provided.update(enrichers_info[index][2]) + + if insights_unmet: + report.append( + "\n1. Enrichers with unmet dependencies (needs not provided by prior enrichers):" + ) + for name, unmet in insights_unmet: + report.append( + f" - {name} requires fields not provided earlier: {', '.join(unmet)}" + ) + else: + report.append( + "\n1. All enrichers' dependencies are met by prior enrichers." + ) + + # Insight 2: Check overlapping provided fields + multi_provided = [ + field for field, providers in provided_fields.items() if len(providers) > 1 + ] + if multi_provided: + report.append( + "\n2. Fields provided by multiple enrichers (possible conflicts):" + ) + for field in multi_provided: + providers = ", ".join(provided_fields[field]) + report.append(f" - Field '{field}' is provided by: {providers}") + else: + report.append("\n2. No fields are provided by multiple enrichers.") + + # Insight 3: Check unused provides + unused_provides = defaultdict(list) + for i, (name, _, provides) in enumerate(enrichers_info): + for field in provides: + used = False + for j in range(i + 1, len(enrichers_info)): + if field in enrichers_info[j][1]: + used = True + break + if not used: + unused_provides[name].append(field) + + if unused_provides: + report.append( + "\n3. Fields provided but not needed by subsequent enrichers:" + ) + for name, fields in unused_provides.items(): + report.append(f" - {name} provides: {', '.join(fields)}") + else: + report.append("\n3. All provided fields are used by subsequent enrichers.") + + return "\n".join(report) diff --git a/lib/services/enrich/file_enricher.py b/lib/services/enrich/file_enricher.py new file mode 100644 index 00000000..9b10930b --- /dev/null +++ b/lib/services/enrich/file_enricher.py @@ -0,0 +1,29 @@ +from lib.domain.interface.enricher_interface import EnricherInterface +from typing import List +from lib.domain.source import Source +from lib.utils.kodi_formats import is_video + + +class FileEnricher(EnricherInterface): + def __init__(self): + pass + + def initialize(self, items: List[Source]) -> None: + return + + def needs(self): + return ["description"] + + def provides(self): + return ["title", "file", "folder"] + + def enrich(self, item: Source) -> None: + description = item.get("description", "").splitlines() + if len(description) > 1 and is_video(description[1]): + item["title"] = description[1] + item["file"] = description[1] + item["folder"] = description[0] + else: + item["title"] = description[0] + item["file"] = description[0] + item["folder"] = "" diff --git a/lib/services/enrich/format_enricher.py b/lib/services/enrich/format_enricher.py new file mode 100644 index 00000000..bc9c865f --- /dev/null +++ b/lib/services/enrich/format_enricher.py @@ -0,0 +1,105 @@ +from lib.domain.interface.enricher_interface import EnricherInterface +from typing import List +from lib.domain.source import Source +import math + +# from lib.utils.debrid_utils import get_debrid_status +from lib.utils.utils import ( + # extract_publish_date, + get_random_color, +) + + +class FormatEnricher(EnricherInterface): + def __init__(self): + pass + + def initialize(self, items: List[Source]) -> None: + return + + def needs(self): + return ["is_cached", "cached_sources"] + + def provides(self): + return ["status", "a1", "a2", "a3", "b1", "b2", "b3"] + + def enrich(self, item: Source) -> None: + # Extract cache-related information if available + if item.get("is_cached") and item.get("cache_sources"): + cache_sources = item.get("cache_sources", []) + + # Separate instant availability and non-instant availability sources + cached_sources = [source for source in cache_sources if source.get("instant_availability")] + caching_sources = [source for source in cache_sources if not source.get("instant_availability")] + + # Build status message for cached and caching sources + status_parts = [] + if cached_sources: + cached_providers = ", ".join( + source.get("cache_provider_name", "Unknown") for source in cached_sources + ) + status_parts.append(f"Cached in {cached_providers}") + + if caching_sources: + caching_providers = ", ".join( + f"{source.get('cache_provider_name', 'Unknown')} ({round(source.get('ratio', 0) * 100)}%)" + for source in caching_sources + ) + status_parts.append(f"Caching in {caching_providers}") + + # Combine status parts into a single string + item["status"] = ", ".join(status_parts) + + # Update additional fields + item.update({ + "a1": str(item.get("seeders", "")), # Seeders count (default to empty string if missing) + "a2": ( + self._bytes_to_human_readable(item.get("size")) + if item.get("size") is not None + else "" + ), # Human-readable size (default to empty string if missing) + "a3": "Torrent", # Quality formatted (default to empty string if missing) + "b1": item.get("title", ""), # Title (default to empty string if missing) + "b2": self._colored_list( + [item.get("quality_formatted", "")] + item.get("languages", []) + [item.get("indexer", "")] + [item.get("provider", "")] + ), # Colored list of languages, indexer, and provider + "b3": item.get("status", ""), # Status (default to empty string if missing) + }) + + def _format_colored_text(self, text: str) -> str: + """Formats text with random color using Kodi markup.""" + color = get_random_color(text) + return f"[COLOR {color}]{text}[/COLOR]" + + def _colored_list(self, languages): + if not languages: + return "" + colored_languages = [] + for lang in languages: + if not lang: + continue + lang_color = get_random_color(lang) + colored_lang = f"[[COLOR {lang_color}]{lang}[/COLOR]]" + colored_languages.append(colored_lang) + colored_languages = " ".join(colored_languages) + return colored_languages + + def _format_significant(self, size, digits=3): + if size == 0: + return "0" # Handle zero case + + order = math.floor(math.log10(abs(size))) # Get the order of magnitude + factor = 10 ** (digits - 1 - order) # Compute scaling factor + rounded = round(size * factor) / factor # Round to significant digits + + return str(rounded) + + def _bytes_to_human_readable(self, size, unit="B"): + units = {"B": 0, "KB": 1, "MB": 2, "GB": 3, "TB": 4, "PB": 5} + + while size >= 1000 and unit != "PB": + size /= 1000 + unit = list(units.keys())[list(units.values()).index(units[unit] + 1)] + + + return f"{self._format_significant(size, 3)} {unit}" diff --git a/lib/sources_tools/is_pack_enricher.py b/lib/services/enrich/is_pack_enricher.py similarity index 82% rename from lib/sources_tools/is_pack_enricher.py rename to lib/services/enrich/is_pack_enricher.py index 1b4c6834..7c306019 100644 --- a/lib/sources_tools/is_pack_enricher.py +++ b/lib/services/enrich/is_pack_enricher.py @@ -1,16 +1,16 @@ -from .enricher import Enricher +from lib.domain.interface.enricher_interface import EnricherInterface import re from re import Pattern from typing import Dict, List +from lib.domain.source import Source - -class IsPackEnricher(Enricher): +class IsPackEnricher(EnricherInterface): def __init__(self, season_number: int): self.season_number = season_number self.season_fill = f"{season_number:02d}" self.pattern = self._build_pattern() - def initialize(self, items: List[Dict]) -> None: + def initialize(self, items: List[Source]) -> None: return def needs(self): @@ -44,6 +44,6 @@ def _build_pattern(self) -> Pattern: "|".join(f"({p})" for p in base_patterns), flags=re.IGNORECASE ) - def enrich(self, item: Dict) -> None: + def enrich(self, item: Source) -> None: title = item.get("title", "") - item["isPack"] = bool(self.pattern.search(title)) + item["is_pack"] = bool(self.pattern.search(title)) diff --git a/lib/sources_tools/language_enricher.py b/lib/services/enrich/language_enricher.py similarity index 67% rename from lib/sources_tools/language_enricher.py rename to lib/services/enrich/language_enricher.py index fbcc7f58..3eff9a26 100644 --- a/lib/sources_tools/language_enricher.py +++ b/lib/services/enrich/language_enricher.py @@ -1,9 +1,9 @@ -from .enricher import Enricher +from lib.domain.interface.enricher_interface import EnricherInterface import re from typing import Dict, Set, List +from lib.domain.source import Source - -class LanguageEnricher(Enricher): +class LanguageEnricher(EnricherInterface): def __init__(self, language_map: Dict[str, str], keywords: Set[str]): self.flag_regex = re.compile(r"[\U0001F1E6-\U0001F1FF]{2}") self.keyword_regex = re.compile( @@ -11,16 +11,16 @@ def __init__(self, language_map: Dict[str, str], keywords: Set[str]): ) self.language_map = language_map - def initialize(self, items: List[Dict]) -> None: + def initialize(self, items: List[Source]) -> None: return def needs(self): return ["description", "languages"] def provides(self): - return ["languages", "fullLanguages"] + return ["languages"] - def enrich(self, item: Dict) -> None: + def enrich(self, item: Source) -> None: desc = item.get("description", "") # Flag-based detection @@ -29,8 +29,7 @@ def enrich(self, item: Dict) -> None: # Keyword-based detection keywords = self.keyword_regex.findall(desc.lower()) - keyword_langs = {self.language_map.get(k, "") for k in keywords} + keyword_langs = {self.language_map.get(k, "") for k in keywords} - {""} - combined = flag_langs | keyword_langs - item["languages"] = list(combined - {""}) - item["fullLanguages"] = item["languages"].copy() + combined = set(item["languages"]) | flag_langs | keyword_langs + item["languages"] = list(combined) \ No newline at end of file diff --git a/lib/services/enrich/magnet_enricher.py b/lib/services/enrich/magnet_enricher.py new file mode 100644 index 00000000..d88f238f --- /dev/null +++ b/lib/services/enrich/magnet_enricher.py @@ -0,0 +1,26 @@ +from lib.domain.interface.enricher_interface import EnricherInterface +from typing import List +from lib.domain.source import Source + +class MagnetEnricher(EnricherInterface): + def __init__(self): + pass + + def initialize(self, items: List[Source]) -> None: + return + + def needs(self): + return ["info_hash"] + + def provides(self): + return ["magnet"] + + def enrich(self, item: Source) -> None: + if "magnet" in item: + return + + if "info_hash" not in item: + return + + infoHash = item.get("info_hash") + item["magnet"] = f"magnet:?xt=urn:btih:{infoHash}" \ No newline at end of file diff --git a/lib/services/enrich/quality_enricher.py b/lib/services/enrich/quality_enricher.py new file mode 100644 index 00000000..2ee91a77 --- /dev/null +++ b/lib/services/enrich/quality_enricher.py @@ -0,0 +1,26 @@ +from lib.domain.interface.enricher_interface import EnricherInterface +from lib.domain.quality_tier import QualityTier +from typing import List +from lib.domain.source import Source + +class QualityEnricher(EnricherInterface): + def __init__(self): + self.tiers = QualityTier.default_quality_tiers() + + def initialize(self, items: List[Source]) -> None: + return + + def needs(self): + return ["title"] + + def provides(self): + return ["quality", "quality_sort", "quality_formatted"] + + def enrich(self, item: Source) -> None: + title = item.get("title", "") + for tier in sorted(self.tiers, key=lambda t: -t.priority): + if tier.regex is None or tier.regex.search(title): + item["quality"] = tier.label + item["quality_formatted"] = tier.label_formatted + item["quality_sort"] = tier.priority + return diff --git a/lib/sources_tools/stats_enricher.py b/lib/services/enrich/stats_enricher.py similarity index 82% rename from lib/sources_tools/stats_enricher.py rename to lib/services/enrich/stats_enricher.py index 1b9609a3..04ddb025 100644 --- a/lib/sources_tools/stats_enricher.py +++ b/lib/services/enrich/stats_enricher.py @@ -1,16 +1,16 @@ -from .enricher import Enricher +from lib.domain.interface.enricher_interface import EnricherInterface from typing import Dict, Callable, List import re +from lib.domain.source import Source - -class StatsEnricher(Enricher): +class StatsEnricher(EnricherInterface): def __init__(self, size_converter: Callable): self.size_pattern = re.compile(r"💾 ([\d.]+ (?:GB|MB))") self.seeders_pattern = re.compile(r"👤 (\d+)") self.provider_pattern = re.compile(r"([🌐🔗⚙️])\s*([^🌐🔗⚙️]+)") self.convert_size = size_converter - def initialize(self, items: List[Dict]) -> None: + def initialize(self, items: List[Source]) -> None: return def needs(self): @@ -19,7 +19,7 @@ def needs(self): def provides(self): return ["size", "seeders", "provider"] - def enrich(self, item: Dict) -> None: + def enrich(self, item: Source) -> None: desc = item.get("description", "") if not desc: return diff --git a/lib/sources_tools/filter_builder.py b/lib/services/filters/__init__.py similarity index 51% rename from lib/sources_tools/filter_builder.py rename to lib/services/filters/__init__.py index a57f7ffc..66700a2f 100644 --- a/lib/sources_tools/filter_builder.py +++ b/lib/services/filters/__init__.py @@ -1,62 +1,64 @@ import re -from abc import ABC, abstractmethod -from typing import List, Dict, Any, Union +from typing import List, Any, Union +from lib.domain.source import Source +from lib.domain.interface.filter_interface import FilterInterface -class Filter(ABC): - @abstractmethod - def apply(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + +class FieldFilter(FilterInterface): + def __init__(self, field: str, value: Any): + self.field = field + self.value = value + + def matches(self, item: Source) -> bool: + return item.get(self.field) == self.value + + def reset(self): pass -class DedupeFilter(Filter): - def apply(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - seen = set() - filtered = [] - - for item in items: - info_hash = item.get("infoHash") - if info_hash not in seen: - if info_hash is not None: - seen.add(info_hash) - filtered.append(item) - - return filtered +class DedupeFilter(FilterInterface): + def __init__(self): + self.seen = set() + + def matches(self, item: Source) -> bool: + info_hash = item.get("info_hash") + if info_hash in self.seen: + return False + if info_hash is not None: + self.seen.add(info_hash) + return True + + def reset(self): + self.seen.clear() -class SourceFilter(Filter): - def apply(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - return [item for item in items if item.get("infoHash") or item.get("guid")] +class SourceFilter(FilterInterface): + def matches(self, item: Source) -> bool: + return bool(item.get("info_hash") or item.get("guid")) -class LanguageFilter(Filter): +class LanguageFilter(FilterInterface): def __init__(self, languages: List[str]): self.languages = languages - def apply(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + def matches(self, item: Source) -> bool: if not self.languages: - return items - return [ - item for item in items - if any(lang in item.get("languages", []) for lang in self.languages) - ] + return True + item_langs = item.get("languages", []) + return any(lang in item_langs for lang in self.languages) -class EpisodeFilter(Filter): +class EpisodeFilter(FilterInterface): def __init__(self, episode_name: str, episode_num: int, season_num: int): self.episode_name = episode_name self.episode_num = episode_num self.season_num = season_num + self.compiled_pattern = self._compile_pattern() - def apply(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - episode_num = self.episode_num - season_num = self.season_num - - if episode_num is None or season_num is None: - return items - - episode_fill = f"{episode_num:02d}" - season_fill = f"{season_num:02d}" + def _compile_pattern(self): + episode_fill = f"{self.episode_num:02d}" + season_fill = f"{self.season_num:02d}" patterns = [ rf"S{season_fill}E{episode_fill}", @@ -71,19 +73,36 @@ def apply(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: if self.episode_name: patterns.append(re.escape(self.episode_name)) - combined_pattern = re.compile("|".join(patterns), flags=re.IGNORECASE) - return [ - item for item in items - if combined_pattern.search(item.get("title", "")) - ] + return re.compile("|".join(patterns), flags=re.IGNORECASE) + def matches(self, item: Source) -> bool: + title = item.get("title", "") + return bool(self.compiled_pattern.search(title)) -class FilterBuilder: - def __init__(self): - self._filters: List[Filter] = [] + +class FilterBuilder(FilterInterface): + def __init__(self, operator: str = "AND"): + self._filters: List[FilterInterface] = [] + self._operator = operator.upper() self._sort_criteria: List[tuple] = [] self._limit: int = 0 + def matches(self, item: Source) -> bool: + if not self._filters: + return True + + results = [f.matches(item) for f in self._filters] + if self._operator == "AND": + return all(results) + elif self._operator == "OR": + return any(results) + else: + raise ValueError(f"Invalid operator: {self._operator}. Use 'AND' or 'OR'.") + + def reset(self): + for f in self._filters: + f.reset() + def sort_by(self, field: str, ascending: bool = True) -> "FilterBuilder": self._sort_criteria.append((field, ascending)) return self @@ -92,6 +111,14 @@ def limit(self, n: int) -> "FilterBuilder": self._limit = n return self + def filter_by_field(self, field: str, value: Any) -> "FilterBuilder": + self._filters.append(FieldFilter(field, value)) + return self + + def filter_by_quality(self, priority: int) -> "FilterBuilder": + self._filters.append(FieldFilter("quality_sort", priority)) + return self + def filter_by_language(self, language_code: str) -> "FilterBuilder": existing = next((f for f in self._filters if isinstance(f, LanguageFilter)), None) if existing: @@ -100,7 +127,7 @@ def filter_by_language(self, language_code: str) -> "FilterBuilder": self._filters.append(LanguageFilter([language_code])) return self - def deduple_by_infoHash(self) -> "FilterBuilder": + def dedupe_by_infoHash(self) -> "FilterBuilder": self.add_filter(DedupeFilter()) return self @@ -112,7 +139,6 @@ def filter_by_episode( ) -> "FilterBuilder": episode_num = int(episode_num) season_num = int(season_num) - # Remove any existing EpisodeFilter self._filters = [f for f in self._filters if not isinstance(f, EpisodeFilter)] self._filters.append(EpisodeFilter(episode_name, episode_num, season_num)) return self @@ -122,20 +148,18 @@ def filter_by_source(self) -> "FilterBuilder": self._filters.append(SourceFilter()) return self - def add_filter(self, filter: Filter) -> "FilterBuilder": + def add_filter(self, filter: FilterInterface) -> "FilterBuilder": self._filters.append(filter) return self - def build(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - filtered_items = items.copy() - for filter in self._filters: - filtered_items = filter.apply(filtered_items) - + def build(self, items: List[Source]) -> List[Source]: + self.reset() + filtered_items = [item for item in items if self.matches(item)] sorted_items = self._apply_sorting(filtered_items) limited_items = self._apply_limit(sorted_items) return limited_items - def _apply_sorting(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + def _apply_sorting(self, items: List[Source]) -> List[Source]: if not self._sort_criteria: return items @@ -156,5 +180,5 @@ def sort_key(item): except TypeError: return items - def _apply_limit(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + def _apply_limit(self, items: List[Source]) -> List[Source]: return items[: self._limit] if self._limit else items \ No newline at end of file diff --git a/lib/sources_tools/cache_enricher.py b/lib/sources_tools/cache_enricher.py deleted file mode 100644 index ae446bf7..00000000 --- a/lib/sources_tools/cache_enricher.py +++ /dev/null @@ -1,32 +0,0 @@ -from .enricher import Enricher -from typing import Dict, Callable, List -import re -from lib.clients.debrid.torbox import Torbox -from lib.api.jacktook.kodi import kodilog - -class CacheEnricher(Enricher): - def __init__(self): - pass - - def initialize(self, items: List[Dict]) -> None: - infoHashes: List[str] = list(set(filter(None, [item.get("infoHash") for item in items]))) - - torbox = Torbox("782153a0-dd26-4865-8f77-91f1dc9b78be") - - response = torbox.get_torrent_instant_availability(infoHashes) - response.get('data', []) - - self.cachedHashes = set(response.get('data', [])) - kodilog(f"CacheEnricher: Cached hashes: {self.cachedHashes}") - - def needs(self): - return ["infoHash"] - - def provides(self): - return ["isCached", "status"] - - def enrich(self, item: Dict) -> None: - if item.get("infoHash") in self.cachedHashes: - item["isCached"] = True - item["status"] = "Cached in Torbox" - item["cachedIn"] = list(set(item.get("cachedIn", []) + ["Torbox"])) \ No newline at end of file diff --git a/lib/sources_tools/enricher_builder.py b/lib/sources_tools/enricher_builder.py deleted file mode 100644 index afc2018d..00000000 --- a/lib/sources_tools/enricher_builder.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import Dict, List -from .enricher import Enricher - - -class EnricherBuilder: - def __init__(self): - self._enrichers: List[Enricher] = [] - - def add(self, enricher: Enricher) -> "EnricherBuilder": - self._enrichers.append(enricher) - return self - - def build(self, items: List[Dict]) -> List[Dict]: - processed = [] - for enricher in self._enrichers: - enricher.initialize(items) - - for item in [item.copy() for item in items]: - for enricher in self._enrichers: - enricher.enrich(item) - processed.append(item) - return processed diff --git a/lib/sources_tools/quality_enricher.py b/lib/sources_tools/quality_enricher.py deleted file mode 100644 index 1c4d8eb6..00000000 --- a/lib/sources_tools/quality_enricher.py +++ /dev/null @@ -1,47 +0,0 @@ -from .enricher import Enricher -import re -from typing import Dict, List - - -class QualityEnricher(Enricher): - class ResolutionTier: - def __init__(self, pattern: str, label: str, priority: int): - self.regex = re.compile(pattern, re.IGNORECASE) - self.label = label - self.priority = priority - - def __init__(self): - self.tiers = [ - self.ResolutionTier( - r"(?i)\b(2160p?|4k)\b", "[B][COLOR yellow]4k[/COLOR][/B]", 4 - ), - self.ResolutionTier( - r"(?i)\b(1080p?)\b", "[B][COLOR blue]1080p[/COLOR][/B]", 3 - ), - self.ResolutionTier( - r"(?i)\b720p?\b", "[B][COLOR orange]720p[/COLOR][/B]", 2 - ), - self.ResolutionTier( - r"(?i)\b480p?\b", "[B][COLOR orange]480p[/COLOR][/B]", 1 - ), - ] - - def initialize(self, items: List[Dict]) -> None: - return - - def needs(self): - return ["title"] - - def provides(self): - return ["quality", "quality_sort"] - - def enrich(self, item: Dict) -> None: - title = item.get("title", "") - for tier in sorted(self.tiers, key=lambda t: -t.priority): - if tier.regex.search(title): - item["quality"] = tier.label - item["quality_sort"] = tier.priority - return - - item["quality"] = "[B][COLOR yellow]N/A[/COLOR][/B]" - item["quality_sort"] = 0 diff --git a/lib/utils/debrid_utils.py b/lib/utils/debrid_utils.py index a102dfb8..3f9a1685 100644 --- a/lib/utils/debrid_utils.py +++ b/lib/utils/debrid_utils.py @@ -133,14 +133,15 @@ def get_rd_status_pack(res): def get_pack_info(type, info_hash): if type == Debrids.PM: - info = get_pm_pack_info(info_hash) + return get_pm_pack_info(info_hash) elif type == Debrids.TB: - info = get_torbox_pack_info(info_hash) + return get_torbox_pack_info(info_hash) elif type == Debrids.RD: - info = get_rd_pack_info(info_hash) + return get_rd_pack_info(info_hash) elif type == Debrids.ED: - info = get_ed_pack_info(info_hash) - return info + return get_ed_pack_info(info_hash) + else: + return None def filter_results(results, direct_results): @@ -150,7 +151,7 @@ def filter_results(results, direct_results): info_hash = extract_info_hash(res) if info_hash: - res["infoHash"] = info_hash + res["info_hash"] = info_hash filtered_results.append(res) elif ( res["indexer"] == Indexer.TELEGRAM @@ -163,8 +164,8 @@ def filter_results(results, direct_results): def extract_info_hash(res): """Extracts and returns the info hash from a result if available.""" - if res.get("infoHash"): - return res["infoHash"].lower() + if res.get("info_hash"): + return res["info_hash"].lower() if (guid := res.get("guid", "")) and ( guid.startswith("magnet:?") or len(guid) == 40 diff --git a/lib/utils/ed_utils.py b/lib/utils/ed_utils.py index c86257ce..4e583f68 100644 --- a/lib/utils/ed_utils.py +++ b/lib/utils/ed_utils.py @@ -20,9 +20,9 @@ def check_ed_cached( results, cached_results, uncached_results, total, dialog, lock ): - filtered_results = [res for res in results if "infoHash" in res] + filtered_results = [res for res in results if "info_hash" in res] if filtered_results: - magnets = [info_hash_to_magnet(res["infoHash"]) for res in filtered_results] + magnets = [info_hash_to_magnet(res["info_hash"]) for res in filtered_results] torrents_info = client.get_torrent_instant_availability(magnets) cached_response = torrents_info.get("cached", []) diff --git a/lib/utils/kodi_utils.py b/lib/utils/kodi_utils.py index 04ddac0b..dd216088 100644 --- a/lib/utils/kodi_utils.py +++ b/lib/utils/kodi_utils.py @@ -310,16 +310,6 @@ def update_kodi_addons_db(addon_name=ADDON_NAME): pass -def bytes_to_human_readable(size, unit="B"): - units = {"B": 0, "KB": 1, "MB": 2, "GB": 3, "TB": 4, "PB": 5} - - while size >= 1024 and unit != "PB": - size /= 1024 - unit = list(units.keys())[list(units.values()).index(units[unit] + 1)] - - return f"{size:.3g} {unit}" - - def convert_size_to_bytes(size_str: str) -> int: """Convert size string to bytes.""" match = re.match(r"(\d+(?:\.\d+)?)\s*(GB|MB)", size_str, re.IGNORECASE) diff --git a/lib/utils/language_detection.py b/lib/utils/language_detection.py index e1e9e961..442b0ea1 100644 --- a/lib/utils/language_detection.py +++ b/lib/utils/language_detection.py @@ -264,6 +264,7 @@ "cas", "castellano", "castilian", + "[cap." } language_codes = { "bosnian": "bs", @@ -530,6 +531,7 @@ "cas": "es", "castellano": "es", "castilian": "es", + "[cap.": "es" } diff --git a/lib/utils/pm_utils.py b/lib/utils/pm_utils.py index 86ed3592..3abd6623 100644 --- a/lib/utils/pm_utils.py +++ b/lib/utils/pm_utils.py @@ -18,7 +18,7 @@ def check_pm_cached(results, cached_results, uncached_results, total, dialog, lock): - hashes = [res.get("infoHash") for res in results] + hashes = [res.get("info_hash") for res in results] torrents_info = pm_client.get_torrent_instant_availability(hashes) cached_response = torrents_info.get("response") diff --git a/lib/utils/rd_utils.py b/lib/utils/rd_utils.py index 8dd9bbaf..b3be213c 100644 --- a/lib/utils/rd_utils.py +++ b/lib/utils/rd_utils.py @@ -28,7 +28,7 @@ def check_rd_cached(results, cached_results, uncached_results, total, dialog, lo debrid_dialog_update("RD", total, dialog, lock) res["type"] = Debrids.RD - if res.get("infoHash") in torr_available_hashes: + if res.get("info_hash") in torr_available_hashes: res["isCached"] = True cached_results.append(res) else: diff --git a/lib/utils/torbox_utils.py b/lib/utils/torbox_utils.py index 24ae9c7e..ba7a6a29 100644 --- a/lib/utils/torbox_utils.py +++ b/lib/utils/torbox_utils.py @@ -21,7 +21,7 @@ def check_torbox_cached( results, cached_results, uncached_results, total, dialog, lock ): - hashes = [res.get("infoHash") for res in results] + hashes = [res.get("info_hash") for res in results] response = client.get_torrent_instant_availability(hashes) cached_response = response.get("data", []) @@ -29,7 +29,7 @@ def check_torbox_cached( debrid_dialog_update("TB", total, dialog, lock) res["type"] = Debrids.TB - if res.get("infoHash") in cached_response: + if res.get("info_hash") in cached_response: with lock: res["isCached"] = True cached_results.append(res) diff --git a/lib/utils/utils.py b/lib/utils/utils.py index 2cbbbdeb..4237d8a1 100644 --- a/lib/utils/utils.py +++ b/lib/utils/utils.py @@ -14,17 +14,6 @@ from lib.api.tvdbapi.tvdbapi import TVDBAPI from lib.db.cached import cache from lib.db.main_db import main_db -from lib.sources_tools import FilterBuilder -from lib.sources_tools import ( - EnricherBuilder, - StatsEnricher, - QualityEnricher, - LanguageEnricher, - IsPackEnricher, - CacheEnricher -) -from lib.utils.kodi_utils import convert_size_to_bytes -from lib.utils.language_detection import language_codes, langsSet from lib.torf._magnet import Magnet from lib.utils.kodi_utils import ( ADDON_HANDLE, @@ -205,9 +194,16 @@ class Cartoons(Enum): ] +class FakeDialog(): + def create(self, message: str): + pass + def update(self, percent: int, title: str, message: str): + pass class DialogListener: def __init__(self): - self._dialog = DialogProgressBG() + # self._dialog = DialogProgressBG() + self._dialog = FakeDialog() + pass @property def dialog(self): @@ -567,18 +563,6 @@ def get_random_color(provider_name): return "FF" + "".join(colors).upper() -def get_colored_languages(languages): - if not languages: - return "" - colored_languages = [] - for lang in languages: - lang_color = get_random_color(lang) - colored_lang = f"[B][COLOR {lang_color}][{lang}][/COLOR][/B]" - colored_languages.append(colored_lang) - colored_languages = " ".join(colored_languages) - return colored_languages - - def execute_thread_pool(results, func, *args, **kwargs): with ThreadPoolExecutor(max_workers=10) as executor: [executor.submit(func, res, *args, **kwargs) for res in results] @@ -637,49 +621,6 @@ def unzip(zip_location, destination_location, destination_check): return status -def pre_process(results, mode, ep_name, episode, season): - results = ( - EnricherBuilder() - .add(StatsEnricher(size_converter=convert_size_to_bytes)) - .add(QualityEnricher()) - .add(LanguageEnricher(language_codes, langsSet)) - .add(CacheEnricher()) - .build(results) - ) - - filters = FilterBuilder() - - if get_setting("stremio_enabled") and get_setting("torrent_enable"): - filters.filter_by_source() - - if mode == "tv" and get_setting("filter_by_episode"): - filters.filter_by_episode(ep_name, episode, season) - - return filters.build(results) - - -def post_process(results, season=0): - sort_by = get_setting("indexers_sort_by") - limit = int(get_setting("indexers_total_results")) - - results = EnricherBuilder().add(IsPackEnricher(season)).build(results) - - filters = FilterBuilder().limit(limit).deduple_by_infoHash() - - if sort_by == "Seeds": - filters.sort_by("seeders", ascending=False) - elif sort_by == "Size": - filters.sort_by("size", ascending=False) - elif sort_by == "Date": - filters.sort_by("publishDate", ascending=False) - elif sort_by == "Quality": - filters.sort_by("quality_sort", ascending=False) - filters.sort_by("seeders", ascending=False) - elif sort_by == "Cached": - filters.sort_by("isCached", ascending=False) - - return filters.build(results) - def clean_auto_play_undesired(results): undesired = ("SD", "CAM", "TELE", "SYNC", "480p") @@ -692,16 +633,6 @@ def clean_auto_play_undesired(results): return results[0] -def is_torrent_url(uri): - res = requests.head(uri, timeout=20, headers=USER_AGENT_HEADER) - if ( - res.status_code == 200 - and res.headers.get("Content-Type") == "application/octet-stream" - ): - return True - else: - return False - def supported_video_extensions(): media_types = getSupportedMedia("video") diff --git a/resources/language/English/strings.po b/resources/language/English/strings.po index 5d155085..f09acefe 100644 --- a/resources/language/English/strings.po +++ b/resources/language/English/strings.po @@ -1253,4 +1253,36 @@ msgstr "Habilitar o deshabilitar los complementos de Stremio." msgctxt "#30873" msgid "Prioritize results in the selected language" +msgstr "" + +msgctxt "#30874" +msgid "Transmission password (if required)." +msgstr "" + +msgctxt "#30875" +msgid "Transmission username (if required)." +msgstr "" + +msgctxt "#30876" +msgid "Password" +msgstr "" + +msgctxt "#30877" +msgid "Transmission Configuration" +msgstr "" + +msgctxt "#30878" +msgid "Enable Transmission torrent client integration." +msgstr "" + +msgctxt "#30879" +msgid "Path to the folder where Transmission downloads are saved." +msgstr "" + +msgctxt "#30880" +msgid "Transmission server URL (e.g., http://localhost:9091)." +msgstr "" + +msgctxt "#30881" +msgid "Download Folder" msgstr "" \ No newline at end of file diff --git a/resources/settings.xml b/resources/settings.xml index edce419a..364e5271 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -643,12 +643,70 @@ + + + false + + + + + + true + + + + + true + + + + + + + true + + + + + true + + + + + + + true + + + + + true + + + + + + + false + true + + + Transmission Download Folder + + + + true + + + + true + diff --git a/resources/skins/Default/1080i/source_select_new.xml b/resources/skins/Default/1080i/source_select_new.xml index ccf9bfd5..37b4f630 100644 --- a/resources/skins/Default/1080i/source_select_new.xml +++ b/resources/skins/Default/1080i/source_select_new.xml @@ -7,7 +7,6 @@ - 0 @@ -18,40 +17,30 @@ white.png - 8000000 - Conditional - !String.IsEqual(Window().Property(instant_close),true) + C0000000 + + $INFO[Window().Property(info.fanart)] - FFFFFFFF - Conditional - !String.IsEqual(Window().Property(instant_close),true) - + FF404040 + scale + - - - white.png - CC000000 - Conditional - !String.IsEqual(Window().Property(instant_close),true) - + - - - - - WindowClose - Conditional - - - - + Conditional + Conditional + 0 70 200 @@ -59,7 +48,6 @@ keep jtk_clearlogo.png CCFFFFFF - !String.IsEmpty(Window().Property(info.clearlogo)) + !String.IsEqual(Window().Property(instant_close),true) @@ -67,53 +55,51 @@ font20 - 28 + 50 560 1620 50 + - font12 - 65 + font20 + 110 560 1620 50 + - WindowOpen - WindowClose - Conditional - Conditional - 1820 - 120 + Conditional + Conditional + 1890 + 190 20 - 890 + 800 false white.png white.png white.png - String.IsEqual(Window().Property(instant_close),false) - WindowOpen - WindowClose - Conditional - Conditional + Conditional + Conditional list 1111 560 - 110 + 180 1340 - 900 + 800 20 vertical - !String.IsEqual(Window().Property(instant_close),true) @@ -140,8 +126,7 @@ 20 center 200 - FFFFFFFF - + @@ -150,8 +135,7 @@ 20 center 200 - FFFFFFFF - + @@ -162,9 +146,8 @@ center 200 font12 - 66FFFFFF left - + @@ -174,9 +157,8 @@ 1000 20 font12 - FFFFFFFF left - + @@ -185,9 +167,8 @@ 1000 20 font12 - FFFFFFFF left - + @@ -196,9 +177,8 @@ 1000 20 font12 - CCFFFFFF left - + @@ -227,8 +207,7 @@ 20 center 200 - FFFFFFFF - + @@ -237,8 +216,7 @@ 20 center 200 - FFFFFFFF - + @@ -249,9 +227,8 @@ center 200 font12 - 66FFFFFF left - + @@ -261,9 +238,9 @@ 1000 20 font12 - FFFFFFFF left - + 66FFFFFF + @@ -272,20 +249,18 @@ 1000 20 font12 - FFFFFFFF left - + - + 110 220 1000 20 font12 - CCFFFFFF left - + @@ -293,8 +268,6 @@ - WindowOpen - WindowClose Conditional Conditional Conditional @@ -303,7 +276,7 @@ 100 20 500 - !String.IsEqual(Window().Property(instant_close),true) + @@ -316,6 +289,7 @@ top $INFO[Window.Property(info.poster)] !String.IsEmpty(Window.Property(info.poster)) + keep From 76581fca0fb892f37bce37d166cc2e8668dd9d56 Mon Sep 17 00:00:00 2001 From: Gabriel Ortega Date: Thu, 6 Feb 2025 17:38:03 +0100 Subject: [PATCH 08/10] few fixes --- lib/clients/search.py | 20 +++--- lib/gui/custom_dialogs.py | 15 ----- lib/gui/source_select.py | 126 ----------------------------------- lib/gui/source_select_new.py | 9 ++- lib/navigation.py | 6 +- lib/player.py | 7 +- lib/router.py | 4 +- lib/search.py | 29 ++++---- lib/utils/utils.py | 11 ++- 9 files changed, 50 insertions(+), 177 deletions(-) delete mode 100644 lib/gui/source_select.py diff --git a/lib/clients/search.py b/lib/clients/search.py index 1a6aa772..cb1f806d 100644 --- a/lib/clients/search.py +++ b/lib/clients/search.py @@ -8,7 +8,7 @@ def search_client( - query, ids, mode, media_type, dialog, rescrape=False, season=1, episode=1 + item, dialog, rescrape=False ): def perform_search(indexer_key, dialog, *args, **kwargs): if indexer_key != Indexer.BURST: @@ -19,20 +19,22 @@ def perform_search(indexer_key, dialog, *args, **kwargs): return client.search(*args, **kwargs) if not rescrape: - if mode == "tv" or media_type == "tv" or mode == "anime": - cached_results = get_cached(query, params=(episode, "index")) + if item["mode"] == "tv" or item["media_type"] == "tv" or item["mode"] == "anime": + cached_results = get_cached(item["query"], params=(episode, "index")) else: - cached_results = get_cached(query, params=("index")) + cached_results = get_cached(item["query"], params=("index")) if cached_results: dialog.create("") return cached_results - if ids: - tmdb_id, _, imdb_id = ids.values() - else: - tmdb_id = imdb_id = None - + tmdb_id = item["tmdb_id"] + imdb_id = item["imdb_id"] + mode = item["mode"] + media_type = item["media_type"] + query = item["query"] + season = item["season"] + episode = item["episode"] dialog.create("") total_results = [] diff --git a/lib/gui/custom_dialogs.py b/lib/gui/custom_dialogs.py index 960a43ca..f6a8a229 100644 --- a/lib/gui/custom_dialogs.py +++ b/lib/gui/custom_dialogs.py @@ -6,7 +6,6 @@ from lib.gui.resolver_window import ResolverWindow from lib.gui.resume_window import ResumeDialog from lib.utils.kodi_utils import ADDON_PATH, PLAYLIST -from lib.gui.source_select import SourceSelect class CustomWindow(WindowXML): @@ -119,20 +118,6 @@ def run_next_mock(): del window -def source_select_mock(): - sources = [mock_source for _ in range(10)] - - window = SourceSelect( - "source_select.xml", - ADDON_PATH, - item_information=_mock_information, - sources=sources, - uncached=sources, - ) - window.doModal() - del window - - def resume_dialog_mock(): try: window = ResumeDialog( diff --git a/lib/gui/source_select.py b/lib/gui/source_select.py deleted file mode 100644 index 73531138..00000000 --- a/lib/gui/source_select.py +++ /dev/null @@ -1,126 +0,0 @@ -import xbmcgui -from lib.gui.base_window import BaseWindow -from lib.gui.resolver_window import ResolverWindow -from lib.gui.resume_window import ResumeDialog -from lib.utils.kodi_utils import ADDON_PATH -from lib.utils.debrid_utils import get_debrid_status -from lib.utils.kodi_utils import bytes_to_human_readable -from lib.utils.utils import ( - extract_publish_date, - get_colored_languages, - get_random_color, -) - - -class SourceSelect(BaseWindow): - def __init__( - self, xml_file, location, item_information=None, sources=None, uncached=None - ): - super().__init__(xml_file, location, item_information=item_information) - self.uncached_sources = uncached or [] - self.position = -1 - self.sources = sources - self.item_information = item_information - self.playback_info = None - self.resume = None - self.CACHE_KEY = ( - self.item_information["tv_data"] or str(self.item_information["ids"]) - ) - self.setProperty("instant_close", "false") - self.setProperty("resolving", "false") - - def onInit(self): - self.display_list = self.getControlList(1000) - self.populate_sources_list() - self.set_default_focus(self.display_list, 1000, control_list_reset=True) - super().onInit() - - def doModal(self): - super().doModal() - return self.playback_info - - def populate_sources_list(self): - self.display_list.reset() - - for source in self.sources: - menu_item = xbmcgui.ListItem(label=f"{source['title']}") - - for info in source: - value = source[info] - if info == "peers": - value = value if value else "" - if info == "publishDate": - value = extract_publish_date(value) - if info == "size": - value = bytes_to_human_readable(int(value)) if value else "" - if info in ["indexer", "provider", "type"]: - color = get_random_color(value) - value = f"[B][COLOR {color}]{value}[/COLOR][/B]" - if info == "fullLanguages": - value = get_colored_languages(value) - if len(value) <= 0: - value = "" - if info == "isCached": - info = "status" - value = get_debrid_status(source) - - menu_item.setProperty(info, str(value)) - - self.display_list.addItem(menu_item) - - def handle_action(self, action_id, control_id=None): - self.position = self.display_list.getSelectedPosition() - - if action_id == 117: - selected_source = self.sources[self.position] - type = selected_source["type"] - if type == "Torrent": - response = xbmcgui.Dialog().contextmenu(["Download to Debrid"]) - if response == 0: - self._download_into() - elif type == "Direct": - pass - else: - response = xbmcgui.Dialog().contextmenu(["Browse into"]) - if response == 0: - self._resolve_item(pack_select=True) - - if action_id == 7: - if control_id == 1000: - control_list = self.getControl(control_id) - self.set_cached_focus(control_id, control_list.getSelectedPosition()) - self._resolve_item(pack_select=False) - - def _download_into(self): - pass - - def _resolve_item(self, pack_select=False): - self.setProperty("resolving", "true") - - selected_source = self.sources[self.position] - - resolver_window = ResolverWindow( - "resolver.xml", - ADDON_PATH, - source=selected_source, - previous_window=self, - item_information=self.item_information, - ) - resolver_window.doModal(pack_select) - self.playback_info = resolver_window.playback_info - - del resolver_window - self.setProperty("instant_close", "true") - self.close() - - def show_resume_dialog(self, playback_percent): - try: - resume_window = ResumeDialog( - "resume_dialog.xml", - ADDON_PATH, - resume_percent=playback_percent, - ) - resume_window.doModal() - return resume_window.resume - finally: - del resume_window diff --git a/lib/gui/source_select_new.py b/lib/gui/source_select_new.py index e6a2f9b5..6b18adc7 100644 --- a/lib/gui/source_select_new.py +++ b/lib/gui/source_select_new.py @@ -92,18 +92,22 @@ def onInit(self) -> None: source["correlative_id"] = i self._sources = sources + self.section_manager = self._create_sections() + self._refresh_ui() + + self.set_default_focus(self._source_list) + def _refresh_ui(self) -> None: """Update all UI components.""" - self.section_manager = self._create_sections() + kodilog(f"Current Section: {self.section_manager.current_section.title}") self._navigation_label.setLabel(self._build_navigation_path()) self._description_label.setLabel( self.section_manager.current_section.description ) self._source_list.reset() self._source_list.addItems(self.section_manager.current_section.sources) - self.set_default_focus(self._source_list) self.setProperty("instant_close", "false") def _build_navigation_path(self) -> str: @@ -125,6 +129,7 @@ def _build_navigation_path(self) -> str: def handle_action(self, action_id: int, control_id: Optional[int] = None) -> None: """Route user actions to appropriate handlers.""" + kodilog(f"Action ID: {action_id}, Control ID: {control_id}") if control_id is None: return elif control_id == self.SOURCE_ITEM_ID: diff --git a/lib/navigation.py b/lib/navigation.py index 8fd00d53..e8ea9e50 100644 --- a/lib/navigation.py +++ b/lib/navigation.py @@ -15,7 +15,7 @@ CustomDialog, resume_dialog_mock, run_next_mock, - source_select_mock, + #source_select_mock, ) from lib.player import JacktookPLayer @@ -846,8 +846,8 @@ def test_run_next(params): run_next_mock() -def test_source_select(params): - source_select_mock() +#def test_source_select(params): + #source_select_mock() def test_resume_dialog(params): diff --git a/lib/player.py b/lib/player.py index 4e5e2e1c..f7426829 100644 --- a/lib/player.py +++ b/lib/player.py @@ -61,6 +61,7 @@ def run(self, data=None): self.play_video(list_item) except Exception as e: kodilog(f"Error in run: {e}") + self.run_error() def play_playlist(self): @@ -264,9 +265,11 @@ def clear_playback_properties(self): def add_external_trakt_scrolling(self): ids = self.data.get("ids") mode = self.data.get("mode") - + tmdb_id = self.data.get("tmdb_id") + tvdb_id = self.data.get("tvdb_id") + imdb_id = self.data.get("imdb_id") + if ids: - tmdb_id, tvdb_id, imdb_id = ids.values() trakt_ids = { "tmdb": tmdb_id, "imdb": imdb_id, diff --git a/lib/router.py b/lib/router.py index ab28f887..641e0e88 100644 --- a/lib/router.py +++ b/lib/router.py @@ -30,7 +30,7 @@ telegram_menu, test_resume_dialog, test_run_next, - test_source_select, + #test_source_select, torrentio_selection, play_torrent, rd_auth, @@ -119,7 +119,7 @@ def addon_router(): "telegram_menu": telegram_menu, "display_picture": display_picture, "display_text": display_text, - "test_source_select": test_source_select, + #"test_source_select": test_source_select, "test_run_next": test_run_next, "test_resume_dialog": test_resume_dialog, "animation_menu": animation_menu, diff --git a/lib/search.py b/lib/search.py index 5f3654f6..7ba3b4c4 100644 --- a/lib/search.py +++ b/lib/search.py @@ -50,7 +50,7 @@ def search(params): episode, season, ep_name = parse_tv_data(tv_data) # Extract TMDb and TVDb IDs - tmdb_id, tvdb_id = extract_ids(ids) + tmdb_id, tvdb_id, imdb_id = extract_ids(ids) # Fetch media details from TMDb details = get_tmdb_media_details(tmdb_id, mode) @@ -67,13 +67,20 @@ def search(params): "ep_name": ep_name, "tvdb_id": tvdb_id, "tmdb_id": tmdb_id, + "imdb_id": imdb_id, "tv_data": tv_data, - "ids": ids, + "ids": { + "tmdb_id": tmdb_id, + "tvdb_id": tvdb_id, + "imdb_id": imdb_id, + }, "mode": mode, "poster": poster, "fanart": fanart_data["fanart"] or poster, "clearlogo": fanart_data["clearlogo"], "plot": overview, + "query": query, + "media_type": media_type, } # Set content type and watched title @@ -82,7 +89,7 @@ def search(params): # Search for sources source = select_source( - item_info, query, ids, mode, media_type, rescrape, season, episode, direct + item_info, rescrape, direct ) if not source: return @@ -116,12 +123,12 @@ def extract_ids(ids): """ Extracts TMDb and TVDb IDs from the input string. """ - tmdb_id, tvdb_id, _ = [id.strip() for id in ids.split(",")] - return tmdb_id, tvdb_id + tmdb_id, tvdb_id, imdb_id = [id.strip() for id in ids.split(",")] + return tmdb_id, tvdb_id, imdb_id def select_source( - item_info, query, ids, mode, media_type, rescrape, season, episode, direct + info_item, rescrape, direct ): """ Searches for and selects a source. @@ -129,17 +136,17 @@ def select_source( def get_sources(): results = search_client( - query, ids, mode, media_type, FakeDialog(), rescrape, season, episode + info_item, FakeDialog(), rescrape, ) if not results: notification("No results found") return None - return process(results, mode, item_info["ep_name"], episode, season) + return process(results, info_item["mode"], info_item["ep_name"], info_item["episode"], info_item["season"]) source_select_window = SourceSelectWindow( "source_select_new.xml", ADDON_PATH, - item_information=item_info, + item_information=info_item, get_sources=get_sources, ) source = source_select_window.doModal() @@ -225,15 +232,13 @@ def handle_results(source, info_item): return get_playback_info( { + **info_item, "title": source["title"], "type": IndexerType.TORRENT, "indexer": source["indexer"], "info_hash": source.get("info_hash", ""), "magnet": source.get("magnet", ""), "is_pack": False, - "mode": info_item["mode"], - "ids": info_item["ids"], - "tv_data": info_item["tv_data"], "is_torrent": True, "url": source["magnet"], } diff --git a/lib/utils/utils.py b/lib/utils/utils.py index e3ad7f72..fa0f54a0 100644 --- a/lib/utils/utils.py +++ b/lib/utils/utils.py @@ -321,18 +321,16 @@ def set_video_info( def make_listing(metadata): title = metadata.get("title") - ids = metadata.get("ids") tv_data = metadata.get("tv_data", {}) mode = metadata.get("mode", "") - + ep_name = metadata.get("ep_name", "") + episode = metadata.get("episode", "") + season = metadata.get("season", "") list_item = ListItem(label=title) list_item.setLabel(title) list_item.setContentLookup(False) - if tv_data: - ep_name, episode, season = tv_data.split("(^)") - else: - ep_name = episode = season = "" + ids={"tmdb_id":metadata["tmdb_id"], "tvdb_id":metadata["tvdb_id"], "imdb_id":metadata["imdb_id"]} set_media_infotag( list_item, @@ -362,6 +360,7 @@ def set_media_infotag( url="", original_name="", ): + RuntimeError("CHECK THIS FUNCTION") info_tag = list_item.getVideoInfoTag() info_tag.setPath(url) info_tag.setTitle(name) From 045e8ee687fc173480aeb094aac53e0888b11c32 Mon Sep 17 00:00:00 2001 From: Gabriel Ortega Date: Thu, 6 Feb 2025 20:17:28 +0100 Subject: [PATCH 09/10] trying to simplify ids --- lib/search.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/lib/search.py b/lib/search.py index 7ba3b4c4..2c28766a 100644 --- a/lib/search.py +++ b/lib/search.py @@ -33,11 +33,13 @@ ) from lib.gui.source_select_new import SourceSelectWindow - +from lib.api.jacktook.kodi import kodilog +import json def search(params): """ Handles media search and playback. """ + kodilog("Search params: %s" % params) query = params["query"] mode = params["mode"] media_type = params.get("media_type", "") @@ -50,7 +52,8 @@ def search(params): episode, season, ep_name = parse_tv_data(tv_data) # Extract TMDb and TVDb IDs - tmdb_id, tvdb_id, imdb_id = extract_ids(ids) + ids = json.loads(ids.replace("'", '"').replace("None", "null")) + tmdb_id, tvdb_id, imdb_id = (ids["tmdb_id"], ids["tvdb_id"], ids["imdb_id"]) # Fetch media details from TMDb details = get_tmdb_media_details(tmdb_id, mode) @@ -119,14 +122,6 @@ def parse_tv_data(tv_data): return int(episode), int(season), ep_name -def extract_ids(ids): - """ - Extracts TMDb and TVDb IDs from the input string. - """ - tmdb_id, tvdb_id, imdb_id = [id.strip() for id in ids.split(",")] - return tmdb_id, tvdb_id, imdb_id - - def select_source( info_item, rescrape, direct ): From a0e135dbbb1287c83916c235a17fd458c9e4b627 Mon Sep 17 00:00:00 2001 From: Gabriel Ortega Date: Thu, 6 Feb 2025 20:18:12 +0100 Subject: [PATCH 10/10] fix translation --- resources/settings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/settings.xml b/resources/settings.xml index de18a1b3..957efd7b 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -685,7 +685,7 @@ - + true