- Add Gluetun dynamic VPN-to-HTTP proxy provider - Add remote services and authentication system - Add country code utilities - Add Docker binary detection - Update proxy providers
1880 lines
72 KiB
Python
1880 lines
72 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
|
|
|
|
|
|
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)
|
|
|
|
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", "wanted", "season", "episode", "proxy", "no_proxy"]:
|
|
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)
|
|
|
|
response_data = {
|
|
"status": "success",
|
|
"title": serialize_title(first_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],
|
|
"session": session_data,
|
|
"geofence": geofence
|
|
}
|
|
|
|
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_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)
|