forked from kenzuya/unshackle
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:
26
CONFIG.md
26
CONFIG.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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}'")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user