mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-06-10 03:02:09 +00:00
fix(api): propagate Click default=None through service instantiation
Manual kwarg binding dropped None defaults, so services with required positional args like `type` or `profile` raised TypeError via the API while local CLI worked. Replace it with `ctx.invoke(service.cli, ...)` so Click handles defaults, and factor the duplicated setup into load_service_yaml / build_parent_ctx / instantiate_service. Also resolve a real CDM (load_full_cdm) for search/list_titles/list_tracks; the lightweight stub lacks device_type/security_levelthat services read in __init__.
This commit is contained in:
@@ -17,7 +17,7 @@ from unshackle.core.tracks import Audio, Subtitle, Video
|
|||||||
log = logging.getLogger("api")
|
log = logging.getLogger("api")
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_log(value: object) -> str:
|
def sanitize_log(value: object) -> str:
|
||||||
"""Sanitize a value for safe logging by removing newlines and control characters."""
|
"""Sanitize a value for safe logging by removing newlines and control characters."""
|
||||||
return str(value).replace("\n", "").replace("\r", "").replace("\x00", "")
|
return str(value).replace("\n", "").replace("\r", "").replace("\x00", "")
|
||||||
|
|
||||||
@@ -80,6 +80,128 @@ DEFAULT_DOWNLOAD_PARAMS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Keys that are part of the API transport envelope, not service.cli options.
|
||||||
|
# Used by instantiate_service to avoid passing them as kwargs to a service.
|
||||||
|
LIST_HANDLER_TRANSPORT_KEYS = {
|
||||||
|
"service",
|
||||||
|
"title_id",
|
||||||
|
"profile",
|
||||||
|
"season",
|
||||||
|
"episode",
|
||||||
|
"wanted",
|
||||||
|
"proxy",
|
||||||
|
"no_proxy",
|
||||||
|
"query",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_full_cdm(service: str, profile: Optional[str], cdm_type: Optional[str] = None) -> Optional[Any]:
|
||||||
|
"""Load a real CDM object for the given service.
|
||||||
|
|
||||||
|
Services often touch ``ctx.obj.cdm.security_level`` / ``.device_type`` / ``.system_id``
|
||||||
|
inside ``__init__``, so the lightweight ``_resolve_server_cdm`` stub is not enough
|
||||||
|
for list_titles / list_tracks / search. Mirrors ``dl.get_cdm`` selection logic but
|
||||||
|
skips the quality-tier shortcuts (no track context yet) and falls back to the stub
|
||||||
|
if no device is configured or loading fails.
|
||||||
|
"""
|
||||||
|
from unshackle.core.cdm import load_cdm
|
||||||
|
from unshackle.core.config import config as app_config
|
||||||
|
|
||||||
|
cdm_name = app_config.cdm.get(service) or app_config.cdm.get("default")
|
||||||
|
if isinstance(cdm_name, dict):
|
||||||
|
lower_keys = {k.lower(): v for k, v in cdm_name.items()}
|
||||||
|
if {"widevine", "playready"} & lower_keys.keys():
|
||||||
|
drm_key = None
|
||||||
|
if cdm_type:
|
||||||
|
drm_key = {"wv": "widevine", "widevine": "widevine", "pr": "playready", "playready": "playready"}.get(
|
||||||
|
cdm_type.lower()
|
||||||
|
)
|
||||||
|
cdm_name = lower_keys.get(drm_key or "widevine") or lower_keys.get("playready")
|
||||||
|
else:
|
||||||
|
cdm_name = cdm_name.get(profile) or cdm_name.get("default") or app_config.cdm.get("default")
|
||||||
|
|
||||||
|
if not cdm_name or not isinstance(cdm_name, str):
|
||||||
|
return _resolve_server_cdm(service, profile, cdm_type)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return load_cdm(cdm_name, service_name=service)
|
||||||
|
except Exception as exc: # noqa: BLE001 - fall back to stub on load failure
|
||||||
|
log.warning(f"load_cdm({cdm_name!r}) failed for {service}: {exc}; using lightweight stub")
|
||||||
|
return _resolve_server_cdm(service, profile, cdm_type)
|
||||||
|
|
||||||
|
|
||||||
|
def load_service_yaml(normalized_service: str) -> dict:
|
||||||
|
"""Load a service's config.yaml and merge it with the global override block."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from unshackle.core.utils.collections import merge_dict
|
||||||
|
|
||||||
|
service_config_path = Services.get_path(normalized_service) / config.filenames.config
|
||||||
|
if service_config_path.exists():
|
||||||
|
service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8")) or {}
|
||||||
|
else:
|
||||||
|
service_config = {}
|
||||||
|
merge_dict(config.services.get(normalized_service), service_config)
|
||||||
|
return service_config
|
||||||
|
|
||||||
|
|
||||||
|
def build_parent_ctx(
|
||||||
|
profile: Optional[str],
|
||||||
|
cdm: Any,
|
||||||
|
proxy_param: Optional[str],
|
||||||
|
no_proxy: bool,
|
||||||
|
proxy_providers: list,
|
||||||
|
service_config: dict,
|
||||||
|
extra_params: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Build a parent click Context for invoking a service.cli via ctx.invoke().
|
||||||
|
|
||||||
|
The service's CLI callback uses ``ctx.parent.params`` (proxy, range_, vcodec, etc.)
|
||||||
|
and ``ctx.obj`` (ContextData). Both flow through Click's parent chain.
|
||||||
|
"""
|
||||||
|
import click
|
||||||
|
|
||||||
|
from unshackle.core.utils.click_types import ContextData
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.pass_context
|
||||||
|
def dummy(ctx: click.Context) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
parent = click.Context(dummy)
|
||||||
|
parent.obj = ContextData(config=service_config, cdm=cdm, proxy_providers=proxy_providers, profile=profile)
|
||||||
|
params = {"proxy": proxy_param, "no_proxy": no_proxy}
|
||||||
|
if extra_params:
|
||||||
|
params.update(extra_params)
|
||||||
|
parent.params = params
|
||||||
|
return parent
|
||||||
|
|
||||||
|
|
||||||
|
def instantiate_service(
|
||||||
|
parent_ctx: Any,
|
||||||
|
service_module: Any,
|
||||||
|
title: str,
|
||||||
|
data: Optional[Dict[str, Any]] = None,
|
||||||
|
transport_keys: Optional[set] = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Instantiate a service by invoking its click cli through Click.
|
||||||
|
|
||||||
|
Click fills option defaults via ``param.get_default()`` and runs type coercion,
|
||||||
|
so we no longer have to inspect ``__init__`` or stitch defaults by hand. Extra
|
||||||
|
kwargs are pulled from ``data`` when the key matches a cli option name and is
|
||||||
|
not in the transport-key blocklist.
|
||||||
|
"""
|
||||||
|
cli_params = getattr(getattr(service_module, "cli", None), "params", []) or []
|
||||||
|
cli_param_names = {p.name for p in cli_params if hasattr(p, "name") and p.name}
|
||||||
|
transport_keys = transport_keys or set()
|
||||||
|
extras: Dict[str, Any] = {}
|
||||||
|
if data:
|
||||||
|
for k, v in data.items():
|
||||||
|
if k in cli_param_names and k not in transport_keys and k != "title":
|
||||||
|
extras[k] = v
|
||||||
|
return parent_ctx.invoke(service_module.cli, title=title, **extras)
|
||||||
|
|
||||||
|
|
||||||
def get_allowed_services(request: Optional[web.Request] = None) -> Optional[List[str]]:
|
def get_allowed_services(request: Optional[web.Request] = None) -> Optional[List[str]]:
|
||||||
"""Get effective service allowlist considering global + per-key config.
|
"""Get effective service allowlist considering global + per-key config.
|
||||||
|
|
||||||
@@ -365,16 +487,7 @@ def serialize_subtitle_track(track: Subtitle, include_url: bool = False) -> Dict
|
|||||||
|
|
||||||
async def search_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
|
async def search_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
|
||||||
"""Handle search request."""
|
"""Handle search request."""
|
||||||
import inspect
|
|
||||||
|
|
||||||
import click
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from unshackle.commands.dl import dl
|
from unshackle.commands.dl import dl
|
||||||
from unshackle.core.config import config
|
|
||||||
from unshackle.core.services import Services
|
|
||||||
from unshackle.core.utils.click_types import ContextData
|
|
||||||
from unshackle.core.utils.collections import merge_dict
|
|
||||||
|
|
||||||
service_tag = data.get("service")
|
service_tag = data.get("service")
|
||||||
query = data.get("query")
|
query = data.get("query")
|
||||||
@@ -404,12 +517,7 @@ async def search_handler(data: Dict[str, Any], request: Optional[web.Request] =
|
|||||||
proxy_param = data.get("proxy")
|
proxy_param = data.get("proxy")
|
||||||
no_proxy = data.get("no_proxy", False)
|
no_proxy = data.get("no_proxy", False)
|
||||||
|
|
||||||
service_config_path = Services.get_path(normalized_service) / config.filenames.config
|
service_config = load_service_yaml(normalized_service)
|
||||||
if service_config_path.exists():
|
|
||||||
service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8"))
|
|
||||||
else:
|
|
||||||
service_config = {}
|
|
||||||
merge_dict(config.services.get(normalized_service), service_config)
|
|
||||||
|
|
||||||
proxy_providers = []
|
proxy_providers = []
|
||||||
if not no_proxy:
|
if not no_proxy:
|
||||||
@@ -426,49 +534,12 @@ async def search_handler(data: Dict[str, Any], request: Optional[web.Request] =
|
|||||||
details={"proxy": proxy_param, "service": normalized_service},
|
details={"proxy": proxy_param, "service": normalized_service},
|
||||||
)
|
)
|
||||||
|
|
||||||
@click.command()
|
cdm = load_full_cdm(normalized_service, profile, data.get("cdm_type"))
|
||||||
@click.pass_context
|
parent_ctx = build_parent_ctx(profile, cdm, proxy_param, no_proxy, proxy_providers, service_config)
|
||||||
def dummy_service(ctx: click.Context) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
ctx = click.Context(dummy_service)
|
|
||||||
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=proxy_providers, profile=profile)
|
|
||||||
ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy}
|
|
||||||
|
|
||||||
service_module = Services.load(normalized_service)
|
service_module = Services.load(normalized_service)
|
||||||
|
|
||||||
dummy_service.name = normalized_service
|
|
||||||
ctx.invoked_subcommand = normalized_service
|
|
||||||
|
|
||||||
service_ctx = click.Context(dummy_service, parent=ctx)
|
|
||||||
service_ctx.obj = ctx.obj
|
|
||||||
|
|
||||||
service_init_params = inspect.signature(service_module.__init__).parameters
|
|
||||||
service_kwargs = {"title": query}
|
|
||||||
|
|
||||||
# Extract default values from the click command
|
|
||||||
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
|
|
||||||
for param in service_module.cli.params:
|
|
||||||
if hasattr(param, "name") and param.name not in service_kwargs:
|
|
||||||
if hasattr(param, "default") and param.default is not None and not isinstance(param.default, enum.Enum):
|
|
||||||
service_kwargs[param.name] = param.default
|
|
||||||
|
|
||||||
for param_name, param_info in service_init_params.items():
|
|
||||||
if param_name not in service_kwargs and param_name not in ["self", "ctx"]:
|
|
||||||
if param_info.default is inspect.Parameter.empty:
|
|
||||||
if param_name == "meta_lang":
|
|
||||||
service_kwargs[param_name] = None
|
|
||||||
elif param_name == "movie":
|
|
||||||
service_kwargs[param_name] = False
|
|
||||||
else:
|
|
||||||
service_kwargs[param_name] = None
|
|
||||||
|
|
||||||
# Filter to only accepted params
|
|
||||||
accepted_params = set(service_init_params.keys()) - {"self", "ctx"}
|
|
||||||
service_kwargs = {k: v for k, v in service_kwargs.items() if k in accepted_params}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
service_instance = service_module(service_ctx, **service_kwargs)
|
service_instance = instantiate_service(parent_ctx, service_module, query)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise APIError(
|
raise APIError(
|
||||||
APIErrorCode.SERVICE_ERROR,
|
APIErrorCode.SERVICE_ERROR,
|
||||||
@@ -533,29 +604,10 @@ async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Reques
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import inspect
|
|
||||||
|
|
||||||
import click
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from unshackle.commands.dl import dl
|
from unshackle.commands.dl import dl
|
||||||
from unshackle.core.config import config
|
|
||||||
from unshackle.core.utils.click_types import ContextData
|
|
||||||
from unshackle.core.utils.collections import merge_dict
|
|
||||||
|
|
||||||
service_config_path = Services.get_path(normalized_service) / config.filenames.config
|
service_config = load_service_yaml(normalized_service)
|
||||||
if service_config_path.exists():
|
|
||||||
service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8"))
|
|
||||||
else:
|
|
||||||
service_config = {}
|
|
||||||
merge_dict(config.services.get(normalized_service), service_config)
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.pass_context
|
|
||||||
def dummy_service(ctx: click.Context) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Handle proxy configuration
|
|
||||||
proxy_param = data.get("proxy")
|
proxy_param = data.get("proxy")
|
||||||
no_proxy = data.get("no_proxy", False)
|
no_proxy = data.get("no_proxy", False)
|
||||||
proxy_providers = []
|
proxy_providers = []
|
||||||
@@ -574,64 +626,10 @@ async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Reques
|
|||||||
details={"proxy": proxy_param, "service": normalized_service},
|
details={"proxy": proxy_param, "service": normalized_service},
|
||||||
)
|
)
|
||||||
|
|
||||||
ctx = click.Context(dummy_service)
|
cdm = load_full_cdm(normalized_service, profile, data.get("cdm_type"))
|
||||||
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=proxy_providers, profile=profile)
|
parent_ctx = build_parent_ctx(profile, cdm, proxy_param, no_proxy, proxy_providers, service_config)
|
||||||
ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy}
|
|
||||||
|
|
||||||
service_module = Services.load(normalized_service)
|
service_module = Services.load(normalized_service)
|
||||||
|
service_instance = instantiate_service(parent_ctx, service_module, title_id, data, LIST_HANDLER_TRANSPORT_KEYS)
|
||||||
dummy_service.name = normalized_service
|
|
||||||
dummy_service.params = [click.Argument([title_id], type=str)]
|
|
||||||
ctx.invoked_subcommand = normalized_service
|
|
||||||
|
|
||||||
service_ctx = click.Context(dummy_service, parent=ctx)
|
|
||||||
service_ctx.obj = ctx.obj
|
|
||||||
|
|
||||||
service_kwargs = {"title": title_id}
|
|
||||||
|
|
||||||
# Add additional parameters from request data
|
|
||||||
for key, value in data.items():
|
|
||||||
if key not in ["service", "title_id", "profile", "season", "episode", "wanted", "proxy", "no_proxy"]:
|
|
||||||
service_kwargs[key] = value
|
|
||||||
|
|
||||||
# Get service parameter info and click command defaults
|
|
||||||
service_init_params = inspect.signature(service_module.__init__).parameters
|
|
||||||
|
|
||||||
# Extract default values from the click command
|
|
||||||
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
|
|
||||||
for param in service_module.cli.params:
|
|
||||||
if hasattr(param, "name") and param.name not in service_kwargs:
|
|
||||||
# Add default value if parameter is not already provided
|
|
||||||
if (
|
|
||||||
hasattr(param, "default")
|
|
||||||
and param.default is not None
|
|
||||||
and not isinstance(param.default, enum.Enum)
|
|
||||||
):
|
|
||||||
service_kwargs[param.name] = param.default
|
|
||||||
|
|
||||||
# Handle required parameters that don't have click defaults
|
|
||||||
for param_name, param_info in service_init_params.items():
|
|
||||||
if param_name not in service_kwargs and param_name not in ["self", "ctx"]:
|
|
||||||
# Check if parameter is required (no default value in signature)
|
|
||||||
if param_info.default is inspect.Parameter.empty:
|
|
||||||
# Provide sensible defaults for common required parameters
|
|
||||||
if param_name == "meta_lang":
|
|
||||||
service_kwargs[param_name] = None
|
|
||||||
elif param_name == "movie":
|
|
||||||
service_kwargs[param_name] = False
|
|
||||||
else:
|
|
||||||
# Log warning for unknown required parameters
|
|
||||||
log.warning(
|
|
||||||
f"Unknown required parameter '{_sanitize_log(param_name)}' for service {_sanitize_log(normalized_service)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter out any parameters that the service doesn't accept
|
|
||||||
filtered_kwargs = {}
|
|
||||||
for key, value in service_kwargs.items():
|
|
||||||
if key in service_init_params:
|
|
||||||
filtered_kwargs[key] = value
|
|
||||||
|
|
||||||
service_instance = service_module(service_ctx, **filtered_kwargs)
|
|
||||||
|
|
||||||
cookies = dl.get_cookie_jar(normalized_service, profile)
|
cookies = dl.get_cookie_jar(normalized_service, profile)
|
||||||
credential = dl.get_credentials(normalized_service, profile)
|
credential = dl.get_credentials(normalized_service, profile)
|
||||||
@@ -687,29 +685,10 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import inspect
|
|
||||||
|
|
||||||
import click
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from unshackle.commands.dl import dl
|
from unshackle.commands.dl import dl
|
||||||
from unshackle.core.config import config
|
|
||||||
from unshackle.core.utils.click_types import ContextData
|
|
||||||
from unshackle.core.utils.collections import merge_dict
|
|
||||||
|
|
||||||
service_config_path = Services.get_path(normalized_service) / config.filenames.config
|
service_config = load_service_yaml(normalized_service)
|
||||||
if service_config_path.exists():
|
|
||||||
service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8"))
|
|
||||||
else:
|
|
||||||
service_config = {}
|
|
||||||
merge_dict(config.services.get(normalized_service), service_config)
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.pass_context
|
|
||||||
def dummy_service(ctx: click.Context) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Handle proxy configuration
|
|
||||||
proxy_param = data.get("proxy")
|
proxy_param = data.get("proxy")
|
||||||
no_proxy = data.get("no_proxy", False)
|
no_proxy = data.get("no_proxy", False)
|
||||||
proxy_providers = []
|
proxy_providers = []
|
||||||
@@ -728,64 +707,10 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques
|
|||||||
details={"proxy": proxy_param, "service": normalized_service},
|
details={"proxy": proxy_param, "service": normalized_service},
|
||||||
)
|
)
|
||||||
|
|
||||||
ctx = click.Context(dummy_service)
|
cdm = load_full_cdm(normalized_service, profile, data.get("cdm_type"))
|
||||||
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=proxy_providers, profile=profile)
|
parent_ctx = build_parent_ctx(profile, cdm, proxy_param, no_proxy, proxy_providers, service_config)
|
||||||
ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy}
|
|
||||||
|
|
||||||
service_module = Services.load(normalized_service)
|
service_module = Services.load(normalized_service)
|
||||||
|
service_instance = instantiate_service(parent_ctx, service_module, title_id, data, LIST_HANDLER_TRANSPORT_KEYS)
|
||||||
dummy_service.name = normalized_service
|
|
||||||
dummy_service.params = [click.Argument([title_id], type=str)]
|
|
||||||
ctx.invoked_subcommand = normalized_service
|
|
||||||
|
|
||||||
service_ctx = click.Context(dummy_service, parent=ctx)
|
|
||||||
service_ctx.obj = ctx.obj
|
|
||||||
|
|
||||||
service_kwargs = {"title": title_id}
|
|
||||||
|
|
||||||
# Add additional parameters from request data
|
|
||||||
for key, value in data.items():
|
|
||||||
if key not in ["service", "title_id", "profile", "season", "episode", "wanted", "proxy", "no_proxy"]:
|
|
||||||
service_kwargs[key] = value
|
|
||||||
|
|
||||||
# Get service parameter info and click command defaults
|
|
||||||
service_init_params = inspect.signature(service_module.__init__).parameters
|
|
||||||
|
|
||||||
# Extract default values from the click command
|
|
||||||
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
|
|
||||||
for param in service_module.cli.params:
|
|
||||||
if hasattr(param, "name") and param.name not in service_kwargs:
|
|
||||||
# Add default value if parameter is not already provided
|
|
||||||
if (
|
|
||||||
hasattr(param, "default")
|
|
||||||
and param.default is not None
|
|
||||||
and not isinstance(param.default, enum.Enum)
|
|
||||||
):
|
|
||||||
service_kwargs[param.name] = param.default
|
|
||||||
|
|
||||||
# Handle required parameters that don't have click defaults
|
|
||||||
for param_name, param_info in service_init_params.items():
|
|
||||||
if param_name not in service_kwargs and param_name not in ["self", "ctx"]:
|
|
||||||
# Check if parameter is required (no default value in signature)
|
|
||||||
if param_info.default is inspect.Parameter.empty:
|
|
||||||
# Provide sensible defaults for common required parameters
|
|
||||||
if param_name == "meta_lang":
|
|
||||||
service_kwargs[param_name] = None
|
|
||||||
elif param_name == "movie":
|
|
||||||
service_kwargs[param_name] = False
|
|
||||||
else:
|
|
||||||
# Log warning for unknown required parameters
|
|
||||||
log.warning(
|
|
||||||
f"Unknown required parameter '{_sanitize_log(param_name)}' for service {_sanitize_log(normalized_service)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter out any parameters that the service doesn't accept
|
|
||||||
filtered_kwargs = {}
|
|
||||||
for key, value in service_kwargs.items():
|
|
||||||
if key in service_init_params:
|
|
||||||
filtered_kwargs[key] = value
|
|
||||||
|
|
||||||
service_instance = service_module(service_ctx, **filtered_kwargs)
|
|
||||||
|
|
||||||
cookies = dl.get_cookie_jar(normalized_service, profile)
|
cookies = dl.get_cookie_jar(normalized_service, profile)
|
||||||
credential = dl.get_credentials(normalized_service, profile)
|
credential = dl.get_credentials(normalized_service, profile)
|
||||||
@@ -1085,15 +1010,13 @@ async def download_handler(data: Dict[str, Any], request: Optional[web.Request]
|
|||||||
service_module = Services.load(normalized_service)
|
service_module = Services.load(normalized_service)
|
||||||
service_specific_defaults = {}
|
service_specific_defaults = {}
|
||||||
|
|
||||||
# Extract default values from the service's click command
|
# Extract default values from the service's click command.
|
||||||
|
# Skip None defaults here: this dict overlays into job params; injecting
|
||||||
|
# None for keys like `profile` would clobber serve-config overrides.
|
||||||
|
# Missing required __init__ params are handled in download_manager._perform_download.
|
||||||
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
|
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
|
||||||
for param in service_module.cli.params:
|
for param in service_module.cli.params:
|
||||||
if (
|
if hasattr(param, "name") and param.default is not None and not isinstance(param.default, enum.Enum):
|
||||||
hasattr(param, "name")
|
|
||||||
and hasattr(param, "default")
|
|
||||||
and param.default is not None
|
|
||||||
and not isinstance(param.default, enum.Enum)
|
|
||||||
):
|
|
||||||
# Store service-specific defaults (e.g., drm_system, hydrate_track, profile for NF)
|
# Store service-specific defaults (e.g., drm_system, hydrate_track, profile for NF)
|
||||||
service_specific_defaults[param.name] = param.default
|
service_specific_defaults[param.name] = param.default
|
||||||
|
|
||||||
@@ -1220,7 +1143,7 @@ async def get_download_job_handler(job_id: str, request: Optional[web.Request] =
|
|||||||
except APIError:
|
except APIError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(f"Error getting download job {_sanitize_log(job_id)}")
|
log.exception(f"Error getting download job {sanitize_log(job_id)}")
|
||||||
debug_mode = request.app.get("debug_api", False) if request else False
|
debug_mode = request.app.get("debug_api", False) if request else False
|
||||||
return handle_api_exception(
|
return handle_api_exception(
|
||||||
e,
|
e,
|
||||||
@@ -1257,7 +1180,7 @@ async def cancel_download_job_handler(job_id: str, request: Optional[web.Request
|
|||||||
except APIError:
|
except APIError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(f"Error cancelling download job {_sanitize_log(job_id)}")
|
log.exception(f"Error cancelling download job {sanitize_log(job_id)}")
|
||||||
debug_mode = request.app.get("debug_api", False) if request else False
|
debug_mode = request.app.get("debug_api", False) if request else False
|
||||||
return handle_api_exception(
|
return handle_api_exception(
|
||||||
e,
|
e,
|
||||||
@@ -1271,6 +1194,26 @@ async def cancel_download_job_handler(job_id: str, request: Optional[web.Request
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
SESSION_TRANSPORT_KEYS = {
|
||||||
|
"service",
|
||||||
|
"title_id",
|
||||||
|
"season",
|
||||||
|
"episode",
|
||||||
|
"wanted",
|
||||||
|
"proxy",
|
||||||
|
"no_proxy",
|
||||||
|
"credentials",
|
||||||
|
"cookies",
|
||||||
|
"cache",
|
||||||
|
"client_region",
|
||||||
|
"cdm_type",
|
||||||
|
"range_",
|
||||||
|
"vcodec",
|
||||||
|
"quality",
|
||||||
|
"best_available",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _create_service_instance(
|
def _create_service_instance(
|
||||||
normalized_service: str,
|
normalized_service: str,
|
||||||
title_id: str,
|
title_id: str,
|
||||||
@@ -1284,38 +1227,17 @@ def _create_service_instance(
|
|||||||
Supports client-sent credentials/cookies (for remote-dl) with fallback
|
Supports client-sent credentials/cookies (for remote-dl) with fallback
|
||||||
to server-local config (for backward compatibility).
|
to server-local config (for backward compatibility).
|
||||||
"""
|
"""
|
||||||
import inspect
|
|
||||||
|
|
||||||
import click
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from unshackle.commands.dl import dl
|
from unshackle.commands.dl import dl
|
||||||
from unshackle.core.config import config
|
|
||||||
from unshackle.core.credential import Credential
|
from unshackle.core.credential import Credential
|
||||||
from unshackle.core.utils.click_types import ContextData
|
|
||||||
from unshackle.core.utils.collections import merge_dict
|
|
||||||
|
|
||||||
service_config_path = Services.get_path(normalized_service) / config.filenames.config
|
|
||||||
if service_config_path.exists():
|
|
||||||
service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8"))
|
|
||||||
else:
|
|
||||||
service_config = {}
|
|
||||||
merge_dict(config.services.get(normalized_service), service_config)
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.pass_context
|
|
||||||
def dummy_service(ctx: click.Context) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
ctx = click.Context(dummy_service)
|
|
||||||
cdm = _resolve_server_cdm(normalized_service, profile, data.get("cdm_type"))
|
|
||||||
|
|
||||||
ctx.obj = ContextData(config=service_config, cdm=cdm, proxy_providers=proxy_providers, profile=profile)
|
|
||||||
# Reconstruct track selection params from client data
|
|
||||||
from unshackle.core.tracks import Video
|
from unshackle.core.tracks import Video
|
||||||
|
|
||||||
|
service_config = load_service_yaml(normalized_service)
|
||||||
|
cdm = _resolve_server_cdm(normalized_service, profile, data.get("cdm_type"))
|
||||||
|
|
||||||
|
# Reconstruct enum track-selection params from client data so service code that reads
|
||||||
|
# ctx.parent.params (Service.__init__ proxy/range/vcodec/best_available block) sees enums.
|
||||||
range_names = data.get("range_")
|
range_names = data.get("range_")
|
||||||
range_values = None
|
range_values: Optional[list] = None
|
||||||
if range_names:
|
if range_names:
|
||||||
range_values = []
|
range_values = []
|
||||||
for name in range_names:
|
for name in range_names:
|
||||||
@@ -1326,7 +1248,7 @@ def _create_service_instance(
|
|||||||
range_values = range_values or None
|
range_values = range_values or None
|
||||||
|
|
||||||
vcodec_names = data.get("vcodec")
|
vcodec_names = data.get("vcodec")
|
||||||
vcodec_values = None
|
vcodec_values: Optional[list] = None
|
||||||
if vcodec_names:
|
if vcodec_names:
|
||||||
vcodec_values = []
|
vcodec_values = []
|
||||||
for name in vcodec_names:
|
for name in vcodec_names:
|
||||||
@@ -1336,72 +1258,25 @@ def _create_service_instance(
|
|||||||
pass
|
pass
|
||||||
vcodec_values = vcodec_values or None
|
vcodec_values = vcodec_values or None
|
||||||
|
|
||||||
ctx.params = {
|
extra_params = {
|
||||||
"proxy": proxy_param,
|
|
||||||
"no_proxy": data.get("no_proxy", False),
|
|
||||||
"range_": range_values,
|
"range_": range_values,
|
||||||
"vcodec": vcodec_values,
|
"vcodec": vcodec_values,
|
||||||
"quality": data.get("quality"),
|
"quality": data.get("quality"),
|
||||||
"best_available": data.get("best_available", False),
|
"best_available": data.get("best_available", False),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parent_ctx = build_parent_ctx(
|
||||||
|
profile,
|
||||||
|
cdm,
|
||||||
|
proxy_param,
|
||||||
|
data.get("no_proxy", False),
|
||||||
|
proxy_providers,
|
||||||
|
service_config,
|
||||||
|
extra_params=extra_params,
|
||||||
|
)
|
||||||
|
|
||||||
service_module = Services.load(normalized_service)
|
service_module = Services.load(normalized_service)
|
||||||
|
service_instance = instantiate_service(parent_ctx, service_module, title_id, data, SESSION_TRANSPORT_KEYS)
|
||||||
dummy_service.name = normalized_service
|
|
||||||
dummy_service.params = [click.Argument([title_id], type=str)]
|
|
||||||
ctx.invoked_subcommand = normalized_service
|
|
||||||
|
|
||||||
service_ctx = click.Context(dummy_service, parent=ctx)
|
|
||||||
service_ctx.obj = ctx.obj
|
|
||||||
|
|
||||||
service_kwargs: Dict[str, Any] = {"title": title_id}
|
|
||||||
|
|
||||||
transport_keys = {
|
|
||||||
"service",
|
|
||||||
"title_id",
|
|
||||||
"season",
|
|
||||||
"episode",
|
|
||||||
"wanted",
|
|
||||||
"proxy",
|
|
||||||
"no_proxy",
|
|
||||||
"credentials",
|
|
||||||
"cookies",
|
|
||||||
"cache",
|
|
||||||
"client_region",
|
|
||||||
"no_proxy",
|
|
||||||
"cdm_type",
|
|
||||||
"range_",
|
|
||||||
"vcodec",
|
|
||||||
"quality",
|
|
||||||
"best_available",
|
|
||||||
}
|
|
||||||
|
|
||||||
service_init_params = inspect.signature(service_module.__init__).parameters
|
|
||||||
|
|
||||||
for key, value in data.items():
|
|
||||||
if key not in transport_keys and key in service_init_params:
|
|
||||||
service_kwargs[key] = value
|
|
||||||
|
|
||||||
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
|
|
||||||
for param in service_module.cli.params:
|
|
||||||
if hasattr(param, "name") and param.name not in service_kwargs:
|
|
||||||
if hasattr(param, "default") and param.default is not None and not isinstance(param.default, enum.Enum):
|
|
||||||
service_kwargs[param.name] = param.default
|
|
||||||
|
|
||||||
for param_name, param_info in service_init_params.items():
|
|
||||||
if param_name not in service_kwargs and param_name not in ["self", "ctx"]:
|
|
||||||
if param_info.default is inspect.Parameter.empty:
|
|
||||||
if param_name == "meta_lang":
|
|
||||||
service_kwargs[param_name] = None
|
|
||||||
elif param_name == "movie":
|
|
||||||
service_kwargs[param_name] = False
|
|
||||||
elif param_name == "profile":
|
|
||||||
service_kwargs[param_name] = profile
|
|
||||||
else:
|
|
||||||
log.warning(f"Unknown required parameter '{param_name}' for service {normalized_service}")
|
|
||||||
|
|
||||||
filtered_kwargs = {k: v for k, v in service_kwargs.items() if k in service_init_params}
|
|
||||||
service_instance = service_module(service_ctx, **filtered_kwargs)
|
|
||||||
|
|
||||||
# Resolve credentials: client-sent > server-local
|
# Resolve credentials: client-sent > server-local
|
||||||
cred_data = data.get("credentials")
|
cred_data = data.get("credentials")
|
||||||
@@ -1706,7 +1581,7 @@ async def session_tracks_handler(
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(f"Error getting tracks for title {_sanitize_log(title_id)}")
|
log.exception(f"Error getting tracks for title {sanitize_log(title_id)}")
|
||||||
debug_mode = request.app.get("debug_api", False) if request else False
|
debug_mode = request.app.get("debug_api", False) if request else False
|
||||||
return handle_api_exception(
|
return handle_api_exception(
|
||||||
e,
|
e,
|
||||||
|
|||||||
Reference in New Issue
Block a user