34 Commits

Author SHA1 Message Date
Andy
eeec4e1f1b feat(tracks): add edition tags to output filenames
Extend track.edition to support a list of tags (e.g., ["IMAX", "3D"]) that are inserted into the output filename before the resolution.
2026-02-26 11:11:00 -07:00
Sp5rky
6cdfd2828b Merge pull request #66 from CodeName393/Config-Filenames
Add option to include episode titles and fix video resolution bug
2026-02-26 10:30:07 -07:00
Andy
9dc56e63c4 fix: correct formatting and add missing newlines in selector and EXAMPLE service 2026-02-26 08:10:21 -07:00
Sp5rky
31f8532131 Merge pull request #83 from CodeName393/service.py
Improve service.py
2026-02-26 08:07:05 -07:00
Sp5rky
8d05a8ceb8 Merge pull request #79 from CodeName393/select-title-update
Select title update
2026-02-26 08:02:31 -07:00
CodeName393
c5ef13df5d Update selector.py 2026-02-26 15:25:09 +09:00
CodeName393
1611fcc971 Update dl.py 2026-02-26 15:18:41 +09:00
CodeName393
0f25b0ce52 Update selector.py 2026-02-26 15:18:21 +09:00
CodeName393
00b4f2cdd1 Update selector.py 2026-02-26 15:16:34 +09:00
CodeName393
65e6ae88d0 Update dl.py 2026-02-26 15:16:22 +09:00
CodeName393
30269b6c17 Fix 2026-02-26 02:07:06 +09:00
CodeName393
547e9f481c Merge branch 'dev' into Config-Filenames 2026-02-26 02:06:07 +09:00
CodeName393
bde1945f67 Fix 2026-02-26 02:05:40 +09:00
CodeName393
a4e1c6bb75 Fix 2026-02-26 02:01:45 +09:00
CodeName393
b26d47fd9b Update dl.py 2026-02-25 19:27:13 +09:00
CodeName393
772bacfc8f Update selector.py 2026-02-25 19:26:35 +09:00
CodeName393
d261b4715d Fix 2026-02-25 19:22:59 +09:00
CodeName393
05dc682a2d Merge branch 'dev' into service.py 2026-02-25 19:21:22 +09:00
CodeName393
86d464dc8e Fix 2026-02-25 19:19:56 +09:00
CodeName393
20bc7d2dba Fix 2026-02-25 19:18:01 +09:00
CodeName393
b1d28d3229 Fix 2026-02-25 19:17:11 +09:00
CodeName393
eefb6fcad7 Fix 2026-02-25 01:44:33 +09:00
CodeName393
c78a649170 Fix 2 2026-02-25 01:34:58 +09:00
CodeName393
c01e3993ce Fix 1 2026-02-25 01:34:24 +09:00
CodeName393
eba9f846b0 Improve service.py
Fix the issue of missing the get_playready_license service helper function on service.py
2026-02-25 00:44:04 +09:00
CodeName393
d2f221f3fc Restore code comments 2026-02-18 03:36:54 +09:00
CodeName393
57ecddfeeb Delete terminal reset logic 2026-02-18 03:27:35 +09:00
CodeName393
d21a59f306 dl.py Update 2026-02-18 02:43:58 +09:00
CodeName393
b9bf8fddf5 Selector Update 2026-02-18 02:41:00 +09:00
CodeName393
ad7dd69ecd Merge branch 'dev' into Config-Filenames 2026-02-03 23:01:18 +09:00
CodeName393
ca3a6cc3ea Update 2026-02-03 23:00:53 +09:00
CodeName393
77d55153e6 Add insert_episodename_into_filenames config option 2026-01-29 02:38:50 +09:00
CodeName393
4988d37fe9 Update episode name handling in filename 2026-01-29 02:38:31 +09:00
CodeName393
d03d54b0bb Add filename configuration options
Added filename configuration options for handling Unicode and episode names.
2026-01-29 02:37:34 +09:00
12 changed files with 269 additions and 89 deletions

View File

@@ -994,6 +994,10 @@ class dl:
self.log.error("--require-subs and --s-lang cannot be used together")
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
if any(r == Video.Range.HYBRID for r in range_):
from unshackle.core.binaries import DoviTool
@@ -1152,7 +1156,7 @@ class dl:
# 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
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
prefix = " " if multiple_seasons else ""
@@ -1172,9 +1176,18 @@ class dl:
# 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_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()
start_time += selection_end - selection_start
@@ -1900,8 +1913,7 @@ class dl:
),
licence=partial(
service.get_playready_license
if (is_playready_cdm(self.cdm))
and hasattr(service, "get_playready_license")
if is_playready_cdm(self.cdm)
else service.get_widevine_license,
title=title,
track=track,

View File

@@ -97,6 +97,7 @@ class Config:
self.dash_naming: bool = kwargs.get("dash_naming", False)
self.series_year: bool = kwargs.get("series_year", True)
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_max_retention: int = kwargs.get("title_cache_max_retention", 86400) # 24 hours default

View File

@@ -394,6 +394,27 @@ class Service(metaclass=ABCMeta):
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
# The following functions *must* be implemented by the Service.
# The functions will be executed in shown order.

View File

@@ -105,22 +105,21 @@ class Episode(Title):
def _get_resolution_token(track: Any) -> str:
if not track or not getattr(track, "height", None):
return ""
resolution = track.height
width = getattr(track, "width", track.height)
resolution = min(width, track.height)
try:
dar = getattr(track, "other_display_aspect_ratio", None) or []
if dar and dar[0]:
aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")]
if len(aspect_ratio) == 1:
aspect_ratio.append(1)
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
resolution = int(track.width * (9 / 16))
ratio = aspect_ratio[0] / aspect_ratio[1]
if ratio not in (16 / 9, 4 / 3, 9 / 16, 3 / 4):
resolution = int(max(width, track.height) * (9 / 16))
except Exception:
pass
scan_suffix = "p"
scan_type = getattr(track, "scan_type", None)
if scan_type and str(scan_type).lower() == "interlaced":
scan_suffix = "i"
scan_suffix = "i" if str(getattr(track, "scan_type", "")).lower() == "interlaced" else "p"
return f"{resolution}{scan_suffix}"
# 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}"
# Add episode name with dash separator
if self.name:
if self.name and config.insert_episodename_into_filenames:
name += f" - {self.name}"
name = name.strip()
@@ -153,12 +152,17 @@ class Episode(Title):
year=f" {self.year}" if self.year and config.series_year else "",
season=self.season,
number=self.number,
name=self.name or "",
name=self.name if self.name and config.insert_episodename_into_filenames else "",
).strip()
if getattr(config, "repack", False):
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:
resolution_token = _get_resolution_token(primary_video_track)
if resolution_token:

View File

@@ -70,22 +70,21 @@ class Movie(Title):
def _get_resolution_token(track: Any) -> str:
if not track or not getattr(track, "height", None):
return ""
resolution = track.height
width = getattr(track, "width", track.height)
resolution = min(width, track.height)
try:
dar = getattr(track, "other_display_aspect_ratio", None) or []
if dar and dar[0]:
aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")]
if len(aspect_ratio) == 1:
aspect_ratio.append(1)
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
resolution = int(track.width * (9 / 16))
ratio = aspect_ratio[0] / aspect_ratio[1]
if ratio not in (16 / 9, 4 / 3, 9 / 16, 3 / 4):
resolution = int(max(width, track.height) * (9 / 16))
except Exception:
pass
scan_suffix = "p"
scan_type = getattr(track, "scan_type", None)
if scan_type and str(scan_type).lower() == "interlaced":
scan_suffix = "i"
scan_suffix = "i" if str(getattr(track, "scan_type", "")).lower() == "interlaced" else "p"
return f"{resolution}{scan_suffix}"
# Name (Year)
@@ -94,6 +93,11 @@ class Movie(Title):
if getattr(config, "repack", False):
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:
resolution_token = _get_resolution_token(primary_video_track)
if resolution_token:

View File

@@ -103,6 +103,11 @@ class Song(Title):
if getattr(config, "repack", False):
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)
if show_service:
source_name = None

View File

@@ -151,7 +151,7 @@ class Audio(Track):
),
f"{self.bitrate // 1000} kb/s" if self.bitrate else None,
self.get_track_name(),
self.edition,
", ".join(self.edition) if self.edition else None,
],
)
)

View File

@@ -66,8 +66,8 @@ class Track:
raise TypeError(f"Expected name to be a {str}, not {type(name)}")
if not isinstance(id_, (str, type(None))):
raise TypeError(f"Expected id_ to be a {str}, not {type(id_)}")
if not isinstance(edition, (str, type(None))):
raise TypeError(f"Expected edition to be a {str}, not {type(edition)}")
if not isinstance(edition, (str, list, type(None))):
raise TypeError(f"Expected edition to be a {str}, {list}, or None, not {type(edition)}")
if not isinstance(downloader, (Callable, type(None))):
raise TypeError(f"Expected downloader to be a {Callable}, not {type(downloader)}")
if not isinstance(downloader_args, (dict, type(None))):
@@ -103,7 +103,7 @@ class Track:
self.needs_repack = needs_repack
self.name = name
self.drm = drm
self.edition: str = edition
self.edition: list[str] = [edition] if isinstance(edition, str) else (edition or [])
self.downloader = downloader
self.downloader_args = downloader_args
self.from_file = from_file

View File

@@ -291,7 +291,7 @@ class Video(Track):
],
)
),
self.edition,
", ".join(self.edition) if self.edition else None,
],
)
)

View File

@@ -17,7 +17,7 @@ if IS_WINDOWS:
class Selector:
"""
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__(
@@ -28,7 +28,7 @@ class Selector:
page_size: int = 8,
minimal_count: int = 0,
dependencies: dict[int, list[int]] = None,
prefixes: list[str] = None,
collapse_on_start: bool = False,
):
"""
Initialize the Selector.
@@ -40,6 +40,7 @@ class Selector:
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.
collapse_on_start: If True, child items are hidden initially.
"""
self.options = options
self.cursor_style = cursor_style
@@ -48,77 +49,160 @@ class Selector:
self.minimal_count = minimal_count
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.selected_indices = set()
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):
"""
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.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
for idx in render_indices:
option = self.options[idx]
is_cursor = idx == self.cursor_index
is_selected = idx in self.selected_indices
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)
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(f"{option}")
content_text.style = style
content_text = Text.from_markup(option)
content_text.style = style
table.add_row(indicator_text, content_text)
table.add_row(indicator_text, content_text)
else:
table.add_row(Text(" "), Text(" "))
# Fill empty rows to maintain height
rows_rendered = len(render_indices)
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
info_text = Text(
f"\n[Space]: Toggle [a]: All [←/→]: Page [Enter]: Confirm (Page {current_page}/{total_pages})",
style="gray",
)
if self.dependencies:
info_text = Text(
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))
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.
Moves the cursor up or down through VISIBLE items only.
"""
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
visible_indices = self.get_visible_indices()
if not visible_indices:
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):
"""
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
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
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
# Move cursor to top of new page
try:
# Calculate what visual index corresponds to the start of the new page
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):
"""
Toggles the selection state of the item currently under the cursor.
Propagates selection to children if defined in dependencies.
"""
target_indices = {self.cursor_index}
@@ -132,10 +216,33 @@ class Selector:
else:
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):
"""
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()
@@ -148,18 +255,20 @@ class Selector:
Returns command strings like 'UP', 'DOWN', 'ENTER', etc.
"""
key = msvcrt.getch()
# Ctrl+C (0x03) or ESC (0x1b)
if key == b"\x03" or key == b"\x1b":
return "CANCEL"
# Special keys prefix (Arrow keys, etc., send 0xe0 or 0x00 first)
if key == b"\xe0" or key == b"\x00":
try:
key = msvcrt.getch()
if key == b"H":
if key == b"H": # Arrow Up
return "UP"
if key == b"P":
if key == b"P": # Arrow Down
return "DOWN"
if key == b"K":
if key == b"K": # Arrow Left
return "LEFT"
if key == b"M":
if key == b"M": # Arrow Right
return "RIGHT"
except Exception:
pass
@@ -177,6 +286,10 @@ class Selector:
return "QUIT"
if char in ("a", "A"):
return "ALL"
if char == "e":
return "EXPAND"
if char == "E":
return "EXPAND_ALL"
if char in ("w", "W", "k", "K"):
return "UP"
if char in ("s", "S", "j", "J"):
@@ -193,28 +306,33 @@ class Selector:
Returns command strings like 'UP', 'DOWN', 'ENTER', etc.
"""
char = click.getchar()
# Ctrl+C
if char == "\x03":
return "CANCEL"
# ANSI Escape Sequences for Arrow Keys
mapping = {
"\x1b[A": "UP",
"\x1b[B": "DOWN",
"\x1b[C": "RIGHT",
"\x1b[D": "LEFT",
"\x1b[A": "UP", # Escape + [ + A
"\x1b[B": "DOWN", # Escape + [ + B
"\x1b[C": "RIGHT", # Escape + [ + C
"\x1b[D": "LEFT", # Escape + [ + D
}
if char in mapping:
return mapping[char]
if char == "\x1b":
# Handling manual Escape sequences
if char == "\x1b": # ESC
try:
next1 = click.getchar()
if next1 in ("[", "O"):
if next1 in ("[", "O"): # Sequence indicators
next2 = click.getchar()
if next2 == "A":
if next2 == "A": # Arrow Up
return "UP"
if next2 == "B":
if next2 == "B": # Arrow Down
return "DOWN"
if next2 == "C":
if next2 == "C": # Arrow Right
return "RIGHT"
if next2 == "D":
if next2 == "D": # Arrow Left
return "LEFT"
return "CANCEL"
except Exception:
@@ -228,6 +346,10 @@ class Selector:
return "QUIT"
if char in ("a", "A"):
return "ALL"
if char == "e":
return "EXPAND"
if char == "E":
return "EXPAND_ALL"
if char in ("w", "W", "k", "K"):
return "UP"
if char in ("s", "S", "j", "J"):
@@ -263,6 +385,10 @@ class Selector:
self.change_page(-1)
elif action == "RIGHT":
self.change_page(1)
elif action == "EXPAND":
self.toggle_expand()
elif action == "EXPAND_ALL":
self.toggle_expand_all()
elif action == "SPACE":
self.toggle_selection()
elif action == "ALL":
@@ -282,6 +408,7 @@ def select_multiple(
page_size: int = 8,
return_indices: bool = True,
cursor_style: str = "pink",
collapse_on_start: bool = False,
**kwargs,
) -> list[int]:
"""
@@ -293,6 +420,7 @@ def select_multiple(
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.
collapse_on_start: If True, child items are hidden initially.
"""
selector = Selector(
options=options,
@@ -300,6 +428,7 @@ def select_multiple(
text_style="text",
page_size=page_size,
minimal_count=minimal_count,
collapse_on_start=collapse_on_start,
**kwargs,
)

View File

@@ -303,21 +303,6 @@ class EXAMPLE(Service):
def get_widevine_service_certificate(self, **_: any) -> str:
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]]:
license_url = self.license_data.get("url") or self.config["endpoints"].get("widevine_license")
if not license_url:
@@ -340,3 +325,18 @@ class EXAMPLE(Service):
return response.json().get("license")
except ValueError:
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

View File

@@ -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_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
# Comprehensive JSON-based debug logging for troubleshooting and service development
debug: