From dbebf68f1806bb72859366673316e2391fd44fc8 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Mon, 9 Feb 2026 02:20:26 +0900 Subject: [PATCH 1/2] Add select-titles --- unshackle/commands/dl.py | 86 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 2bf08c8..612e608 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -65,6 +65,7 @@ from unshackle.core.utils import tags from unshackle.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, ContextData, MultipleChoice, SubtitleCodecChoice, VideoCodecChoice) from unshackle.core.utils.collections import merge_dict +from unshackle.core.utils.selector import select_multiple from unshackle.core.utils.subprocess import ffprobe from unshackle.core.vaults import Vaults @@ -346,6 +347,12 @@ class dl: default=None, help="Create separate output files per audio codec instead of merging all audio.", ) + @click.option( + "--select-titles", + is_flag=True, + default=False, + help="Interactively select downloads from a list. Only use with Series to select Episodes", + ) @click.option( "-w", "--wanted", @@ -859,6 +866,7 @@ class dl: range_: list[Video.Range], channels: float, no_atmos: bool, + select_titles: bool, wanted: list[str], latest_episode: bool, lang: list[str], @@ -1047,6 +1055,84 @@ class dl: if list_titles: return + # Enables manual selection for Series when --select-titles is set + if select_titles and type(titles) == Series: + console.print(Padding(Rule(f"[rule.text]Select Titles"), (1, 2))) + + selection_titles = [] + dependencies = {} + original_indices = {} + + current_season = None + current_season_header_idx = -1 + + unique_seasons = {t.season for t in titles} + multiple_seasons = len(unique_seasons) > 1 + + # Build selection options + for i, t in enumerate(titles): + # Insert season header only if multiple seasons exist + if multiple_seasons and t.season != current_season: + current_season = t.season + header_text = f"Season {t.season}" + selection_titles.append(header_text) + current_season_header_idx = len(selection_titles) - 1 + dependencies[current_season_header_idx] = [] + # Note: Headers are not mapped to actual title indices + + # Format display name + display_name = ((t.name[:35].rstrip() + "…") if len(t.name) > 35 else t.name) if t.name else None + + # Apply indentation only for multiple seasons + prefix = " " if multiple_seasons else "" + option_text = ( + f"{prefix}{t.number}" + (f". {display_name}" if t.name else "") + ) + + selection_titles.append(option_text) + current_ui_idx = len(selection_titles) - 1 + + # Map UI index to actual title index + original_indices[current_ui_idx] = i + + # Link episode to season header for group selection + if current_season_header_idx != -1: + dependencies[current_season_header_idx].append(current_ui_idx) + + selection_start = time.time() + + # Execute selector with dependencies (headers select all children) + selected_ui_idx = select_multiple( + selection_titles, + minimal_count=1, + page_size=8, + return_indices=True, + dependencies=dependencies + ) + + selection_end = time.time() + start_time += (selection_end - selection_start) + + # Map UI indices back to title indices (excluding headers) + selected_idx = [] + for idx in selected_ui_idx: + if idx in original_indices: + selected_idx.append(original_indices[idx]) + + # Ensure indices are unique and ordered + selected_idx = sorted(set(selected_idx)) + keep = set(selected_idx) + + # In-place filter: remove unselected items (iterate backwards) + for i in range(len(titles) - 1, -1, -1): + if i not in keep: + del titles[i] + + # Show selected count + if titles: + count = len(titles) + console.print(Padding(f"[text]Total selected: {count}[/]", (0, 5))) + # Determine the latest episode if --latest-episode is set latest_episode_id = None if latest_episode and isinstance(titles, Series) and len(titles) > 0: From dd19f405a453b2350df99c82056be6743a6381e5 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Mon, 9 Feb 2026 02:21:04 +0900 Subject: [PATCH 2/2] Add selector --- unshackle/core/utils/selector.py | 269 +++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 unshackle/core/utils/selector.py diff --git a/unshackle/core/utils/selector.py b/unshackle/core/utils/selector.py new file mode 100644 index 0000000..07d0fab --- /dev/null +++ b/unshackle/core/utils/selector.py @@ -0,0 +1,269 @@ +import click +import sys +from rich.console import Group +from rich.live import Live +from rich.padding import Padding +from rich.table import Table +from rich.text import Text +from unshackle.core.console import console + +IS_WINDOWS = sys.platform == "win32" +if IS_WINDOWS: + import msvcrt + +class Selector: + """ + A custom interactive selector class using the Rich library. + Allows for multi-selection of items with pagination. + """ + def __init__( + self, + options: list[str], + cursor_style: str = "pink", + text_style: str = "text", + page_size: int = 8, + minimal_count: int = 0, + dependencies: dict[int, list[int]] = None, + prefixes: list[str] = None + ): + """ + Initialize the Selector. + + Args: + options: List of strings to select from. + cursor_style: Rich style for the highlighted cursor item. + text_style: Rich style for normal items. + page_size: Number of items to show per page. + minimal_count: Minimum number of items that must be selected. + dependencies: Dictionary mapping parent index to list of child indices. + """ + self.options = options + self.cursor_style = cursor_style + self.text_style = text_style + self.page_size = page_size + self.minimal_count = minimal_count + self.dependencies = dependencies or {} + + self.cursor_index = 0 + self.selected_indices = set() + self.scroll_offset = 0 + + def get_renderable(self): + """ + Constructs and returns the renderable object (Table + Info) for the current state. + """ + table = Table(show_header=False, show_edge=False, box=None, pad_edge=False, padding=(0, 1, 0, 0)) + table.add_column("Indicator", justify="right", no_wrap=True) + table.add_column("Option", overflow="ellipsis", no_wrap=True) + + for i in range(self.page_size): + idx = self.scroll_offset + i + + if idx < len(self.options): + option = self.options[idx] + is_cursor = (idx == self.cursor_index) + is_selected = (idx in self.selected_indices) + + symbol = "[X]" if is_selected else "[ ]" + style = self.cursor_style if is_cursor else self.text_style + indicator_text = Text(f"{symbol}", style=style) + + content_text = Text.from_markup(option) + content_text.style = style + + table.add_row(indicator_text, content_text) + else: + table.add_row(Text(" "), Text(" ")) + + total_pages = (len(self.options) + self.page_size - 1) // self.page_size + current_page = (self.scroll_offset // self.page_size) + 1 + + info_text = Text( + f"\n[Space]: Toggle [a]: All [←/→]: Page [Enter]: Confirm (Page {current_page}/{total_pages})", + style="gray" + ) + + return Padding(Group(table, info_text), (0, 5)) + + def move_cursor(self, delta: int): + """ + Moves the cursor up or down by the specified delta. + Updates the scroll offset if the cursor moves out of the current view. + """ + self.cursor_index = (self.cursor_index + delta) % len(self.options) + new_page_idx = self.cursor_index // self.page_size + self.scroll_offset = new_page_idx * self.page_size + + def change_page(self, delta: int): + """ + Changes the current page view by the specified delta (previous/next page). + Also moves the cursor to the first item of the new page. + """ + current_page = self.scroll_offset // self.page_size + total_pages = (len(self.options) + self.page_size - 1) // self.page_size + new_page = current_page + delta + + if 0 <= new_page < total_pages: + self.scroll_offset = new_page * self.page_size + first_idx_of_page = self.scroll_offset + if first_idx_of_page < len(self.options): + self.cursor_index = first_idx_of_page + else: + self.cursor_index = len(self.options) - 1 + + def toggle_selection(self): + """ + Toggles the selection state of the item currently under the cursor. + Propagates selection to children if defined in dependencies. + """ + target_indices = {self.cursor_index} + + if self.cursor_index in self.dependencies: + target_indices.update(self.dependencies[self.cursor_index]) + + should_select = self.cursor_index not in self.selected_indices + + if should_select: + self.selected_indices.update(target_indices) + else: + self.selected_indices.difference_update(target_indices) + + def toggle_all(self): + """ + Toggles the selection of all items. + If all are selected, clears selection. Otherwise, selects all. + """ + if len(self.selected_indices) == len(self.options): + self.selected_indices.clear() + else: + self.selected_indices = set(range(len(self.options))) + + def get_input_windows(self): + """ + Captures and parses keyboard input on Windows systems using msvcrt. + Returns command strings like 'UP', 'DOWN', 'ENTER', etc. + """ + key = msvcrt.getch() + if key == b'\x03' or key == b'\x1b': + return 'CANCEL' + if key == b'\xe0' or key == b'\x00': + try: + key = msvcrt.getch() + if key == b'H': return 'UP' + if key == b'P': return 'DOWN' + if key == b'K': return 'LEFT' + if key == b'M': return 'RIGHT' + except: pass + + try: char = key.decode('utf-8', errors='ignore') + except: return None + + if char in ('\r', '\n'): return 'ENTER' + if char == ' ': return 'SPACE' + if char in ('q', 'Q'): return 'QUIT' + if char in ('a', 'A'): return 'ALL' + if char in ('w', 'W', 'k', 'K'): return 'UP' + if char in ('s', 'S', 'j', 'J'): return 'DOWN' + if char in ('h', 'H'): return 'LEFT' + if char in ('d', 'D', 'l', 'L'): return 'RIGHT' + return None + + def get_input_unix(self): + """ + Captures and parses keyboard input on Unix/Linux systems using click.getchar(). + Returns command strings like 'UP', 'DOWN', 'ENTER', etc. + """ + char = click.getchar() + if char == '\x03': + return 'CANCEL' + mapping = { + '\x1b[A': 'UP', + '\x1b[B': 'DOWN', + '\x1b[C': 'RIGHT', + '\x1b[D': 'LEFT', + } + if char in mapping: + return mapping[char] + if char == '\x1b': + try: + next1 = click.getchar() + if next1 in ('[', 'O'): + next2 = click.getchar() + if next2 == 'A': return 'UP' + if next2 == 'B': return 'DOWN' + if next2 == 'C': return 'RIGHT' + if next2 == 'D': return 'LEFT' + return 'CANCEL' + except: + return 'CANCEL' + + if char in ('\r', '\n'): return 'ENTER' + if char == ' ': return 'SPACE' + if char in ('q', 'Q'): return 'QUIT' + if char in ('a', 'A'): return 'ALL' + if char in ('w', 'W', 'k', 'K'): return 'UP' + if char in ('s', 'S', 'j', 'J'): return 'DOWN' + if char in ('h', 'H'): return 'LEFT' + if char in ('d', 'D', 'l', 'L'): return 'RIGHT' + return None + + def run(self) -> list[int]: + """ + Starts the main event loop for the selector. + Renders the UI and processes input until confirmed or cancelled. + + Returns: + list[int]: A sorted list of selected indices. + """ + try: + with Live(self.get_renderable(), console=console, auto_refresh=False, transient=True) as live: + while True: + live.update(self.get_renderable(), refresh=True) + if IS_WINDOWS: action = self.get_input_windows() + else: action = self.get_input_unix() + + if action == 'UP': self.move_cursor(-1) + elif action == 'DOWN': self.move_cursor(1) + elif action == 'LEFT': self.change_page(-1) + elif action == 'RIGHT': self.change_page(1) + elif action == 'SPACE': self.toggle_selection() + elif action == 'ALL': self.toggle_all() + elif action in ('ENTER', 'QUIT'): + if len(self.selected_indices) >= self.minimal_count: + return sorted(list(self.selected_indices)) + elif action == 'CANCEL': raise KeyboardInterrupt + except KeyboardInterrupt: + return [] + +def select_multiple( + options: list[str], + minimal_count: int = 1, + page_size: int = 8, + return_indices: bool = True, + cursor_style: str = "pink", + **kwargs +) -> list[int]: + """ + Drop-in replacement using custom Selector with global console. + + Args: + options: List of options to display. + minimal_count: Minimum number of selections required. + page_size: Number of items per page. + return_indices: If True, returns indices; otherwise returns the option strings. + cursor_style: Style color for the cursor. + """ + selector = Selector( + options=options, + cursor_style=cursor_style, + text_style="text", + page_size=page_size, + minimal_count=minimal_count, + **kwargs + ) + + selected_indices = selector.run() + + if return_indices: + return selected_indices + return [options[i] for i in selected_indices]