Merge branch 'dev' into service.py
This commit is contained in:
@@ -64,7 +64,8 @@ from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger
|
||||
is_close_match, suggest_font_packages, time_elapsed_since)
|
||||
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)
|
||||
ContextData, MultipleChoice, MultipleVideoCodecChoice,
|
||||
SubtitleCodecChoice)
|
||||
from unshackle.core.utils.collections import merge_dict
|
||||
from unshackle.core.utils.selector import select_multiple
|
||||
from unshackle.core.utils.subprocess import ffprobe
|
||||
@@ -288,9 +289,9 @@ class dl:
|
||||
@click.option(
|
||||
"-v",
|
||||
"--vcodec",
|
||||
type=VideoCodecChoice(Video.Codec),
|
||||
default=None,
|
||||
help="Video Codec to download, defaults to any codec.",
|
||||
type=MultipleVideoCodecChoice(Video.Codec),
|
||||
default=[],
|
||||
help="Video Codec(s) to download, defaults to any codec.",
|
||||
)
|
||||
@click.option(
|
||||
"-a",
|
||||
@@ -486,6 +487,14 @@ class dl:
|
||||
help="Max workers/threads to download with per-track. Default depends on the downloader.",
|
||||
)
|
||||
@click.option("--downloads", type=int, default=1, help="Amount of tracks to download concurrently.")
|
||||
@click.option(
|
||||
"-o",
|
||||
"--output",
|
||||
"output_dir",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Override the output directory for this download, instead of the one in config.",
|
||||
)
|
||||
@click.option("--no-cache", "no_cache", is_flag=True, default=False, help="Bypass title cache for this download.")
|
||||
@click.option(
|
||||
"--reset-cache", "reset_cache", is_flag=True, default=False, help="Clear title cache before fetching."
|
||||
@@ -514,6 +523,7 @@ class dl:
|
||||
tmdb_id: Optional[int] = None,
|
||||
tmdb_name: bool = False,
|
||||
tmdb_year: bool = False,
|
||||
output_dir: Optional[Path] = None,
|
||||
*_: Any,
|
||||
**__: Any,
|
||||
):
|
||||
@@ -559,6 +569,7 @@ class dl:
|
||||
self.tmdb_id = tmdb_id
|
||||
self.tmdb_name = tmdb_name
|
||||
self.tmdb_year = tmdb_year
|
||||
self.output_dir = output_dir
|
||||
|
||||
# Initialize debug logger with service name if debug logging is enabled
|
||||
if config.debug or logging.root.level == logging.DEBUG:
|
||||
@@ -913,7 +924,7 @@ class dl:
|
||||
self,
|
||||
service: Service,
|
||||
quality: list[int],
|
||||
vcodec: Optional[Video.Codec],
|
||||
vcodec: list[Video.Codec],
|
||||
acodec: list[Audio.Codec],
|
||||
vbitrate: int,
|
||||
abitrate: int,
|
||||
@@ -1384,9 +1395,12 @@ class dl:
|
||||
if isinstance(title, (Movie, Episode)):
|
||||
# filter video tracks
|
||||
if vcodec:
|
||||
title.tracks.select_video(lambda x: x.codec == vcodec)
|
||||
title.tracks.select_video(lambda x: x.codec in vcodec)
|
||||
missing_codecs = [c for c in vcodec if not any(x.codec == c for x in title.tracks.videos)]
|
||||
for codec in missing_codecs:
|
||||
self.log.warning(f"Skipping {codec.name} video tracks as none are available.")
|
||||
if not title.tracks.videos:
|
||||
self.log.error(f"There's no {vcodec.name} Video Track...")
|
||||
self.log.error(f"There's no {', '.join(c.name for c in vcodec)} Video Track...")
|
||||
sys.exit(1)
|
||||
|
||||
if range_:
|
||||
@@ -1438,10 +1452,38 @@ class dl:
|
||||
self.log.error(f"There's no {processed_video_lang} Video Track...")
|
||||
sys.exit(1)
|
||||
|
||||
has_hybrid = any(r == Video.Range.HYBRID for r in range_)
|
||||
non_hybrid_ranges = [r for r in range_ if r != Video.Range.HYBRID]
|
||||
|
||||
if quality:
|
||||
missing_resolutions = []
|
||||
if any(r == Video.Range.HYBRID for r in range_):
|
||||
title.tracks.select_video(title.tracks.select_hybrid(title.tracks.videos, quality))
|
||||
if has_hybrid:
|
||||
# Split tracks: hybrid candidates vs non-hybrid
|
||||
hybrid_candidate_tracks = [
|
||||
v for v in title.tracks.videos
|
||||
if v.range in (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV)
|
||||
]
|
||||
non_hybrid_tracks = [
|
||||
v for v in title.tracks.videos
|
||||
if v.range not in (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV)
|
||||
]
|
||||
|
||||
# Apply hybrid selection to HDR10+DV tracks
|
||||
hybrid_filter = title.tracks.select_hybrid(hybrid_candidate_tracks, quality)
|
||||
hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks))
|
||||
|
||||
if non_hybrid_ranges and non_hybrid_tracks:
|
||||
# Also filter non-hybrid tracks by resolution
|
||||
non_hybrid_selected = [
|
||||
v for v in non_hybrid_tracks
|
||||
if any(
|
||||
v.height == res or int(v.width * (9 / 16)) == res
|
||||
for res in quality
|
||||
)
|
||||
]
|
||||
title.tracks.videos = hybrid_selected + non_hybrid_selected
|
||||
else:
|
||||
title.tracks.videos = hybrid_selected
|
||||
else:
|
||||
title.tracks.by_resolutions(quality)
|
||||
|
||||
@@ -1468,21 +1510,63 @@ class dl:
|
||||
sys.exit(1)
|
||||
|
||||
# choose best track by range and quality
|
||||
if any(r == Video.Range.HYBRID for r in range_):
|
||||
# For hybrid mode, always apply hybrid selection
|
||||
# If no quality specified, use only the best (highest) resolution
|
||||
if has_hybrid:
|
||||
# Apply hybrid selection for HYBRID tracks
|
||||
hybrid_candidate_tracks = [
|
||||
v for v in title.tracks.videos
|
||||
if v.range in (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV)
|
||||
]
|
||||
non_hybrid_tracks = [
|
||||
v for v in title.tracks.videos
|
||||
if v.range not in (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV)
|
||||
]
|
||||
|
||||
if not quality:
|
||||
# Get the highest resolution available
|
||||
best_resolution = max((v.height for v in title.tracks.videos), default=None)
|
||||
best_resolution = max(
|
||||
(v.height for v in hybrid_candidate_tracks), default=None
|
||||
)
|
||||
if best_resolution:
|
||||
# Use the hybrid selection logic with only the best resolution
|
||||
title.tracks.select_video(
|
||||
title.tracks.select_hybrid(title.tracks.videos, [best_resolution])
|
||||
hybrid_filter = title.tracks.select_hybrid(
|
||||
hybrid_candidate_tracks, [best_resolution]
|
||||
)
|
||||
# If quality was specified, hybrid selection was already applied above
|
||||
hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks))
|
||||
else:
|
||||
hybrid_selected = []
|
||||
else:
|
||||
hybrid_filter = title.tracks.select_hybrid(
|
||||
hybrid_candidate_tracks, quality
|
||||
)
|
||||
hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks))
|
||||
|
||||
# For non-hybrid ranges, apply Cartesian product selection
|
||||
non_hybrid_selected: list[Video] = []
|
||||
if non_hybrid_ranges and non_hybrid_tracks:
|
||||
for resolution, color_range, codec in product(
|
||||
quality or [None], non_hybrid_ranges, vcodec or [None]
|
||||
):
|
||||
match = next(
|
||||
(
|
||||
t
|
||||
for t in non_hybrid_tracks
|
||||
if (
|
||||
not resolution
|
||||
or t.height == resolution
|
||||
or int(t.width * (9 / 16)) == resolution
|
||||
)
|
||||
and (not color_range or t.range == color_range)
|
||||
and (not codec or t.codec == codec)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if match and match not in non_hybrid_selected:
|
||||
non_hybrid_selected.append(match)
|
||||
|
||||
title.tracks.videos = hybrid_selected + non_hybrid_selected
|
||||
else:
|
||||
selected_videos: list[Video] = []
|
||||
for resolution, color_range in product(quality or [None], range_ or [None]):
|
||||
for resolution, color_range, codec in product(
|
||||
quality or [None], range_ or [None], vcodec or [None]
|
||||
):
|
||||
match = next(
|
||||
(
|
||||
t
|
||||
@@ -1493,6 +1577,7 @@ class dl:
|
||||
or int(t.width * (9 / 16)) == resolution
|
||||
)
|
||||
and (not color_range or t.range == color_range)
|
||||
and (not codec or t.codec == codec)
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -1508,29 +1593,38 @@ class dl:
|
||||
]
|
||||
dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV]
|
||||
|
||||
hybrid_failed = False
|
||||
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/HDR10+ and DV tracks, but neither is available"
|
||||
)
|
||||
self.log.error(
|
||||
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but neither is available"
|
||||
msg_detail = (
|
||||
f"Available ranges: {', '.join(available_ranges) if available_ranges else 'none'}"
|
||||
)
|
||||
sys.exit(1)
|
||||
hybrid_failed = True
|
||||
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/HDR10+ and DV tracks, but only DV is available"
|
||||
)
|
||||
self.log.error(f"Available ranges: {', '.join(available_ranges)}")
|
||||
sys.exit(1)
|
||||
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only DV is available"
|
||||
msg_detail = f"Available ranges: {', '.join(available_ranges)}"
|
||||
hybrid_failed = True
|
||||
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/HDR10+ and DV tracks, but only HDR10 is available"
|
||||
)
|
||||
self.log.error(f"Available ranges: {', '.join(available_ranges)}")
|
||||
sys.exit(1)
|
||||
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only HDR10 is available"
|
||||
msg_detail = f"Available ranges: {', '.join(available_ranges)}"
|
||||
hybrid_failed = True
|
||||
|
||||
if hybrid_failed:
|
||||
other_ranges = [r for r in range_ if r != Video.Range.HYBRID]
|
||||
if best_available and other_ranges:
|
||||
self.log.warning(msg)
|
||||
self.log.warning(
|
||||
f"Continuing with remaining range(s): "
|
||||
f"{', '.join(r.name for r in other_ranges)}"
|
||||
)
|
||||
range_ = other_ranges
|
||||
else:
|
||||
self.log.error(msg)
|
||||
self.log.error(msg_detail)
|
||||
sys.exit(1)
|
||||
|
||||
# filter subtitle tracks
|
||||
if require_subs:
|
||||
@@ -1777,11 +1871,6 @@ 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:
|
||||
@@ -2121,6 +2210,8 @@ class dl:
|
||||
task_description += f" {video_track.height}p"
|
||||
if len(range_) > 1:
|
||||
task_description += f" {video_track.range.name}"
|
||||
if len(vcodec) > 1:
|
||||
task_description += f" {video_track.codec.name}"
|
||||
|
||||
task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments
|
||||
if video_track:
|
||||
@@ -2172,7 +2263,7 @@ class dl:
|
||||
else:
|
||||
base_filename = str(title)
|
||||
|
||||
sidecar_dir = config.directories.downloads
|
||||
sidecar_dir = self.output_dir or 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
|
||||
@@ -2226,7 +2317,7 @@ class dl:
|
||||
|
||||
if no_mux:
|
||||
# Handle individual track files without muxing
|
||||
final_dir = config.directories.downloads
|
||||
final_dir = self.output_dir or config.directories.downloads
|
||||
if not no_folder and isinstance(title, (Episode, Song)):
|
||||
# Create folder based on title
|
||||
# Use first available track for filename generation
|
||||
@@ -2279,7 +2370,7 @@ class dl:
|
||||
used_final_paths: set[Path] = set()
|
||||
for muxed_path in muxed_paths:
|
||||
media_info = MediaInfo.parse(muxed_path)
|
||||
final_dir = config.directories.downloads
|
||||
final_dir = self.output_dir or config.directories.downloads
|
||||
final_filename = title.get_filename(media_info, show_service=not no_source)
|
||||
audio_codec_suffix = muxed_audio_codecs.get(muxed_path)
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.0.0"
|
||||
__version__ = "3.1.0"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import base64
|
||||
import logging
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Callable, Generator
|
||||
from dataclasses import dataclass, field
|
||||
from http.cookiejar import CookieJar
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
@@ -24,9 +25,26 @@ from unshackle.core.search_result import SearchResult
|
||||
from unshackle.core.title_cacher import TitleCacher, get_account_hash, get_region_from_proxy
|
||||
from unshackle.core.titles import Title_T, Titles_T
|
||||
from unshackle.core.tracks import Chapters, Tracks
|
||||
from unshackle.core.tracks.video import Video
|
||||
from unshackle.core.utilities import get_cached_ip_info, get_ip_info
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackRequest:
|
||||
"""Holds what the user requested for video codec and range selection.
|
||||
|
||||
Services read from this instead of ctx.parent.params for vcodec/range.
|
||||
|
||||
Attributes:
|
||||
codecs: Requested codecs from CLI. Empty list means no filter (accept any).
|
||||
ranges: Requested ranges from CLI. Defaults to [SDR].
|
||||
"""
|
||||
|
||||
codecs: list[Video.Codec] = field(default_factory=list)
|
||||
ranges: list[Video.Range] = field(default_factory=lambda: [Video.Range.SDR])
|
||||
best_available: bool = False
|
||||
|
||||
|
||||
def sanitize_proxy_for_log(uri: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Sanitize a proxy URI for logs by redacting any embedded userinfo (username/password).
|
||||
@@ -89,6 +107,16 @@ class Service(metaclass=ABCMeta):
|
||||
self.credential = None # Will be set in authenticate()
|
||||
self.current_region = None # Will be set based on proxy/geolocation
|
||||
|
||||
# Set track request from CLI params - services can read/override in their __init__
|
||||
vcodec = ctx.parent.params.get("vcodec") if ctx.parent else None
|
||||
range_ = ctx.parent.params.get("range_") if ctx.parent else None
|
||||
best_available = ctx.parent.params.get("best_available", False) if ctx.parent else False
|
||||
self.track_request = TrackRequest(
|
||||
codecs=list(vcodec) if vcodec else [],
|
||||
ranges=list(range_) if range_ else [Video.Range.SDR],
|
||||
best_available=bool(best_available),
|
||||
)
|
||||
|
||||
if not ctx.parent or not ctx.parent.params.get("no_proxy"):
|
||||
if ctx.parent:
|
||||
proxy = ctx.parent.params["proxy"]
|
||||
@@ -205,6 +233,76 @@ class Service(metaclass=ABCMeta):
|
||||
self.log.debug(f"Failed to get cached IP info: {e}")
|
||||
self.current_region = None
|
||||
|
||||
def _get_tracks_for_variants(
|
||||
self,
|
||||
title: Title_T,
|
||||
fetch_fn: Callable[..., Tracks],
|
||||
) -> Tracks:
|
||||
"""Call fetch_fn for each codec/range combo in track_request, merge results.
|
||||
|
||||
Services that need separate API calls per codec/range combo can use this
|
||||
helper from their get_tracks() implementation.
|
||||
|
||||
The fetch_fn signature should be: (title, codec, range_) -> Tracks
|
||||
|
||||
For HYBRID range, fetch_fn is called with HDR10 and DV separately and
|
||||
the DV video tracks are merged into the HDR10 result.
|
||||
|
||||
Args:
|
||||
title: The title being processed.
|
||||
fetch_fn: A callable that fetches tracks for a specific codec/range.
|
||||
"""
|
||||
all_tracks = Tracks()
|
||||
first = True
|
||||
|
||||
codecs = self.track_request.codecs or [None]
|
||||
ranges = self.track_request.ranges or [Video.Range.SDR]
|
||||
|
||||
for range_val in ranges:
|
||||
if range_val == Video.Range.HYBRID:
|
||||
# HYBRID: fetch HDR10 first (full tracks), then DV (video only)
|
||||
for codec_val in codecs:
|
||||
try:
|
||||
hdr_tracks = fetch_fn(title, codec=codec_val, range_=Video.Range.HDR10)
|
||||
except (ValueError, SystemExit) as e:
|
||||
if self.track_request.best_available:
|
||||
self.log.warning(f" - HDR10 not available for HYBRID, skipping ({e})")
|
||||
continue
|
||||
raise
|
||||
if first:
|
||||
all_tracks.add(hdr_tracks, warn_only=True)
|
||||
first = False
|
||||
else:
|
||||
for video in hdr_tracks.videos:
|
||||
all_tracks.add(video, warn_only=True)
|
||||
|
||||
try:
|
||||
dv_tracks = fetch_fn(title, codec=codec_val, range_=Video.Range.DV)
|
||||
for video in dv_tracks.videos:
|
||||
all_tracks.add(video, warn_only=True)
|
||||
except (ValueError, SystemExit):
|
||||
self.log.info(" - No DolbyVision manifest available for HYBRID")
|
||||
else:
|
||||
for codec_val in codecs:
|
||||
try:
|
||||
tracks = fetch_fn(title, codec=codec_val, range_=range_val)
|
||||
except (ValueError, SystemExit) as e:
|
||||
if self.track_request.best_available:
|
||||
codec_name = codec_val.name if codec_val else "default"
|
||||
self.log.warning(
|
||||
f" - {range_val.name}/{codec_name} not available, skipping ({e})"
|
||||
)
|
||||
continue
|
||||
raise
|
||||
if first:
|
||||
all_tracks.add(tracks, warn_only=True)
|
||||
first = False
|
||||
else:
|
||||
for video in tracks.videos:
|
||||
all_tracks.add(video, warn_only=True)
|
||||
|
||||
return all_tracks
|
||||
|
||||
# Optional Abstract functions
|
||||
# The following functions may be implemented by the Service.
|
||||
# Otherwise, the base service code (if any) of the function will be executed on call.
|
||||
@@ -222,7 +320,7 @@ class Service(metaclass=ABCMeta):
|
||||
session.mount(
|
||||
"https://",
|
||||
HTTPAdapter(
|
||||
max_retries=Retry(total=15, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504]),
|
||||
max_retries=Retry(total=5, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504]),
|
||||
pool_block=True,
|
||||
),
|
||||
)
|
||||
@@ -482,4 +580,4 @@ class Service(metaclass=ABCMeta):
|
||||
"""
|
||||
|
||||
|
||||
__all__ = ("Service",)
|
||||
__all__ = ("Service", "TrackRequest")
|
||||
|
||||
@@ -56,7 +56,7 @@ class MaxRetriesError(exceptions.RequestException):
|
||||
class CurlSession(Session):
|
||||
def __init__(
|
||||
self,
|
||||
max_retries: int = 10,
|
||||
max_retries: int = 5,
|
||||
backoff_factor: float = 0.2,
|
||||
max_backoff: float = 60.0,
|
||||
status_forcelist: list[int] | None = None,
|
||||
@@ -150,7 +150,7 @@ def session(
|
||||
browser: Browser to impersonate (e.g. "chrome124", "firefox", "safari") OR
|
||||
fingerprint preset name (e.g. "okhttp4").
|
||||
Uses the configured default from curl_impersonate.browser if not specified.
|
||||
Available presets: okhttp4
|
||||
Available presets: okhttp4, okhttp5
|
||||
See https://github.com/lexiforest/curl_cffi#sessions for browser options.
|
||||
ja3: Custom JA3 TLS fingerprint string (format: "SSLVersion,Ciphers,Extensions,Curves,PointFormats").
|
||||
When provided, curl_cffi will use this exact TLS fingerprint instead of the browser's default.
|
||||
@@ -172,7 +172,7 @@ def session(
|
||||
- cert: Client certificate (str or tuple)
|
||||
|
||||
Extra arguments for retry handler:
|
||||
- max_retries: Maximum number of retries (int, default 10)
|
||||
- max_retries: Maximum number of retries (int, default 5)
|
||||
- backoff_factor: Backoff factor (float, default 0.2)
|
||||
- max_backoff: Maximum backoff time (float, default 60.0)
|
||||
- status_forcelist: List of status codes to force retry (list, default [429, 500, 502, 503, 504])
|
||||
|
||||
@@ -24,7 +24,7 @@ from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY
|
||||
from unshackle.core.downloaders import aria2c, curl_impersonate, n_m3u8dl_re, requests
|
||||
from unshackle.core.drm import DRM_T, PlayReady, Widevine
|
||||
from unshackle.core.events import events
|
||||
from unshackle.core.utilities import get_boxes, get_extension, try_ensure_utf8
|
||||
from unshackle.core.utilities import get_boxes, try_ensure_utf8
|
||||
from unshackle.core.utils.subprocess import ffprobe
|
||||
|
||||
|
||||
@@ -210,23 +210,12 @@ class Track:
|
||||
save_path = config.directories.temp / f"{track_type}_{self.id}.mp4"
|
||||
if track_type == "Subtitle":
|
||||
save_path = save_path.with_suffix(f".{self.codec.extension}")
|
||||
# n_m3u8dl_re doesn't support directly downloading subtitles from URLs
|
||||
# or when the subtitle has a direct file extension
|
||||
if self.downloader.__name__ == "n_m3u8dl_re" and (
|
||||
self.descriptor == self.Descriptor.URL
|
||||
or get_extension(self.url)
|
||||
in {
|
||||
".srt",
|
||||
".vtt",
|
||||
".ttml",
|
||||
".ssa",
|
||||
".ass",
|
||||
".stpp",
|
||||
".wvtt",
|
||||
".xml",
|
||||
}
|
||||
):
|
||||
self.downloader = requests
|
||||
|
||||
if self.downloader.__name__ == "n_m3u8dl_re" and (
|
||||
self.descriptor == self.Descriptor.URL
|
||||
or track_type in ("Subtitle", "Attachment")
|
||||
):
|
||||
self.downloader = requests
|
||||
|
||||
if self.descriptor != self.Descriptor.URL:
|
||||
save_dir = save_path.with_name(save_path.name + "_segments")
|
||||
|
||||
@@ -44,6 +44,33 @@ class VideoCodecChoice(click.Choice):
|
||||
self.fail(f"'{value}' is not a valid video codec", param, ctx)
|
||||
|
||||
|
||||
class MultipleVideoCodecChoice(VideoCodecChoice):
|
||||
"""
|
||||
A multiple-value variant of VideoCodecChoice that accepts comma-separated codecs.
|
||||
|
||||
Accepts both enum names and values, e.g.: ``-v hevc,avc`` or ``-v H.264,H.265``
|
||||
"""
|
||||
|
||||
name = "multiple_video_codec_choice"
|
||||
|
||||
def convert(
|
||||
self, value: Any, param: Optional[click.Parameter] = None, ctx: Optional[click.Context] = None
|
||||
) -> list[Any]:
|
||||
if not value:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
values = value
|
||||
elif isinstance(value, str):
|
||||
values = value.split(",")
|
||||
else:
|
||||
self.fail(f"{value!r} is not a supported value.", param, ctx)
|
||||
|
||||
chosen_values: list[Any] = []
|
||||
for v in values:
|
||||
chosen_values.append(super().convert(v.strip(), param, ctx))
|
||||
return chosen_values
|
||||
|
||||
|
||||
class SubtitleCodecChoice(click.Choice):
|
||||
"""
|
||||
A custom Choice type for subtitle codecs that accepts both enum names, values, and common aliases.
|
||||
|
||||
@@ -325,7 +325,7 @@ 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:
|
||||
@@ -339,4 +339,4 @@ class EXAMPLE(Service):
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
return response.content
|
||||
Reference in New Issue
Block a user