Merge branch 'dev' into service.py

This commit is contained in:
CodeName393
2026-02-25 19:21:22 +09:00
committed by GitHub
19 changed files with 625 additions and 1606 deletions

View File

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

View File

@@ -1 +1 @@
__version__ = "3.0.0"
__version__ = "3.1.0"

View File

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

View File

@@ -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])

View File

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

View File

@@ -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.

View File

@@ -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