From dbebf68f1806bb72859366673316e2391fd44fc8 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Mon, 9 Feb 2026 02:20:26 +0900 Subject: [PATCH] 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: