mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-03-13 01:49:00 +00:00
Compare commits
6 Commits
132d3549f9
...
9f9a609d71
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f9a609d71 | ||
|
|
cee7d9a75f | ||
|
|
bf9087a1ce | ||
|
|
23cc351f77 | ||
|
|
dd19f405a4 | ||
|
|
dbebf68f18 |
35
CHANGELOG.md
35
CHANGELOG.md
@@ -6,7 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
||||
|
||||
This changelog is automatically generated using [git-cliff](https://git-cliff.org).
|
||||
|
||||
## [Unreleased]
|
||||
## [3.0.0] - 2026-02-15
|
||||
|
||||
### Features
|
||||
|
||||
@@ -21,6 +21,9 @@ This changelog is automatically generated using [git-cliff](https://git-cliff.or
|
||||
- *drm*: Add MonaLisa DRM support to core infrastructure
|
||||
- *audio*: Codec lists and split muxing
|
||||
- *proxy*: Add specific server selection for WindscribeVPN
|
||||
- *cdm*: Normalize CDM detection for local and remote implementations
|
||||
- *HLS*: Improve audio codec handling with error handling for codec extraction
|
||||
- *tracks*: Prioritize Atmos audio tracks over higher bitrate non-Atmos
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -53,11 +56,39 @@ This changelog is automatically generated using [git-cliff](https://git-cliff.or
|
||||
- *dl*: Always clean up hybrid temp hevc outputs
|
||||
- *hls*: Finalize n_m3u8dl_re outputs
|
||||
- *downloader*: Restore requests progress for single-url downloads
|
||||
- *dl*: Invert audio codec suffixing when splitting
|
||||
- *dl*: Support snake_case keys for RemoteCdm
|
||||
- *aria2c*: Warn on config mismatch and wait for RPC ready
|
||||
- *serve*: [**breaking**] Make PlayReady users config consistently a mapping
|
||||
- *dl*: Preserve proxy_query selector (not resolved URI)
|
||||
- *gluetun*: Stop leaking proxy/vpn secrets to process list
|
||||
- *monalisa*: Avoid leaking secrets and add worker safety
|
||||
- *dl*: Avoid selecting all variants when multiple audio codecs requested
|
||||
- *hls*: Keep range offset numeric and align MonaLisa licensing
|
||||
- *titles*: Remove trailing space from HDR dynamic range label
|
||||
- *config*: Normalize playready_remote remote_cdm keys
|
||||
- *titles*: Avoid None/double spaces in HDR tokens
|
||||
- *naming*: Keep technical tokens with scene_naming off
|
||||
- *api*: Log PSSH extraction failures
|
||||
- *proxies*: Harden surfshark and windscribe selection
|
||||
- *service*: Redact proxy credentials in logs
|
||||
- *monalisa*: Harden wasm calls and license handling
|
||||
- *hls*: Remove no-op encryption_data reassignment
|
||||
- *serve*: Default PlayReady access to none
|
||||
- *tracks*: Close temp session and improve path type error
|
||||
- *main*: Update copyright year dynamically in version display
|
||||
|
||||
### Reverts
|
||||
|
||||
- *monalisa*: Pass key via argv again
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add configuration documentation WIP
|
||||
- *changelog*: Add 2.4.0 release notes
|
||||
- *changelog*: Update cliff config and regenerate changelog
|
||||
- *changelog*: Complete 2.4.0 notes
|
||||
- *config*: Clarify sdh_method uses subtitle-filter
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
@@ -451,7 +482,7 @@ This changelog is automatically generated using [git-cliff](https://git-cliff.or
|
||||
- Reorganize Planned Features section in README for clarity
|
||||
- Improve track selection logic in dl.py
|
||||
|
||||
[unreleased]: https://github.com/unshackle-dl/unshackle/compare/2.3.0..HEAD
|
||||
[3.0.0]: https://github.com/unshackle-dl/unshackle/compare/2.3.0..3.0.0
|
||||
[2.3.0]: https://github.com/unshackle-dl/unshackle/compare/2.2.0..2.3.0
|
||||
[2.2.0]: https://github.com/unshackle-dl/unshackle/compare/2.1.0..2.2.0
|
||||
[2.1.0]: https://github.com/unshackle-dl/unshackle/compare/2.0.0..2.1.0
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "unshackle"
|
||||
version = "2.4.0"
|
||||
version = "3.0.0"
|
||||
description = "Modular Movie, TV, and Music Archival Software."
|
||||
authors = [{ name = "unshackle team" }]
|
||||
requires-python = ">=3.10,<3.13"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.4.0"
|
||||
__version__ = "3.0.0"
|
||||
|
||||
@@ -192,8 +192,10 @@ def build_download_args(
|
||||
if ad_keyword:
|
||||
args["--ad-keyword"] = ad_keyword
|
||||
|
||||
key_args = []
|
||||
if content_keys:
|
||||
args["--key"] = next((f"{kid.hex}:{key.lower()}" for kid, key in content_keys.items()), None)
|
||||
for kid, key in content_keys.items():
|
||||
key_args.extend(["--key", f"{kid.hex}:{key.lower()}"])
|
||||
|
||||
decryption_config = config.decryption.lower()
|
||||
engine_name = DECRYPTION_ENGINE.get(decryption_config) or "SHAKA_PACKAGER"
|
||||
@@ -221,6 +223,9 @@ def build_download_args(
|
||||
elif value is not False and value is not None:
|
||||
command.extend([flag, str(value)])
|
||||
|
||||
# Append all content keys (multiple --key flags supported by N_m3u8DL-RE)
|
||||
command.extend(key_args)
|
||||
|
||||
if headers:
|
||||
for key, value in headers.items():
|
||||
if key.lower() not in ("accept-encoding", "cookie"):
|
||||
|
||||
@@ -221,13 +221,15 @@ class Tracks:
|
||||
self.videos.sort(key=lambda x: not is_close_match(language, [x.language]))
|
||||
|
||||
def sort_audio(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None:
|
||||
"""Sort audio tracks by bitrate, descriptive, and optionally language."""
|
||||
"""Sort audio tracks by bitrate, Atmos, descriptive, and optionally language."""
|
||||
if not self.audio:
|
||||
return
|
||||
# descriptive
|
||||
self.audio.sort(key=lambda x: x.descriptive)
|
||||
# bitrate (within each descriptive group)
|
||||
# bitrate (highest first)
|
||||
self.audio.sort(key=lambda x: float(x.bitrate or 0.0), reverse=True)
|
||||
# Atmos tracks first (prioritize over higher bitrate non-Atmos)
|
||||
self.audio.sort(key=lambda x: not x.atmos)
|
||||
# descriptive tracks last
|
||||
self.audio.sort(key=lambda x: x.descriptive)
|
||||
# language
|
||||
for language in reversed(by_language or []):
|
||||
if str(language) in ("all", "best"):
|
||||
|
||||
269
unshackle/core/utils/selector.py
Normal file
269
unshackle/core/utils/selector.py
Normal file
@@ -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]
|
||||
Reference in New Issue
Block a user