3 Commits

Author SHA1 Message Date
Andy
9921690339 feat: add service-specific configuration overrides
Add support for per-service configuration overrides allowing fine-tuned control of downloader and command options on a service-by-service basis.

Fixes #13
2025-10-18 07:32:17 +00:00
Andy
3dd12b0cbe chore(api): fix import ordering in download_manager and handlers 2025-10-18 07:05:05 +00:00
Andy
ed1314572b feat(dl): add --latest-episode option to download only the most recent episode
Adds a new CLI option `-le, --latest-episode` that automatically selects and downloads only the single most recent episode from a series, regardless of which season it's in.

Fixes #28
2025-10-18 07:04:11 +00:00
4 changed files with 283 additions and 184 deletions

View File

@@ -152,6 +152,13 @@ class dl:
default=None, default=None,
help="Wanted episodes, e.g. `S01-S05,S07`, `S01E01-S02E03`, `S02-S02E03`, e.t.c, defaults to all.", help="Wanted episodes, e.g. `S01-S05,S07`, `S01E01-S02E03`, `S02-S02E03`, e.t.c, defaults to all.",
) )
@click.option(
"-le",
"--latest-episode",
is_flag=True,
default=False,
help="Download only the single most recent episode available.",
)
@click.option( @click.option(
"-l", "-l",
"--lang", "--lang",
@@ -322,11 +329,7 @@ class dl:
debug_log_path = config.directories.logs / config.filenames.debug_log.format_map( debug_log_path = config.directories.logs / config.filenames.debug_log.format_map(
defaultdict(str, service=self.service, time=datetime.now().strftime("%Y%m%d-%H%M%S")) defaultdict(str, service=self.service, time=datetime.now().strftime("%Y%m%d-%H%M%S"))
) )
init_debug_logger( init_debug_logger(log_path=debug_log_path, enabled=True, log_keys=config.debug_keys)
log_path=debug_log_path,
enabled=True,
log_keys=config.debug_keys
)
self.debug_logger = get_debug_logger() self.debug_logger = get_debug_logger()
if self.debug_logger: if self.debug_logger:
@@ -342,8 +345,12 @@ class dl:
"tmdb_id": tmdb_id, "tmdb_id": tmdb_id,
"tmdb_name": tmdb_name, "tmdb_name": tmdb_name,
"tmdb_year": tmdb_year, "tmdb_year": tmdb_year,
"cli_params": {k: v for k, v in ctx.params.items() if k not in ['profile', 'proxy', 'tag', 'tmdb_id', 'tmdb_name', 'tmdb_year']} "cli_params": {
} k: v
for k, v in ctx.params.items()
if k not in ["profile", "proxy", "tag", "tmdb_id", "tmdb_name", "tmdb_year"]
},
},
) )
else: else:
self.debug_logger = None self.debug_logger = None
@@ -361,7 +368,7 @@ class dl:
level="DEBUG", level="DEBUG",
operation="load_service_config", operation="load_service_config",
service=self.service, service=self.service,
context={"config_path": str(service_config_path), "config": self.service_config} context={"config_path": str(service_config_path), "config": self.service_config},
) )
else: else:
self.service_config = {} self.service_config = {}
@@ -373,6 +380,33 @@ class dl:
if getattr(config, "decryption_map", None): if getattr(config, "decryption_map", None):
config.decryption = config.decryption_map.get(self.service, config.decryption) config.decryption = config.decryption_map.get(self.service, config.decryption)
service_config = config.services.get(self.service, {})
reserved_keys = {
"profiles",
"api_key",
"certificate",
"api_endpoint",
"region",
"device",
"endpoints",
"client",
}
for config_key, override_value in service_config.items():
if config_key in reserved_keys:
continue
if isinstance(override_value, dict) and hasattr(config, config_key):
current_config = getattr(config, config_key, {})
if isinstance(current_config, dict):
merged_config = {**current_config, **override_value}
setattr(config, config_key, merged_config)
self.log.debug(
f"Applied service-specific '{config_key}' overrides for {self.service}: {override_value}"
)
with console.status("Loading Key Vaults...", spinner="dots"): with console.status("Loading Key Vaults...", spinner="dots"):
self.vaults = Vaults(self.service) self.vaults = Vaults(self.service)
total_vaults = len(config.key_vaults) total_vaults = len(config.key_vaults)
@@ -438,19 +472,25 @@ class dl:
cdm_info = {"type": "DecryptLabs", "drm_type": drm_type, "security_level": self.cdm.security_level} cdm_info = {"type": "DecryptLabs", "drm_type": drm_type, "security_level": self.cdm.security_level}
elif hasattr(self.cdm, "device_type") and self.cdm.device_type.name in ["ANDROID", "CHROME"]: elif hasattr(self.cdm, "device_type") and self.cdm.device_type.name in ["ANDROID", "CHROME"]:
self.log.info(f"Loaded Widevine CDM: {self.cdm.system_id} (L{self.cdm.security_level})") self.log.info(f"Loaded Widevine CDM: {self.cdm.system_id} (L{self.cdm.security_level})")
cdm_info = {"type": "Widevine", "system_id": self.cdm.system_id, "security_level": self.cdm.security_level, "device_type": self.cdm.device_type.name} cdm_info = {
"type": "Widevine",
"system_id": self.cdm.system_id,
"security_level": self.cdm.security_level,
"device_type": self.cdm.device_type.name,
}
else: else:
self.log.info( self.log.info(
f"Loaded PlayReady CDM: {self.cdm.certificate_chain.get_name()} (L{self.cdm.security_level})" f"Loaded PlayReady CDM: {self.cdm.certificate_chain.get_name()} (L{self.cdm.security_level})"
) )
cdm_info = {"type": "PlayReady", "certificate": self.cdm.certificate_chain.get_name(), "security_level": self.cdm.security_level} cdm_info = {
"type": "PlayReady",
"certificate": self.cdm.certificate_chain.get_name(),
"security_level": self.cdm.security_level,
}
if self.debug_logger and cdm_info: if self.debug_logger and cdm_info:
self.debug_logger.log( self.debug_logger.log(
level="INFO", level="INFO", operation="load_cdm", service=self.service, context={"cdm": cdm_info}
operation="load_cdm",
service=self.service,
context={"cdm": cdm_info}
) )
self.proxy_providers = [] self.proxy_providers = []
@@ -526,6 +566,7 @@ class dl:
channels: float, channels: float,
no_atmos: bool, no_atmos: bool,
wanted: list[str], wanted: list[str],
latest_episode: bool,
lang: list[str], lang: list[str],
v_lang: list[str], v_lang: list[str],
a_lang: list[str], a_lang: list[str],
@@ -587,8 +628,8 @@ class dl:
context={ context={
"cdm_only": cdm_only, "cdm_only": cdm_only,
"vaults_only": vaults_only, "vaults_only": vaults_only,
"mode": "CDM only" if cdm_only else ("Vaults only" if vaults_only else "Both CDM and Vaults") "mode": "CDM only" if cdm_only else ("Vaults only" if vaults_only else "Both CDM and Vaults"),
} },
) )
with console.status("Authenticating with Service...", spinner="dots"): with console.status("Authenticating with Service...", spinner="dots"):
@@ -606,16 +647,13 @@ class dl:
context={ context={
"has_cookies": bool(cookies), "has_cookies": bool(cookies),
"has_credentials": bool(credential), "has_credentials": bool(credential),
"profile": self.profile "profile": self.profile,
} },
) )
except Exception as e: except Exception as e:
if self.debug_logger: if self.debug_logger:
self.debug_logger.log_error( self.debug_logger.log_error(
"authenticate", "authenticate", e, service=self.service, context={"profile": self.profile}
e,
service=self.service,
context={"profile": self.profile}
) )
raise raise
@@ -630,31 +668,24 @@ class dl:
operation="get_titles", operation="get_titles",
service=self.service, service=self.service,
message="No titles returned from service", message="No titles returned from service",
success=False success=False,
) )
sys.exit(1) sys.exit(1)
except Exception as e: except Exception as e:
if self.debug_logger: if self.debug_logger:
self.debug_logger.log_error( self.debug_logger.log_error("get_titles", e, service=self.service)
"get_titles",
e,
service=self.service
)
raise raise
if self.debug_logger: if self.debug_logger:
titles_info = { titles_info = {
"type": titles.__class__.__name__, "type": titles.__class__.__name__,
"count": len(titles) if hasattr(titles, "__len__") else 1, "count": len(titles) if hasattr(titles, "__len__") else 1,
"title": str(titles) "title": str(titles),
} }
if hasattr(titles, "seasons"): if hasattr(titles, "seasons"):
titles_info["seasons"] = len(titles.seasons) if hasattr(titles, "seasons") else 0 titles_info["seasons"] = len(titles.seasons) if hasattr(titles, "seasons") else 0
self.debug_logger.log( self.debug_logger.log(
level="INFO", level="INFO", operation="get_titles", service=self.service, context={"titles": titles_info}
operation="get_titles",
service=self.service,
context={"titles": titles_info}
) )
if self.tmdb_year and self.tmdb_id: if self.tmdb_year and self.tmdb_id:
@@ -674,8 +705,21 @@ class dl:
if list_titles: if list_titles:
return return
# Determine the latest episode if --latest-episode is set
latest_episode_id = None
if latest_episode and isinstance(titles, Series) and len(titles) > 0:
# Series is already sorted by (season, number, year)
# The last episode in the sorted list is the latest
latest_ep = titles[-1]
latest_episode_id = f"{latest_ep.season}x{latest_ep.number}"
self.log.info(f"Latest episode mode: Selecting S{latest_ep.season:02}E{latest_ep.number:02}")
for i, title in enumerate(titles): for i, title in enumerate(titles):
if isinstance(title, Episode) and wanted and f"{title.season}x{title.number}" not in wanted: if isinstance(title, Episode) and latest_episode and latest_episode_id:
# If --latest-episode is set, only process the latest episode
if f"{title.season}x{title.number}" != latest_episode_id:
continue
elif isinstance(title, Episode) and wanted and f"{title.season}x{title.number}" not in wanted:
continue continue
console.print(Padding(Rule(f"[rule.text]{title}"), (1, 2))) console.print(Padding(Rule(f"[rule.text]{title}"), (1, 2)))
@@ -750,10 +794,7 @@ class dl:
except Exception as e: except Exception as e:
if self.debug_logger: if self.debug_logger:
self.debug_logger.log_error( self.debug_logger.log_error(
"get_tracks", "get_tracks", e, service=self.service, context={"title": str(title)}
e,
service=self.service,
context={"title": str(title)}
) )
raise raise
@@ -764,34 +805,40 @@ class dl:
"audio_tracks": len(title.tracks.audio), "audio_tracks": len(title.tracks.audio),
"subtitle_tracks": len(title.tracks.subtitles), "subtitle_tracks": len(title.tracks.subtitles),
"has_chapters": bool(title.tracks.chapters), "has_chapters": bool(title.tracks.chapters),
"videos": [{ "videos": [
{
"codec": str(v.codec), "codec": str(v.codec),
"resolution": f"{v.width}x{v.height}" if v.width and v.height else "unknown", "resolution": f"{v.width}x{v.height}" if v.width and v.height else "unknown",
"bitrate": v.bitrate, "bitrate": v.bitrate,
"range": str(v.range), "range": str(v.range),
"language": str(v.language) if v.language else None, "language": str(v.language) if v.language else None,
"drm": [str(type(d).__name__) for d in v.drm] if v.drm else [] "drm": [str(type(d).__name__) for d in v.drm] if v.drm else [],
} for v in title.tracks.videos], }
"audio": [{ for v in title.tracks.videos
],
"audio": [
{
"codec": str(a.codec), "codec": str(a.codec),
"bitrate": a.bitrate, "bitrate": a.bitrate,
"channels": a.channels, "channels": a.channels,
"language": str(a.language) if a.language else None, "language": str(a.language) if a.language else None,
"descriptive": a.descriptive, "descriptive": a.descriptive,
"drm": [str(type(d).__name__) for d in a.drm] if a.drm else [] "drm": [str(type(d).__name__) for d in a.drm] if a.drm else [],
} for a in title.tracks.audio], }
"subtitles": [{ for a in title.tracks.audio
],
"subtitles": [
{
"codec": str(s.codec), "codec": str(s.codec),
"language": str(s.language) if s.language else None, "language": str(s.language) if s.language else None,
"forced": s.forced, "forced": s.forced,
"sdh": s.sdh "sdh": s.sdh,
} for s in title.tracks.subtitles] }
for s in title.tracks.subtitles
],
} }
self.debug_logger.log( self.debug_logger.log(
level="INFO", level="INFO", operation="get_tracks", service=self.service, context=tracks_info
operation="get_tracks",
service=self.service,
context=tracks_info
) )
# strip SDH subs to non-SDH if no equivalent same-lang non-SDH is available # strip SDH subs to non-SDH if no equivalent same-lang non-SDH is available
@@ -1185,7 +1232,7 @@ class dl:
operation="download_tracks", operation="download_tracks",
service=self.service, service=self.service,
message="Download cancelled by user", message="Download cancelled by user",
context={"title": str(title)} context={"title": str(title)},
) )
return return
except Exception as e: # noqa except Exception as e: # noqa
@@ -1219,8 +1266,8 @@ class dl:
"title": str(title), "title": str(title),
"error_type": type(e).__name__, "error_type": type(e).__name__,
"tracks_count": len(title.tracks), "tracks_count": len(title.tracks),
"returncode": getattr(e, "returncode", None) "returncode": getattr(e, "returncode", None),
} },
) )
return return
@@ -1475,9 +1522,13 @@ class dl:
if not no_folder and isinstance(title, (Episode, Song)): if not no_folder and isinstance(title, (Episode, Song)):
# Create folder based on title # Create folder based on title
# Use first available track for filename generation # Use first available track for filename generation
sample_track = title.tracks.videos[0] if title.tracks.videos else ( sample_track = (
title.tracks.audio[0] if title.tracks.audio else ( title.tracks.videos[0]
title.tracks.subtitles[0] if title.tracks.subtitles else None if title.tracks.videos
else (
title.tracks.audio[0]
if title.tracks.audio
else (title.tracks.subtitles[0] if title.tracks.subtitles else None)
) )
) )
if sample_track and sample_track.path: if sample_track and sample_track.path:
@@ -1498,7 +1549,9 @@ class dl:
track_suffix = f".{track.codec.name if hasattr(track.codec, 'name') else 'video'}" track_suffix = f".{track.codec.name if hasattr(track.codec, 'name') else 'video'}"
elif isinstance(track, Audio): elif isinstance(track, Audio):
lang_suffix = f".{track.language}" if track.language else "" lang_suffix = f".{track.language}" if track.language else ""
track_suffix = f"{lang_suffix}.{track.codec.name if hasattr(track.codec, 'name') else 'audio'}" track_suffix = (
f"{lang_suffix}.{track.codec.name if hasattr(track.codec, 'name') else 'audio'}"
)
elif isinstance(track, Subtitle): elif isinstance(track, Subtitle):
lang_suffix = f".{track.language}" if track.language else "" lang_suffix = f".{track.language}" if track.language else ""
forced_suffix = ".forced" if track.forced else "" forced_suffix = ".forced" if track.forced else ""
@@ -1595,8 +1648,8 @@ class dl:
"title": str(title), "title": str(title),
"pssh": drm.pssh.dumps() if drm.pssh else None, "pssh": drm.pssh.dumps() if drm.pssh else None,
"kids": [k.hex for k in drm.kids], "kids": [k.hex for k in drm.kids],
"track_kid": track_kid.hex if track_kid else None "track_kid": track_kid.hex if track_kid else None,
} },
) )
with self.DRM_TABLE_LOCK: with self.DRM_TABLE_LOCK:
@@ -1637,8 +1690,8 @@ class dl:
"kid": kid.hex, "kid": kid.hex,
"content_key": content_key, "content_key": content_key,
"track": str(track), "track": str(track),
"from_cache": True "from_cache": True,
} },
) )
elif vaults_only: elif vaults_only:
msg = f"No Vault has a Key for {kid.hex} and --vaults-only was used" msg = f"No Vault has a Key for {kid.hex} and --vaults-only was used"
@@ -1651,7 +1704,7 @@ class dl:
operation="vault_key_not_found", operation="vault_key_not_found",
service=self.service, service=self.service,
message=msg, message=msg,
context={"kid": kid.hex, "track": str(track)} context={"kid": kid.hex, "track": str(track)},
) )
raise Widevine.Exceptions.CEKNotFound(msg) raise Widevine.Exceptions.CEKNotFound(msg)
else: else:
@@ -1671,8 +1724,8 @@ class dl:
message="Requesting Widevine license from service", message="Requesting Widevine license from service",
context={ context={
"track": str(track), "track": str(track),
"kids_needed": [k.hex for k in all_kids if k not in drm.content_keys] "kids_needed": [k.hex for k in all_kids if k not in drm.content_keys],
} },
) )
try: try:
@@ -1693,10 +1746,7 @@ class dl:
"get_license", "get_license",
e, e,
service=self.service, service=self.service,
context={ context={"track": str(track), "exception_type": type(e).__name__},
"track": str(track),
"exception_type": type(e).__name__
}
) )
raise e raise e
@@ -1708,8 +1758,8 @@ class dl:
context={ context={
"track": str(track), "track": str(track),
"keys_count": len(drm.content_keys), "keys_count": len(drm.content_keys),
"kids": [k.hex for k in drm.content_keys.keys()] "kids": [k.hex for k in drm.content_keys.keys()],
} },
) )
for kid_, key in drm.content_keys.items(): for kid_, key in drm.content_keys.items():
@@ -1767,8 +1817,8 @@ class dl:
"title": str(title), "title": str(title),
"pssh": drm.pssh_b64 or "", "pssh": drm.pssh_b64 or "",
"kids": [k.hex for k in drm.kids], "kids": [k.hex for k in drm.kids],
"track_kid": track_kid.hex if track_kid else None "track_kid": track_kid.hex if track_kid else None,
} },
) )
with self.DRM_TABLE_LOCK: with self.DRM_TABLE_LOCK:
@@ -1816,8 +1866,8 @@ class dl:
"content_key": content_key, "content_key": content_key,
"track": str(track), "track": str(track),
"from_cache": True, "from_cache": True,
"drm_type": "PlayReady" "drm_type": "PlayReady",
} },
) )
elif vaults_only: elif vaults_only:
msg = f"No Vault has a Key for {kid.hex} and --vaults-only was used" msg = f"No Vault has a Key for {kid.hex} and --vaults-only was used"
@@ -1830,7 +1880,7 @@ class dl:
operation="vault_key_not_found", operation="vault_key_not_found",
service=self.service, service=self.service,
message=msg, message=msg,
context={"kid": kid.hex, "track": str(track), "drm_type": "PlayReady"} context={"kid": kid.hex, "track": str(track), "drm_type": "PlayReady"},
) )
raise PlayReady.Exceptions.CEKNotFound(msg) raise PlayReady.Exceptions.CEKNotFound(msg)
else: else:
@@ -1860,8 +1910,8 @@ class dl:
context={ context={
"track": str(track), "track": str(track),
"exception_type": type(e).__name__, "exception_type": type(e).__name__,
"drm_type": "PlayReady" "drm_type": "PlayReady",
} },
) )
raise e raise e
@@ -1937,7 +1987,7 @@ class dl:
@staticmethod @staticmethod
def save_cookies(path: Path, cookies: CookieJar): def save_cookies(path: Path, cookies: CookieJar):
if hasattr(cookies, 'jar'): if hasattr(cookies, "jar"):
cookies = cookies.jar cookies = cookies.jar
cookie_jar = MozillaCookieJar(path) cookie_jar = MozillaCookieJar(path)
@@ -2084,12 +2134,12 @@ class dl:
else: else:
return RemoteCdm( return RemoteCdm(
device_type=cdm_api['Device Type'], device_type=cdm_api["Device Type"],
system_id=cdm_api['System ID'], system_id=cdm_api["System ID"],
security_level=cdm_api['Security Level'], security_level=cdm_api["Security Level"],
host=cdm_api['Host'], host=cdm_api["Host"],
secret=cdm_api['Secret'], secret=cdm_api["Secret"],
device_name=cdm_api['Device Name'], device_name=cdm_api["Device Name"],
) )
prd_path = config.directories.prds / f"{cdm_name}.prd" prd_path = config.directories.prds / f"{cdm_name}.prd"

View File

@@ -6,11 +6,11 @@ import sys
import tempfile import tempfile
import threading import threading
import uuid import uuid
from contextlib import suppress
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum from enum import Enum
from typing import Any, Callable, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
from datetime import datetime, timedelta
from contextlib import suppress
log = logging.getLogger("download_manager") log = logging.getLogger("download_manager")
@@ -87,14 +87,15 @@ def _perform_download(
if cancel_event and cancel_event.is_set(): if cancel_event and cancel_event.is_set():
raise Exception(f"Job was cancelled {stage}") raise Exception(f"Job was cancelled {stage}")
from contextlib import redirect_stderr, redirect_stdout
from io import StringIO from io import StringIO
from contextlib import redirect_stdout, redirect_stderr
_check_cancel("before execution started") _check_cancel("before execution started")
# Import dl.py components lazily to avoid circular deps during module import # Import dl.py components lazily to avoid circular deps during module import
import click import click
import yaml import yaml
from unshackle.commands.dl import dl from unshackle.commands.dl import dl
from unshackle.core.config import config from unshackle.core.config import config
from unshackle.core.services import Services from unshackle.core.services import Services

View File

@@ -20,7 +20,6 @@ def initialize_proxy_providers() -> List[Any]:
proxy_providers = [] proxy_providers = []
try: try:
from unshackle.core import binaries from unshackle.core import binaries
# Load the main unshackle config to get proxy provider settings # Load the main unshackle config to get proxy provider settings
from unshackle.core.config import config as main_config from unshackle.core.config import config as main_config

View File

@@ -34,7 +34,8 @@ title_cache_max_retention: 86400 # Maximum cache retention for fallback when API
# Debug logging configuration # Debug logging configuration
# Comprehensive JSON-based debug logging for troubleshooting and service development # Comprehensive JSON-based debug logging for troubleshooting and service development
debug: false # Enable structured JSON debug logging (default: false) debug:
false # Enable structured JSON debug logging (default: false)
# When enabled with --debug flag or set to true: # When enabled with --debug flag or set to true:
# - Creates JSON Lines (.jsonl) log files with complete debugging context # - Creates JSON Lines (.jsonl) log files with complete debugging context
# - Logs: session info, CLI params, service config, CDM details, authentication, # - Logs: session info, CLI params, service config, CDM details, authentication,
@@ -42,7 +43,8 @@ debug: false # Enable structured JSON debug logging (default: false)
# - File location: logs/unshackle_debug_{service}_{timestamp}.jsonl # - File location: logs/unshackle_debug_{service}_{timestamp}.jsonl
# - Also creates text log: logs/unshackle_root_{timestamp}.log # - Also creates text log: logs/unshackle_root_{timestamp}.log
debug_keys: false # Log decryption keys in debug logs (default: false) debug_keys:
false # Log decryption keys in debug logs (default: false)
# Set to true to include actual decryption keys in logs # Set to true to include actual decryption keys in logs
# Useful for debugging key retrieval and decryption issues # Useful for debugging key retrieval and decryption issues
# SECURITY NOTE: Passwords, tokens, cookies, and session tokens # SECURITY NOTE: Passwords, tokens, cookies, and session tokens
@@ -128,72 +130,72 @@ cdm:
# Use pywidevine Serve-compliant Remote CDMs # Use pywidevine Serve-compliant Remote CDMs
# Example: Custom CDM API Configuration # Example: Custom CDM API Configuration
# This demonstrates the highly configurable custom_api type that can adapt to any CDM API format # This demonstrates the highly configurable custom_api type that can adapt to any CDM API format
# - name: "chrome" # - name: "chrome"
# type: "custom_api" # type: "custom_api"
# host: "http://remotecdm.test/" # host: "http://remotecdm.test/"
# timeout: 30 # timeout: 30
# device: # device:
# name: "ChromeCDM" # name: "ChromeCDM"
# type: "CHROME" # type: "CHROME"
# system_id: 34312 # system_id: 34312
# security_level: 3 # security_level: 3
# auth: # auth:
# type: "header" # type: "header"
# header_name: "x-api-key" # header_name: "x-api-key"
# key: "YOUR_API_KEY_HERE" # key: "YOUR_API_KEY_HERE"
# custom_headers: # custom_headers:
# User-Agent: "Unshackle/2.0.0" # User-Agent: "Unshackle/2.0.0"
# endpoints: # endpoints:
# get_request: # get_request:
# path: "/get-challenge" # path: "/get-challenge"
# method: "POST" # method: "POST"
# timeout: 30 # timeout: 30
# decrypt_response: # decrypt_response:
# path: "/get-keys" # path: "/get-keys"
# method: "POST" # method: "POST"
# timeout: 30 # timeout: 30
# request_mapping: # request_mapping:
# get_request: # get_request:
# param_names: # param_names:
# scheme: "device" # scheme: "device"
# init_data: "init_data" # init_data: "init_data"
# static_params: # static_params:
# scheme: "Widevine" # scheme: "Widevine"
# decrypt_response: # decrypt_response:
# param_names: # param_names:
# scheme: "device" # scheme: "device"
# license_request: "license_request" # license_request: "license_request"
# license_response: "license_response" # license_response: "license_response"
# static_params: # static_params:
# scheme: "Widevine" # scheme: "Widevine"
# response_mapping: # response_mapping:
# get_request: # get_request:
# fields: # fields:
# challenge: "challenge" # challenge: "challenge"
# session_id: "session_id" # session_id: "session_id"
# message: "message" # message: "message"
# message_type: "message_type" # message_type: "message_type"
# response_types: # response_types:
# - condition: "message_type == 'license-request'" # - condition: "message_type == 'license-request'"
# type: "license_request" # type: "license_request"
# success_conditions: # success_conditions:
# - "message == 'success'" # - "message == 'success'"
# decrypt_response: # decrypt_response:
# fields: # fields:
# keys: "keys" # keys: "keys"
# message: "message" # message: "message"
# key_fields: # key_fields:
# kid: "kid" # kid: "kid"
# key: "key" # key: "key"
# type: "type" # type: "type"
# success_conditions: # success_conditions:
# - "message == 'success'" # - "message == 'success'"
# caching: # caching:
# enabled: true # enabled: true
# use_vaults: true # use_vaults: true
# check_cached_first: true # check_cached_first: true
remote_cdm: remote_cdm:
- name: "chrome" - name: "chrome"
@@ -360,9 +362,13 @@ services:
# Service-specific configuration goes here # Service-specific configuration goes here
# Profile-specific configurations can be nested under service names # Profile-specific configurations can be nested under service names
# Example: with profile-specific device configs # You can override ANY global configuration option on a per-service basis
# This allows fine-tuned control for services with special requirements
# Supported overrides: dl, aria2c, n_m3u8dl_re, curl_impersonate, subtitle, muxing, headers, etc.
# Example: Comprehensive service configuration showing all features
EXAMPLE: EXAMPLE:
# Global service config # Standard service config
api_key: "service_api_key" api_key: "service_api_key"
# Service certificate for Widevine L1/L2 (base64 encoded) # Service certificate for Widevine L1/L2 (base64 encoded)
@@ -383,6 +389,42 @@ services:
app_name: "AIV" app_name: "AIV"
device_model: "Fire TV Stick 4K" device_model: "Fire TV Stick 4K"
# NEW: Configuration overrides (can be combined with profiles and certificates)
# Override dl command defaults for this service
dl:
downloads: 4 # Limit concurrent track downloads (global default: 6)
workers: 8 # Reduce workers per track (global default: 16)
lang: ["en", "es-419"] # Different language priority for this service
sub_format: srt # Force SRT subtitle format
# Override n_m3u8dl_re downloader settings
n_m3u8dl_re:
thread_count: 8 # Lower thread count for rate-limited service (global default: 16)
use_proxy: true # Force proxy usage for this service
retry_count: 10 # More retries for unstable connections
ad_keyword: "advertisement" # Service-specific ad filtering
# Override aria2c downloader settings
aria2c:
max_concurrent_downloads: 2 # Limit concurrent downloads (global default: 4)
max_connection_per_server: 1 # Single connection per server
split: 3 # Fewer splits (global default: 5)
file_allocation: none # Faster allocation for this service
# Override subtitle processing for this service
subtitle:
conversion_method: pycaption # Use specific subtitle converter
sdh_method: auto
# Service-specific headers
headers:
User-Agent: "Service-specific user agent string"
Accept-Language: "en-US,en;q=0.9"
# Override muxing options
muxing:
set_title: true
# Example: Service with different regions per profile # Example: Service with different regions per profile
SERVICE_NAME: SERVICE_NAME:
profiles: profiles:
@@ -393,6 +435,13 @@ services:
region: "GB" region: "GB"
api_endpoint: "https://api.uk.service.com" api_endpoint: "https://api.uk.service.com"
# Notes on service-specific overrides:
# - Overrides are merged with global config, not replaced
# - Only specified keys are overridden, others use global defaults
# - Reserved keys (profiles, api_key, certificate, etc.) are NOT treated as overrides
# - Any dict-type config option can be overridden (dl, aria2c, n_m3u8dl_re, etc.)
# - Use --debug flag to see which overrides are applied during downloads
# External proxy provider services # External proxy provider services
proxy_providers: proxy_providers:
nordvpn: nordvpn: