mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-03-18 17:17:30 +00:00
Compare commits
34 Commits
820db5f179
...
eeec4e1f1b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeec4e1f1b | ||
|
|
6cdfd2828b | ||
|
|
9dc56e63c4 | ||
|
|
31f8532131 | ||
|
|
8d05a8ceb8 | ||
|
|
c5ef13df5d | ||
|
|
1611fcc971 | ||
|
|
0f25b0ce52 | ||
|
|
00b4f2cdd1 | ||
|
|
65e6ae88d0 | ||
|
|
30269b6c17 | ||
|
|
547e9f481c | ||
|
|
bde1945f67 | ||
|
|
a4e1c6bb75 | ||
|
|
b26d47fd9b | ||
|
|
772bacfc8f | ||
|
|
d261b4715d | ||
|
|
05dc682a2d | ||
|
|
86d464dc8e | ||
|
|
20bc7d2dba | ||
|
|
b1d28d3229 | ||
|
|
eefb6fcad7 | ||
|
|
c78a649170 | ||
|
|
c01e3993ce | ||
|
|
eba9f846b0 | ||
|
|
d2f221f3fc | ||
|
|
57ecddfeeb | ||
|
|
d21a59f306 | ||
|
|
b9bf8fddf5 | ||
|
|
ad7dd69ecd | ||
|
|
ca3a6cc3ea | ||
|
|
77d55153e6 | ||
|
|
4988d37fe9 | ||
|
|
d03d54b0bb |
@@ -994,6 +994,10 @@ class dl:
|
|||||||
self.log.error("--require-subs and --s-lang cannot be used together")
|
self.log.error("--require-subs and --s-lang cannot be used together")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
if select_titles and wanted:
|
||||||
|
self.log.error("--select-titles and -w/--wanted cannot be used together")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# Check if dovi_tool is available when hybrid mode is requested
|
# Check if dovi_tool is available when hybrid mode is requested
|
||||||
if any(r == Video.Range.HYBRID for r in range_):
|
if any(r == Video.Range.HYBRID for r in range_):
|
||||||
from unshackle.core.binaries import DoviTool
|
from unshackle.core.binaries import DoviTool
|
||||||
@@ -1152,7 +1156,7 @@ class dl:
|
|||||||
# Note: Headers are not mapped to actual title indices
|
# Note: Headers are not mapped to actual title indices
|
||||||
|
|
||||||
# Format display name
|
# Format display name
|
||||||
display_name = ((t.name[:35].rstrip() + "…") if len(t.name) > 35 else t.name) if t.name else None
|
display_name = ((t.name[:30].rstrip() + "…") if len(t.name) > 30 else t.name) if t.name else None
|
||||||
|
|
||||||
# Apply indentation only for multiple seasons
|
# Apply indentation only for multiple seasons
|
||||||
prefix = " " if multiple_seasons else ""
|
prefix = " " if multiple_seasons else ""
|
||||||
@@ -1172,9 +1176,18 @@ class dl:
|
|||||||
|
|
||||||
# Execute selector with dependencies (headers select all children)
|
# Execute selector with dependencies (headers select all children)
|
||||||
selected_ui_idx = select_multiple(
|
selected_ui_idx = select_multiple(
|
||||||
selection_titles, minimal_count=1, page_size=8, return_indices=True, dependencies=dependencies
|
selection_titles,
|
||||||
|
minimal_count=1,
|
||||||
|
page_size=8,
|
||||||
|
return_indices=True,
|
||||||
|
dependencies=dependencies,
|
||||||
|
collapse_on_start=multiple_seasons
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not selected_ui_idx:
|
||||||
|
console.print(Padding(":x: Selection Cancelled...", (0, 5, 1, 5)))
|
||||||
|
return
|
||||||
|
|
||||||
selection_end = time.time()
|
selection_end = time.time()
|
||||||
start_time += selection_end - selection_start
|
start_time += selection_end - selection_start
|
||||||
|
|
||||||
@@ -1900,8 +1913,7 @@ class dl:
|
|||||||
),
|
),
|
||||||
licence=partial(
|
licence=partial(
|
||||||
service.get_playready_license
|
service.get_playready_license
|
||||||
if (is_playready_cdm(self.cdm))
|
if is_playready_cdm(self.cdm)
|
||||||
and hasattr(service, "get_playready_license")
|
|
||||||
else service.get_widevine_license,
|
else service.get_widevine_license,
|
||||||
title=title,
|
title=title,
|
||||||
track=track,
|
track=track,
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ class Config:
|
|||||||
self.dash_naming: bool = kwargs.get("dash_naming", False)
|
self.dash_naming: bool = kwargs.get("dash_naming", False)
|
||||||
self.series_year: bool = kwargs.get("series_year", True)
|
self.series_year: bool = kwargs.get("series_year", True)
|
||||||
self.unicode_filenames: bool = kwargs.get("unicode_filenames", False)
|
self.unicode_filenames: bool = kwargs.get("unicode_filenames", False)
|
||||||
|
self.insert_episodename_into_filenames: bool = kwargs.get("insert_episodename_into_filenames", True)
|
||||||
|
|
||||||
self.title_cache_time: int = kwargs.get("title_cache_time", 1800) # 30 minutes default
|
self.title_cache_time: int = kwargs.get("title_cache_time", 1800) # 30 minutes default
|
||||||
self.title_cache_max_retention: int = kwargs.get("title_cache_max_retention", 86400) # 24 hours default
|
self.title_cache_max_retention: int = kwargs.get("title_cache_max_retention", 86400) # 24 hours default
|
||||||
|
|||||||
@@ -394,6 +394,27 @@ class Service(metaclass=ABCMeta):
|
|||||||
Decode the data, return as is to reduce unnecessary computations.
|
Decode the data, return as is to reduce unnecessary computations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
||||||
|
"""
|
||||||
|
Get a PlayReady License message by sending a License Request (challenge).
|
||||||
|
|
||||||
|
This License message contains the encrypted Content Decryption Keys and will be
|
||||||
|
read by the CDM and decrypted.
|
||||||
|
|
||||||
|
This is a very important request to get correct. A bad, unexpected, or missing
|
||||||
|
value in the request can cause your key to be detected and promptly banned,
|
||||||
|
revoked, disabled, or downgraded.
|
||||||
|
|
||||||
|
:param challenge: The license challenge from the PlayReady CDM.
|
||||||
|
:param title: The current `Title` from get_titles that is being executed. This is provided in
|
||||||
|
case it has data needed to be used, e.g. for a HTTP request.
|
||||||
|
:param track: The current `Track` needing decryption. Provided for same reason as `title`.
|
||||||
|
:return: The License response as Bytes or a Base64 string. Don't Base64 Encode or
|
||||||
|
Decode the data, return as is to reduce unnecessary computations.
|
||||||
|
"""
|
||||||
|
# Delegates license handling to the Widevine license method by default if a service-specific PlayReady implementation is not provided.
|
||||||
|
return self.get_widevine_license(challenge=challenge, title=title, track=track)
|
||||||
|
|
||||||
# Required Abstract functions
|
# Required Abstract functions
|
||||||
# The following functions *must* be implemented by the Service.
|
# The following functions *must* be implemented by the Service.
|
||||||
# The functions will be executed in shown order.
|
# The functions will be executed in shown order.
|
||||||
|
|||||||
@@ -105,22 +105,21 @@ class Episode(Title):
|
|||||||
def _get_resolution_token(track: Any) -> str:
|
def _get_resolution_token(track: Any) -> str:
|
||||||
if not track or not getattr(track, "height", None):
|
if not track or not getattr(track, "height", None):
|
||||||
return ""
|
return ""
|
||||||
resolution = track.height
|
width = getattr(track, "width", track.height)
|
||||||
|
resolution = min(width, track.height)
|
||||||
try:
|
try:
|
||||||
dar = getattr(track, "other_display_aspect_ratio", None) or []
|
dar = getattr(track, "other_display_aspect_ratio", None) or []
|
||||||
if dar and dar[0]:
|
if dar and dar[0]:
|
||||||
aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")]
|
aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")]
|
||||||
if len(aspect_ratio) == 1:
|
if len(aspect_ratio) == 1:
|
||||||
aspect_ratio.append(1)
|
aspect_ratio.append(1)
|
||||||
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
|
ratio = aspect_ratio[0] / aspect_ratio[1]
|
||||||
resolution = int(track.width * (9 / 16))
|
if ratio not in (16 / 9, 4 / 3, 9 / 16, 3 / 4):
|
||||||
|
resolution = int(max(width, track.height) * (9 / 16))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
scan_suffix = "p"
|
scan_suffix = "i" if str(getattr(track, "scan_type", "")).lower() == "interlaced" else "p"
|
||||||
scan_type = getattr(track, "scan_type", None)
|
|
||||||
if scan_type and str(scan_type).lower() == "interlaced":
|
|
||||||
scan_suffix = "i"
|
|
||||||
return f"{resolution}{scan_suffix}"
|
return f"{resolution}{scan_suffix}"
|
||||||
|
|
||||||
# Title [Year] SXXEXX Name (or Title [Year] SXX if folder)
|
# Title [Year] SXXEXX Name (or Title [Year] SXX if folder)
|
||||||
@@ -142,7 +141,7 @@ class Episode(Title):
|
|||||||
name += f" - S{self.season:02}E{self.number:02}"
|
name += f" - S{self.season:02}E{self.number:02}"
|
||||||
|
|
||||||
# Add episode name with dash separator
|
# Add episode name with dash separator
|
||||||
if self.name:
|
if self.name and config.insert_episodename_into_filenames:
|
||||||
name += f" - {self.name}"
|
name += f" - {self.name}"
|
||||||
|
|
||||||
name = name.strip()
|
name = name.strip()
|
||||||
@@ -153,12 +152,17 @@ class Episode(Title):
|
|||||||
year=f" {self.year}" if self.year and config.series_year else "",
|
year=f" {self.year}" if self.year and config.series_year else "",
|
||||||
season=self.season,
|
season=self.season,
|
||||||
number=self.number,
|
number=self.number,
|
||||||
name=self.name or "",
|
name=self.name if self.name and config.insert_episodename_into_filenames else "",
|
||||||
).strip()
|
).strip()
|
||||||
|
|
||||||
if getattr(config, "repack", False):
|
if getattr(config, "repack", False):
|
||||||
name += " REPACK"
|
name += " REPACK"
|
||||||
|
|
||||||
|
if self.tracks:
|
||||||
|
first_track = next(iter(self.tracks), None)
|
||||||
|
if first_track and first_track.edition:
|
||||||
|
name += " " + " ".join(first_track.edition)
|
||||||
|
|
||||||
if primary_video_track:
|
if primary_video_track:
|
||||||
resolution_token = _get_resolution_token(primary_video_track)
|
resolution_token = _get_resolution_token(primary_video_track)
|
||||||
if resolution_token:
|
if resolution_token:
|
||||||
|
|||||||
@@ -70,22 +70,21 @@ class Movie(Title):
|
|||||||
def _get_resolution_token(track: Any) -> str:
|
def _get_resolution_token(track: Any) -> str:
|
||||||
if not track or not getattr(track, "height", None):
|
if not track or not getattr(track, "height", None):
|
||||||
return ""
|
return ""
|
||||||
resolution = track.height
|
width = getattr(track, "width", track.height)
|
||||||
|
resolution = min(width, track.height)
|
||||||
try:
|
try:
|
||||||
dar = getattr(track, "other_display_aspect_ratio", None) or []
|
dar = getattr(track, "other_display_aspect_ratio", None) or []
|
||||||
if dar and dar[0]:
|
if dar and dar[0]:
|
||||||
aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")]
|
aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")]
|
||||||
if len(aspect_ratio) == 1:
|
if len(aspect_ratio) == 1:
|
||||||
aspect_ratio.append(1)
|
aspect_ratio.append(1)
|
||||||
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
|
ratio = aspect_ratio[0] / aspect_ratio[1]
|
||||||
resolution = int(track.width * (9 / 16))
|
if ratio not in (16 / 9, 4 / 3, 9 / 16, 3 / 4):
|
||||||
|
resolution = int(max(width, track.height) * (9 / 16))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
scan_suffix = "p"
|
scan_suffix = "i" if str(getattr(track, "scan_type", "")).lower() == "interlaced" else "p"
|
||||||
scan_type = getattr(track, "scan_type", None)
|
|
||||||
if scan_type and str(scan_type).lower() == "interlaced":
|
|
||||||
scan_suffix = "i"
|
|
||||||
return f"{resolution}{scan_suffix}"
|
return f"{resolution}{scan_suffix}"
|
||||||
|
|
||||||
# Name (Year)
|
# Name (Year)
|
||||||
@@ -94,6 +93,11 @@ class Movie(Title):
|
|||||||
if getattr(config, "repack", False):
|
if getattr(config, "repack", False):
|
||||||
name += " REPACK"
|
name += " REPACK"
|
||||||
|
|
||||||
|
if self.tracks:
|
||||||
|
first_track = next(iter(self.tracks), None)
|
||||||
|
if first_track and first_track.edition:
|
||||||
|
name += " " + " ".join(first_track.edition)
|
||||||
|
|
||||||
if primary_video_track:
|
if primary_video_track:
|
||||||
resolution_token = _get_resolution_token(primary_video_track)
|
resolution_token = _get_resolution_token(primary_video_track)
|
||||||
if resolution_token:
|
if resolution_token:
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ class Song(Title):
|
|||||||
if getattr(config, "repack", False):
|
if getattr(config, "repack", False):
|
||||||
name += " REPACK"
|
name += " REPACK"
|
||||||
|
|
||||||
|
if self.tracks:
|
||||||
|
first_track = next(iter(self.tracks), None)
|
||||||
|
if first_track and first_track.edition:
|
||||||
|
name += " " + " ".join(first_track.edition)
|
||||||
|
|
||||||
# Service (use track source if available)
|
# Service (use track source if available)
|
||||||
if show_service:
|
if show_service:
|
||||||
source_name = None
|
source_name = None
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ class Audio(Track):
|
|||||||
),
|
),
|
||||||
f"{self.bitrate // 1000} kb/s" if self.bitrate else None,
|
f"{self.bitrate // 1000} kb/s" if self.bitrate else None,
|
||||||
self.get_track_name(),
|
self.get_track_name(),
|
||||||
self.edition,
|
", ".join(self.edition) if self.edition else None,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ class Track:
|
|||||||
raise TypeError(f"Expected name to be a {str}, not {type(name)}")
|
raise TypeError(f"Expected name to be a {str}, not {type(name)}")
|
||||||
if not isinstance(id_, (str, type(None))):
|
if not isinstance(id_, (str, type(None))):
|
||||||
raise TypeError(f"Expected id_ to be a {str}, not {type(id_)}")
|
raise TypeError(f"Expected id_ to be a {str}, not {type(id_)}")
|
||||||
if not isinstance(edition, (str, type(None))):
|
if not isinstance(edition, (str, list, type(None))):
|
||||||
raise TypeError(f"Expected edition to be a {str}, not {type(edition)}")
|
raise TypeError(f"Expected edition to be a {str}, {list}, or None, not {type(edition)}")
|
||||||
if not isinstance(downloader, (Callable, type(None))):
|
if not isinstance(downloader, (Callable, type(None))):
|
||||||
raise TypeError(f"Expected downloader to be a {Callable}, not {type(downloader)}")
|
raise TypeError(f"Expected downloader to be a {Callable}, not {type(downloader)}")
|
||||||
if not isinstance(downloader_args, (dict, type(None))):
|
if not isinstance(downloader_args, (dict, type(None))):
|
||||||
@@ -103,7 +103,7 @@ class Track:
|
|||||||
self.needs_repack = needs_repack
|
self.needs_repack = needs_repack
|
||||||
self.name = name
|
self.name = name
|
||||||
self.drm = drm
|
self.drm = drm
|
||||||
self.edition: str = edition
|
self.edition: list[str] = [edition] if isinstance(edition, str) else (edition or [])
|
||||||
self.downloader = downloader
|
self.downloader = downloader
|
||||||
self.downloader_args = downloader_args
|
self.downloader_args = downloader_args
|
||||||
self.from_file = from_file
|
self.from_file = from_file
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ class Video(Track):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
self.edition,
|
", ".join(self.edition) if self.edition else None,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ if IS_WINDOWS:
|
|||||||
class Selector:
|
class Selector:
|
||||||
"""
|
"""
|
||||||
A custom interactive selector class using the Rich library.
|
A custom interactive selector class using the Rich library.
|
||||||
Allows for multi-selection of items with pagination.
|
Allows for multi-selection of items with pagination and collapsible headers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -28,7 +28,7 @@ class Selector:
|
|||||||
page_size: int = 8,
|
page_size: int = 8,
|
||||||
minimal_count: int = 0,
|
minimal_count: int = 0,
|
||||||
dependencies: dict[int, list[int]] = None,
|
dependencies: dict[int, list[int]] = None,
|
||||||
prefixes: list[str] = None,
|
collapse_on_start: bool = False,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize the Selector.
|
Initialize the Selector.
|
||||||
@@ -40,6 +40,7 @@ class Selector:
|
|||||||
page_size: Number of items to show per page.
|
page_size: Number of items to show per page.
|
||||||
minimal_count: Minimum number of items that must be selected.
|
minimal_count: Minimum number of items that must be selected.
|
||||||
dependencies: Dictionary mapping parent index to list of child indices.
|
dependencies: Dictionary mapping parent index to list of child indices.
|
||||||
|
collapse_on_start: If True, child items are hidden initially.
|
||||||
"""
|
"""
|
||||||
self.options = options
|
self.options = options
|
||||||
self.cursor_style = cursor_style
|
self.cursor_style = cursor_style
|
||||||
@@ -48,77 +49,160 @@ class Selector:
|
|||||||
self.minimal_count = minimal_count
|
self.minimal_count = minimal_count
|
||||||
self.dependencies = dependencies or {}
|
self.dependencies = dependencies or {}
|
||||||
|
|
||||||
|
# Parent-Child mapping for quick lookup
|
||||||
|
self.child_to_parent = {}
|
||||||
|
for parent, children in self.dependencies.items():
|
||||||
|
for child in children:
|
||||||
|
self.child_to_parent[child] = parent
|
||||||
|
|
||||||
self.cursor_index = 0
|
self.cursor_index = 0
|
||||||
self.selected_indices = set()
|
self.selected_indices = set()
|
||||||
self.scroll_offset = 0
|
self.scroll_offset = 0
|
||||||
|
|
||||||
|
# Tree view state
|
||||||
|
self.expanded_headers = set()
|
||||||
|
if not collapse_on_start:
|
||||||
|
# Expand all by default
|
||||||
|
self.expanded_headers.update(self.dependencies.keys())
|
||||||
|
|
||||||
|
def get_visible_indices(self) -> list[int]:
|
||||||
|
"""
|
||||||
|
Returns a sorted list of indices that should be currently visible.
|
||||||
|
A child is visible only if its parent is in self.expanded_headers.
|
||||||
|
"""
|
||||||
|
visible = []
|
||||||
|
for idx in range(len(self.options)):
|
||||||
|
# If it's a child, check if parent is expanded
|
||||||
|
if idx in self.child_to_parent:
|
||||||
|
parent = self.child_to_parent[idx]
|
||||||
|
if parent in self.expanded_headers:
|
||||||
|
visible.append(idx)
|
||||||
|
else:
|
||||||
|
# It's a header or independent item, always visible
|
||||||
|
visible.append(idx)
|
||||||
|
return visible
|
||||||
|
|
||||||
def get_renderable(self):
|
def get_renderable(self):
|
||||||
"""
|
"""
|
||||||
Constructs and returns the renderable object (Table + Info) for the current state.
|
Constructs and returns the renderable object (Table + Info) for the current state.
|
||||||
"""
|
"""
|
||||||
|
visible_indices = self.get_visible_indices()
|
||||||
|
|
||||||
|
# Adjust scroll offset to ensure cursor is visible
|
||||||
|
if self.cursor_index not in visible_indices:
|
||||||
|
# Fallback if cursor got hidden (should be handled in move, but safety check)
|
||||||
|
self.cursor_index = visible_indices[0] if visible_indices else 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor_visual_pos = visible_indices.index(self.cursor_index)
|
||||||
|
except ValueError:
|
||||||
|
cursor_visual_pos = 0
|
||||||
|
self.cursor_index = visible_indices[0]
|
||||||
|
|
||||||
|
# Calculate logical page start/end based on VISIBLE items
|
||||||
|
start_idx = self.scroll_offset
|
||||||
|
end_idx = start_idx + self.page_size
|
||||||
|
|
||||||
|
# Dynamic scroll adjustment
|
||||||
|
if cursor_visual_pos < start_idx:
|
||||||
|
self.scroll_offset = cursor_visual_pos
|
||||||
|
elif cursor_visual_pos >= end_idx:
|
||||||
|
self.scroll_offset = cursor_visual_pos - self.page_size + 1
|
||||||
|
|
||||||
|
# Re-calc render range
|
||||||
|
render_indices = visible_indices[self.scroll_offset : self.scroll_offset + self.page_size]
|
||||||
|
|
||||||
table = Table(show_header=False, show_edge=False, box=None, pad_edge=False, padding=(0, 1, 0, 0))
|
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("Indicator", justify="right", no_wrap=True)
|
||||||
table.add_column("Option", overflow="ellipsis", no_wrap=True)
|
table.add_column("Option", overflow="ellipsis", no_wrap=True)
|
||||||
|
|
||||||
for i in range(self.page_size):
|
for idx in render_indices:
|
||||||
idx = self.scroll_offset + i
|
option = self.options[idx]
|
||||||
|
is_cursor = idx == self.cursor_index
|
||||||
|
is_selected = idx in self.selected_indices
|
||||||
|
|
||||||
if idx < len(self.options):
|
symbol = "[X]" if is_selected else "[ ]"
|
||||||
option = self.options[idx]
|
style = self.cursor_style if is_cursor else self.text_style
|
||||||
is_cursor = idx == self.cursor_index
|
indicator_text = Text(f"{symbol}", style=style)
|
||||||
is_selected = idx in self.selected_indices
|
|
||||||
|
|
||||||
symbol = "[X]" if is_selected else "[ ]"
|
content_text = Text.from_markup(f"{option}")
|
||||||
style = self.cursor_style if is_cursor else self.text_style
|
content_text.style = style
|
||||||
indicator_text = Text(f"{symbol}", style=style)
|
|
||||||
|
|
||||||
content_text = Text.from_markup(option)
|
table.add_row(indicator_text, content_text)
|
||||||
content_text.style = style
|
|
||||||
|
|
||||||
table.add_row(indicator_text, content_text)
|
# Fill empty rows to maintain height
|
||||||
else:
|
rows_rendered = len(render_indices)
|
||||||
table.add_row(Text(" "), Text(" "))
|
for _ in range(self.page_size - rows_rendered):
|
||||||
|
table.add_row(Text(" "), Text(" "))
|
||||||
|
|
||||||
total_pages = (len(self.options) + self.page_size - 1) // self.page_size
|
total_visible = len(visible_indices)
|
||||||
|
total_pages = (total_visible + self.page_size - 1) // self.page_size
|
||||||
|
if total_pages == 0:
|
||||||
|
total_pages = 1
|
||||||
current_page = (self.scroll_offset // self.page_size) + 1
|
current_page = (self.scroll_offset // self.page_size) + 1
|
||||||
|
|
||||||
info_text = Text(
|
if self.dependencies:
|
||||||
f"\n[Space]: Toggle [a]: All [←/→]: Page [Enter]: Confirm (Page {current_page}/{total_pages})",
|
info_text = Text(
|
||||||
style="gray",
|
f"\n[Space]: Toggle [a]: All [e]: Fold/Unfold [E]: All Fold/Unfold\n[Enter]: Confirm [↑/↓]: Move [←/→]: Page (Page {current_page}/{total_pages})",
|
||||||
)
|
style="gray",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
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))
|
return Padding(Group(table, info_text), (0, 5))
|
||||||
|
|
||||||
def move_cursor(self, delta: int):
|
def move_cursor(self, delta: int):
|
||||||
"""
|
"""
|
||||||
Moves the cursor up or down by the specified delta.
|
Moves the cursor up or down through VISIBLE items only.
|
||||||
Updates the scroll offset if the cursor moves out of the current view.
|
|
||||||
"""
|
"""
|
||||||
self.cursor_index = (self.cursor_index + delta) % len(self.options)
|
visible_indices = self.get_visible_indices()
|
||||||
new_page_idx = self.cursor_index // self.page_size
|
if not visible_indices:
|
||||||
self.scroll_offset = new_page_idx * self.page_size
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_visual_idx = visible_indices.index(self.cursor_index)
|
||||||
|
except ValueError:
|
||||||
|
current_visual_idx = 0
|
||||||
|
|
||||||
|
new_visual_idx = (current_visual_idx + delta) % len(visible_indices)
|
||||||
|
self.cursor_index = visible_indices[new_visual_idx]
|
||||||
|
|
||||||
def change_page(self, delta: int):
|
def change_page(self, delta: int):
|
||||||
"""
|
"""
|
||||||
Changes the current page view by the specified delta (previous/next page).
|
Changes the current page view by the specified delta (previous/next page).
|
||||||
Also moves the cursor to the first item of the new page.
|
|
||||||
"""
|
"""
|
||||||
|
visible_indices = self.get_visible_indices()
|
||||||
|
if not visible_indices:
|
||||||
|
return
|
||||||
|
|
||||||
|
total_visible = len(visible_indices)
|
||||||
|
|
||||||
|
# Calculate current logical page
|
||||||
current_page = self.scroll_offset // self.page_size
|
current_page = self.scroll_offset // self.page_size
|
||||||
total_pages = (len(self.options) + self.page_size - 1) // self.page_size
|
total_pages = (total_visible + self.page_size - 1) // self.page_size
|
||||||
|
|
||||||
new_page = current_page + delta
|
new_page = current_page + delta
|
||||||
|
|
||||||
if 0 <= new_page < total_pages:
|
if 0 <= new_page < total_pages:
|
||||||
self.scroll_offset = new_page * self.page_size
|
self.scroll_offset = new_page * self.page_size
|
||||||
first_idx_of_page = self.scroll_offset
|
|
||||||
if first_idx_of_page < len(self.options):
|
# Move cursor to top of new page
|
||||||
self.cursor_index = first_idx_of_page
|
try:
|
||||||
else:
|
# Calculate what visual index corresponds to the start of the new page
|
||||||
self.cursor_index = len(self.options) - 1
|
new_visual_cursor = self.scroll_offset
|
||||||
|
if new_visual_cursor < len(visible_indices):
|
||||||
|
self.cursor_index = visible_indices[new_visual_cursor]
|
||||||
|
else:
|
||||||
|
self.cursor_index = visible_indices[-1]
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
|
||||||
def toggle_selection(self):
|
def toggle_selection(self):
|
||||||
"""
|
"""
|
||||||
Toggles the selection state of the item currently under the cursor.
|
Toggles the selection state of the item currently under the cursor.
|
||||||
Propagates selection to children if defined in dependencies.
|
|
||||||
"""
|
"""
|
||||||
target_indices = {self.cursor_index}
|
target_indices = {self.cursor_index}
|
||||||
|
|
||||||
@@ -132,10 +216,33 @@ class Selector:
|
|||||||
else:
|
else:
|
||||||
self.selected_indices.difference_update(target_indices)
|
self.selected_indices.difference_update(target_indices)
|
||||||
|
|
||||||
|
def toggle_expand(self):
|
||||||
|
"""
|
||||||
|
Expands or collapses the current header.
|
||||||
|
"""
|
||||||
|
if self.cursor_index in self.dependencies:
|
||||||
|
if self.cursor_index in self.expanded_headers:
|
||||||
|
self.expanded_headers.remove(self.cursor_index)
|
||||||
|
else:
|
||||||
|
self.expanded_headers.add(self.cursor_index)
|
||||||
|
|
||||||
|
def toggle_expand_all(self):
|
||||||
|
"""
|
||||||
|
Toggles expansion state of ALL headers.
|
||||||
|
If all are expanded -> Collapse all.
|
||||||
|
Otherwise -> Expand all.
|
||||||
|
"""
|
||||||
|
if not self.dependencies:
|
||||||
|
return
|
||||||
|
all_headers = set(self.dependencies.keys())
|
||||||
|
if self.expanded_headers == all_headers:
|
||||||
|
self.expanded_headers.clear()
|
||||||
|
else:
|
||||||
|
self.expanded_headers = all_headers.copy()
|
||||||
|
|
||||||
def toggle_all(self):
|
def toggle_all(self):
|
||||||
"""
|
"""
|
||||||
Toggles the selection of all items.
|
Toggles the selection of all items.
|
||||||
If all are selected, clears selection. Otherwise, selects all.
|
|
||||||
"""
|
"""
|
||||||
if len(self.selected_indices) == len(self.options):
|
if len(self.selected_indices) == len(self.options):
|
||||||
self.selected_indices.clear()
|
self.selected_indices.clear()
|
||||||
@@ -148,18 +255,20 @@ class Selector:
|
|||||||
Returns command strings like 'UP', 'DOWN', 'ENTER', etc.
|
Returns command strings like 'UP', 'DOWN', 'ENTER', etc.
|
||||||
"""
|
"""
|
||||||
key = msvcrt.getch()
|
key = msvcrt.getch()
|
||||||
|
# Ctrl+C (0x03) or ESC (0x1b)
|
||||||
if key == b"\x03" or key == b"\x1b":
|
if key == b"\x03" or key == b"\x1b":
|
||||||
return "CANCEL"
|
return "CANCEL"
|
||||||
|
# Special keys prefix (Arrow keys, etc., send 0xe0 or 0x00 first)
|
||||||
if key == b"\xe0" or key == b"\x00":
|
if key == b"\xe0" or key == b"\x00":
|
||||||
try:
|
try:
|
||||||
key = msvcrt.getch()
|
key = msvcrt.getch()
|
||||||
if key == b"H":
|
if key == b"H": # Arrow Up
|
||||||
return "UP"
|
return "UP"
|
||||||
if key == b"P":
|
if key == b"P": # Arrow Down
|
||||||
return "DOWN"
|
return "DOWN"
|
||||||
if key == b"K":
|
if key == b"K": # Arrow Left
|
||||||
return "LEFT"
|
return "LEFT"
|
||||||
if key == b"M":
|
if key == b"M": # Arrow Right
|
||||||
return "RIGHT"
|
return "RIGHT"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -177,6 +286,10 @@ class Selector:
|
|||||||
return "QUIT"
|
return "QUIT"
|
||||||
if char in ("a", "A"):
|
if char in ("a", "A"):
|
||||||
return "ALL"
|
return "ALL"
|
||||||
|
if char == "e":
|
||||||
|
return "EXPAND"
|
||||||
|
if char == "E":
|
||||||
|
return "EXPAND_ALL"
|
||||||
if char in ("w", "W", "k", "K"):
|
if char in ("w", "W", "k", "K"):
|
||||||
return "UP"
|
return "UP"
|
||||||
if char in ("s", "S", "j", "J"):
|
if char in ("s", "S", "j", "J"):
|
||||||
@@ -193,28 +306,33 @@ class Selector:
|
|||||||
Returns command strings like 'UP', 'DOWN', 'ENTER', etc.
|
Returns command strings like 'UP', 'DOWN', 'ENTER', etc.
|
||||||
"""
|
"""
|
||||||
char = click.getchar()
|
char = click.getchar()
|
||||||
|
# Ctrl+C
|
||||||
if char == "\x03":
|
if char == "\x03":
|
||||||
return "CANCEL"
|
return "CANCEL"
|
||||||
|
|
||||||
|
# ANSI Escape Sequences for Arrow Keys
|
||||||
mapping = {
|
mapping = {
|
||||||
"\x1b[A": "UP",
|
"\x1b[A": "UP", # Escape + [ + A
|
||||||
"\x1b[B": "DOWN",
|
"\x1b[B": "DOWN", # Escape + [ + B
|
||||||
"\x1b[C": "RIGHT",
|
"\x1b[C": "RIGHT", # Escape + [ + C
|
||||||
"\x1b[D": "LEFT",
|
"\x1b[D": "LEFT", # Escape + [ + D
|
||||||
}
|
}
|
||||||
if char in mapping:
|
if char in mapping:
|
||||||
return mapping[char]
|
return mapping[char]
|
||||||
if char == "\x1b":
|
|
||||||
|
# Handling manual Escape sequences
|
||||||
|
if char == "\x1b": # ESC
|
||||||
try:
|
try:
|
||||||
next1 = click.getchar()
|
next1 = click.getchar()
|
||||||
if next1 in ("[", "O"):
|
if next1 in ("[", "O"): # Sequence indicators
|
||||||
next2 = click.getchar()
|
next2 = click.getchar()
|
||||||
if next2 == "A":
|
if next2 == "A": # Arrow Up
|
||||||
return "UP"
|
return "UP"
|
||||||
if next2 == "B":
|
if next2 == "B": # Arrow Down
|
||||||
return "DOWN"
|
return "DOWN"
|
||||||
if next2 == "C":
|
if next2 == "C": # Arrow Right
|
||||||
return "RIGHT"
|
return "RIGHT"
|
||||||
if next2 == "D":
|
if next2 == "D": # Arrow Left
|
||||||
return "LEFT"
|
return "LEFT"
|
||||||
return "CANCEL"
|
return "CANCEL"
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -228,6 +346,10 @@ class Selector:
|
|||||||
return "QUIT"
|
return "QUIT"
|
||||||
if char in ("a", "A"):
|
if char in ("a", "A"):
|
||||||
return "ALL"
|
return "ALL"
|
||||||
|
if char == "e":
|
||||||
|
return "EXPAND"
|
||||||
|
if char == "E":
|
||||||
|
return "EXPAND_ALL"
|
||||||
if char in ("w", "W", "k", "K"):
|
if char in ("w", "W", "k", "K"):
|
||||||
return "UP"
|
return "UP"
|
||||||
if char in ("s", "S", "j", "J"):
|
if char in ("s", "S", "j", "J"):
|
||||||
@@ -263,6 +385,10 @@ class Selector:
|
|||||||
self.change_page(-1)
|
self.change_page(-1)
|
||||||
elif action == "RIGHT":
|
elif action == "RIGHT":
|
||||||
self.change_page(1)
|
self.change_page(1)
|
||||||
|
elif action == "EXPAND":
|
||||||
|
self.toggle_expand()
|
||||||
|
elif action == "EXPAND_ALL":
|
||||||
|
self.toggle_expand_all()
|
||||||
elif action == "SPACE":
|
elif action == "SPACE":
|
||||||
self.toggle_selection()
|
self.toggle_selection()
|
||||||
elif action == "ALL":
|
elif action == "ALL":
|
||||||
@@ -282,6 +408,7 @@ def select_multiple(
|
|||||||
page_size: int = 8,
|
page_size: int = 8,
|
||||||
return_indices: bool = True,
|
return_indices: bool = True,
|
||||||
cursor_style: str = "pink",
|
cursor_style: str = "pink",
|
||||||
|
collapse_on_start: bool = False,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> list[int]:
|
) -> list[int]:
|
||||||
"""
|
"""
|
||||||
@@ -293,6 +420,7 @@ def select_multiple(
|
|||||||
page_size: Number of items per page.
|
page_size: Number of items per page.
|
||||||
return_indices: If True, returns indices; otherwise returns the option strings.
|
return_indices: If True, returns indices; otherwise returns the option strings.
|
||||||
cursor_style: Style color for the cursor.
|
cursor_style: Style color for the cursor.
|
||||||
|
collapse_on_start: If True, child items are hidden initially.
|
||||||
"""
|
"""
|
||||||
selector = Selector(
|
selector = Selector(
|
||||||
options=options,
|
options=options,
|
||||||
@@ -300,6 +428,7 @@ def select_multiple(
|
|||||||
text_style="text",
|
text_style="text",
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
minimal_count=minimal_count,
|
minimal_count=minimal_count,
|
||||||
|
collapse_on_start=collapse_on_start,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -303,21 +303,6 @@ class EXAMPLE(Service):
|
|||||||
def get_widevine_service_certificate(self, **_: any) -> str:
|
def get_widevine_service_certificate(self, **_: any) -> str:
|
||||||
return self.config.get("certificate")
|
return self.config.get("certificate")
|
||||||
|
|
||||||
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
|
|
||||||
license_url = self.config["endpoints"].get("playready_license")
|
|
||||||
if not license_url:
|
|
||||||
raise ValueError("PlayReady license endpoint not configured")
|
|
||||||
|
|
||||||
response = self.session.post(
|
|
||||||
url=license_url,
|
|
||||||
data=challenge,
|
|
||||||
headers={
|
|
||||||
"user-agent": self.config["client"][self.device]["license_user_agent"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.content
|
|
||||||
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
||||||
license_url = self.license_data.get("url") or self.config["endpoints"].get("widevine_license")
|
license_url = self.license_data.get("url") or self.config["endpoints"].get("widevine_license")
|
||||||
if not license_url:
|
if not license_url:
|
||||||
@@ -340,3 +325,18 @@ class EXAMPLE(Service):
|
|||||||
return response.json().get("license")
|
return response.json().get("license")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return response.content
|
return response.content
|
||||||
|
|
||||||
|
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
||||||
|
license_url = self.config["endpoints"].get("playready_license")
|
||||||
|
if not license_url:
|
||||||
|
raise ValueError("PlayReady license endpoint not configured")
|
||||||
|
|
||||||
|
response = self.session.post(
|
||||||
|
url=license_url,
|
||||||
|
data=challenge,
|
||||||
|
headers={
|
||||||
|
"user-agent": self.config["client"][self.device]["license_user_agent"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ title_cache_enabled: true # Enable/disable title caching globally (default: true
|
|||||||
title_cache_time: 1800 # Cache duration in seconds (default: 1800 = 30 minutes)
|
title_cache_time: 1800 # Cache duration in seconds (default: 1800 = 30 minutes)
|
||||||
title_cache_max_retention: 86400 # Maximum cache retention for fallback when API fails (default: 86400 = 24 hours)
|
title_cache_max_retention: 86400 # Maximum cache retention for fallback when API fails (default: 86400 = 24 hours)
|
||||||
|
|
||||||
|
# Filename Configuration
|
||||||
|
unicode_filenames: false # optionally replace non-ASCII characters with ASCII equivalents
|
||||||
|
insert_episodename_into_filenames: true # optionally determines whether the specific name of an episode is automatically included within the filename for series content.
|
||||||
|
|
||||||
# Debug logging configuration
|
# Debug logging configuration
|
||||||
# Comprehensive JSON-based debug logging for troubleshooting and service development
|
# Comprehensive JSON-based debug logging for troubleshooting and service development
|
||||||
debug:
|
debug:
|
||||||
|
|||||||
Reference in New Issue
Block a user