18 Commits

Author SHA1 Message Date
Andy
8b63be4f3e feat(dl): add --repack flag to insert REPACK tag in output filenames 2026-02-22 11:42:35 -07:00
Andy
8a4399665e fix(dl): handle cross-device moves when temp and downloads differ 2026-02-22 10:52:04 -07:00
Andy
4814ba9144 fix(dl): overwrite existing files on re-download and use atomic replace
The collision-avoidance logic was preventing overwrites of files from previous runs.

Also switch subtitles with OnSegmentFilter from n_m3u8dl_re to the requests downloader so segment filtering works at download time.
2026-02-21 15:33:07 -07:00
Andy
ff093a7896 fix(dl): allow selection of audio tracks for 'all' languages in addition to 'best' 2026-02-20 21:40:41 -07:00
Andy
829ae01000 fix(hybrid): accept HDR10+ tracks as valid base layer for HYBRID mode
HYBRID mode previously required a plain HDR10 track, rejecting HDR10+ (HDR10P) even though it's a perfectly valid (and superior) base layer.
HDR10+ is now preferred over HDR10 when both are available, preserving dynamic metadata in the final DV Profile 8 output.
2026-02-18 15:56:55 -07:00
Andy
e7120bd063 fix(attachment): sanitize filenames with illegal Windows characters 2026-02-17 16:02:59 -07:00
Andy
42ee9d67a3 fix(hybrid): skip bitrate filter for DV tracks in HYBRID mode 2026-02-17 15:38:55 -07:00
Andy
b0f5b11820 feat(debug): log binary tool versions at session start 2026-02-17 14:39:28 -07:00
Andy
c10257b8dc Revert "feat(debug): add JSONL debug logging to decryption, muxing, and all downloaders"
This reverts commit cc89f4ca93.
2026-02-17 14:37:50 -07:00
Andy
cc89f4ca93 feat(debug): add JSONL debug logging to decryption, muxing, and all downloaders
Expand debug logging coverage for better diagnostics when investigating download/decryption issues like QUICKTIME/cbcs problem.
2026-02-17 13:58:36 -07:00
Andy
0217086abf style: fix ruff E721, E701, and E722 lint errors 2026-02-16 13:37:23 -07:00
Andy
df92f9e4b6 refactor(hybrid): replace log.info with console status and add JSONL debug logging 2026-02-16 13:33:11 -07:00
Andy
9ed56709cd Merge branch 'dev' of https://github.com/unshackle-dl/unshackle into dev 2026-02-15 17:38:52 -07:00
Andy
f96f1f9a95 feat(hybrid): add L5 active area and dynamic L6 luminance metadata
Add crop detection via ffmpeg to generate Level 5 active area metadata, resolving DV/HDR10 black bar mismatches. Update Level 6 to extract actual luminance values from the RPU instead of hardcoding defaults.
2026-02-15 17:38:43 -07:00
Sp5rky
9f9a609d71 Merge pull request #77 from CodeName393/Select-Titles
Add Select titles option
2026-02-15 16:19:42 -07:00
Andy
cee7d9a75f fix(n_m3u8dl_re): pass all content keys for DualKey DRM decryption 2026-02-15 13:37:49 -07:00
CodeName393
dd19f405a4 Add selector 2026-02-09 02:21:04 +09:00
CodeName393
dbebf68f18 Add select-titles 2026-02-09 02:20:26 +09:00
9 changed files with 984 additions and 97 deletions

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import html
import logging
import math
import os
import random
import re
import shutil
@@ -65,6 +66,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
@@ -194,12 +196,7 @@ class dl:
sdh_suffix = ".sdh" if (subtitle.sdh or subtitle.cc) else ""
extension = (target_codec or subtitle.codec or Subtitle.Codec.SubRip).extension
if (
not target_codec
and not subtitle.codec
and source_path
and source_path.suffix
):
if not target_codec and not subtitle.codec and source_path and source_path.suffix:
extension = source_path.suffix.lstrip(".")
filename = f"{base_filename}.{lang_suffix}{forced_suffix}{sdh_suffix}.{extension}"
@@ -346,6 +343,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",
@@ -403,6 +406,7 @@ class dl:
@click.option(
"--tag", type=str, default=None, help="Set the Group Tag to be used, overriding the one in config if any."
)
@click.option("--repack", is_flag=True, default=False, help="Add REPACK tag to the output filename.")
@click.option(
"--tmdb",
"tmdb_id",
@@ -505,6 +509,7 @@ class dl:
no_proxy: bool,
profile: Optional[str] = None,
proxy: Optional[str] = None,
repack: bool = False,
tag: Optional[str] = None,
tmdb_id: Optional[int] = None,
tmdb_name: bool = False,
@@ -586,6 +591,59 @@ class dl:
},
},
)
# Log binary versions for diagnostics
binary_versions = {}
for name, binary in [
("shaka_packager", binaries.ShakaPackager),
("mp4decrypt", binaries.Mp4decrypt),
("n_m3u8dl_re", binaries.N_m3u8DL_RE),
("mkvmerge", binaries.MKVToolNix),
("ffmpeg", binaries.FFMPEG),
("ffprobe", binaries.FFProbe),
]:
if binary:
version = None
try:
if name == "shaka_packager":
r = subprocess.run(
[str(binary), "--version"], capture_output=True, text=True, timeout=5
)
version = (r.stdout or r.stderr or "").strip()
elif name in ("ffmpeg", "ffprobe"):
r = subprocess.run(
[str(binary), "-version"], capture_output=True, text=True, timeout=5
)
version = (r.stdout or "").split("\n")[0].strip()
elif name == "mkvmerge":
r = subprocess.run(
[str(binary), "--version"], capture_output=True, text=True, timeout=5
)
version = (r.stdout or "").strip()
elif name == "mp4decrypt":
r = subprocess.run(
[str(binary)], capture_output=True, text=True, timeout=5
)
output = (r.stdout or "") + (r.stderr or "")
lines = [line.strip() for line in output.split("\n") if line.strip()]
version = " | ".join(lines[:2]) if lines else None
elif name == "n_m3u8dl_re":
r = subprocess.run(
[str(binary), "--version"], capture_output=True, text=True, timeout=5
)
version = (r.stdout or r.stderr or "").strip().split("\n")[0]
except Exception:
version = "<error getting version>"
binary_versions[name] = {"path": str(binary), "version": version}
else:
binary_versions[name] = None
self.debug_logger.log(
level="DEBUG",
operation="binary_versions",
message="Binary tool versions",
context=binary_versions,
)
else:
self.debug_logger = None
@@ -841,6 +899,9 @@ class dl:
config=self.service_config, cdm=self.cdm, proxy_providers=self.proxy_providers, profile=self.profile
)
if repack:
config.repack = True
if tag:
config.tag = tag
@@ -859,6 +920,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 +1109,78 @@ class dl:
if list_titles:
return
# Enables manual selection for Series when --select-titles is set
if select_titles and isinstance(titles, Series):
console.print(Padding(Rule("[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:
@@ -1264,10 +1398,20 @@ class dl:
self.log.warning(f"Skipping {color_range.name} video tracks as none are available.")
if vbitrate:
title.tracks.select_video(lambda x: x.bitrate and x.bitrate // 1000 == vbitrate)
if not title.tracks.videos:
self.log.error(f"There's no {vbitrate}kbps Video Track...")
sys.exit(1)
if any(r == Video.Range.HYBRID for r in range_):
# In HYBRID mode, only apply bitrate filter to non-DV tracks
# DV tracks are kept regardless since they're only used for RPU metadata
title.tracks.select_video(
lambda x: x.range == Video.Range.DV or (x.bitrate and x.bitrate // 1000 == vbitrate)
)
if not any(x.range != Video.Range.DV for x in title.tracks.videos):
self.log.error(f"There's no {vbitrate}kbps Video Track...")
sys.exit(1)
else:
title.tracks.select_video(lambda x: x.bitrate and x.bitrate // 1000 == vbitrate)
if not title.tracks.videos:
self.log.error(f"There's no {vbitrate}kbps Video Track...")
sys.exit(1)
video_languages = [lang for lang in (v_lang or lang) if lang != "best"]
if video_languages and "all" not in video_languages:
@@ -1358,24 +1502,33 @@ class dl:
# validate hybrid mode requirements
if any(r == Video.Range.HYBRID for r in range_):
hdr10_tracks = [v for v in title.tracks.videos if v.range == Video.Range.HDR10]
base_tracks = [
v for v in title.tracks.videos
if v.range in (Video.Range.HDR10, Video.Range.HDR10P)
]
dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV]
if not hdr10_tracks and not dv_tracks:
if not base_tracks and not dv_tracks:
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
self.log.error("HYBRID mode requires both HDR10 and DV tracks, but neither is available")
self.log.error(
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but neither is available"
)
self.log.error(
f"Available ranges: {', '.join(available_ranges) if available_ranges else 'none'}"
)
sys.exit(1)
elif not hdr10_tracks:
elif not base_tracks:
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
self.log.error("HYBRID mode requires both HDR10 and DV tracks, but only DV is available")
self.log.error(
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only DV is available"
)
self.log.error(f"Available ranges: {', '.join(available_ranges)}")
sys.exit(1)
elif not dv_tracks:
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
self.log.error("HYBRID mode requires both HDR10 and DV tracks, but only HDR10 is available")
self.log.error(
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only HDR10 is available"
)
self.log.error(f"Available ranges: {', '.join(available_ranges)}")
sys.exit(1)
@@ -1461,7 +1614,7 @@ class dl:
if language not in processed_lang:
processed_lang.append(language)
if "best" in processed_lang:
if "best" in processed_lang or "all" in processed_lang:
unique_languages = {track.language for track in title.tracks.audio}
selected_audio = []
for language in unique_languages:
@@ -1484,7 +1637,7 @@ class dl:
else:
selected_audio.append(max(base_candidates, key=lambda x: x.bitrate or 0))
title.tracks.audio = selected_audio
elif "all" not in processed_lang:
else:
# If multiple codecs were explicitly requested, pick the best track per codec per
# requested language instead of selecting *all* bitrate variants of a codec.
if acodec and len(acodec) > 1:
@@ -1518,7 +1671,10 @@ class dl:
if audio_description:
standard_audio = [a for a in title.tracks.audio if not a.descriptive]
selected_standards = title.tracks.by_language(
standard_audio, processed_lang, per_language=per_language, exact_match=exact_lang
standard_audio,
processed_lang,
per_language=per_language,
exact_match=exact_lang,
)
desc_audio = [a for a in title.tracks.audio if a.descriptive]
# Include all descriptive tracks for the requested languages.
@@ -1621,6 +1777,11 @@ class dl:
)
self.cdm = quality_based_cdm
for track in title.tracks.subtitles:
if callable(track.OnSegmentFilter) and track.downloader.__name__ == "n_m3u8dl_re":
from unshackle.core.downloaders import requests as requests_downloader
track.downloader = requests_downloader
dl_start_time = time.time()
try:
@@ -1642,9 +1803,7 @@ class dl:
),
licence=partial(
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,
title=title,
@@ -1762,9 +1921,7 @@ class dl:
# Subtitle output mode configuration (for sidecar originals)
subtitle_output_mode = config.subtitle.get("output_mode", "mux")
sidecar_format = config.subtitle.get("sidecar_format", "srt")
skip_subtitle_mux = (
subtitle_output_mode == "sidecar" and (title.tracks.videos or title.tracks.audio)
)
skip_subtitle_mux = subtitle_output_mode == "sidecar" and (title.tracks.videos or title.tracks.audio)
sidecar_subtitles: list[Subtitle] = []
sidecar_original_paths: dict[str, Path] = {}
if subtitle_output_mode in ("sidecar", "both") and not no_mux:
@@ -1901,12 +2058,15 @@ class dl:
# Hybrid mode: process DV and HDR10 tracks separately for each resolution
self.log.info("Processing Hybrid HDR10+DV tracks...")
# Group video tracks by resolution
# Group video tracks by resolution (prefer HDR10+ over HDR10 as base)
resolutions_processed = set()
hdr10_tracks = [v for v in title.tracks.videos if v.range == Video.Range.HDR10]
base_tracks_list = [
v for v in title.tracks.videos
if v.range in (Video.Range.HDR10P, Video.Range.HDR10)
]
dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV]
for hdr10_track in hdr10_tracks:
for hdr10_track in base_tracks_list:
resolution = hdr10_track.height
if resolution in resolutions_processed:
continue
@@ -2015,7 +2175,9 @@ class dl:
sidecar_dir = config.directories.downloads
if not no_folder and isinstance(title, (Episode, Song)) and media_info:
sidecar_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
sidecar_dir /= title.get_filename(
media_info, show_service=not no_source, folder=True
)
sidecar_dir.mkdir(parents=True, exist_ok=True)
with console.status("Saving subtitle sidecar files..."):
@@ -2115,6 +2277,7 @@ class dl:
self.log.debug(f"Saved: {final_path.name}")
else:
# Handle muxed files
used_final_paths: set[Path] = set()
for muxed_path in muxed_paths:
media_info = MediaInfo.parse(muxed_path)
final_dir = config.directories.downloads
@@ -2131,14 +2294,20 @@ class dl:
final_filename = f"{final_filename.rstrip()}{sep}{audio_codec_suffix.name}"
final_path = final_dir / f"{final_filename}{muxed_path.suffix}"
if final_path.exists():
if final_path in used_final_paths:
sep = "." if config.scene_naming else " "
i = 2
while final_path.exists():
while final_path in used_final_paths:
final_path = final_dir / f"{final_filename.rstrip()}{sep}{i}{muxed_path.suffix}"
i += 1
shutil.move(muxed_path, final_path)
try:
os.replace(muxed_path, final_path)
except OSError:
if final_path.exists():
final_path.unlink()
shutil.move(muxed_path, final_path)
used_final_paths.add(final_path)
tags.tag_file(final_path, title, self.tmdb_id)
title_dl_time = time_elapsed_since(dl_start_time)

View File

@@ -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"):

View File

@@ -156,6 +156,9 @@ class Episode(Title):
name=self.name or "",
).strip()
if getattr(config, "repack", False):
name += " REPACK"
if primary_video_track:
resolution_token = _get_resolution_token(primary_video_track)
if resolution_token:

View File

@@ -91,6 +91,9 @@ class Movie(Title):
# Name (Year)
name = str(self).replace("$", "S") # e.g., Arli$$
if getattr(config, "repack", False):
name += " REPACK"
if primary_video_track:
resolution_token = _get_resolution_token(primary_video_track)
if resolution_token:

View File

@@ -100,6 +100,9 @@ class Song(Title):
# NN. Song Name
name = str(self).split(" / ")[1]
if getattr(config, "repack", False):
name += " REPACK"
# Service (use track source if available)
if show_service:
source_name = None

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import mimetypes
import os
import re
from pathlib import Path
from typing import Optional, Union
from urllib.parse import urlparse
@@ -56,7 +57,8 @@ class Attachment:
# Use provided name for the file if available
if name:
file_name = f"{name.replace(' ', '_')}{os.path.splitext(file_name)[1]}"
safe_name = re.sub(r'[<>:"/\\|?*]', "", name).replace(" ", "_")
file_name = f"{safe_name}{os.path.splitext(file_name)[1]}"
download_path = config.directories.temp / file_name

View File

@@ -1,6 +1,8 @@
import json
import logging
import os
import random
import re
import subprocess
import sys
from pathlib import Path
@@ -8,14 +10,16 @@ from pathlib import Path
from rich.padding import Padding
from rich.rule import Rule
from unshackle.core.binaries import FFMPEG, DoviTool, HDR10PlusTool
from unshackle.core.binaries import FFMPEG, DoviTool, FFProbe, HDR10PlusTool
from unshackle.core.config import config
from unshackle.core.console import console
from unshackle.core.utilities import get_debug_logger
class Hybrid:
def __init__(self, videos, source) -> None:
self.log = logging.getLogger("hybrid")
self.debug_logger = get_debug_logger()
"""
Takes the Dolby Vision and HDR10(+) streams out of the VideoTracks.
@@ -41,6 +45,19 @@ class Hybrid:
console.print(Padding(Rule(f"[rule.text]HDR10+DV Hybrid ({self.resolution})"), (1, 2)))
if self.debug_logger:
self.debug_logger.log(
level="DEBUG",
operation="hybrid_init",
message="Starting HDR10+DV hybrid processing",
context={
"source": source,
"resolution": self.resolution,
"video_count": len(videos),
"video_ranges": [str(v.range) for v in videos],
},
)
for video in self.videos:
if not video.path or not os.path.exists(video.path):
raise ValueError(f"Video track {video.id} was not downloaded before injection.")
@@ -50,18 +67,18 @@ class Hybrid:
has_hdr10 = any(video.range == Video.Range.HDR10 for video in self.videos)
has_hdr10p = any(video.range == Video.Range.HDR10P for video in self.videos)
if not has_hdr10:
raise ValueError("No HDR10 track available for hybrid processing.")
if not has_hdr10 and not has_hdr10p:
raise ValueError("No HDR10 or HDR10+ track available for hybrid processing.")
# If we have HDR10+ but no DV, we can convert HDR10+ to DV
if not has_dv and has_hdr10p:
self.log.info("No DV track found, but HDR10+ is available. Will convert HDR10+ to DV.")
console.status("No DV track found, but HDR10+ is available. Will convert HDR10+ to DV.")
self.hdr10plus_to_dv = True
elif not has_dv:
raise ValueError("No DV track available and no HDR10+ to convert.")
if os.path.isfile(config.directories.temp / self.hevc_file):
self.log.info("Already Injected")
console.status("Already Injected")
return
for video in videos:
@@ -89,14 +106,34 @@ class Hybrid:
self.extract_rpu(dv_video)
if os.path.isfile(config.directories.temp / "RPU_UNT.bin"):
self.rpu_file = "RPU_UNT.bin"
self.level_6()
# Mode 3 conversion already done during extraction when not untouched
elif os.path.isfile(config.directories.temp / "RPU.bin"):
# RPU already extracted with mode 3
pass
# Edit L6 with actual luminance values from RPU, then L5 active area
self.level_6()
base_video = next(
(v for v in videos if v.range in (Video.Range.HDR10, Video.Range.HDR10P)), None
)
if base_video and base_video.path:
self.level_5(base_video.path)
self.injecting()
if self.debug_logger:
self.debug_logger.log(
level="INFO",
operation="hybrid_complete",
message="Injection Completed",
context={
"hdr_type": self.hdr_type,
"resolution": self.resolution,
"hdr10plus_to_dv": self.hdr10plus_to_dv,
"rpu_file": self.rpu_file,
"output_file": self.hevc_file,
},
)
self.log.info("✓ Injection Completed")
if self.source == ("itunes" or "appletvplus"):
Path.unlink(config.directories.temp / "hdr10.mkv")
@@ -104,6 +141,10 @@ class Hybrid:
Path.unlink(config.directories.temp / "HDR10.hevc", missing_ok=True)
Path.unlink(config.directories.temp / "DV.hevc", missing_ok=True)
Path.unlink(config.directories.temp / f"{self.rpu_file}", missing_ok=True)
Path.unlink(config.directories.temp / "RPU_L6.bin", missing_ok=True)
Path.unlink(config.directories.temp / "RPU_L5.bin", missing_ok=True)
Path.unlink(config.directories.temp / "L5.json", missing_ok=True)
Path.unlink(config.directories.temp / "L6.json", missing_ok=True)
def ffmpeg_simple(self, save_path, output):
"""Simple ffmpeg execution without progress tracking"""
@@ -121,20 +162,41 @@ class Hybrid:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
return p.returncode
return p
def extract_stream(self, save_path, type_):
output = Path(config.directories.temp / f"{type_}.hevc")
with console.status(f"Extracting {type_} stream...", spinner="dots"):
returncode = self.ffmpeg_simple(save_path, output)
result = self.ffmpeg_simple(save_path, output)
if returncode:
if result.returncode:
output.unlink(missing_ok=True)
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_extract_stream",
message=f"Failed extracting {type_} stream",
context={
"type": type_,
"input": str(save_path),
"output": str(output),
"returncode": result.returncode,
"stderr": (result.stderr or b"").decode(errors="replace"),
"stdout": (result.stdout or b"").decode(errors="replace"),
},
)
self.log.error(f"x Failed extracting {type_} stream")
sys.exit(1)
self.log.info(f"Extracted {type_} stream")
if self.debug_logger:
self.debug_logger.log(
level="DEBUG",
operation="hybrid_extract_stream",
message=f"Extracted {type_} stream",
context={"type": type_, "input": str(save_path), "output": str(output)},
success=True,
)
def extract_rpu(self, video, untouched=False):
if os.path.isfile(config.directories.temp / "RPU.bin") or os.path.isfile(
@@ -161,58 +223,326 @@ class Hybrid:
stderr=subprocess.PIPE,
)
rpu_name = "RPU" if not untouched else "RPU_UNT"
if rpu_extraction.returncode:
Path.unlink(config.directories.temp / f"{'RPU' if not untouched else 'RPU_UNT'}.bin")
Path.unlink(config.directories.temp / f"{rpu_name}.bin")
stderr_text = rpu_extraction.stderr.decode(errors="replace") if rpu_extraction.stderr else ""
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_extract_rpu",
message=f"Failed extracting{' untouched ' if untouched else ' '}RPU",
context={
"untouched": untouched,
"returncode": rpu_extraction.returncode,
"stderr": stderr_text,
"args": [str(a) for a in extraction_args],
},
)
if b"MAX_PQ_LUMINANCE" in rpu_extraction.stderr:
self.extract_rpu(video, untouched=True)
elif b"Invalid PPS index" in rpu_extraction.stderr:
raise ValueError("Dolby Vision VideoTrack seems to be corrupt")
else:
raise ValueError(f"Failed extracting{' untouched ' if untouched else ' '}RPU from Dolby Vision stream")
elif self.debug_logger:
self.debug_logger.log(
level="DEBUG",
operation="hybrid_extract_rpu",
message=f"Extracted{' untouched ' if untouched else ' '}RPU from Dolby Vision stream",
context={"untouched": untouched, "output": f"{rpu_name}.bin"},
success=True,
)
self.log.info(f"Extracted{' untouched ' if untouched else ' '}RPU from Dolby Vision stream")
def level_5(self, input_video):
"""Generate Level 5 active area metadata via crop detection on the HDR10 stream.
def level_6(self):
"""Edit RPU Level 6 values"""
with open(config.directories.temp / "L6.json", "w+") as level6_file:
level6 = {
"cm_version": "V29",
"length": 0,
"level6": {
"max_display_mastering_luminance": 1000,
"min_display_mastering_luminance": 1,
"max_content_light_level": 0,
"max_frame_average_light_level": 0,
},
}
This resolves mismatches where DV has no black bars but HDR10 does (or vice versa)
by telling the display the correct active area.
"""
if os.path.isfile(config.directories.temp / "RPU_L5.bin"):
return
json.dump(level6, level6_file, indent=3)
ffprobe_bin = str(FFProbe) if FFProbe else "ffprobe"
ffmpeg_bin = str(FFMPEG) if FFMPEG else "ffmpeg"
if not os.path.isfile(config.directories.temp / "RPU_L6.bin"):
with console.status("Editing RPU Level 6 values...", spinner="dots"):
level6 = subprocess.run(
# Get video duration for random sampling
with console.status("Detecting active area (crop detection)...", spinner="dots"):
result_duration = subprocess.run(
[ffprobe_bin, "-v", "error", "-show_entries", "format=duration", "-of", "json", str(input_video)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if result_duration.returncode != 0:
if self.debug_logger:
self.debug_logger.log(
level="WARNING",
operation="hybrid_level5",
message="Could not probe video duration",
context={"returncode": result_duration.returncode, "stderr": (result_duration.stderr or "")},
)
self.log.warning("Could not probe video duration, skipping L5 crop detection")
return
duration_info = json.loads(result_duration.stdout)
duration = float(duration_info["format"]["duration"])
# Get video resolution for proper border calculation
result_streams = subprocess.run(
[
ffprobe_bin,
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"json",
str(input_video),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if result_streams.returncode != 0:
if self.debug_logger:
self.debug_logger.log(
level="WARNING",
operation="hybrid_level5",
message="Could not probe video resolution",
context={"returncode": result_streams.returncode, "stderr": (result_streams.stderr or "")},
)
self.log.warning("Could not probe video resolution, skipping L5 crop detection")
return
stream_info = json.loads(result_streams.stdout)
original_width = int(stream_info["streams"][0]["width"])
original_height = int(stream_info["streams"][0]["height"])
# Sample 10 random timestamps and run cropdetect on each
random_times = sorted(random.uniform(0, duration) for _ in range(10))
crop_results = []
for t in random_times:
result_cropdetect = subprocess.run(
[
str(DoviTool),
"editor",
ffmpeg_bin,
"-y",
"-nostdin",
"-loglevel",
"info",
"-ss",
f"{t:.2f}",
"-i",
config.directories.temp / self.rpu_file,
"-j",
config.directories.temp / "L6.json",
"-o",
config.directories.temp / "RPU_L6.bin",
str(input_video),
"-vf",
"cropdetect=round=2",
"-vframes",
"10",
"-f",
"null",
"-",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if level6.returncode:
Path.unlink(config.directories.temp / "RPU_L6.bin")
raise ValueError("Failed editing RPU Level 6 values")
# cropdetect outputs crop=w:h:x:y
crop_match = re.search(
r"crop=(\d+):(\d+):(\d+):(\d+)",
(result_cropdetect.stdout or "") + (result_cropdetect.stderr or ""),
)
if crop_match:
w, h = int(crop_match.group(1)), int(crop_match.group(2))
x, y = int(crop_match.group(3)), int(crop_match.group(4))
# Calculate actual border sizes from crop geometry
left = x
top = y
right = original_width - w - x
bottom = original_height - h - y
crop_results.append((left, top, right, bottom))
self.log.info("Edited RPU Level 6 values")
if not crop_results:
if self.debug_logger:
self.debug_logger.log(
level="WARNING",
operation="hybrid_level5",
message="No crop data detected, skipping L5",
context={"samples": len(random_times)},
)
self.log.warning("No crop data detected, skipping L5")
return
# Update rpu_file to use the edited version
self.rpu_file = "RPU_L6.bin"
# Find the most common crop values
crop_counts = {}
for crop in crop_results:
crop_counts[crop] = crop_counts.get(crop, 0) + 1
most_common = max(crop_counts, key=crop_counts.get)
left, top, right, bottom = most_common
# If all borders are 0 there's nothing to correct
if left == 0 and top == 0 and right == 0 and bottom == 0:
return
l5_json = {
"active_area": {
"crop": False,
"presets": [{"id": 0, "left": left, "right": right, "top": top, "bottom": bottom}],
"edits": {"all": 0},
}
}
l5_path = config.directories.temp / "L5.json"
with open(l5_path, "w") as f:
json.dump(l5_json, f, indent=4)
with console.status("Editing RPU Level 5 active area...", spinner="dots"):
result = subprocess.run(
[
str(DoviTool),
"editor",
"-i",
str(config.directories.temp / self.rpu_file),
"-j",
str(l5_path),
"-o",
str(config.directories.temp / "RPU_L5.bin"),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode:
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_level5",
message="Failed editing RPU Level 5 values",
context={"returncode": result.returncode, "stderr": (result.stderr or b"").decode(errors="replace")},
)
Path.unlink(config.directories.temp / "RPU_L5.bin", missing_ok=True)
raise ValueError("Failed editing RPU Level 5 values")
if self.debug_logger:
self.debug_logger.log(
level="DEBUG",
operation="hybrid_level5",
message="Edited RPU Level 5 active area",
context={"crop": {"left": left, "right": right, "top": top, "bottom": bottom}, "samples": len(crop_results)},
success=True,
)
self.rpu_file = "RPU_L5.bin"
def level_6(self):
"""Edit RPU Level 6 values using actual luminance data from the RPU."""
if os.path.isfile(config.directories.temp / "RPU_L6.bin"):
return
with console.status("Reading RPU luminance metadata...", spinner="dots"):
result = subprocess.run(
[str(DoviTool), "info", "-i", str(config.directories.temp / self.rpu_file), "-s"],
capture_output=True,
text=True,
)
if result.returncode != 0:
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_level6",
message="Failed reading RPU metadata for Level 6 values",
context={"returncode": result.returncode, "stderr": (result.stderr or "")},
)
raise ValueError("Failed reading RPU metadata for Level 6 values")
max_cll = None
max_fall = None
max_mdl = None
min_mdl = None
for line in result.stdout.splitlines():
if "RPU content light level (L1):" in line:
parts = line.split("MaxCLL:")[1].split(",")
max_cll = int(float(parts[0].strip().split()[0]))
if len(parts) > 1 and "MaxFALL:" in parts[1]:
max_fall = int(float(parts[1].split("MaxFALL:")[1].strip().split()[0]))
elif "RPU mastering display:" in line:
mastering = line.split(":", 1)[1].strip()
min_lum, max_lum = mastering.split("/")[0], mastering.split("/")[1].split(" ")[0]
min_mdl = int(float(min_lum) * 10000)
max_mdl = int(float(max_lum))
if any(v is None for v in (max_cll, max_fall, max_mdl, min_mdl)):
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_level6",
message="Could not extract Level 6 luminance data from RPU",
context={"max_cll": max_cll, "max_fall": max_fall, "max_mdl": max_mdl, "min_mdl": min_mdl},
)
raise ValueError("Could not extract Level 6 luminance data from RPU")
level6_data = {
"level6": {
"remove_cmv4": False,
"remove_mapping": False,
"max_display_mastering_luminance": max_mdl,
"min_display_mastering_luminance": min_mdl,
"max_content_light_level": max_cll,
"max_frame_average_light_level": max_fall,
}
}
l6_path = config.directories.temp / "L6.json"
with open(l6_path, "w") as f:
json.dump(level6_data, f, indent=4)
with console.status("Editing RPU Level 6 values...", spinner="dots"):
result = subprocess.run(
[
str(DoviTool),
"editor",
"-i",
str(config.directories.temp / self.rpu_file),
"-j",
str(l6_path),
"-o",
str(config.directories.temp / "RPU_L6.bin"),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode:
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_level6",
message="Failed editing RPU Level 6 values",
context={"returncode": result.returncode, "stderr": (result.stderr or b"").decode(errors="replace")},
)
Path.unlink(config.directories.temp / "RPU_L6.bin", missing_ok=True)
raise ValueError("Failed editing RPU Level 6 values")
if self.debug_logger:
self.debug_logger.log(
level="DEBUG",
operation="hybrid_level6",
message="Edited RPU Level 6 luminance values",
context={
"max_cll": max_cll,
"max_fall": max_fall,
"max_mdl": max_mdl,
"min_mdl": min_mdl,
},
success=True,
)
self.rpu_file = "RPU_L6.bin"
def injecting(self):
if os.path.isfile(config.directories.temp / self.hevc_file):
@@ -228,12 +558,6 @@ class Hybrid:
config.directories.temp / self.rpu_file,
]
# If we converted from HDR10+, optionally remove HDR10+ metadata during injection
# Default to removing HDR10+ metadata since we're converting to DV
if self.hdr10plus_to_dv:
inject_cmd.append("--drop-hdr10plus")
self.log.info(" - Removing HDR10+ metadata during injection")
inject_cmd.extend(["-o", config.directories.temp / self.hevc_file])
inject = subprocess.run(
@@ -243,10 +567,29 @@ class Hybrid:
)
if inject.returncode:
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_inject_rpu",
message="Failed injecting Dolby Vision metadata into HDR10 stream",
context={
"returncode": inject.returncode,
"stderr": (inject.stderr or b"").decode(errors="replace"),
"stdout": (inject.stdout or b"").decode(errors="replace"),
"cmd": [str(a) for a in inject_cmd],
},
)
Path.unlink(config.directories.temp / self.hevc_file)
raise ValueError("Failed injecting Dolby Vision metadata into HDR10 stream")
self.log.info(f"Injected Dolby Vision metadata into {self.hdr_type} stream")
if self.debug_logger:
self.debug_logger.log(
level="DEBUG",
operation="hybrid_inject_rpu",
message=f"Injected Dolby Vision metadata into {self.hdr_type} stream",
context={"hdr_type": self.hdr_type, "rpu_file": self.rpu_file, "output": self.hevc_file, "drop_hdr10plus": self.hdr10plus_to_dv},
success=True,
)
def extract_hdr10plus(self, _video):
"""Extract HDR10+ metadata from the video stream"""
@@ -271,13 +614,39 @@ class Hybrid:
)
if extraction.returncode:
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_extract_hdr10plus",
message="Failed extracting HDR10+ metadata",
context={
"returncode": extraction.returncode,
"stderr": (extraction.stderr or b"").decode(errors="replace"),
"stdout": (extraction.stdout or b"").decode(errors="replace"),
},
)
raise ValueError("Failed extracting HDR10+ metadata")
# Check if the extracted file has content
if os.path.getsize(config.directories.temp / self.hdr10plus_file) == 0:
file_size = os.path.getsize(config.directories.temp / self.hdr10plus_file)
if file_size == 0:
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_extract_hdr10plus",
message="No HDR10+ metadata found in the stream",
context={"file_size": 0},
)
raise ValueError("No HDR10+ metadata found in the stream")
self.log.info("Extracted HDR10+ metadata")
if self.debug_logger:
self.debug_logger.log(
level="DEBUG",
operation="hybrid_extract_hdr10plus",
message="Extracted HDR10+ metadata",
context={"output": self.hdr10plus_file, "file_size": file_size},
success=True,
)
def convert_hdr10plus_to_dv(self):
"""Convert HDR10+ metadata to Dolby Vision RPU"""
@@ -317,10 +686,26 @@ class Hybrid:
)
if conversion.returncode:
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_convert_hdr10plus",
message="Failed converting HDR10+ to Dolby Vision",
context={
"returncode": conversion.returncode,
"stderr": (conversion.stderr or b"").decode(errors="replace"),
"stdout": (conversion.stdout or b"").decode(errors="replace"),
},
)
raise ValueError("Failed converting HDR10+ to Dolby Vision")
self.log.info("Converted HDR10+ metadata to Dolby Vision")
self.log.info("✓ HDR10+ successfully converted to Dolby Vision Profile 8")
if self.debug_logger:
self.debug_logger.log(
level="DEBUG",
operation="hybrid_convert_hdr10plus",
message="Converted HDR10+ metadata to Dolby Vision Profile 8",
success=True,
)
# Clean up temporary files
Path.unlink(config.directories.temp / "extra.json")

View File

@@ -278,23 +278,30 @@ class Tracks:
self.subtitles = list(filter(x, self.subtitles))
def select_hybrid(self, tracks, quality):
hdr10_tracks = [
v
for v in tracks
if v.range == Video.Range.HDR10 and (v.height in quality or int(v.width * 9 / 16) in quality)
]
hdr10 = []
# Prefer HDR10+ over HDR10 as the base layer (preserves dynamic metadata)
base_ranges = (Video.Range.HDR10P, Video.Range.HDR10)
base_tracks = []
for range_type in base_ranges:
base_tracks = [
v
for v in tracks
if v.range == range_type and (v.height in quality or int(v.width * 9 / 16) in quality)
]
if base_tracks:
break
base_selected = []
for res in quality:
candidates = [v for v in hdr10_tracks if v.height == res or int(v.width * 9 / 16) == res]
candidates = [v for v in base_tracks if v.height == res or int(v.width * 9 / 16) == res]
if candidates:
best = max(candidates, key=lambda v: v.bitrate) # assumes .bitrate exists
hdr10.append(best)
best = max(candidates, key=lambda v: v.bitrate)
base_selected.append(best)
dv_tracks = [v for v in tracks if v.range == Video.Range.DV]
lowest_dv = min(dv_tracks, key=lambda v: v.height) if dv_tracks else None
def select(x):
if x in hdr10:
if x in base_selected:
return True
if lowest_dv and x is lowest_dv:
return True

View File

@@ -0,0 +1,310 @@
import sys
import click
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 Exception:
pass
try:
char = key.decode("utf-8", errors="ignore")
except Exception:
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 Exception:
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]