Files
unshackle-SeFree/unshackle/core/api/remote_handlers.py
Andy 965482a1e4 feat: merge upstream dev branch
- 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
2025-11-25 20:23:06 +00:00

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)