refactor: remove remote-service code until feature is more complete

Temporarily removes client-side remote service discovery and authentication until the implementation is more fleshed out and working.
This commit is contained in:
Andy
2026-01-24 16:10:45 -07:00
parent 4b30090d87
commit 91a2d76f88
13 changed files with 420 additions and 2000 deletions

View File

@@ -1182,32 +1182,6 @@ remote_cdm:
[pywidevine]: https://github.com/rlaphoenix/pywidevine
## remote_services (list\[dict])
Configure connections to remote unshackle REST API servers to access services running on other instances.
This allows you to use services from remote unshackle installations as if they were local.
Each entry requires:
- `url` (str): The base URL of the remote unshackle REST API server
- `api_key` (str): API key for authenticating with the remote server
- `name` (str, optional): Friendly name for the remote service (for logging/display purposes)
For example,
```yaml
remote_services:
- url: "https://remote-unshackle.example.com"
api_key: "your_api_key_here"
name: "Remote US Server"
- url: "https://remote-unshackle-eu.example.com"
api_key: "another_api_key"
name: "Remote EU Server"
```
**Note**: The remote unshackle instances must have the REST API enabled and running. Services from all
configured remote servers will be available alongside your local services.
## scene_naming (bool)
Set scene-style naming for titles. When `true` uses scene naming patterns (e.g., `Prime.Suspect.S07E01...`), when

View File

@@ -1,225 +0,0 @@
"""CLI command for authenticating remote services."""
from typing import Optional
import click
from rich.table import Table
from unshackle.core.config import config
from unshackle.core.console import console
from unshackle.core.constants import context_settings
from unshackle.core.remote_auth import RemoteAuthenticator
@click.group(short_help="Manage remote service authentication.", context_settings=context_settings)
def remote_auth() -> None:
"""Authenticate and manage sessions for remote services."""
pass
@remote_auth.command(name="authenticate")
@click.argument("service", type=str)
@click.option(
"-r", "--remote", type=str, help="Remote server name or URL (from config)", required=False
)
@click.option("-p", "--profile", type=str, help="Profile to use for authentication")
def authenticate_command(service: str, remote: Optional[str], profile: Optional[str]) -> None:
"""
Authenticate a service locally and upload session to remote server.
This command:
1. Authenticates the service locally (shows browser, handles 2FA, etc.)
2. Extracts the authenticated session
3. Uploads the session to the remote server
The server will use this pre-authenticated session for all requests.
Examples:
unshackle remote-auth authenticate DSNP
unshackle remote-auth authenticate NF --profile john
unshackle remote-auth auth AMZN --remote my-server
"""
# Get remote server config
remote_config = _get_remote_config(remote)
if not remote_config:
return
remote_url = remote_config["url"]
api_key = remote_config["api_key"]
server_name = remote_config.get("name", remote_url)
console.print(f"\n[bold cyan]Authenticating {service} for remote server:[/bold cyan] {server_name}")
console.print(f"[dim]Server: {remote_url}[/dim]\n")
# Create authenticator
authenticator = RemoteAuthenticator(remote_url, api_key)
# Authenticate and save locally
success = authenticator.authenticate_and_save(service, profile)
if success:
console.print(f"\n[bold green]✓ Success![/bold green] Session saved locally. You can now use remote_{service} service.")
else:
console.print(f"\n[bold red]✗ Failed to authenticate {service}[/bold red]")
raise click.Abort()
@remote_auth.command(name="status")
@click.option(
"-r", "--remote", type=str, help="Remote server name or URL (from config)", required=False
)
def status_command(remote: Optional[str]) -> None:
"""
Show status of all authenticated sessions in local cache.
Examples:
unshackle remote-auth status
unshackle remote-auth status --remote my-server
"""
import datetime
from unshackle.core.local_session_cache import get_local_session_cache
# Get local session cache
cache = get_local_session_cache()
# Get remote server config (optional filter)
remote_url = None
if remote:
remote_config = _get_remote_config(remote)
if remote_config:
remote_url = remote_config["url"]
server_name = remote_config.get("name", remote_url)
else:
server_name = "All Remotes"
# Get sessions (filtered by remote if specified)
sessions = cache.list_sessions(remote_url)
if not sessions:
if remote_url:
console.print(f"\n[yellow]No authenticated sessions for {server_name}[/yellow]")
else:
console.print("\n[yellow]No authenticated sessions in local cache[/yellow]")
console.print("\nUse [cyan]unshackle remote-auth authenticate <SERVICE>[/cyan] to add sessions")
return
# Display sessions in table
table = Table(title=f"Local Authenticated Sessions - {server_name}")
table.add_column("Remote", style="magenta")
table.add_column("Service", style="cyan")
table.add_column("Profile", style="green")
table.add_column("Cached", style="dim")
table.add_column("Age", style="yellow")
table.add_column("Status", style="bold")
for session in sessions:
cached_time = datetime.datetime.fromtimestamp(session["cached_at"]).strftime("%Y-%m-%d %H:%M")
# Format age
age_seconds = session["age_seconds"]
if age_seconds < 3600:
age_str = f"{age_seconds // 60}m"
elif age_seconds < 86400:
age_str = f"{age_seconds // 3600}h"
else:
age_str = f"{age_seconds // 86400}d"
# Status
status = "[red]Expired" if session["expired"] else "[green]Valid"
# Short remote URL for display
remote_display = session["remote_url"].replace("https://", "").replace("http://", "")
if len(remote_display) > 30:
remote_display = remote_display[:27] + "..."
table.add_row(
remote_display,
session["service_tag"],
session["profile"],
cached_time,
age_str,
status
)
console.print()
console.print(table)
console.print("\n[dim]Sessions are stored locally and expire after 24 hours[/dim]")
console.print()
@remote_auth.command(name="delete")
@click.argument("service", type=str)
@click.option(
"-r", "--remote", type=str, help="Remote server name or URL (from config)", required=False
)
@click.option("-p", "--profile", type=str, default="default", help="Profile name")
def delete_command(service: str, remote: Optional[str], profile: str) -> None:
"""
Delete an authenticated session from local cache.
Examples:
unshackle remote-auth delete DSNP
unshackle remote-auth delete NF --profile john
"""
from unshackle.core.local_session_cache import get_local_session_cache
# Get remote server config
remote_config = _get_remote_config(remote)
if not remote_config:
return
remote_url = remote_config["url"]
cache = get_local_session_cache()
console.print(f"\n[yellow]Deleting local session for {service} (profile: {profile})...[/yellow]")
deleted = cache.delete_session(remote_url, service, profile)
if deleted:
console.print("[green]✓ Session deleted from local cache[/green]")
else:
console.print(f"[red]✗ No session found for {service} (profile: {profile})[/red]")
def _get_remote_config(remote: Optional[str]) -> Optional[dict]:
"""
Get remote server configuration.
Args:
remote: Remote server name or URL, or None for first configured remote
Returns:
Remote config dict or None
"""
if not config.remote_services:
console.print("[red]No remote services configured in unshackle.yaml[/red]")
console.print("\nAdd a remote service to your config:")
console.print("[dim]remote_services:")
console.print(" - url: https://your-server.com")
console.print(" api_key: your-api-key")
console.print(" name: my-server[/dim]")
return None
# If no remote specified, use the first one
if not remote:
return config.remote_services[0]
# Check if remote is a name
for remote_config in config.remote_services:
if remote_config.get("name") == remote:
return remote_config
# Check if remote is a URL
for remote_config in config.remote_services:
if remote_config.get("url") == remote:
return remote_config
console.print(f"[red]Remote server '{remote}' not found in config[/red]")
console.print("\nAvailable remotes:")
for remote_config in config.remote_services:
name = remote_config.get("name", remote_config.get("url"))
console.print(f" - {name}")
return None

View File

@@ -62,29 +62,6 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug
tier: "premium"
default_cdm: "chromecdm_2101"
allowed_cdms: ["*"] # or list specific CDMs: ["chromecdm_2101", "chromecdm_2202"]
\b
REMOTE SERVICES:
The server exposes endpoints that allow remote unshackle clients to use
your configured services without needing the service implementations.
Remote clients can authenticate, get titles/tracks, and receive session data
for downloading. Configure remote clients in unshackle.yaml:
\b
remote_services:
- url: "http://your-server:8786"
api_key: "your-api-key"
name: "my-server"
\b
Available remote endpoints:
- GET /api/remote/services - List available services
- POST /api/remote/{service}/search - Search for content
- POST /api/remote/{service}/titles - Get titles
- POST /api/remote/{service}/tracks - Get tracks
- POST /api/remote/{service}/chapters - Get chapters
- POST /api/remote/{service}/license - Get DRM license (uses client CDM)
- POST /api/remote/{service}/decrypt - Decrypt using server CDM (premium only)
"""
from pywidevine import serve as pywidevine_serve

View File

@@ -191,12 +191,73 @@ def serialize_title(title: Title_T) -> Dict[str, Any]:
return result
def serialize_video_track(track: Video) -> Dict[str, Any]:
def serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]:
"""Serialize DRM objects to JSON-serializable list."""
if not drm_list:
return None
if not isinstance(drm_list, list):
drm_list = [drm_list]
result = []
for drm in drm_list:
drm_info = {}
drm_class = drm.__class__.__name__
drm_info["type"] = drm_class.lower()
# Get PSSH - handle both Widevine and PlayReady
if hasattr(drm, "_pssh") and drm._pssh:
try:
pssh_obj = drm._pssh
# Try to get base64 representation
if hasattr(pssh_obj, "dumps"):
# pywidevine PSSH has dumps() method
drm_info["pssh"] = pssh_obj.dumps()
elif hasattr(pssh_obj, "__bytes__"):
# Convert to base64
import base64
drm_info["pssh"] = base64.b64encode(bytes(pssh_obj)).decode()
elif hasattr(pssh_obj, "to_base64"):
drm_info["pssh"] = pssh_obj.to_base64()
else:
# Fallback - str() works for pywidevine PSSH
pssh_str = str(pssh_obj)
# Check if it's already base64-like or an object repr
if not pssh_str.startswith("<"):
drm_info["pssh"] = pssh_str
except Exception:
pass
# Get KIDs
if hasattr(drm, "kids") and drm.kids:
drm_info["kids"] = [str(kid) for kid in drm.kids]
# Get content keys if available
if hasattr(drm, "content_keys") and drm.content_keys:
drm_info["content_keys"] = {str(k): v for k, v in drm.content_keys.items()}
# Get license URL - essential for remote licensing
if hasattr(drm, "license_url") and drm.license_url:
drm_info["license_url"] = str(drm.license_url)
elif hasattr(drm, "_license_url") and drm._license_url:
drm_info["license_url"] = str(drm._license_url)
result.append(drm_info)
return result if result else None
def serialize_video_track(track: Video, include_url: bool = False) -> Dict[str, Any]:
"""Convert video track to JSON-serializable dict."""
codec_name = track.codec.name if hasattr(track.codec, "name") else str(track.codec)
range_name = track.range.name if hasattr(track.range, "name") else str(track.range)
return {
# Get descriptor for N_m3u8DL-RE compatibility (HLS, DASH, URL, etc.)
descriptor_name = None
if hasattr(track, "descriptor") and track.descriptor:
descriptor_name = track.descriptor.name if hasattr(track.descriptor, "name") else str(track.descriptor)
result = {
"id": str(track.id),
"codec": codec_name,
"codec_display": VIDEO_CODEC_MAP.get(codec_name, codec_name),
@@ -208,15 +269,24 @@ def serialize_video_track(track: Video) -> Dict[str, Any]:
"range": range_name,
"range_display": DYNAMIC_RANGE_MAP.get(range_name, range_name),
"language": str(track.language) if track.language else None,
"drm": str(track.drm) if hasattr(track, "drm") and track.drm else None,
"drm": serialize_drm(track.drm) if hasattr(track, "drm") and track.drm else None,
"descriptor": descriptor_name,
}
if include_url and hasattr(track, "url") and track.url:
result["url"] = str(track.url)
return result
def serialize_audio_track(track: Audio) -> Dict[str, Any]:
def serialize_audio_track(track: Audio, include_url: bool = False) -> Dict[str, Any]:
"""Convert audio track to JSON-serializable dict."""
codec_name = track.codec.name if hasattr(track.codec, "name") else str(track.codec)
return {
# Get descriptor for N_m3u8DL-RE compatibility
descriptor_name = None
if hasattr(track, "descriptor") and track.descriptor:
descriptor_name = track.descriptor.name if hasattr(track.descriptor, "name") else str(track.descriptor)
result = {
"id": str(track.id),
"codec": codec_name,
"codec_display": AUDIO_CODEC_MAP.get(codec_name, codec_name),
@@ -225,20 +295,33 @@ def serialize_audio_track(track: Audio) -> Dict[str, Any]:
"language": str(track.language) if track.language else None,
"atmos": track.atmos if hasattr(track, "atmos") else False,
"descriptive": track.descriptive if hasattr(track, "descriptive") else False,
"drm": str(track.drm) if hasattr(track, "drm") and track.drm else None,
"drm": serialize_drm(track.drm) if hasattr(track, "drm") and track.drm else None,
"descriptor": descriptor_name,
}
if include_url and hasattr(track, "url") and track.url:
result["url"] = str(track.url)
return result
def serialize_subtitle_track(track: Subtitle) -> Dict[str, Any]:
def serialize_subtitle_track(track: Subtitle, include_url: bool = False) -> Dict[str, Any]:
"""Convert subtitle track to JSON-serializable dict."""
return {
# Get descriptor for compatibility
descriptor_name = None
if hasattr(track, "descriptor") and track.descriptor:
descriptor_name = track.descriptor.name if hasattr(track.descriptor, "name") else str(track.descriptor)
result = {
"id": str(track.id),
"codec": track.codec.name if hasattr(track.codec, "name") else str(track.codec),
"language": str(track.language) if track.language else None,
"forced": track.forced if hasattr(track, "forced") else False,
"sdh": track.sdh if hasattr(track, "sdh") else False,
"cc": track.cc if hasattr(track, "cc") else False,
"descriptor": descriptor_name,
}
if include_url and hasattr(track, "url") and track.url:
result["url"] = str(track.url)
return result
async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:

View File

@@ -31,6 +31,31 @@ log = logging.getLogger("api.remote")
SESSION_EXPIRY_TIME = 86400
class CDMProxy:
"""
Lightweight CDM proxy that holds CDM properties sent from client.
This allows services to check CDM properties (like security_level)
without needing an actual CDM loaded on the server.
"""
def __init__(self, cdm_info: Dict[str, Any]):
"""
Initialize CDM proxy from client-provided info.
Args:
cdm_info: Dictionary with CDM properties (type, security_level, etc.)
"""
self.cdm_type = cdm_info.get("type", "widevine")
self.security_level = cdm_info.get("security_level", 3)
self.is_playready = self.cdm_type == "playready"
self.device_type = cdm_info.get("device_type")
self.is_remote = cdm_info.get("is_remote", False)
def __repr__(self):
return f"CDMProxy(type={self.cdm_type}, L{self.security_level})"
def load_cookies_from_content(cookies_content: Optional[str]) -> Optional[http.cookiejar.MozillaCookieJar]:
"""
Load cookies from raw cookie file content.
@@ -754,8 +779,12 @@ async def remote_get_tracks(request: web.Request) -> web.Response:
f"Please resolve the proxy on the client side before sending to server."
}, status=400)
# Create CDM proxy from client-provided info (default to L3 Widevine if not provided)
cdm_info = data.get("cdm_info") or {"type": "widevine", "security_level": 3}
cdm = CDMProxy(cdm_info)
ctx = click.Context(dummy_service)
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile)
ctx.obj = ContextData(config=service_config, cdm=cdm, proxy_providers=[], profile=profile)
ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy}
service_module = Services.load(normalized_service)
@@ -771,7 +800,7 @@ async def remote_get_tracks(request: web.Request) -> web.Response:
# Add additional parameters
for key, value in data.items():
if key not in ["title", "title_id", "url", "profile", "wanted", "season", "episode", "proxy", "no_proxy"]:
if key not in ["title", "title_id", "url", "profile", "wanted", "season", "episode", "proxy", "no_proxy", "cdm_info"]:
service_kwargs[key] = value
# Get service parameters
@@ -942,14 +971,35 @@ async def remote_get_tracks(request: web.Request) -> web.Response:
if hasattr(service_module, "GEOFENCE"):
geofence = list(service_module.GEOFENCE)
# Try to extract license URL from service (for remote licensing)
license_url = None
title_id = first_title.id if hasattr(first_title, "id") else str(first_title)
# Check playback_data for license URL
if hasattr(service_instance, "playback_data") and title_id in service_instance.playback_data:
playback_data = service_instance.playback_data[title_id]
# DSNP pattern
if "drm" in playback_data and "licenseServerUrl" in playback_data.get("drm", {}):
license_url = playback_data["drm"]["licenseServerUrl"]
elif "stream" in playback_data and "drm" in playback_data["stream"]:
drm_info = playback_data["stream"]["drm"]
if isinstance(drm_info, dict) and "licenseServerUrl" in drm_info:
license_url = drm_info["licenseServerUrl"]
# Check service config for license URL
if not license_url and hasattr(service_instance, "config"):
if "license_url" in service_instance.config:
license_url = service_instance.config["license_url"]
response_data = {
"status": "success",
"title": serialize_title(first_title),
"video": [serialize_video_track(t) 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],
"video": [serialize_video_track(t, include_url=True) for t in video_tracks],
"audio": [serialize_audio_track(t, include_url=True) for t in audio_tracks],
"subtitles": [serialize_subtitle_track(t, include_url=True) for t in tracks.subtitles],
"session": session_data,
"geofence": geofence
"geofence": geofence,
"license_url": license_url,
}
return web.json_response(response_data)
@@ -959,6 +1009,272 @@ async def remote_get_tracks(request: web.Request) -> web.Response:
return web.json_response({"status": "error", "message": "Internal server error while getting tracks"}, status=500)
async def remote_get_manifest(request: web.Request) -> web.Response:
"""
Get manifest URL and session from a remote service.
This endpoint returns the manifest URL and authenticated session,
allowing the client to fetch and parse the manifest locally.
---
summary: Get manifest info from remote service
description: Get manifest URL and session for client-side parsing
parameters:
- name: service
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- title
properties:
title:
type: string
description: Title identifier
cdm_info:
type: object
description: Client CDM info (type, security_level)
responses:
'200':
description: Manifest info
"""
service_tag = request.match_info.get("service")
try:
data = await request.json()
except Exception:
return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400)
title = data.get("title") or data.get("title_id") or data.get("url")
if not title:
return web.json_response(
{"status": "error", "message": "Missing required parameter: title"},
status=400,
)
normalized_service = validate_service(service_tag)
if not normalized_service:
return web.json_response(
{"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400
)
try:
profile = data.get("profile")
service_config_path = Services.get_path(normalized_service) / config.filenames.config
if service_config_path.exists():
service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8"))
else:
service_config = {}
merge_dict(config.services.get(normalized_service), service_config)
@click.command()
@click.pass_context
def dummy_service(ctx: click.Context) -> None:
pass
proxy_param = data.get("proxy")
no_proxy = data.get("no_proxy", False)
if proxy_param and not no_proxy:
import re
if not re.match(r"^https?://", proxy_param):
return web.json_response({
"status": "error",
"error_code": "INVALID_PROXY",
"message": "Proxy must be a fully resolved URL"
}, status=400)
# Create CDM proxy from client-provided info
cdm_info = data.get("cdm_info") or {"type": "widevine", "security_level": 3}
cdm = CDMProxy(cdm_info)
ctx = click.Context(dummy_service)
ctx.obj = ContextData(config=service_config, cdm=cdm, proxy_providers=[], profile=profile)
ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy}
service_module = Services.load(normalized_service)
dummy_service.name = normalized_service
dummy_service.params = [click.Argument([title], type=str)]
ctx.invoked_subcommand = normalized_service
service_ctx = click.Context(dummy_service, parent=ctx)
service_ctx.obj = ctx.obj
service_kwargs = {"title": title}
for key, value in data.items():
if key not in ["title", "title_id", "url", "profile", "proxy", "no_proxy", "cdm_info"]:
service_kwargs[key] = value
service_init_params = inspect.signature(service_module.__init__).parameters
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
for param in service_module.cli.params:
if hasattr(param, "name") and param.name not in service_kwargs:
if hasattr(param, "default") and param.default is not None:
service_kwargs[param.name] = param.default
for param_name, param_info in service_init_params.items():
if param_name not in service_kwargs and param_name not in ["self", "ctx"]:
if param_info.default is inspect.Parameter.empty:
if param_name == "meta_lang":
service_kwargs[param_name] = None
elif param_name == "movie":
service_kwargs[param_name] = False
filtered_kwargs = {k: v for k, v in service_kwargs.items() if k in service_init_params}
service_instance = service_module(service_ctx, **filtered_kwargs)
# Authenticate
cookies, credential, pre_authenticated_session, session_error = get_auth_from_request(data, normalized_service, profile)
if session_error == "SESSION_EXPIRED":
return web.json_response({
"status": "error",
"error_code": "SESSION_EXPIRED",
"message": f"Session expired for {normalized_service}. Please re-authenticate."
}, status=401)
try:
if pre_authenticated_session:
deserialize_session(pre_authenticated_session, service_instance.session)
else:
if not cookies and not credential:
return web.json_response({
"status": "error",
"error_code": "AUTH_REQUIRED",
"message": f"Authentication required for {normalized_service}"
}, status=401)
service_instance.authenticate(cookies, credential)
except Exception as e:
log.error(f"Authentication failed: {e}")
return web.json_response({
"status": "error",
"error_code": "AUTH_REQUIRED",
"message": f"Authentication failed for {normalized_service}"
}, status=401)
# Get titles
titles = service_instance.get_titles()
if hasattr(titles, "__iter__") and not isinstance(titles, str):
titles_list = list(titles)
else:
titles_list = [titles] if titles else []
if not titles_list:
return web.json_response({"status": "error", "message": "No titles found"}, status=404)
# Handle episode filtering (wanted parameter)
wanted_param = data.get("wanted")
season = data.get("season")
episode = data.get("episode")
target_title = None
if wanted_param or (season is not None and episode is not None):
# Filter to matching episode
wanted = None
if wanted_param:
from unshackle.core.utils.click_types import SeasonRange
try:
season_range = SeasonRange()
wanted = season_range.parse_tokens(wanted_param)
except Exception:
pass
elif season is not None and episode is not None:
wanted = [f"{season}x{episode}"]
if wanted:
for t in titles_list:
if isinstance(t, Episode):
episode_key = f"{t.season}x{t.number}"
if episode_key in wanted:
target_title = t
break
if not target_title:
target_title = titles_list[0]
# Now we need to get the manifest URL
# This is service-specific, so we call get_tracks but extract manifest info
# Call get_tracks to populate playback_data
try:
_ = service_instance.get_tracks(target_title)
except Exception as e:
log.warning(f"get_tracks failed, trying to extract manifest anyway: {e}")
# Extract manifest URL from service's playback_data
manifest_url = None
manifest_type = "hls" # Default
playback_data = {}
# Check for playback_data (DSNP, HMAX, etc.)
if hasattr(service_instance, "playback_data"):
title_id = target_title.id if hasattr(target_title, "id") else str(target_title)
if title_id in service_instance.playback_data:
playback_data = service_instance.playback_data[title_id]
# Try to extract manifest URL from common patterns
# Pattern 1: DSNP style - stream.sources[0].complete.url
if "stream" in playback_data and "sources" in playback_data["stream"]:
sources = playback_data["stream"]["sources"]
if sources and "complete" in sources[0]:
manifest_url = sources[0]["complete"].get("url")
# Pattern 2: Direct manifest_url field
if not manifest_url and "manifest_url" in playback_data:
manifest_url = playback_data["manifest_url"]
# Pattern 3: url field at top level
if not manifest_url and "url" in playback_data:
manifest_url = playback_data["url"]
# Check for manifest attribute on service
if not manifest_url and hasattr(service_instance, "manifest"):
manifest_url = service_instance.manifest
# Check for manifest_url attribute on service
if not manifest_url and hasattr(service_instance, "manifest_url"):
manifest_url = service_instance.manifest_url
# Detect manifest type from URL
if manifest_url:
if manifest_url.endswith(".mpd") or "dash" in manifest_url.lower():
manifest_type = "dash"
elif manifest_url.endswith(".m3u8") or manifest_url.endswith(".m3u"):
manifest_type = "hls"
# Serialize session
session_data = serialize_session(service_instance.session)
# Serialize title info
title_info = serialize_title(target_title)
response_data = {
"status": "success",
"title": title_info,
"manifest_url": manifest_url,
"manifest_type": manifest_type,
"playback_data": playback_data,
"session": session_data,
}
return web.json_response(response_data)
except Exception:
log.exception("Error getting remote manifest")
return web.json_response({"status": "error", "message": "Internal server error while getting manifest"}, status=500)
async def remote_get_chapters(request: web.Request) -> web.Response:
"""
Get chapters from a remote service.

View File

@@ -9,8 +9,8 @@ from unshackle.core.api.errors import APIError, APIErrorCode, build_error_respon
from unshackle.core.api.handlers import (cancel_download_job_handler, download_handler, get_download_job_handler,
list_download_jobs_handler, list_titles_handler, list_tracks_handler)
from unshackle.core.api.remote_handlers import (remote_decrypt, remote_get_chapters, remote_get_license,
remote_get_titles, remote_get_tracks, remote_list_services,
remote_search)
remote_get_manifest, remote_get_titles, remote_get_tracks,
remote_list_services, remote_search)
from unshackle.core.services import Services
from unshackle.core.update_checker import UpdateChecker
@@ -738,6 +738,7 @@ def setup_routes(app: web.Application) -> None:
app.router.add_post("/api/remote/{service}/search", remote_search)
app.router.add_post("/api/remote/{service}/titles", remote_get_titles)
app.router.add_post("/api/remote/{service}/tracks", remote_get_tracks)
app.router.add_post("/api/remote/{service}/manifest", remote_get_manifest)
app.router.add_post("/api/remote/{service}/chapters", remote_get_chapters)
app.router.add_post("/api/remote/{service}/license", remote_get_license)
app.router.add_post("/api/remote/{service}/decrypt", remote_decrypt)
@@ -771,6 +772,7 @@ def setup_swagger(app: web.Application) -> None:
web.post("/api/remote/{service}/search", remote_search),
web.post("/api/remote/{service}/titles", remote_get_titles),
web.post("/api/remote/{service}/tracks", remote_get_tracks),
web.post("/api/remote/{service}/manifest", remote_get_manifest),
web.post("/api/remote/{service}/chapters", remote_get_chapters),
web.post("/api/remote/{service}/license", remote_get_license),
web.post("/api/remote/{service}/decrypt", remote_decrypt),

View File

@@ -105,8 +105,6 @@ class Config:
self.debug: bool = kwargs.get("debug", False)
self.debug_keys: bool = kwargs.get("debug_keys", False)
self.remote_services: list[dict] = kwargs.get("remote_services") or []
@classmethod
def from_yaml(cls, path: Path) -> Config:
if not path.exists():

View File

@@ -1,279 +0,0 @@
"""Cryptographic utilities for secure remote service authentication."""
import base64
import json
import logging
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
try:
from nacl.public import Box, PrivateKey, PublicKey
NACL_AVAILABLE = True
except ImportError:
NACL_AVAILABLE = False
log = logging.getLogger("crypto")
class CryptoError(Exception):
"""Cryptographic operation error."""
pass
class ServerKeyPair:
"""
Server-side key pair for secure remote authentication.
Uses NaCl (libsodium) for public key cryptography.
The server generates a key pair and shares the public key with clients.
Clients encrypt sensitive data with the public key, which only the server can decrypt.
"""
def __init__(self, private_key: Optional[PrivateKey] = None):
"""
Initialize server key pair.
Args:
private_key: Existing private key, or None to generate new
"""
if not NACL_AVAILABLE:
raise CryptoError("PyNaCl is not installed. Install with: pip install pynacl")
self.private_key = private_key or PrivateKey.generate()
self.public_key = self.private_key.public_key
def get_public_key_b64(self) -> str:
"""
Get base64-encoded public key for sharing with clients.
Returns:
Base64-encoded public key
"""
return base64.b64encode(bytes(self.public_key)).decode("utf-8")
def decrypt_message(self, encrypted_message: str, client_public_key_b64: str) -> Dict[str, Any]:
"""
Decrypt a message from a client.
Args:
encrypted_message: Base64-encoded encrypted message
client_public_key_b64: Base64-encoded client public key
Returns:
Decrypted message as dictionary
"""
try:
# Decode keys
client_public_key = PublicKey(base64.b64decode(client_public_key_b64))
encrypted_data = base64.b64decode(encrypted_message)
# Create box for decryption
box = Box(self.private_key, client_public_key)
# Decrypt
decrypted = box.decrypt(encrypted_data)
return json.loads(decrypted.decode("utf-8"))
except Exception as e:
log.error(f"Decryption failed: {e}")
raise CryptoError(f"Failed to decrypt message: {e}")
def save_to_file(self, path: Path) -> None:
"""
Save private key to file.
Args:
path: Path to save the key
"""
path.parent.mkdir(parents=True, exist_ok=True)
key_data = {
"private_key": base64.b64encode(bytes(self.private_key)).decode("utf-8"),
"public_key": self.get_public_key_b64(),
}
path.write_text(json.dumps(key_data, indent=2), encoding="utf-8")
log.info(f"Server key pair saved to {path}")
@classmethod
def load_from_file(cls, path: Path) -> "ServerKeyPair":
"""
Load private key from file.
Args:
path: Path to load the key from
Returns:
ServerKeyPair instance
"""
if not path.exists():
raise CryptoError(f"Key file not found: {path}")
try:
key_data = json.loads(path.read_text(encoding="utf-8"))
private_key_bytes = base64.b64decode(key_data["private_key"])
private_key = PrivateKey(private_key_bytes)
log.info(f"Server key pair loaded from {path}")
return cls(private_key)
except Exception as e:
raise CryptoError(f"Failed to load key from {path}: {e}")
class ClientCrypto:
"""
Client-side cryptography for secure remote authentication.
Generates ephemeral key pairs and encrypts sensitive data for the server.
"""
def __init__(self):
"""Initialize client crypto with ephemeral key pair."""
if not NACL_AVAILABLE:
raise CryptoError("PyNaCl is not installed. Install with: pip install pynacl")
# Generate ephemeral key pair for this session
self.private_key = PrivateKey.generate()
self.public_key = self.private_key.public_key
def get_public_key_b64(self) -> str:
"""
Get base64-encoded public key for sending to server.
Returns:
Base64-encoded public key
"""
return base64.b64encode(bytes(self.public_key)).decode("utf-8")
def encrypt_credentials(
self, credentials: Dict[str, Any], server_public_key_b64: str
) -> Tuple[str, str]:
"""
Encrypt credentials for the server.
Args:
credentials: Dictionary containing sensitive data (username, password, cookies, etc.)
server_public_key_b64: Base64-encoded server public key
Returns:
Tuple of (encrypted_message_b64, client_public_key_b64)
"""
try:
# Decode server public key
server_public_key = PublicKey(base64.b64decode(server_public_key_b64))
# Create box for encryption
box = Box(self.private_key, server_public_key)
# Encrypt
message = json.dumps(credentials).encode("utf-8")
encrypted = box.encrypt(message)
# Return base64-encoded encrypted message and client public key
encrypted_b64 = base64.b64encode(encrypted).decode("utf-8")
client_public_key_b64 = self.get_public_key_b64()
return encrypted_b64, client_public_key_b64
except Exception as e:
log.error(f"Encryption failed: {e}")
raise CryptoError(f"Failed to encrypt credentials: {e}")
def encrypt_credential_data(
username: Optional[str], password: Optional[str], cookies: Optional[str], server_public_key_b64: str
) -> Tuple[str, str]:
"""
Helper function to encrypt credential data.
Args:
username: Username or None
password: Password or None
cookies: Cookie file content or None
server_public_key_b64: Server's public key
Returns:
Tuple of (encrypted_data_b64, client_public_key_b64)
"""
client_crypto = ClientCrypto()
credentials = {}
if username and password:
credentials["username"] = username
credentials["password"] = password
if cookies:
credentials["cookies"] = cookies
return client_crypto.encrypt_credentials(credentials, server_public_key_b64)
def decrypt_credential_data(encrypted_data_b64: str, client_public_key_b64: str, server_keypair: ServerKeyPair) -> Dict[str, Any]:
"""
Helper function to decrypt credential data.
Args:
encrypted_data_b64: Base64-encoded encrypted data
client_public_key_b64: Client's public key
server_keypair: Server's key pair
Returns:
Decrypted credentials dictionary
"""
return server_keypair.decrypt_message(encrypted_data_b64, client_public_key_b64)
# Session-only authentication helpers
def serialize_authenticated_session(service_instance) -> Dict[str, Any]:
"""
Serialize an authenticated service session for remote use.
This extracts session cookies and headers WITHOUT including credentials.
Args:
service_instance: Authenticated service instance
Returns:
Dictionary with session data (cookies, headers) but NO credentials
"""
from unshackle.core.api.session_serializer import serialize_session
session_data = serialize_session(service_instance.session)
# Add additional metadata
session_data["authenticated"] = True
session_data["service_tag"] = service_instance.__class__.__name__
return session_data
def is_session_valid(session_data: Dict[str, Any]) -> bool:
"""
Check if session data appears valid.
Args:
session_data: Session data dictionary
Returns:
True if session has cookies or auth headers
"""
if not session_data:
return False
# Check for cookies or authorization headers
has_cookies = bool(session_data.get("cookies"))
has_auth = "Authorization" in session_data.get("headers", {})
return has_cookies or has_auth
__all__ = [
"ServerKeyPair",
"ClientCrypto",
"CryptoError",
"encrypt_credential_data",
"decrypt_credential_data",
"serialize_authenticated_session",
"is_session_valid",
"NACL_AVAILABLE",
]

View File

@@ -1,274 +0,0 @@
"""Local client-side session cache for remote services.
Sessions are stored ONLY on the client machine, never on the server.
The server is completely stateless and receives session data with each request.
"""
import json
import logging
import time
from pathlib import Path
from typing import Any, Dict, Optional
log = logging.getLogger("LocalSessionCache")
class LocalSessionCache:
"""
Client-side session cache.
Stores authenticated sessions locally (similar to cookies/cache).
Server never stores sessions - client sends session with each request.
"""
def __init__(self, cache_dir: Path):
"""
Initialize local session cache.
Args:
cache_dir: Directory to store session cache files
"""
self.cache_dir = cache_dir
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.sessions_file = cache_dir / "remote_sessions.json"
# Load existing sessions
self.sessions: Dict[str, Dict[str, Dict[str, Any]]] = self._load_sessions()
def _load_sessions(self) -> Dict[str, Dict[str, Dict[str, Any]]]:
"""Load sessions from cache file."""
if not self.sessions_file.exists():
return {}
try:
data = json.loads(self.sessions_file.read_text(encoding="utf-8"))
log.debug(f"Loaded {len(data)} remote sessions from cache")
return data
except Exception as e:
log.error(f"Failed to load sessions cache: {e}")
return {}
def _save_sessions(self) -> None:
"""Save sessions to cache file."""
try:
self.sessions_file.write_text(
json.dumps(self.sessions, indent=2, ensure_ascii=False),
encoding="utf-8"
)
log.debug(f"Saved {len(self.sessions)} remote sessions to cache")
except Exception as e:
log.error(f"Failed to save sessions cache: {e}")
def store_session(
self,
remote_url: str,
service_tag: str,
profile: str,
session_data: Dict[str, Any]
) -> None:
"""
Store an authenticated session locally.
Args:
remote_url: Remote server URL (as key)
service_tag: Service tag
profile: Profile name
session_data: Authenticated session data
"""
# Create nested structure
if remote_url not in self.sessions:
self.sessions[remote_url] = {}
if service_tag not in self.sessions[remote_url]:
self.sessions[remote_url][service_tag] = {}
# Store session with metadata
self.sessions[remote_url][service_tag][profile] = {
"session_data": session_data,
"cached_at": time.time(),
"service_tag": service_tag,
"profile": profile,
}
self._save_sessions()
log.info(f"Cached session for {service_tag} (profile: {profile}, remote: {remote_url})")
def get_session(
self,
remote_url: str,
service_tag: str,
profile: str
) -> Optional[Dict[str, Any]]:
"""
Retrieve a cached session.
Args:
remote_url: Remote server URL
service_tag: Service tag
profile: Profile name
Returns:
Session data or None if not found/expired
"""
try:
session_entry = self.sessions[remote_url][service_tag][profile]
# Check if expired (24 hours)
age = time.time() - session_entry["cached_at"]
if age > 86400: # 24 hours
log.info(f"Session expired for {service_tag} (age: {age:.0f}s)")
self.delete_session(remote_url, service_tag, profile)
return None
log.debug(f"Using cached session for {service_tag} (profile: {profile})")
return session_entry["session_data"]
except KeyError:
log.debug(f"No cached session for {service_tag} (profile: {profile})")
return None
def has_session(
self,
remote_url: str,
service_tag: str,
profile: str
) -> bool:
"""
Check if a valid session exists.
Args:
remote_url: Remote server URL
service_tag: Service tag
profile: Profile name
Returns:
True if valid session exists
"""
session = self.get_session(remote_url, service_tag, profile)
return session is not None
def delete_session(
self,
remote_url: str,
service_tag: str,
profile: str
) -> bool:
"""
Delete a cached session.
Args:
remote_url: Remote server URL
service_tag: Service tag
profile: Profile name
Returns:
True if session was deleted
"""
try:
del self.sessions[remote_url][service_tag][profile]
# Clean up empty nested dicts
if not self.sessions[remote_url][service_tag]:
del self.sessions[remote_url][service_tag]
if not self.sessions[remote_url]:
del self.sessions[remote_url]
self._save_sessions()
log.info(f"Deleted cached session for {service_tag} (profile: {profile})")
return True
except KeyError:
return False
def list_sessions(self, remote_url: Optional[str] = None) -> list[Dict[str, Any]]:
"""
List all cached sessions.
Args:
remote_url: Optional filter by remote URL
Returns:
List of session metadata
"""
sessions = []
remotes = [remote_url] if remote_url else self.sessions.keys()
for remote in remotes:
if remote not in self.sessions:
continue
for service_tag, profiles in self.sessions[remote].items():
for profile, entry in profiles.items():
age = time.time() - entry["cached_at"]
sessions.append({
"remote_url": remote,
"service_tag": service_tag,
"profile": profile,
"cached_at": entry["cached_at"],
"age_seconds": int(age),
"expired": age > 86400,
"has_cookies": bool(entry["session_data"].get("cookies")),
"has_headers": bool(entry["session_data"].get("headers")),
})
return sessions
def cleanup_expired(self) -> int:
"""
Remove expired sessions (older than 24 hours).
Returns:
Number of sessions removed
"""
removed = 0
current_time = time.time()
for remote_url in list(self.sessions.keys()):
for service_tag in list(self.sessions[remote_url].keys()):
for profile in list(self.sessions[remote_url][service_tag].keys()):
entry = self.sessions[remote_url][service_tag][profile]
age = current_time - entry["cached_at"]
if age > 86400: # 24 hours
del self.sessions[remote_url][service_tag][profile]
removed += 1
log.info(f"Removed expired session for {service_tag} (age: {age:.0f}s)")
# Clean up empty dicts
if not self.sessions[remote_url][service_tag]:
del self.sessions[remote_url][service_tag]
if not self.sessions[remote_url]:
del self.sessions[remote_url]
if removed > 0:
self._save_sessions()
return removed
# Global instance
_local_session_cache: Optional[LocalSessionCache] = None
def get_local_session_cache() -> LocalSessionCache:
"""
Get the global local session cache instance.
Returns:
LocalSessionCache instance
"""
global _local_session_cache
if _local_session_cache is None:
from unshackle.core.config import config
cache_dir = config.directories.cache / "remote_sessions"
_local_session_cache = LocalSessionCache(cache_dir)
# Clean up expired sessions on init
_local_session_cache.cleanup_expired()
return _local_session_cache
__all__ = ["LocalSessionCache", "get_local_session_cache"]

View File

@@ -1,276 +0,0 @@
"""Client-side authentication for remote services.
This module handles authenticating services locally on the client side,
then sending the authenticated session to the remote server.
This approach allows:
- Interactive browser-based logins
- 2FA/CAPTCHA handling
- OAuth flows
- Any authentication that requires user interaction
The server NEVER sees credentials - only authenticated sessions.
"""
import logging
from typing import Any, Dict, Optional
import click
import yaml
from unshackle.core.api.session_serializer import serialize_session
from unshackle.core.config import config
from unshackle.core.console import console
from unshackle.core.credential import Credential
from unshackle.core.local_session_cache import get_local_session_cache
from unshackle.core.services import Services
from unshackle.core.utils.click_types import ContextData
from unshackle.core.utils.collections import merge_dict
log = logging.getLogger("RemoteAuth")
class RemoteAuthenticator:
"""
Handles client-side authentication for remote services.
Workflow:
1. Load service locally
2. Authenticate using local credentials/cookies (can show browser, handle 2FA)
3. Extract authenticated session
4. Upload session to remote server
5. Server uses the pre-authenticated session
"""
def __init__(self, remote_url: str, api_key: str):
"""
Initialize remote authenticator.
Args:
remote_url: Base URL of remote server
api_key: API key for remote server
"""
self.remote_url = remote_url.rstrip("/")
self.api_key = api_key
def authenticate_service_locally(
self, service_tag: str, profile: Optional[str] = None, force_reauth: bool = False
) -> Dict[str, Any]:
"""
Authenticate a service locally and extract the session.
This runs the service authentication on the CLIENT side where browsers,
2FA, and interactive prompts can work.
Args:
service_tag: Service to authenticate (e.g., "DSNP", "NF")
profile: Optional profile to use for credentials
force_reauth: Force re-authentication even if session exists
Returns:
Serialized session data
Raises:
ValueError: If service not found or authentication fails
"""
console.print(f"[cyan]Authenticating {service_tag} locally...[/cyan]")
# Validate service exists
if service_tag not in Services.get_tags():
raise ValueError(f"Service {service_tag} not found locally")
# Load service
service_module = Services.load(service_tag)
# Load service config
service_config_path = Services.get_path(service_tag) / 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(service_tag), service_config)
# Create Click context
@click.command()
@click.pass_context
def dummy_command(ctx: click.Context) -> None:
pass
ctx = click.Context(dummy_command)
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile)
# Create service instance
try:
# Get service initialization parameters
import inspect
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
# Filter to only valid parameters
filtered_kwargs = {k: v for k, v in service_kwargs.items() if k in service_init_params}
# Create service instance
service_instance = service_module(ctx, **filtered_kwargs)
# Get credentials and cookies
cookies = self._get_cookie_jar(service_tag, profile)
credential = self._get_credentials(service_tag, profile)
# Authenticate the service
console.print("[yellow]Authenticating... (this may show browser or prompts)[/yellow]")
service_instance.authenticate(cookies=cookies, credential=credential)
# Serialize the authenticated session
session_data = serialize_session(service_instance.session)
# Add metadata
session_data["service_tag"] = service_tag
session_data["profile"] = profile
session_data["authenticated"] = True
console.print(f"[green]✓ {service_tag} authenticated successfully![/green]")
log.info(f"Authenticated {service_tag} (profile: {profile or 'default'})")
return session_data
except Exception as e:
console.print(f"[red]✗ Authentication failed: {e}[/red]")
log.error(f"Failed to authenticate {service_tag}: {e}")
raise ValueError(f"Authentication failed for {service_tag}: {e}")
def save_session_locally(self, session_data: Dict[str, Any]) -> bool:
"""
Save authenticated session to local cache.
The session is stored only on the client machine, never on the server.
The server is completely stateless.
Args:
session_data: Serialized session data
Returns:
True if save successful
"""
service_tag = session_data.get("service_tag")
profile = session_data.get("profile", "default")
console.print("[cyan]Saving session to local cache...[/cyan]")
try:
# Get local session cache
cache = get_local_session_cache()
# Store session locally
cache.store_session(
remote_url=self.remote_url,
service_tag=service_tag,
profile=profile,
session_data=session_data
)
console.print("[green]✓ Session saved locally![/green]")
log.info(f"Saved session for {service_tag} (profile: {profile}) to local cache")
return True
except Exception as e:
console.print(f"[red]✗ Save failed: {e}[/red]")
log.error(f"Failed to save session locally: {e}")
return False
def authenticate_and_save(self, service_tag: str, profile: Optional[str] = None) -> bool:
"""
Authenticate locally and save session to local cache in one step.
Args:
service_tag: Service to authenticate
profile: Optional profile
Returns:
True if successful
"""
try:
# Authenticate locally
session_data = self.authenticate_service_locally(service_tag, profile)
# Save to local cache
return self.save_session_locally(session_data)
except Exception as e:
console.print(f"[red]Authentication and save failed: {e}[/red]")
return False
def check_local_session_status(self, service_tag: str, profile: Optional[str] = None) -> Dict[str, Any]:
"""
Check if a session exists in local cache.
Args:
service_tag: Service tag
profile: Optional profile
Returns:
Session status info
"""
try:
cache = get_local_session_cache()
session_data = cache.get_session(self.remote_url, service_tag, profile or "default")
if session_data:
# Get metadata
sessions = cache.list_sessions(self.remote_url)
for session in sessions:
if session["service_tag"] == service_tag and session["profile"] == (profile or "default"):
return {
"status": "success",
"exists": True,
"session_info": session
}
return {
"status": "success",
"exists": False,
"message": f"No session found for {service_tag} (profile: {profile or 'default'})"
}
except Exception as e:
log.error(f"Failed to check session status: {e}")
return {"status": "error", "message": "Failed to check session status"}
def _get_cookie_jar(self, service_tag: str, profile: Optional[str]):
"""Get cookie jar for service and profile."""
from unshackle.commands.dl import dl
return dl.get_cookie_jar(service_tag, profile)
def _get_credentials(self, service_tag: str, profile: Optional[str]) -> Optional[Credential]:
"""Get credentials for service and profile."""
from unshackle.commands.dl import dl
return dl.get_credentials(service_tag, profile)
def authenticate_remote_service(remote_url: str, api_key: str, service_tag: str, profile: Optional[str] = None) -> bool:
"""
Helper function to authenticate a remote service.
Args:
remote_url: Remote server URL
api_key: API key
service_tag: Service to authenticate
profile: Optional profile
Returns:
True if successful
"""
authenticator = RemoteAuthenticator(remote_url, api_key)
return authenticator.authenticate_and_save(service_tag, profile)
__all__ = ["RemoteAuthenticator", "authenticate_remote_service"]

View File

@@ -1,593 +0,0 @@
"""Remote service implementation for connecting to remote unshackle servers."""
import logging
import time
from collections.abc import Generator
from http.cookiejar import CookieJar
from typing import Any, Dict, Optional, Union
import click
import requests
from rich.padding import Padding
from rich.rule import Rule
from unshackle.core.api.session_serializer import deserialize_session
from unshackle.core.console import console
from unshackle.core.credential import Credential
from unshackle.core.local_session_cache import get_local_session_cache
from unshackle.core.search_result import SearchResult
from unshackle.core.titles import Episode, Movie, Movies, Series
from unshackle.core.tracks import Chapter, Chapters, Tracks
from unshackle.core.tracks.audio import Audio
from unshackle.core.tracks.subtitle import Subtitle
from unshackle.core.tracks.video import Video
class RemoteService:
"""
Remote Service wrapper that connects to a remote unshackle server.
This class mimics the Service interface but delegates all operations
to a remote unshackle server via API calls. It receives session data
from the remote server which is then used locally for downloading.
"""
ALIASES: tuple[str, ...] = ()
GEOFENCE: tuple[str, ...] = ()
def __init__(
self,
ctx: click.Context,
remote_url: str,
api_key: str,
service_tag: str,
service_metadata: Dict[str, Any],
**kwargs,
):
"""
Initialize remote service.
Args:
ctx: Click context
remote_url: Base URL of the remote unshackle server
api_key: API key for authentication
service_tag: The service tag on the remote server (e.g., "DSNP")
service_metadata: Metadata about the service from remote discovery
**kwargs: Additional service-specific parameters
"""
console.print(Padding(Rule(f"[rule.text]Remote Service: {service_tag}"), (1, 2)))
self.log = logging.getLogger(f"RemoteService.{service_tag}")
self.remote_url = remote_url.rstrip("/")
self.api_key = api_key
self.service_tag = service_tag
self.service_metadata = service_metadata
self.ctx = ctx
self.kwargs = kwargs
# Set GEOFENCE and ALIASES from metadata
if "geofence" in service_metadata:
self.GEOFENCE = tuple(service_metadata["geofence"])
if "aliases" in service_metadata:
self.ALIASES = tuple(service_metadata["aliases"])
# Create a session for API calls to the remote server
self.api_session = requests.Session()
self.api_session.headers.update({"X-API-Key": self.api_key, "Content-Type": "application/json"})
# This session will receive data from remote for actual downloading
self.session = requests.Session()
# Store authentication state
self.authenticated = False
self.credential = None
self.cookies_content = None # Raw cookie file content to send to remote
# Get profile from context if available
self.profile = "default"
if hasattr(ctx, "obj") and hasattr(ctx.obj, "profile"):
self.profile = ctx.obj.profile or "default"
# Initialize proxy providers for resolving proxy credentials
self._proxy_providers = None
if hasattr(ctx, "obj") and hasattr(ctx.obj, "proxy_providers"):
self._proxy_providers = ctx.obj.proxy_providers
def _resolve_proxy_locally(self, proxy: str) -> Optional[str]:
"""
Resolve proxy parameter locally using client's proxy providers.
This allows the client to resolve proxy providers (like NordVPN) and
send the full proxy URI with credentials to the server.
Args:
proxy: Proxy parameter (e.g., "nordvpn:ca1066", "us2104", or full URI)
Returns:
Resolved proxy URI with credentials, or None if no_proxy
"""
if not proxy:
return None
import re
# If already a full URI, return as-is
if re.match(r"^https?://", proxy):
self.log.debug(f"Using explicit proxy URI: {proxy}")
return proxy
# Try to resolve using local proxy providers
if self._proxy_providers:
try:
from unshackle.core.api.handlers import resolve_proxy
resolved = resolve_proxy(proxy, self._proxy_providers)
self.log.info(f"Resolved proxy '{proxy}' to: {resolved}")
return resolved
except Exception as e:
self.log.warning(f"Failed to resolve proxy locally: {e}")
# Fall back to sending proxy parameter as-is for server to resolve
return proxy
else:
self.log.debug(f"No proxy providers available, sending proxy as-is: {proxy}")
return proxy
def _add_proxy_to_request(self, data: Dict[str, Any]) -> None:
"""
Add resolved proxy information to request data.
Resolves proxy using local proxy providers and adds to request.
Server will use the resolved proxy URI (with credentials).
Args:
data: Request data dictionary to modify
"""
if hasattr(self.ctx, "params"):
no_proxy = self.ctx.params.get("no_proxy", False)
proxy_param = self.ctx.params.get("proxy")
if no_proxy:
data["no_proxy"] = True
elif proxy_param:
# Resolve proxy locally to get credentials
resolved_proxy = self._resolve_proxy_locally(proxy_param)
if resolved_proxy:
data["proxy"] = resolved_proxy
self.log.debug(f"Sending resolved proxy to server: {resolved_proxy}")
def _make_request(self, endpoint: str, data: Optional[Dict[str, Any]] = None, retry_count: int = 0) -> Dict[str, Any]:
"""
Make an API request to the remote server with retry logic.
Automatically handles authentication:
1. Check for cached session - send with request if found
2. If session expired, re-authenticate automatically
3. If no session, send credentials (server tries to auth)
4. If server returns AUTH_REQUIRED, authenticate locally
5. Retry request with new session
Args:
endpoint: API endpoint path (e.g., "/api/remote/DSNP/titles")
data: Optional JSON data to send
retry_count: Current retry attempt (for internal use)
Returns:
Response JSON data
Raises:
ConnectionError: If the request fails after all retries
"""
url = f"{self.remote_url}{endpoint}"
max_retries = 3 # Max network retries
retry_delays = [2, 4, 8] # Exponential backoff in seconds
# Ensure data is a dictionary
if data is None:
data = {}
# Priority 1: Check for pre-authenticated session in local cache
cache = get_local_session_cache()
cached_session = cache.get_session(self.remote_url, self.service_tag, self.profile)
if cached_session:
# Send pre-authenticated session data (server never stores it)
self.log.debug(f"Using cached session for {self.service_tag}")
data["pre_authenticated_session"] = cached_session
else:
# Priority 2: Fallback to credentials/cookies (old behavior)
# This allows server to authenticate if no local session exists
if self.cookies_content:
data["cookies"] = self.cookies_content
if self.credential:
data["credential"] = {"username": self.credential.username, "password": self.credential.password}
try:
if data:
response = self.api_session.post(url, json=data)
else:
response = self.api_session.get(url)
response.raise_for_status()
result = response.json()
# Check if session expired - re-authenticate automatically
if result.get("error_code") == "SESSION_EXPIRED":
console.print(f"[yellow]Session expired for {self.service_tag}[/yellow]")
console.print("[cyan]Re-authenticating...[/cyan]")
# Delete expired session from cache
cache.delete_session(self.remote_url, self.service_tag, self.profile)
# Perform local authentication
session_data = self._authenticate_locally()
if session_data:
# Save to cache for future requests
cache.store_session(
remote_url=self.remote_url,
service_tag=self.service_tag,
profile=self.profile,
session_data=session_data
)
# Retry request with new session
data["pre_authenticated_session"] = session_data
# Remove old auth data
data.pop("cookies", None)
data.pop("credential", None)
# Retry the request
response = self.api_session.post(url, json=data)
response.raise_for_status()
result = response.json()
# Check if server requires authentication
elif result.get("error_code") == "AUTH_REQUIRED" and not cached_session:
console.print(f"[yellow]Authentication required for {self.service_tag}[/yellow]")
console.print("[cyan]Authenticating locally...[/cyan]")
# Perform local authentication
session_data = self._authenticate_locally()
if session_data:
# Save to cache for future requests
cache.store_session(
remote_url=self.remote_url,
service_tag=self.service_tag,
profile=self.profile,
session_data=session_data
)
# Retry request with authenticated session
data["pre_authenticated_session"] = session_data
# Remove old auth data
data.pop("cookies", None)
data.pop("credential", None)
# Retry the request
response = self.api_session.post(url, json=data)
response.raise_for_status()
result = response.json()
# Apply session data if present
if "session" in result:
deserialize_session(result["session"], self.session)
return result
except requests.RequestException as e:
# Retry on network errors with exponential backoff
if retry_count < max_retries:
delay = retry_delays[retry_count]
self.log.warning(f"Request failed (attempt {retry_count + 1}/{max_retries + 1}): {e}")
self.log.info(f"Retrying in {delay} seconds...")
time.sleep(delay)
return self._make_request(endpoint, data, retry_count + 1)
else:
self.log.error(f"Remote API request failed after {max_retries + 1} attempts: {e}")
raise ConnectionError(f"Failed to communicate with remote server after {max_retries + 1} attempts: {e}")
def _authenticate_locally(self) -> Optional[Dict[str, Any]]:
"""
Authenticate the service locally when server requires it.
This performs interactive authentication (browser, 2FA, etc.)
and returns the authenticated session.
Returns:
Serialized session data or None if authentication fails
"""
from unshackle.core.remote_auth import RemoteAuthenticator
try:
authenticator = RemoteAuthenticator(self.remote_url, self.api_key)
session_data = authenticator.authenticate_service_locally(self.service_tag, self.profile)
console.print("[green]✓ Authentication successful![/green]")
return session_data
except Exception as e:
console.print(f"[red]✗ Authentication failed: {e}[/red]")
self.log.error(f"Local authentication failed: {e}")
return None
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
"""
Prepare authentication data to send to remote service.
Stores cookies and credentials to send with each API request.
The remote server will use these for authentication.
Args:
cookies: Cookie jar from local configuration
credential: Credentials from local configuration
"""
self.log.info("Preparing authentication for remote server...")
self.credential = credential
# Read cookies file content if cookies provided
if cookies and hasattr(cookies, "filename") and cookies.filename:
try:
from pathlib import Path
cookie_file = Path(cookies.filename)
if cookie_file.exists():
self.cookies_content = cookie_file.read_text()
self.log.info(f"Loaded cookies from {cookie_file}")
except Exception as e:
self.log.warning(f"Could not read cookie file: {e}")
self.authenticated = True
self.log.info("Authentication data ready for remote server")
def search(self, query: Optional[str] = None) -> Generator[SearchResult, None, None]:
"""
Search for content on the remote service.
Args:
query: Search query string
Yields:
SearchResult objects
"""
if query is None:
query = self.kwargs.get("query", "")
self.log.info(f"Searching remote service for: {query}")
data = {"query": query}
# Add proxy information (resolved locally with credentials)
self._add_proxy_to_request(data)
response = self._make_request(f"/api/remote/{self.service_tag}/search", data)
if response.get("status") == "success" and "results" in response:
for result in response["results"]:
yield SearchResult(
id_=result["id"],
title=result["title"],
description=result.get("description"),
label=result.get("label"),
url=result.get("url"),
)
def get_titles(self) -> Union[Movies, Series]:
"""
Get titles from the remote service.
Returns:
Movies or Series object containing title information
"""
title = self.kwargs.get("title")
if not title:
raise ValueError("No title provided")
self.log.info(f"Getting titles from remote service for: {title}")
data = {"title": title}
# Add additional parameters
for key, value in self.kwargs.items():
if key not in ["title"]:
data[key] = value
# Add proxy information (resolved locally with credentials)
self._add_proxy_to_request(data)
response = self._make_request(f"/api/remote/{self.service_tag}/titles", data)
if response.get("status") != "success" or "titles" not in response:
raise ValueError(f"Failed to get titles from remote: {response.get('message', 'Unknown error')}")
titles_data = response["titles"]
# Deserialize titles
titles = []
for title_info in titles_data:
if title_info["type"] == "movie":
titles.append(
Movie(
id_=title_info.get("id", title),
service=self.__class__,
name=title_info["name"],
year=title_info.get("year"),
data=title_info,
)
)
elif title_info["type"] == "episode":
titles.append(
Episode(
id_=title_info.get("id", title),
service=self.__class__,
title=title_info.get("series_title", title_info["name"]),
season=title_info.get("season", 0),
number=title_info.get("number", 0),
name=title_info.get("name"),
year=title_info.get("year"),
data=title_info,
)
)
# Return appropriate container
if titles and isinstance(titles[0], Episode):
return Series(titles)
else:
return Movies(titles)
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
"""
Get tracks from the remote service.
Args:
title: Title object to get tracks for
Returns:
Tracks object containing video, audio, and subtitle tracks
"""
self.log.info(f"Getting tracks from remote service for: {title}")
title_input = self.kwargs.get("title")
data = {"title": title_input}
# Add episode information if applicable
if isinstance(title, Episode):
data["season"] = title.season
data["episode"] = title.number
# Add additional parameters
for key, value in self.kwargs.items():
if key not in ["title"]:
data[key] = value
# Add proxy information (resolved locally with credentials)
self._add_proxy_to_request(data)
response = self._make_request(f"/api/remote/{self.service_tag}/tracks", data)
if response.get("status") != "success":
raise ValueError(f"Failed to get tracks from remote: {response.get('message', 'Unknown error')}")
# Handle multiple episodes response
if "episodes" in response:
# For multiple episodes, return tracks for the matching title
for episode_data in response["episodes"]:
episode_title = episode_data["title"]
if (
isinstance(title, Episode)
and episode_title.get("season") == title.season
and episode_title.get("number") == title.number
):
return self._deserialize_tracks(episode_data, title)
raise ValueError(f"Could not find tracks for {title.season}x{title.number} in remote response")
# Single title response
return self._deserialize_tracks(response, title)
def _deserialize_tracks(self, data: Dict[str, Any], title: Union[Movie, Episode]) -> Tracks:
"""
Deserialize tracks from API response.
Args:
data: Track data from API
title: Title object these tracks belong to
Returns:
Tracks object
"""
tracks = Tracks()
# Deserialize video tracks
for video_data in data.get("video", []):
video = Video(
id_=video_data["id"],
url="", # URL will be populated during download from manifests
codec=Video.Codec[video_data["codec"]],
bitrate=video_data.get("bitrate", 0) * 1000 if video_data.get("bitrate") else None,
width=video_data.get("width"),
height=video_data.get("height"),
fps=video_data.get("fps"),
range_=Video.Range[video_data["range"]] if video_data.get("range") else None,
language=video_data.get("language"),
drm=video_data.get("drm"),
)
tracks.add(video)
# Deserialize audio tracks
for audio_data in data.get("audio", []):
audio = Audio(
id_=audio_data["id"],
url="", # URL will be populated during download
codec=Audio.Codec[audio_data["codec"]],
bitrate=audio_data.get("bitrate", 0) * 1000 if audio_data.get("bitrate") else None,
channels=audio_data.get("channels"),
language=audio_data.get("language"),
descriptive=audio_data.get("descriptive", False),
drm=audio_data.get("drm"),
)
if audio_data.get("atmos"):
audio.atmos = True
tracks.add(audio)
# Deserialize subtitle tracks
for subtitle_data in data.get("subtitles", []):
subtitle = Subtitle(
id_=subtitle_data["id"],
url="", # URL will be populated during download
codec=Subtitle.Codec[subtitle_data["codec"]],
language=subtitle_data.get("language"),
forced=subtitle_data.get("forced", False),
sdh=subtitle_data.get("sdh", False),
cc=subtitle_data.get("cc", False),
)
tracks.add(subtitle)
return tracks
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
"""
Get chapters from the remote service.
Args:
title: Title object to get chapters for
Returns:
Chapters object
"""
self.log.info(f"Getting chapters from remote service for: {title}")
title_input = self.kwargs.get("title")
data = {"title": title_input}
# Add episode information if applicable
if isinstance(title, Episode):
data["season"] = title.season
data["episode"] = title.number
# Add proxy information (resolved locally with credentials)
self._add_proxy_to_request(data)
response = self._make_request(f"/api/remote/{self.service_tag}/chapters", data)
if response.get("status") != "success":
self.log.warning(f"Failed to get chapters from remote: {response.get('message', 'Unknown error')}")
return Chapters()
chapters = Chapters()
for chapter_data in response.get("chapters", []):
chapters.add(Chapter(timestamp=chapter_data["timestamp"], name=chapter_data.get("name")))
return chapters
@staticmethod
def get_session() -> requests.Session:
"""
Create a session for the remote service.
Returns:
A requests.Session object
"""
session = requests.Session()
return session

View File

@@ -1,245 +0,0 @@
"""Remote service discovery and management."""
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
import requests
from unshackle.core.config import config
from unshackle.core.remote_service import RemoteService
log = logging.getLogger("RemoteServices")
class RemoteServiceManager:
"""
Manages discovery and registration of remote services.
This class connects to configured remote unshackle servers,
discovers available services, and creates RemoteService instances
that can be used like local services.
"""
def __init__(self):
"""Initialize the remote service manager."""
self.remote_services: Dict[str, type] = {}
self.remote_configs: List[Dict[str, Any]] = []
def discover_services(self) -> None:
"""
Discover services from all configured remote servers.
Reads the remote_services configuration, connects to each server,
retrieves available services, and creates RemoteService classes
for each discovered service.
"""
if not config.remote_services:
log.debug("No remote services configured")
return
log.info(f"Discovering services from {len(config.remote_services)} remote server(s)...")
for remote_config in config.remote_services:
try:
self._discover_from_server(remote_config)
except Exception as e:
log.error(f"Failed to discover services from {remote_config.get('url')}: {e}")
continue
log.info(f"Discovered {len(self.remote_services)} remote service(s)")
def _discover_from_server(self, remote_config: Dict[str, Any]) -> None:
"""
Discover services from a single remote server.
Args:
remote_config: Configuration for the remote server
(must contain 'url' and 'api_key')
"""
url = remote_config.get("url", "").rstrip("/")
api_key = remote_config.get("api_key", "")
server_name = remote_config.get("name", url)
if not url:
log.warning("Remote service configuration missing 'url', skipping")
return
if not api_key:
log.warning(f"Remote service {url} missing 'api_key', skipping")
return
log.info(f"Connecting to remote server: {server_name}")
try:
# Query the remote server for available services
response = requests.get(
f"{url}/api/remote/services",
headers={"X-API-Key": api_key, "Content-Type": "application/json"},
timeout=10,
)
response.raise_for_status()
data = response.json()
if data.get("status") != "success" or "services" not in data:
log.error(f"Invalid response from {url}: {data}")
return
services = data["services"]
log.info(f"Found {len(services)} service(s) on {server_name}")
# Create RemoteService classes for each service
for service_info in services:
self._register_remote_service(url, api_key, service_info, server_name)
except requests.RequestException as e:
log.error(f"Failed to connect to remote server {url}: {e}")
raise
def _register_remote_service(
self, remote_url: str, api_key: str, service_info: Dict[str, Any], server_name: str
) -> None:
"""
Register a remote service as a local service class.
Args:
remote_url: Base URL of the remote server
api_key: API key for authentication
service_info: Service metadata from the remote server
server_name: Friendly name of the remote server
"""
service_tag = service_info.get("tag")
if not service_tag:
log.warning(f"Service info missing 'tag': {service_info}")
return
# Create a unique tag for the remote service
# Use "remote_" prefix to distinguish from local services
remote_tag = f"remote_{service_tag}"
# Check if this remote service is already registered
if remote_tag in self.remote_services:
log.debug(f"Remote service {remote_tag} already registered, skipping")
return
log.info(f"Registering remote service: {remote_tag} from {server_name}")
# Create a dynamic class that inherits from RemoteService
# This allows us to create instances with the cli() method for Click integration
class DynamicRemoteService(RemoteService):
"""Dynamically created remote service class."""
def __init__(self, ctx, **kwargs):
super().__init__(
ctx=ctx,
remote_url=remote_url,
api_key=api_key,
service_tag=service_tag,
service_metadata=service_info,
**kwargs,
)
@staticmethod
def cli():
"""CLI method for Click integration."""
import click
# Create a dynamic Click command for this service
@click.command(
name=remote_tag,
short_help=f"Remote: {service_info.get('help', service_tag)}",
help=service_info.get("help", f"Remote service for {service_tag}"),
)
@click.argument("title", type=str, required=False)
@click.option("-q", "--query", type=str, help="Search query")
@click.pass_context
def remote_service_cli(ctx, title=None, query=None, **kwargs):
# Combine title and kwargs
params = {**kwargs}
if title:
params["title"] = title
if query:
params["query"] = query
return DynamicRemoteService(ctx, **params)
return remote_service_cli
# Set class name for better debugging
DynamicRemoteService.__name__ = remote_tag
DynamicRemoteService.__module__ = "unshackle.remote_services"
# Set GEOFENCE and ALIASES
if "geofence" in service_info:
DynamicRemoteService.GEOFENCE = tuple(service_info["geofence"])
if "aliases" in service_info:
# Add "remote_" prefix to aliases too
DynamicRemoteService.ALIASES = tuple(f"remote_{alias}" for alias in service_info["aliases"])
# Register the service
self.remote_services[remote_tag] = DynamicRemoteService
def get_service(self, tag: str) -> Optional[type]:
"""
Get a remote service class by tag.
Args:
tag: Service tag (e.g., "remote_DSNP")
Returns:
RemoteService class or None if not found
"""
return self.remote_services.get(tag)
def get_all_services(self) -> Dict[str, type]:
"""
Get all registered remote services.
Returns:
Dictionary mapping service tags to RemoteService classes
"""
return self.remote_services.copy()
def get_service_path(self, tag: str) -> Optional[Path]:
"""
Get the path for a remote service.
Remote services don't have local paths, so this returns None.
This method exists for compatibility with the Services interface.
Args:
tag: Service tag
Returns:
None (remote services have no local path)
"""
return None
# Global instance
_remote_service_manager: Optional[RemoteServiceManager] = None
def get_remote_service_manager() -> RemoteServiceManager:
"""
Get the global RemoteServiceManager instance.
Creates the instance on first call and discovers services.
Returns:
RemoteServiceManager instance
"""
global _remote_service_manager
if _remote_service_manager is None:
_remote_service_manager = RemoteServiceManager()
try:
_remote_service_manager.discover_services()
except Exception as e:
log.error(f"Failed to discover remote services: {e}")
return _remote_service_manager
__all__ = ("RemoteServiceManager", "get_remote_service_manager")

View File

@@ -25,17 +25,6 @@ class Services(click.MultiCommand):
# Click-specific methods
@staticmethod
def _get_remote_services():
"""Get remote services from the manager (lazy import to avoid circular dependency)."""
try:
from unshackle.core.remote_services import get_remote_service_manager
manager = get_remote_service_manager()
return manager.get_all_services()
except Exception:
return {}
def list_commands(self, ctx: click.Context) -> list[str]:
"""Returns a list of all available Services as command names for Click."""
return Services.get_tags()
@@ -62,25 +51,14 @@ class Services(click.MultiCommand):
@staticmethod
def get_tags() -> list[str]:
"""Returns a list of service tags from all available Services (local + remote)."""
local_tags = [x.parent.stem for x in _SERVICES]
remote_services = Services._get_remote_services()
remote_tags = list(remote_services.keys())
return local_tags + remote_tags
"""Returns a list of service tags from all available Services."""
return [x.parent.stem for x in _SERVICES]
@staticmethod
def get_path(name: str) -> Path:
"""Get the directory path of a command."""
tag = Services.get_tag(name)
# Check if it's a remote service
remote_services = Services._get_remote_services()
if tag in remote_services:
# Remote services don't have local paths
# Return a dummy path or raise an appropriate error
# For now, we'll raise KeyError to indicate no path exists
raise KeyError(f"Remote service '{tag}' has no local path")
for service in _SERVICES:
if service.parent.stem == tag:
return service.parent
@@ -96,36 +74,20 @@ class Services(click.MultiCommand):
original_value = value
value = value.lower()
# Check local services
for path in _SERVICES:
tag = path.parent.stem
if value in (tag.lower(), *_ALIASES.get(tag, [])):
return tag
# Check remote services
remote_services = Services._get_remote_services()
for tag, service_class in remote_services.items():
if value == tag.lower():
return tag
if hasattr(service_class, "ALIASES"):
if value in (alias.lower() for alias in service_class.ALIASES):
return tag
return original_value
@staticmethod
def load(tag: str) -> Service:
"""Load a Service module by Service tag (local or remote)."""
# Check local services first
"""Load a Service module by Service tag."""
module = _MODULES.get(tag)
if module:
return module
# Check remote services
remote_services = Services._get_remote_services()
if tag in remote_services:
return remote_services[tag]
raise KeyError(f"There is no Service added by the Tag '{tag}'")