Temporarily removes client-side remote service discovery and authentication until the implementation is more fleshed out and working.
2196 lines
84 KiB
Python
2196 lines
84 KiB
Python
"""API handlers for remote service functionality."""
|
|
|
|
import http.cookiejar
|
|
import inspect
|
|
import logging
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
import click
|
|
import yaml
|
|
from aiohttp import web
|
|
|
|
from unshackle.commands.dl import dl
|
|
from unshackle.core.api.api_keys import can_use_cdm, get_api_key_from_request, get_default_cdm, is_premium_user
|
|
from unshackle.core.api.handlers import (serialize_audio_track, serialize_subtitle_track, serialize_title,
|
|
serialize_video_track, validate_service)
|
|
from unshackle.core.api.session_serializer import deserialize_session, serialize_session
|
|
from unshackle.core.config import config
|
|
from unshackle.core.credential import Credential
|
|
from unshackle.core.search_result import SearchResult
|
|
from unshackle.core.services import Services
|
|
from unshackle.core.titles import Episode
|
|
from unshackle.core.utils.click_types import ContextData
|
|
from unshackle.core.utils.collections import merge_dict
|
|
|
|
log = logging.getLogger("api.remote")
|
|
|
|
# Session expiry time in seconds (24 hours)
|
|
SESSION_EXPIRY_TIME = 86400
|
|
|
|
|
|
class CDMProxy:
|
|
"""
|
|
Lightweight CDM proxy that holds CDM properties sent from client.
|
|
|
|
This allows services to check CDM properties (like security_level)
|
|
without needing an actual CDM loaded on the server.
|
|
"""
|
|
|
|
def __init__(self, cdm_info: Dict[str, Any]):
|
|
"""
|
|
Initialize CDM proxy from client-provided info.
|
|
|
|
Args:
|
|
cdm_info: Dictionary with CDM properties (type, security_level, etc.)
|
|
"""
|
|
self.cdm_type = cdm_info.get("type", "widevine")
|
|
self.security_level = cdm_info.get("security_level", 3)
|
|
self.is_playready = self.cdm_type == "playready"
|
|
self.device_type = cdm_info.get("device_type")
|
|
self.is_remote = cdm_info.get("is_remote", False)
|
|
|
|
def __repr__(self):
|
|
return f"CDMProxy(type={self.cdm_type}, L{self.security_level})"
|
|
|
|
|
|
def load_cookies_from_content(cookies_content: Optional[str]) -> Optional[http.cookiejar.MozillaCookieJar]:
|
|
"""
|
|
Load cookies from raw cookie file content.
|
|
|
|
Args:
|
|
cookies_content: Raw content of a Netscape/Mozilla format cookie file
|
|
|
|
Returns:
|
|
MozillaCookieJar object or None
|
|
"""
|
|
if not cookies_content:
|
|
return None
|
|
|
|
# Write to temporary file
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
|
f.write(cookies_content)
|
|
temp_path = f.name
|
|
|
|
try:
|
|
# Load using standard cookie jar
|
|
cookie_jar = http.cookiejar.MozillaCookieJar(temp_path)
|
|
cookie_jar.load(ignore_discard=True, ignore_expires=True)
|
|
return cookie_jar
|
|
finally:
|
|
# Clean up temp file
|
|
Path(temp_path).unlink(missing_ok=True)
|
|
|
|
|
|
def create_credential_from_dict(cred_data: Optional[Dict[str, str]]) -> Optional[Credential]:
|
|
"""
|
|
Create a Credential object from dictionary.
|
|
|
|
Args:
|
|
cred_data: Dictionary with 'username' and 'password' keys
|
|
|
|
Returns:
|
|
Credential object or None
|
|
"""
|
|
if not cred_data or "username" not in cred_data or "password" not in cred_data:
|
|
return None
|
|
|
|
return Credential(username=cred_data["username"], password=cred_data["password"])
|
|
|
|
|
|
def validate_session_expiry(session_data: Dict[str, Any]) -> Optional[str]:
|
|
"""
|
|
Validate if a session is expired.
|
|
|
|
Args:
|
|
session_data: Session data with cached_at timestamp
|
|
|
|
Returns:
|
|
Error code if session is expired, None if valid
|
|
"""
|
|
if not session_data:
|
|
return None
|
|
|
|
cached_at = session_data.get("cached_at")
|
|
if not cached_at:
|
|
# No timestamp - assume valid (backward compatibility)
|
|
return None
|
|
|
|
age = time.time() - cached_at
|
|
if age > SESSION_EXPIRY_TIME:
|
|
log.warning(f"Session expired (age: {age:.0f}s, limit: {SESSION_EXPIRY_TIME}s)")
|
|
return "SESSION_EXPIRED"
|
|
|
|
# Warn if session is close to expiry (within 1 hour)
|
|
if age > (SESSION_EXPIRY_TIME - 3600):
|
|
remaining = SESSION_EXPIRY_TIME - age
|
|
log.info(f"Session expires soon (remaining: {remaining:.0f}s)")
|
|
|
|
return None
|
|
|
|
|
|
def get_auth_from_request(data: Dict[str, Any], service_tag: str, profile: Optional[str] = None):
|
|
"""
|
|
Get authentication from request data or fallback to server config.
|
|
|
|
Server is STATELESS - it never stores sessions.
|
|
Client sends pre-authenticated session with each request.
|
|
|
|
Priority order:
|
|
1. Pre-authenticated session from client (sent with request)
|
|
2. Client-provided credentials/cookies in request
|
|
3. Server-side credentials/cookies from config (fallback)
|
|
|
|
Args:
|
|
data: Request data
|
|
service_tag: Service tag
|
|
profile: Profile name
|
|
|
|
Returns:
|
|
Tuple of (cookies, credential, pre_authenticated_session, session_error)
|
|
where session_error is an error code if session is expired
|
|
"""
|
|
# First priority: Check for pre-authenticated session sent by client
|
|
pre_authenticated_session = data.get("pre_authenticated_session")
|
|
|
|
if pre_authenticated_session:
|
|
log.info(f"Using client's pre-authenticated session for {service_tag}")
|
|
|
|
# Validate session expiry
|
|
session_error = validate_session_expiry(pre_authenticated_session)
|
|
if session_error:
|
|
log.warning(f"Session validation failed: {session_error}")
|
|
return None, None, None, session_error
|
|
|
|
# Return None, None to indicate we'll use the pre-authenticated session
|
|
return None, None, pre_authenticated_session, None
|
|
|
|
# Second priority: Try to get from client request
|
|
cookies_content = data.get("cookies")
|
|
credential_data = data.get("credential")
|
|
|
|
if cookies_content:
|
|
cookies = load_cookies_from_content(cookies_content)
|
|
else:
|
|
# Fallback to server-side cookies if not provided by client
|
|
cookies = dl.get_cookie_jar(service_tag, profile)
|
|
|
|
if credential_data:
|
|
credential = create_credential_from_dict(credential_data)
|
|
else:
|
|
# Fallback to server-side credentials if not provided by client
|
|
credential = dl.get_credentials(service_tag, profile)
|
|
|
|
return cookies, credential, None, None
|
|
|
|
|
|
async def remote_list_services(request: web.Request) -> web.Response:
|
|
"""
|
|
List all available services on this remote server.
|
|
---
|
|
summary: List remote services
|
|
description: Get all available services that can be accessed remotely
|
|
responses:
|
|
'200':
|
|
description: List of available services
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
example: success
|
|
services:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
tag:
|
|
type: string
|
|
aliases:
|
|
type: array
|
|
items:
|
|
type: string
|
|
geofence:
|
|
type: array
|
|
items:
|
|
type: string
|
|
help:
|
|
type: string
|
|
'500':
|
|
description: Server error
|
|
"""
|
|
try:
|
|
service_tags = Services.get_tags()
|
|
services_info = []
|
|
|
|
for tag in service_tags:
|
|
service_data = {
|
|
"tag": tag,
|
|
"aliases": [],
|
|
"geofence": [],
|
|
"help": None,
|
|
}
|
|
|
|
try:
|
|
service_module = Services.load(tag)
|
|
|
|
if hasattr(service_module, "ALIASES"):
|
|
service_data["aliases"] = list(service_module.ALIASES)
|
|
|
|
if hasattr(service_module, "GEOFENCE"):
|
|
service_data["geofence"] = list(service_module.GEOFENCE)
|
|
|
|
if service_module.__doc__:
|
|
service_data["help"] = service_module.__doc__.strip()
|
|
|
|
except Exception as e:
|
|
log.warning(f"Could not load details for service {tag}: {e}")
|
|
|
|
services_info.append(service_data)
|
|
|
|
return web.json_response({"status": "success", "services": services_info})
|
|
|
|
except Exception:
|
|
log.exception("Error listing remote services")
|
|
return web.json_response({"status": "error", "message": "Internal server error while listing services"}, status=500)
|
|
|
|
|
|
async def remote_search(request: web.Request) -> web.Response:
|
|
"""
|
|
Search for content on a remote service.
|
|
---
|
|
summary: Search remote service
|
|
description: Search for content using a remote service
|
|
parameters:
|
|
- name: service
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- query
|
|
properties:
|
|
query:
|
|
type: string
|
|
description: Search query
|
|
profile:
|
|
type: string
|
|
description: Profile to use for credentials
|
|
responses:
|
|
'200':
|
|
description: Search results
|
|
'400':
|
|
description: Invalid request
|
|
'500':
|
|
description: Server error
|
|
"""
|
|
service_tag = request.match_info.get("service")
|
|
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400)
|
|
|
|
query = data.get("query")
|
|
if not query:
|
|
return web.json_response({"status": "error", "message": "Missing required parameter: query"}, status=400)
|
|
|
|
normalized_service = validate_service(service_tag)
|
|
if not normalized_service:
|
|
return web.json_response(
|
|
{"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400
|
|
)
|
|
|
|
try:
|
|
profile = data.get("profile")
|
|
|
|
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
|
|
|
|
# Handle proxy configuration
|
|
# Client MUST send resolved proxy with credentials (e.g., http://user:pass@host:port)
|
|
# Server does NOT resolve proxy providers - client must do that
|
|
proxy_param = data.get("proxy")
|
|
no_proxy = data.get("no_proxy", False)
|
|
|
|
if proxy_param and not no_proxy:
|
|
import re
|
|
|
|
# Validate that client sent a fully resolved proxy URL
|
|
if re.match(r"^https?://", proxy_param):
|
|
log.info("Using client-resolved proxy with credentials")
|
|
else:
|
|
# Reject unresolved proxy parameters
|
|
log.error(f"[SECURITY] Client sent unresolved proxy parameter: {proxy_param}")
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "INVALID_PROXY",
|
|
"message": f"Proxy must be a fully resolved URL (http://... or https://...). "
|
|
f"Cannot use proxy provider shortcuts like '{proxy_param}'. "
|
|
f"Please resolve the proxy on the client side before sending to server."
|
|
}, status=400)
|
|
|
|
ctx = click.Context(dummy_service)
|
|
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile)
|
|
ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy}
|
|
|
|
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
|
|
|
|
# Get service initialization parameters
|
|
service_init_params = inspect.signature(service_module.__init__).parameters
|
|
service_kwargs = {}
|
|
|
|
# Extract defaults from 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:
|
|
service_kwargs[param.name] = param.default
|
|
|
|
# Add query parameter
|
|
if "query" in service_init_params:
|
|
service_kwargs["query"] = query
|
|
|
|
# Filter to only valid parameters
|
|
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)
|
|
|
|
# Authenticate with client-provided or server-side auth
|
|
cookies, credential, pre_authenticated_session, session_error = get_auth_from_request(data, normalized_service, profile)
|
|
|
|
# Check for session expiry
|
|
if session_error == "SESSION_EXPIRED":
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "SESSION_EXPIRED",
|
|
"message": f"Session expired for {normalized_service}. Please re-authenticate."
|
|
}, status=401)
|
|
|
|
try:
|
|
if pre_authenticated_session:
|
|
# Use pre-authenticated session sent by client (server is stateless)
|
|
deserialize_session(pre_authenticated_session, service_instance.session)
|
|
else:
|
|
# Authenticate with credentials/cookies
|
|
if not cookies and not credential:
|
|
# No auth data available - tell client to authenticate
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "AUTH_REQUIRED",
|
|
"message": f"Authentication required for {normalized_service}. No credentials or session available."
|
|
}, status=401)
|
|
|
|
service_instance.authenticate(cookies, credential)
|
|
except Exception as auth_error:
|
|
# Authentication failed - tell client to re-authenticate
|
|
log.warning(f"Authentication failed for {normalized_service}: {auth_error}")
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "AUTH_REQUIRED",
|
|
"message": f"Authentication failed for {normalized_service}. Please authenticate locally."
|
|
}, status=401)
|
|
|
|
# Perform search
|
|
search_results = []
|
|
if hasattr(service_instance, "search"):
|
|
for result in service_instance.search():
|
|
if isinstance(result, SearchResult):
|
|
search_results.append(
|
|
{
|
|
"id": str(result.id_),
|
|
"title": result.title,
|
|
"description": result.description,
|
|
"label": result.label,
|
|
"url": result.url,
|
|
}
|
|
)
|
|
|
|
# Serialize session data
|
|
session_data = serialize_session(service_instance.session)
|
|
|
|
return web.json_response({"status": "success", "results": search_results, "session": session_data})
|
|
|
|
except Exception:
|
|
log.exception("Error performing remote search")
|
|
return web.json_response({"status": "error", "message": "Internal server error while performing search"}, status=500)
|
|
|
|
|
|
async def remote_get_titles(request: web.Request) -> web.Response:
|
|
"""
|
|
Get titles from a remote service.
|
|
---
|
|
summary: Get titles from remote service
|
|
description: Get available titles for content from a remote service
|
|
parameters:
|
|
- name: service
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- title
|
|
properties:
|
|
title:
|
|
type: string
|
|
description: Title identifier, URL, or any format accepted by the service
|
|
profile:
|
|
type: string
|
|
description: Profile to use for credentials
|
|
proxy:
|
|
type: string
|
|
description: Proxy region code (e.g., "ca", "us") or full proxy URL - uses server's proxy configuration
|
|
no_proxy:
|
|
type: boolean
|
|
description: Disable proxy usage
|
|
cookies:
|
|
type: string
|
|
description: Raw Netscape/Mozilla format cookie file content (optional - uses server cookies if not provided)
|
|
credential:
|
|
type: object
|
|
description: Credentials object with username and password (optional - uses server credentials if not provided)
|
|
properties:
|
|
username:
|
|
type: string
|
|
password:
|
|
type: string
|
|
responses:
|
|
'200':
|
|
description: Titles and session data
|
|
'400':
|
|
description: Invalid request
|
|
'500':
|
|
description: Server error
|
|
"""
|
|
service_tag = request.match_info.get("service")
|
|
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400)
|
|
|
|
# Accept 'title', 'title_id', or 'url' for flexibility
|
|
title = data.get("title") or data.get("title_id") or data.get("url")
|
|
if not title:
|
|
return web.json_response(
|
|
{
|
|
"status": "error",
|
|
"message": "Missing required parameter: title (can be URL, ID, or any format accepted by the service)",
|
|
},
|
|
status=400,
|
|
)
|
|
|
|
normalized_service = validate_service(service_tag)
|
|
if not normalized_service:
|
|
return web.json_response(
|
|
{"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400
|
|
)
|
|
|
|
try:
|
|
profile = data.get("profile")
|
|
|
|
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
|
|
|
|
# Handle proxy configuration
|
|
# Client MUST send resolved proxy with credentials (e.g., http://user:pass@host:port)
|
|
# Server does NOT resolve proxy providers - client must do that
|
|
proxy_param = data.get("proxy")
|
|
no_proxy = data.get("no_proxy", False)
|
|
|
|
if proxy_param and not no_proxy:
|
|
import re
|
|
|
|
# Validate that client sent a fully resolved proxy URL
|
|
if re.match(r"^https?://", proxy_param):
|
|
log.info("Using client-resolved proxy with credentials")
|
|
else:
|
|
# Reject unresolved proxy parameters
|
|
log.error(f"[SECURITY] Client sent unresolved proxy parameter: {proxy_param}")
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "INVALID_PROXY",
|
|
"message": f"Proxy must be a fully resolved URL (http://... or https://...). "
|
|
f"Cannot use proxy provider shortcuts like '{proxy_param}'. "
|
|
f"Please resolve the proxy on the client side before sending to server."
|
|
}, status=400)
|
|
|
|
ctx = click.Context(dummy_service)
|
|
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile)
|
|
ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy}
|
|
|
|
service_module = Services.load(normalized_service)
|
|
|
|
dummy_service.name = normalized_service
|
|
dummy_service.params = [click.Argument([title], type=str)]
|
|
ctx.invoked_subcommand = normalized_service
|
|
|
|
service_ctx = click.Context(dummy_service, parent=ctx)
|
|
service_ctx.obj = ctx.obj
|
|
|
|
service_kwargs = {"title": title}
|
|
|
|
# Add additional parameters from request data
|
|
for key, value in data.items():
|
|
if key not in ["title", "title_id", "url", "profile", "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:
|
|
if hasattr(param, "default") and param.default is not None:
|
|
service_kwargs[param.name] = param.default
|
|
|
|
# Handle required parameters
|
|
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
|
|
|
|
# Filter to only valid parameters
|
|
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)
|
|
|
|
# Authenticate with client-provided or server-side auth
|
|
cookies, credential, pre_authenticated_session, session_error = get_auth_from_request(data, normalized_service, profile)
|
|
|
|
# Check for session expiry
|
|
if session_error == "SESSION_EXPIRED":
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "SESSION_EXPIRED",
|
|
"message": f"Session expired for {normalized_service}. Please re-authenticate."
|
|
}, status=401)
|
|
|
|
try:
|
|
if pre_authenticated_session:
|
|
# Use pre-authenticated session sent by client (server is stateless)
|
|
deserialize_session(pre_authenticated_session, service_instance.session)
|
|
else:
|
|
# Authenticate with credentials/cookies
|
|
if not cookies and not credential:
|
|
# No auth data available - tell client to authenticate
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "AUTH_REQUIRED",
|
|
"message": f"Authentication required for {normalized_service}. No credentials or session available."
|
|
}, status=401)
|
|
|
|
service_instance.authenticate(cookies, credential)
|
|
except Exception as auth_error:
|
|
# Authentication failed - tell client to re-authenticate
|
|
log.warning(f"Authentication failed for {normalized_service}: {auth_error}")
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "AUTH_REQUIRED",
|
|
"message": f"Authentication failed for {normalized_service}. Please authenticate locally."
|
|
}, status=401)
|
|
|
|
# Get titles
|
|
titles = service_instance.get_titles()
|
|
|
|
if hasattr(titles, "__iter__") and not isinstance(titles, str):
|
|
title_list = [serialize_title(t) for t in titles]
|
|
else:
|
|
title_list = [serialize_title(titles)]
|
|
|
|
# Serialize session data
|
|
session_data = serialize_session(service_instance.session)
|
|
|
|
# Include geofence info so client knows to activate VPN
|
|
geofence = []
|
|
if hasattr(service_module, "GEOFENCE"):
|
|
geofence = list(service_module.GEOFENCE)
|
|
|
|
return web.json_response({
|
|
"status": "success",
|
|
"titles": title_list,
|
|
"session": session_data,
|
|
"geofence": geofence
|
|
})
|
|
|
|
except Exception:
|
|
log.exception("Error getting remote titles")
|
|
return web.json_response({"status": "error", "message": "Internal server error while getting titles"}, status=500)
|
|
|
|
|
|
async def remote_get_tracks(request: web.Request) -> web.Response:
|
|
"""
|
|
Get tracks from a remote service.
|
|
---
|
|
summary: Get tracks from remote service
|
|
description: Get available tracks for a title from a remote service
|
|
parameters:
|
|
- name: service
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- title
|
|
properties:
|
|
title:
|
|
type: string
|
|
description: Title identifier, URL, or any format accepted by the service
|
|
wanted:
|
|
type: string
|
|
description: Specific episodes/seasons
|
|
profile:
|
|
type: string
|
|
description: Profile to use for credentials
|
|
proxy:
|
|
type: string
|
|
description: Proxy region code (e.g., "ca", "us") or full proxy URL - uses server's proxy configuration
|
|
no_proxy:
|
|
type: boolean
|
|
description: Disable proxy usage
|
|
cookies:
|
|
type: string
|
|
description: Raw Netscape/Mozilla format cookie file content (optional - uses server cookies if not provided)
|
|
credential:
|
|
type: object
|
|
description: Credentials object with username and password (optional - uses server credentials if not provided)
|
|
properties:
|
|
username:
|
|
type: string
|
|
password:
|
|
type: string
|
|
responses:
|
|
'200':
|
|
description: Tracks and session data
|
|
'400':
|
|
description: Invalid request
|
|
'500':
|
|
description: Server error
|
|
"""
|
|
service_tag = request.match_info.get("service")
|
|
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400)
|
|
|
|
# Accept 'title', 'title_id', or 'url' for flexibility
|
|
title = data.get("title") or data.get("title_id") or data.get("url")
|
|
if not title:
|
|
return web.json_response(
|
|
{
|
|
"status": "error",
|
|
"message": "Missing required parameter: title (can be URL, ID, or any format accepted by the service)",
|
|
},
|
|
status=400,
|
|
)
|
|
|
|
normalized_service = validate_service(service_tag)
|
|
if not normalized_service:
|
|
return web.json_response(
|
|
{"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400
|
|
)
|
|
|
|
try:
|
|
profile = data.get("profile")
|
|
|
|
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
|
|
|
|
# Handle proxy configuration
|
|
# Client MUST send resolved proxy with credentials (e.g., http://user:pass@host:port)
|
|
# Server does NOT resolve proxy providers - client must do that
|
|
proxy_param = data.get("proxy")
|
|
no_proxy = data.get("no_proxy", False)
|
|
|
|
if proxy_param and not no_proxy:
|
|
import re
|
|
|
|
# Validate that client sent a fully resolved proxy URL
|
|
if re.match(r"^https?://", proxy_param):
|
|
log.info("Using client-resolved proxy with credentials")
|
|
else:
|
|
# Reject unresolved proxy parameters
|
|
log.error(f"[SECURITY] Client sent unresolved proxy parameter: {proxy_param}")
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "INVALID_PROXY",
|
|
"message": f"Proxy must be a fully resolved URL (http://... or https://...). "
|
|
f"Cannot use proxy provider shortcuts like '{proxy_param}'. "
|
|
f"Please resolve the proxy on the client side before sending to server."
|
|
}, status=400)
|
|
|
|
# Create CDM proxy from client-provided info (default to L3 Widevine if not provided)
|
|
cdm_info = data.get("cdm_info") or {"type": "widevine", "security_level": 3}
|
|
cdm = CDMProxy(cdm_info)
|
|
|
|
ctx = click.Context(dummy_service)
|
|
ctx.obj = ContextData(config=service_config, cdm=cdm, proxy_providers=[], profile=profile)
|
|
ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy}
|
|
|
|
service_module = Services.load(normalized_service)
|
|
|
|
dummy_service.name = normalized_service
|
|
dummy_service.params = [click.Argument([title], type=str)]
|
|
ctx.invoked_subcommand = normalized_service
|
|
|
|
service_ctx = click.Context(dummy_service, parent=ctx)
|
|
service_ctx.obj = ctx.obj
|
|
|
|
service_kwargs = {"title": title}
|
|
|
|
# Add additional parameters
|
|
for key, value in data.items():
|
|
if key not in ["title", "title_id", "url", "profile", "wanted", "season", "episode", "proxy", "no_proxy", "cdm_info"]:
|
|
service_kwargs[key] = value
|
|
|
|
# Get service parameters
|
|
service_init_params = inspect.signature(service_module.__init__).parameters
|
|
|
|
# Extract defaults from 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:
|
|
service_kwargs[param.name] = param.default
|
|
|
|
# Handle required parameters
|
|
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
|
|
|
|
# Filter to valid parameters
|
|
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)
|
|
|
|
# Authenticate with client-provided or server-side auth
|
|
cookies, credential, pre_authenticated_session, session_error = get_auth_from_request(data, normalized_service, profile)
|
|
|
|
# Check for session expiry
|
|
if session_error == "SESSION_EXPIRED":
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "SESSION_EXPIRED",
|
|
"message": f"Session expired for {normalized_service}. Please re-authenticate."
|
|
}, status=401)
|
|
|
|
try:
|
|
if pre_authenticated_session:
|
|
# Use pre-authenticated session sent by client (server is stateless)
|
|
deserialize_session(pre_authenticated_session, service_instance.session)
|
|
else:
|
|
# Authenticate with credentials/cookies
|
|
if not cookies and not credential:
|
|
# No auth data available - tell client to authenticate
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "AUTH_REQUIRED",
|
|
"message": f"Authentication required for {normalized_service}. No credentials or session available."
|
|
}, status=401)
|
|
|
|
service_instance.authenticate(cookies, credential)
|
|
except Exception as auth_error:
|
|
# Authentication failed - tell client to re-authenticate
|
|
log.warning(f"Authentication failed for {normalized_service}: {auth_error}")
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "AUTH_REQUIRED",
|
|
"message": f"Authentication failed for {normalized_service}. Please authenticate locally."
|
|
}, status=401)
|
|
|
|
# Get titles
|
|
titles = service_instance.get_titles()
|
|
|
|
wanted_param = data.get("wanted")
|
|
season = data.get("season")
|
|
episode = data.get("episode")
|
|
|
|
if hasattr(titles, "__iter__") and not isinstance(titles, str):
|
|
titles_list = list(titles)
|
|
|
|
wanted = None
|
|
if wanted_param:
|
|
from unshackle.core.utils.click_types import SeasonRange
|
|
|
|
try:
|
|
season_range = SeasonRange()
|
|
wanted = season_range.parse_tokens(wanted_param)
|
|
except Exception as e:
|
|
return web.json_response(
|
|
{"status": "error", "message": f"Invalid wanted parameter: {e}"}, status=400
|
|
)
|
|
elif season is not None and episode is not None:
|
|
wanted = [f"{season}x{episode}"]
|
|
|
|
if wanted:
|
|
matching_titles = []
|
|
for title in titles_list:
|
|
if isinstance(title, Episode):
|
|
episode_key = f"{title.season}x{title.number}"
|
|
if episode_key in wanted:
|
|
matching_titles.append(title)
|
|
else:
|
|
matching_titles.append(title)
|
|
|
|
if not matching_titles:
|
|
return web.json_response(
|
|
{"status": "error", "message": "No episodes found matching wanted criteria"}, status=404
|
|
)
|
|
|
|
# Handle multiple episodes
|
|
if len(matching_titles) > 1 and all(isinstance(t, Episode) for t in matching_titles):
|
|
episodes_data = []
|
|
failed_episodes = []
|
|
|
|
sorted_titles = sorted(matching_titles, key=lambda t: (t.season, t.number))
|
|
|
|
for title in sorted_titles:
|
|
try:
|
|
tracks = service_instance.get_tracks(title)
|
|
video_tracks = sorted(tracks.videos, key=lambda t: t.bitrate or 0, reverse=True)
|
|
audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True)
|
|
|
|
episode_data = {
|
|
"title": serialize_title(title),
|
|
"video": [serialize_video_track(t) for t in video_tracks],
|
|
"audio": [serialize_audio_track(t) for t in audio_tracks],
|
|
"subtitles": [serialize_subtitle_track(t) for t in tracks.subtitles],
|
|
}
|
|
episodes_data.append(episode_data)
|
|
except (SystemExit, Exception):
|
|
failed_episodes.append(f"S{title.season}E{title.number:02d}")
|
|
continue
|
|
|
|
if episodes_data:
|
|
session_data = serialize_session(service_instance.session)
|
|
|
|
# Include geofence info
|
|
geofence = []
|
|
if hasattr(service_module, "GEOFENCE"):
|
|
geofence = list(service_module.GEOFENCE)
|
|
|
|
response = {
|
|
"status": "success",
|
|
"episodes": episodes_data,
|
|
"session": session_data,
|
|
"geofence": geofence
|
|
}
|
|
if failed_episodes:
|
|
response["unavailable_episodes"] = failed_episodes
|
|
return web.json_response(response)
|
|
else:
|
|
return web.json_response(
|
|
{
|
|
"status": "error",
|
|
"message": f"No available episodes. Unavailable: {', '.join(failed_episodes)}",
|
|
},
|
|
status=404,
|
|
)
|
|
else:
|
|
first_title = matching_titles[0]
|
|
else:
|
|
first_title = titles_list[0]
|
|
else:
|
|
first_title = titles
|
|
|
|
# Get tracks for single title
|
|
tracks = service_instance.get_tracks(first_title)
|
|
|
|
video_tracks = sorted(tracks.videos, key=lambda t: t.bitrate or 0, reverse=True)
|
|
audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True)
|
|
|
|
# Serialize session data
|
|
session_data = serialize_session(service_instance.session)
|
|
|
|
# Include geofence info
|
|
geofence = []
|
|
if hasattr(service_module, "GEOFENCE"):
|
|
geofence = list(service_module.GEOFENCE)
|
|
|
|
# Try to extract license URL from service (for remote licensing)
|
|
license_url = None
|
|
title_id = first_title.id if hasattr(first_title, "id") else str(first_title)
|
|
|
|
# Check playback_data for license URL
|
|
if hasattr(service_instance, "playback_data") and title_id in service_instance.playback_data:
|
|
playback_data = service_instance.playback_data[title_id]
|
|
# DSNP pattern
|
|
if "drm" in playback_data and "licenseServerUrl" in playback_data.get("drm", {}):
|
|
license_url = playback_data["drm"]["licenseServerUrl"]
|
|
elif "stream" in playback_data and "drm" in playback_data["stream"]:
|
|
drm_info = playback_data["stream"]["drm"]
|
|
if isinstance(drm_info, dict) and "licenseServerUrl" in drm_info:
|
|
license_url = drm_info["licenseServerUrl"]
|
|
|
|
# Check service config for license URL
|
|
if not license_url and hasattr(service_instance, "config"):
|
|
if "license_url" in service_instance.config:
|
|
license_url = service_instance.config["license_url"]
|
|
|
|
response_data = {
|
|
"status": "success",
|
|
"title": serialize_title(first_title),
|
|
"video": [serialize_video_track(t, include_url=True) for t in video_tracks],
|
|
"audio": [serialize_audio_track(t, include_url=True) for t in audio_tracks],
|
|
"subtitles": [serialize_subtitle_track(t, include_url=True) for t in tracks.subtitles],
|
|
"session": session_data,
|
|
"geofence": geofence,
|
|
"license_url": license_url,
|
|
}
|
|
|
|
return web.json_response(response_data)
|
|
|
|
except Exception:
|
|
log.exception("Error getting remote tracks")
|
|
return web.json_response({"status": "error", "message": "Internal server error while getting tracks"}, status=500)
|
|
|
|
|
|
async def remote_get_manifest(request: web.Request) -> web.Response:
|
|
"""
|
|
Get manifest URL and session from a remote service.
|
|
|
|
This endpoint returns the manifest URL and authenticated session,
|
|
allowing the client to fetch and parse the manifest locally.
|
|
---
|
|
summary: Get manifest info from remote service
|
|
description: Get manifest URL and session for client-side parsing
|
|
parameters:
|
|
- name: service
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- title
|
|
properties:
|
|
title:
|
|
type: string
|
|
description: Title identifier
|
|
cdm_info:
|
|
type: object
|
|
description: Client CDM info (type, security_level)
|
|
responses:
|
|
'200':
|
|
description: Manifest info
|
|
"""
|
|
service_tag = request.match_info.get("service")
|
|
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400)
|
|
|
|
title = data.get("title") or data.get("title_id") or data.get("url")
|
|
if not title:
|
|
return web.json_response(
|
|
{"status": "error", "message": "Missing required parameter: title"},
|
|
status=400,
|
|
)
|
|
|
|
normalized_service = validate_service(service_tag)
|
|
if not normalized_service:
|
|
return web.json_response(
|
|
{"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400
|
|
)
|
|
|
|
try:
|
|
profile = data.get("profile")
|
|
|
|
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
|
|
|
|
proxy_param = data.get("proxy")
|
|
no_proxy = data.get("no_proxy", False)
|
|
|
|
if proxy_param and not no_proxy:
|
|
import re
|
|
if not re.match(r"^https?://", proxy_param):
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "INVALID_PROXY",
|
|
"message": "Proxy must be a fully resolved URL"
|
|
}, status=400)
|
|
|
|
# Create CDM proxy from client-provided info
|
|
cdm_info = data.get("cdm_info") or {"type": "widevine", "security_level": 3}
|
|
cdm = CDMProxy(cdm_info)
|
|
|
|
ctx = click.Context(dummy_service)
|
|
ctx.obj = ContextData(config=service_config, cdm=cdm, proxy_providers=[], profile=profile)
|
|
ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy}
|
|
|
|
service_module = Services.load(normalized_service)
|
|
|
|
dummy_service.name = normalized_service
|
|
dummy_service.params = [click.Argument([title], type=str)]
|
|
ctx.invoked_subcommand = normalized_service
|
|
|
|
service_ctx = click.Context(dummy_service, parent=ctx)
|
|
service_ctx.obj = ctx.obj
|
|
|
|
service_kwargs = {"title": title}
|
|
|
|
for key, value in data.items():
|
|
if key not in ["title", "title_id", "url", "profile", "proxy", "no_proxy", "cdm_info"]:
|
|
service_kwargs[key] = value
|
|
|
|
service_init_params = inspect.signature(service_module.__init__).parameters
|
|
|
|
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:
|
|
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
|
|
|
|
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)
|
|
|
|
# Authenticate
|
|
cookies, credential, pre_authenticated_session, session_error = get_auth_from_request(data, normalized_service, profile)
|
|
|
|
if session_error == "SESSION_EXPIRED":
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "SESSION_EXPIRED",
|
|
"message": f"Session expired for {normalized_service}. Please re-authenticate."
|
|
}, status=401)
|
|
|
|
try:
|
|
if pre_authenticated_session:
|
|
deserialize_session(pre_authenticated_session, service_instance.session)
|
|
else:
|
|
if not cookies and not credential:
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "AUTH_REQUIRED",
|
|
"message": f"Authentication required for {normalized_service}"
|
|
}, status=401)
|
|
service_instance.authenticate(cookies, credential)
|
|
except Exception as e:
|
|
log.error(f"Authentication failed: {e}")
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "AUTH_REQUIRED",
|
|
"message": f"Authentication failed for {normalized_service}"
|
|
}, status=401)
|
|
|
|
# Get titles
|
|
titles = service_instance.get_titles()
|
|
|
|
if hasattr(titles, "__iter__") and not isinstance(titles, str):
|
|
titles_list = list(titles)
|
|
else:
|
|
titles_list = [titles] if titles else []
|
|
|
|
if not titles_list:
|
|
return web.json_response({"status": "error", "message": "No titles found"}, status=404)
|
|
|
|
# Handle episode filtering (wanted parameter)
|
|
wanted_param = data.get("wanted")
|
|
season = data.get("season")
|
|
episode = data.get("episode")
|
|
target_title = None
|
|
|
|
if wanted_param or (season is not None and episode is not None):
|
|
# Filter to matching episode
|
|
wanted = None
|
|
if wanted_param:
|
|
from unshackle.core.utils.click_types import SeasonRange
|
|
try:
|
|
season_range = SeasonRange()
|
|
wanted = season_range.parse_tokens(wanted_param)
|
|
except Exception:
|
|
pass
|
|
elif season is not None and episode is not None:
|
|
wanted = [f"{season}x{episode}"]
|
|
|
|
if wanted:
|
|
for t in titles_list:
|
|
if isinstance(t, Episode):
|
|
episode_key = f"{t.season}x{t.number}"
|
|
if episode_key in wanted:
|
|
target_title = t
|
|
break
|
|
|
|
if not target_title:
|
|
target_title = titles_list[0]
|
|
|
|
# Now we need to get the manifest URL
|
|
# This is service-specific, so we call get_tracks but extract manifest info
|
|
|
|
# Call get_tracks to populate playback_data
|
|
try:
|
|
_ = service_instance.get_tracks(target_title)
|
|
except Exception as e:
|
|
log.warning(f"get_tracks failed, trying to extract manifest anyway: {e}")
|
|
|
|
# Extract manifest URL from service's playback_data
|
|
manifest_url = None
|
|
manifest_type = "hls" # Default
|
|
playback_data = {}
|
|
|
|
# Check for playback_data (DSNP, HMAX, etc.)
|
|
if hasattr(service_instance, "playback_data"):
|
|
title_id = target_title.id if hasattr(target_title, "id") else str(target_title)
|
|
if title_id in service_instance.playback_data:
|
|
playback_data = service_instance.playback_data[title_id]
|
|
|
|
# Try to extract manifest URL from common patterns
|
|
# Pattern 1: DSNP style - stream.sources[0].complete.url
|
|
if "stream" in playback_data and "sources" in playback_data["stream"]:
|
|
sources = playback_data["stream"]["sources"]
|
|
if sources and "complete" in sources[0]:
|
|
manifest_url = sources[0]["complete"].get("url")
|
|
|
|
# Pattern 2: Direct manifest_url field
|
|
if not manifest_url and "manifest_url" in playback_data:
|
|
manifest_url = playback_data["manifest_url"]
|
|
|
|
# Pattern 3: url field at top level
|
|
if not manifest_url and "url" in playback_data:
|
|
manifest_url = playback_data["url"]
|
|
|
|
# Check for manifest attribute on service
|
|
if not manifest_url and hasattr(service_instance, "manifest"):
|
|
manifest_url = service_instance.manifest
|
|
|
|
# Check for manifest_url attribute on service
|
|
if not manifest_url and hasattr(service_instance, "manifest_url"):
|
|
manifest_url = service_instance.manifest_url
|
|
|
|
# Detect manifest type from URL
|
|
if manifest_url:
|
|
if manifest_url.endswith(".mpd") or "dash" in manifest_url.lower():
|
|
manifest_type = "dash"
|
|
elif manifest_url.endswith(".m3u8") or manifest_url.endswith(".m3u"):
|
|
manifest_type = "hls"
|
|
|
|
# Serialize session
|
|
session_data = serialize_session(service_instance.session)
|
|
|
|
# Serialize title info
|
|
title_info = serialize_title(target_title)
|
|
|
|
response_data = {
|
|
"status": "success",
|
|
"title": title_info,
|
|
"manifest_url": manifest_url,
|
|
"manifest_type": manifest_type,
|
|
"playback_data": playback_data,
|
|
"session": session_data,
|
|
}
|
|
|
|
return web.json_response(response_data)
|
|
|
|
except Exception:
|
|
log.exception("Error getting remote manifest")
|
|
return web.json_response({"status": "error", "message": "Internal server error while getting manifest"}, status=500)
|
|
|
|
|
|
async def remote_get_chapters(request: web.Request) -> web.Response:
|
|
"""
|
|
Get chapters from a remote service.
|
|
---
|
|
summary: Get chapters from remote service
|
|
description: Get available chapters for a title from a remote service
|
|
parameters:
|
|
- name: service
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- title
|
|
properties:
|
|
title:
|
|
type: string
|
|
description: Title identifier, URL, or any format accepted by the service
|
|
profile:
|
|
type: string
|
|
description: Profile to use for credentials
|
|
proxy:
|
|
type: string
|
|
description: Proxy region code (e.g., "ca", "us") or full proxy URL - uses server's proxy configuration
|
|
no_proxy:
|
|
type: boolean
|
|
description: Disable proxy usage
|
|
cookies:
|
|
type: string
|
|
description: Raw Netscape/Mozilla format cookie file content (optional - uses server cookies if not provided)
|
|
credential:
|
|
type: object
|
|
description: Credentials object with username and password (optional - uses server credentials if not provided)
|
|
properties:
|
|
username:
|
|
type: string
|
|
password:
|
|
type: string
|
|
responses:
|
|
'200':
|
|
description: Chapters and session data
|
|
'400':
|
|
description: Invalid request
|
|
'500':
|
|
description: Server error
|
|
"""
|
|
service_tag = request.match_info.get("service")
|
|
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400)
|
|
|
|
# Accept 'title', 'title_id', or 'url' for flexibility
|
|
title = data.get("title") or data.get("title_id") or data.get("url")
|
|
if not title:
|
|
return web.json_response(
|
|
{
|
|
"status": "error",
|
|
"message": "Missing required parameter: title (can be URL, ID, or any format accepted by the service)",
|
|
},
|
|
status=400,
|
|
)
|
|
|
|
normalized_service = validate_service(service_tag)
|
|
if not normalized_service:
|
|
return web.json_response(
|
|
{"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400
|
|
)
|
|
|
|
try:
|
|
profile = data.get("profile")
|
|
|
|
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
|
|
|
|
# Handle proxy configuration
|
|
# Client MUST send resolved proxy with credentials (e.g., http://user:pass@host:port)
|
|
# Server does NOT resolve proxy providers - client must do that
|
|
proxy_param = data.get("proxy")
|
|
no_proxy = data.get("no_proxy", False)
|
|
|
|
if proxy_param and not no_proxy:
|
|
import re
|
|
|
|
# Validate that client sent a fully resolved proxy URL
|
|
if re.match(r"^https?://", proxy_param):
|
|
log.info("Using client-resolved proxy with credentials")
|
|
else:
|
|
# Reject unresolved proxy parameters
|
|
log.error(f"[SECURITY] Client sent unresolved proxy parameter: {proxy_param}")
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "INVALID_PROXY",
|
|
"message": f"Proxy must be a fully resolved URL (http://... or https://...). "
|
|
f"Cannot use proxy provider shortcuts like '{proxy_param}'. "
|
|
f"Please resolve the proxy on the client side before sending to server."
|
|
}, status=400)
|
|
|
|
ctx = click.Context(dummy_service)
|
|
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile)
|
|
ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy}
|
|
|
|
service_module = Services.load(normalized_service)
|
|
|
|
dummy_service.name = normalized_service
|
|
dummy_service.params = [click.Argument([title], type=str)]
|
|
ctx.invoked_subcommand = normalized_service
|
|
|
|
service_ctx = click.Context(dummy_service, parent=ctx)
|
|
service_ctx.obj = ctx.obj
|
|
|
|
service_kwargs = {"title": title}
|
|
|
|
# Add additional parameters
|
|
for key, value in data.items():
|
|
if key not in ["title", "title_id", "url", "profile", "proxy", "no_proxy"]:
|
|
service_kwargs[key] = value
|
|
|
|
# Get service parameters
|
|
service_init_params = inspect.signature(service_module.__init__).parameters
|
|
|
|
# Extract defaults
|
|
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:
|
|
service_kwargs[param.name] = param.default
|
|
|
|
# Handle required parameters
|
|
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
|
|
|
|
# Filter to valid parameters
|
|
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)
|
|
|
|
# Authenticate with client-provided or server-side auth
|
|
cookies, credential, pre_authenticated_session, session_error = get_auth_from_request(data, normalized_service, profile)
|
|
|
|
# Check for session expiry
|
|
if session_error == "SESSION_EXPIRED":
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "SESSION_EXPIRED",
|
|
"message": f"Session expired for {normalized_service}. Please re-authenticate."
|
|
}, status=401)
|
|
|
|
try:
|
|
if pre_authenticated_session:
|
|
# Use pre-authenticated session sent by client (server is stateless)
|
|
deserialize_session(pre_authenticated_session, service_instance.session)
|
|
else:
|
|
# Authenticate with credentials/cookies
|
|
if not cookies and not credential:
|
|
# No auth data available - tell client to authenticate
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "AUTH_REQUIRED",
|
|
"message": f"Authentication required for {normalized_service}. No credentials or session available."
|
|
}, status=401)
|
|
|
|
service_instance.authenticate(cookies, credential)
|
|
except Exception as auth_error:
|
|
# Authentication failed - tell client to re-authenticate
|
|
log.warning(f"Authentication failed for {normalized_service}: {auth_error}")
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "AUTH_REQUIRED",
|
|
"message": f"Authentication failed for {normalized_service}. Please authenticate locally."
|
|
}, status=401)
|
|
|
|
# Get titles
|
|
titles = service_instance.get_titles()
|
|
|
|
if hasattr(titles, "__iter__") and not isinstance(titles, str):
|
|
first_title = list(titles)[0]
|
|
else:
|
|
first_title = titles
|
|
|
|
# Get chapters if service supports it
|
|
chapters_data = []
|
|
if hasattr(service_instance, "get_chapters"):
|
|
chapters = service_instance.get_chapters(first_title)
|
|
if chapters:
|
|
for chapter in chapters:
|
|
chapters_data.append(
|
|
{
|
|
"timestamp": chapter.timestamp,
|
|
"name": chapter.name if hasattr(chapter, "name") else None,
|
|
}
|
|
)
|
|
|
|
# Serialize session data
|
|
session_data = serialize_session(service_instance.session)
|
|
|
|
return web.json_response({"status": "success", "chapters": chapters_data, "session": session_data})
|
|
|
|
except Exception:
|
|
log.exception("Error getting remote chapters")
|
|
return web.json_response({"status": "error", "message": "Internal server error while getting chapters"}, status=500)
|
|
|
|
|
|
async def remote_get_license(request: web.Request) -> web.Response:
|
|
"""
|
|
Get DRM license from a remote service using client's CDM.
|
|
|
|
The server does NOT need a CDM - it just facilitates the license request
|
|
using the client's pre-authenticated session. The client decrypts using
|
|
their own CDM.
|
|
---
|
|
summary: Get DRM license from remote service
|
|
description: Request license acquisition using client session (server does not need CDM)
|
|
parameters:
|
|
- name: service
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- title
|
|
- track_id
|
|
- challenge
|
|
properties:
|
|
title:
|
|
type: string
|
|
description: Title identifier
|
|
track_id:
|
|
type: string
|
|
description: Track ID for license
|
|
challenge:
|
|
type: string
|
|
description: Base64-encoded license challenge from client's CDM
|
|
session:
|
|
type: integer
|
|
description: CDM session ID
|
|
profile:
|
|
type: string
|
|
description: Profile to use
|
|
pre_authenticated_session:
|
|
type: object
|
|
description: Client's pre-authenticated session
|
|
responses:
|
|
'200':
|
|
description: License response
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
example: success
|
|
license:
|
|
type: string
|
|
description: Base64-encoded license response
|
|
session:
|
|
type: object
|
|
description: Updated session data
|
|
'400':
|
|
description: Invalid request
|
|
'401':
|
|
description: Authentication required
|
|
'500':
|
|
description: Server error
|
|
"""
|
|
service_tag = request.match_info.get("service")
|
|
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400)
|
|
|
|
# Validate required parameters
|
|
title = data.get("title")
|
|
track_id = data.get("track_id")
|
|
challenge = data.get("challenge")
|
|
|
|
if not all([title, track_id, challenge]):
|
|
return web.json_response(
|
|
{
|
|
"status": "error",
|
|
"message": "Missing required parameters: title, track_id, challenge"
|
|
},
|
|
status=400
|
|
)
|
|
|
|
normalized_service = validate_service(service_tag)
|
|
if not normalized_service:
|
|
return web.json_response(
|
|
{"status": "error", "message": f"Invalid or unavailable service: {service_tag}"},
|
|
status=400
|
|
)
|
|
|
|
try:
|
|
profile = data.get("profile")
|
|
|
|
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
|
|
|
|
# Handle proxy configuration
|
|
# Client MUST send resolved proxy with credentials (e.g., http://user:pass@host:port)
|
|
# Server does NOT resolve proxy providers - client must do that
|
|
proxy_param = data.get("proxy")
|
|
no_proxy = data.get("no_proxy", False)
|
|
|
|
if proxy_param and not no_proxy:
|
|
import re
|
|
|
|
# Validate that client sent a fully resolved proxy URL
|
|
if re.match(r"^https?://", proxy_param):
|
|
log.info("Using client-resolved proxy with credentials")
|
|
else:
|
|
# Reject unresolved proxy parameters
|
|
log.error(f"[SECURITY] Client sent unresolved proxy parameter: {proxy_param}")
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "INVALID_PROXY",
|
|
"message": f"Proxy must be a fully resolved URL (http://... or https://...). "
|
|
f"Cannot use proxy provider shortcuts like '{proxy_param}'. "
|
|
f"Please resolve the proxy on the client side before sending to server."
|
|
}, status=400)
|
|
|
|
ctx = click.Context(dummy_service)
|
|
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile)
|
|
ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy}
|
|
|
|
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_kwargs = {"title": title}
|
|
|
|
# Add additional parameters
|
|
for key, value in data.items():
|
|
if key not in ["title", "track_id", "challenge", "session", "profile", "proxy", "no_proxy", "pre_authenticated_session", "credential", "cookies"]:
|
|
service_kwargs[key] = value
|
|
|
|
# Get service parameters
|
|
service_init_params = inspect.signature(service_module.__init__).parameters
|
|
|
|
# Extract defaults
|
|
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:
|
|
service_kwargs[param.name] = param.default
|
|
|
|
# Handle required parameters
|
|
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
|
|
|
|
# Filter to valid parameters
|
|
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)
|
|
|
|
# Authenticate with client-provided or server-side auth
|
|
cookies, credential, pre_authenticated_session, session_error = get_auth_from_request(data, normalized_service, profile)
|
|
|
|
# Check for session expiry
|
|
if session_error == "SESSION_EXPIRED":
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "SESSION_EXPIRED",
|
|
"message": f"Session expired for {normalized_service}. Please re-authenticate."
|
|
}, status=401)
|
|
|
|
try:
|
|
if pre_authenticated_session:
|
|
# Use pre-authenticated session sent by client (server is stateless)
|
|
deserialize_session(pre_authenticated_session, service_instance.session)
|
|
else:
|
|
# Authenticate with credentials/cookies
|
|
if not cookies and not credential:
|
|
# No auth data available - tell client to authenticate
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "AUTH_REQUIRED",
|
|
"message": f"Authentication required for {normalized_service}. No credentials or session available."
|
|
}, status=401)
|
|
|
|
service_instance.authenticate(cookies, credential)
|
|
except Exception as auth_error:
|
|
# Authentication failed - tell client to re-authenticate
|
|
log.warning(f"Authentication failed for {normalized_service}: {auth_error}")
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "AUTH_REQUIRED",
|
|
"message": f"Authentication failed for {normalized_service}. Please authenticate locally."
|
|
}, status=401)
|
|
|
|
# Get titles to find the correct one
|
|
titles = service_instance.get_titles()
|
|
if hasattr(titles, "__iter__") and not isinstance(titles, str):
|
|
first_title = list(titles)[0]
|
|
else:
|
|
first_title = titles
|
|
|
|
# Get tracks to find license URL
|
|
tracks = service_instance.get_tracks(first_title)
|
|
|
|
# Find the track with the matching ID
|
|
target_track = None
|
|
for track in tracks.videos + tracks.audio:
|
|
if str(track.id) == str(track_id) or track.id == track_id:
|
|
target_track = track
|
|
break
|
|
|
|
if not target_track:
|
|
return web.json_response({
|
|
"status": "error",
|
|
"message": f"Track {track_id} not found"
|
|
}, status=404)
|
|
|
|
# Get license URL and headers from track
|
|
if not hasattr(target_track, "drm") or not target_track.drm:
|
|
return web.json_response({
|
|
"status": "error",
|
|
"message": f"Track {track_id} is not DRM-protected"
|
|
}, status=400)
|
|
|
|
# Extract license information
|
|
license_url = None
|
|
license_headers = {}
|
|
|
|
# Try to get license URL from DRM info
|
|
for drm_info in target_track.drm:
|
|
if hasattr(drm_info, "license_url"):
|
|
license_url = drm_info.license_url
|
|
if hasattr(drm_info, "license_headers"):
|
|
license_headers = drm_info.license_headers or {}
|
|
break
|
|
|
|
if not license_url:
|
|
return web.json_response({
|
|
"status": "error",
|
|
"message": "No license URL found for track"
|
|
}, status=400)
|
|
|
|
# Make license request using service session
|
|
import base64
|
|
challenge_data = base64.b64decode(challenge)
|
|
|
|
license_response = service_instance.session.post(
|
|
license_url,
|
|
data=challenge_data,
|
|
headers=license_headers
|
|
)
|
|
|
|
if license_response.status_code != 200:
|
|
return web.json_response({
|
|
"status": "error",
|
|
"message": f"License request failed: {license_response.status_code}"
|
|
}, status=500)
|
|
|
|
# Return base64-encoded license
|
|
license_b64 = base64.b64encode(license_response.content).decode("utf-8")
|
|
|
|
# Serialize session data
|
|
session_data = serialize_session(service_instance.session)
|
|
|
|
return web.json_response({
|
|
"status": "success",
|
|
"license": license_b64,
|
|
"session": session_data
|
|
})
|
|
|
|
except Exception:
|
|
log.exception("Error getting remote license")
|
|
return web.json_response({"status": "error", "message": "Internal server error while getting license"}, status=500)
|
|
|
|
|
|
async def remote_decrypt(request: web.Request) -> web.Response:
|
|
"""
|
|
Decrypt DRM content using server's CDM (premium users only).
|
|
|
|
This endpoint is for premium API key holders who can use the server's
|
|
CDM infrastructure. Regular users must use their own CDM with the
|
|
license endpoint.
|
|
|
|
---
|
|
summary: Decrypt DRM content using server CDM
|
|
description: Use server's CDM to decrypt content (premium tier only)
|
|
parameters:
|
|
- name: service
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- title
|
|
- track_id
|
|
- pssh
|
|
properties:
|
|
title:
|
|
type: string
|
|
description: Title identifier
|
|
track_id:
|
|
type: string
|
|
description: Track ID for decryption
|
|
pssh:
|
|
type: string
|
|
description: Base64-encoded PSSH box
|
|
cdm:
|
|
type: string
|
|
description: Specific CDM to use (optional, uses default if not specified)
|
|
license_url:
|
|
type: string
|
|
description: License server URL (optional, extracted from track if not provided)
|
|
profile:
|
|
type: string
|
|
description: Profile to use
|
|
pre_authenticated_session:
|
|
type: object
|
|
description: Client's pre-authenticated session
|
|
responses:
|
|
'200':
|
|
description: Decryption keys
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
example: success
|
|
keys:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
kid:
|
|
type: string
|
|
key:
|
|
type: string
|
|
type:
|
|
type: string
|
|
session:
|
|
type: object
|
|
description: Updated session data
|
|
'400':
|
|
description: Invalid request
|
|
'401':
|
|
description: Authentication required
|
|
'403':
|
|
description: Not authorized for premium features
|
|
'500':
|
|
description: Server error
|
|
"""
|
|
service_tag = request.match_info.get("service")
|
|
|
|
# Check if user is premium
|
|
api_key = get_api_key_from_request(request)
|
|
if not api_key:
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "NO_API_KEY",
|
|
"message": "API key required"
|
|
}, status=401)
|
|
|
|
if not is_premium_user(request.app, api_key):
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "PREMIUM_REQUIRED",
|
|
"message": "This endpoint requires a premium API key. Use /api/remote/{service}/license with your own CDM instead."
|
|
}, status=403)
|
|
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400)
|
|
|
|
# Validate required parameters
|
|
title = data.get("title")
|
|
track_id = data.get("track_id")
|
|
pssh = data.get("pssh")
|
|
|
|
if not all([title, track_id, pssh]):
|
|
return web.json_response(
|
|
{
|
|
"status": "error",
|
|
"message": "Missing required parameters: title, track_id, pssh"
|
|
},
|
|
status=400
|
|
)
|
|
|
|
# Determine which CDM to use
|
|
requested_cdm = data.get("cdm")
|
|
if not requested_cdm:
|
|
# Use default CDM for this API key
|
|
requested_cdm = get_default_cdm(request.app, api_key)
|
|
|
|
if not requested_cdm:
|
|
return web.json_response({
|
|
"status": "error",
|
|
"message": "No CDM specified and no default CDM configured for your API key"
|
|
}, status=400)
|
|
|
|
# Check if user can use this CDM
|
|
if not can_use_cdm(request.app, api_key, requested_cdm):
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "CDM_NOT_ALLOWED",
|
|
"message": f"Your API key is not authorized to use CDM: {requested_cdm}"
|
|
}, status=403)
|
|
|
|
normalized_service = validate_service(service_tag)
|
|
if not normalized_service:
|
|
return web.json_response(
|
|
{"status": "error", "message": f"Invalid or unavailable service: {service_tag}"},
|
|
status=400
|
|
)
|
|
|
|
try:
|
|
from pywidevine.cdm import Cdm as WidevineCdm
|
|
from pywidevine.device import Device
|
|
|
|
# Load the requested CDM
|
|
log.info(f"Premium user using server CDM: {requested_cdm}")
|
|
|
|
# Get CDM device path
|
|
cdm_device_path = None
|
|
if requested_cdm.endswith(".wvd"):
|
|
# Direct path to WVD file
|
|
cdm_device_path = Path(requested_cdm)
|
|
else:
|
|
# Look in configured CDM directory
|
|
cdm_dir = config.directories.wvds
|
|
potential_path = cdm_dir / f"{requested_cdm}.wvd"
|
|
if potential_path.exists():
|
|
cdm_device_path = potential_path
|
|
|
|
if not cdm_device_path or not cdm_device_path.exists():
|
|
return web.json_response({
|
|
"status": "error",
|
|
"message": f"CDM device not found: {requested_cdm}"
|
|
}, status=404)
|
|
|
|
# Initialize CDM
|
|
device = Device.load(cdm_device_path)
|
|
cdm = WidevineCdm.from_device(device)
|
|
|
|
# Open CDM session
|
|
session_id = cdm.open()
|
|
|
|
# Parse PSSH
|
|
import base64
|
|
pssh_data = base64.b64decode(pssh)
|
|
|
|
# Set service certificate if needed (some services require it)
|
|
# This would be service-specific
|
|
|
|
# Get challenge
|
|
challenge = cdm.get_license_challenge(session_id, pssh_data)
|
|
|
|
# Get license URL
|
|
license_url = data.get("license_url")
|
|
|
|
# If no license URL provided, get it from track
|
|
if not license_url:
|
|
profile = data.get("profile")
|
|
|
|
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
|
|
|
|
# Handle proxy configuration
|
|
# Client MUST send resolved proxy with credentials
|
|
# Server does NOT resolve proxy providers - client must do that
|
|
proxy_param = data.get("proxy")
|
|
no_proxy = data.get("no_proxy", False)
|
|
|
|
if proxy_param and not no_proxy:
|
|
import re
|
|
|
|
# Validate that client sent a fully resolved proxy URL
|
|
if re.match(r"^https?://", proxy_param):
|
|
log.info("Using client-resolved proxy with credentials")
|
|
else:
|
|
# Reject unresolved proxy parameters
|
|
log.error(f"[SECURITY] Client sent unresolved proxy parameter: {proxy_param}")
|
|
cdm.close(session_id)
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "INVALID_PROXY",
|
|
"message": f"Proxy must be a fully resolved URL (http://... or https://...). "
|
|
f"Cannot use proxy provider shortcuts like '{proxy_param}'. "
|
|
f"Please resolve the proxy on the client side before sending to server."
|
|
}, status=400)
|
|
|
|
ctx = click.Context(dummy_service)
|
|
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile)
|
|
ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy}
|
|
|
|
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_kwargs = {"title": title}
|
|
|
|
# Get service parameters
|
|
service_init_params = inspect.signature(service_module.__init__).parameters
|
|
|
|
# Extract defaults
|
|
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:
|
|
service_kwargs[param.name] = param.default
|
|
|
|
# Handle required parameters
|
|
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
|
|
|
|
# Filter to valid parameters
|
|
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)
|
|
|
|
# Authenticate
|
|
cookies, credential, pre_authenticated_session, session_error = get_auth_from_request(data, normalized_service, profile)
|
|
|
|
if session_error == "SESSION_EXPIRED":
|
|
cdm.close(session_id)
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "SESSION_EXPIRED",
|
|
"message": f"Session expired for {normalized_service}. Please re-authenticate."
|
|
}, status=401)
|
|
|
|
try:
|
|
if pre_authenticated_session:
|
|
deserialize_session(pre_authenticated_session, service_instance.session)
|
|
else:
|
|
if not cookies and not credential:
|
|
cdm.close(session_id)
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "AUTH_REQUIRED",
|
|
"message": f"Authentication required for {normalized_service}."
|
|
}, status=401)
|
|
service_instance.authenticate(cookies, credential)
|
|
except Exception as auth_error:
|
|
cdm.close(session_id)
|
|
log.warning(f"Authentication failed for {normalized_service}: {auth_error}")
|
|
return web.json_response({
|
|
"status": "error",
|
|
"error_code": "AUTH_REQUIRED",
|
|
"message": f"Authentication failed for {normalized_service}.",
|
|
"details": str(auth_error)
|
|
}, status=401)
|
|
|
|
# Get titles and tracks to find license URL
|
|
titles = service_instance.get_titles()
|
|
if hasattr(titles, "__iter__") and not isinstance(titles, str):
|
|
first_title = list(titles)[0]
|
|
else:
|
|
first_title = titles
|
|
|
|
tracks = service_instance.get_tracks(first_title)
|
|
|
|
# Find the track
|
|
target_track = None
|
|
for track in tracks.videos + tracks.audio:
|
|
if str(track.id) == str(track_id) or track.id == track_id:
|
|
target_track = track
|
|
break
|
|
|
|
if not target_track:
|
|
cdm.close(session_id)
|
|
return web.json_response({
|
|
"status": "error",
|
|
"message": f"Track {track_id} not found"
|
|
}, status=404)
|
|
|
|
if not hasattr(target_track, "drm") or not target_track.drm:
|
|
cdm.close(session_id)
|
|
return web.json_response({
|
|
"status": "error",
|
|
"message": f"Track {track_id} is not DRM-protected"
|
|
}, status=400)
|
|
|
|
# Extract license URL
|
|
license_headers = {}
|
|
for drm_info in target_track.drm:
|
|
if hasattr(drm_info, "license_url"):
|
|
license_url = drm_info.license_url
|
|
if hasattr(drm_info, "license_headers"):
|
|
license_headers = drm_info.license_headers or {}
|
|
break
|
|
|
|
if not license_url:
|
|
cdm.close(session_id)
|
|
return web.json_response({
|
|
"status": "error",
|
|
"message": "No license URL found for track"
|
|
}, status=400)
|
|
|
|
# Make license request
|
|
license_response = service_instance.session.post(
|
|
license_url,
|
|
data=challenge,
|
|
headers=license_headers
|
|
)
|
|
|
|
if license_response.status_code != 200:
|
|
cdm.close(session_id)
|
|
return web.json_response({
|
|
"status": "error",
|
|
"message": f"License request failed: {license_response.status_code}"
|
|
}, status=500)
|
|
|
|
# Parse license
|
|
cdm.parse_license(session_id, license_response.content)
|
|
|
|
# Get keys
|
|
keys = []
|
|
for key in cdm.get_keys(session_id):
|
|
if key.type == "CONTENT":
|
|
keys.append({
|
|
"kid": key.kid.hex(),
|
|
"key": key.key.hex(),
|
|
"type": key.type
|
|
})
|
|
|
|
# Close CDM session
|
|
cdm.close(session_id)
|
|
|
|
# Serialize session
|
|
session_data = serialize_session(service_instance.session)
|
|
|
|
return web.json_response({
|
|
"status": "success",
|
|
"keys": keys,
|
|
"session": session_data,
|
|
"cdm_used": requested_cdm
|
|
})
|
|
|
|
else:
|
|
# License URL provided directly
|
|
# Make license request (need to provide session for this)
|
|
cdm.close(session_id)
|
|
return web.json_response({
|
|
"status": "error",
|
|
"message": "Direct license URL not yet supported, omit license_url to auto-detect from service"
|
|
}, status=400)
|
|
|
|
except Exception:
|
|
log.exception("Error in server-side decryption")
|
|
return web.json_response({"status": "error", "message": "Internal server error during decryption"}, status=500)
|