mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-03-12 09:29:02 +00:00
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
|
[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)
|
## scene_naming (bool)
|
||||||
|
|
||||||
Set scene-style naming for titles. When `true` uses scene naming patterns (e.g., `Prime.Suspect.S07E01...`), when
|
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"
|
tier: "premium"
|
||||||
default_cdm: "chromecdm_2101"
|
default_cdm: "chromecdm_2101"
|
||||||
allowed_cdms: ["*"] # or list specific CDMs: ["chromecdm_2101", "chromecdm_2202"]
|
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
|
from pywidevine import serve as pywidevine_serve
|
||||||
|
|
||||||
|
|||||||
@@ -191,12 +191,73 @@ def serialize_title(title: Title_T) -> Dict[str, Any]:
|
|||||||
return result
|
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."""
|
"""Convert video track to JSON-serializable dict."""
|
||||||
codec_name = track.codec.name if hasattr(track.codec, "name") else str(track.codec)
|
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)
|
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),
|
"id": str(track.id),
|
||||||
"codec": codec_name,
|
"codec": codec_name,
|
||||||
"codec_display": VIDEO_CODEC_MAP.get(codec_name, 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": range_name,
|
||||||
"range_display": DYNAMIC_RANGE_MAP.get(range_name, range_name),
|
"range_display": DYNAMIC_RANGE_MAP.get(range_name, range_name),
|
||||||
"language": str(track.language) if track.language else None,
|
"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."""
|
"""Convert audio track to JSON-serializable dict."""
|
||||||
codec_name = track.codec.name if hasattr(track.codec, "name") else str(track.codec)
|
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),
|
"id": str(track.id),
|
||||||
"codec": codec_name,
|
"codec": codec_name,
|
||||||
"codec_display": AUDIO_CODEC_MAP.get(codec_name, 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,
|
"language": str(track.language) if track.language else None,
|
||||||
"atmos": track.atmos if hasattr(track, "atmos") else False,
|
"atmos": track.atmos if hasattr(track, "atmos") else False,
|
||||||
"descriptive": track.descriptive if hasattr(track, "descriptive") 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."""
|
"""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),
|
"id": str(track.id),
|
||||||
"codec": track.codec.name if hasattr(track.codec, "name") else str(track.codec),
|
"codec": track.codec.name if hasattr(track.codec, "name") else str(track.codec),
|
||||||
"language": str(track.language) if track.language else None,
|
"language": str(track.language) if track.language else None,
|
||||||
"forced": track.forced if hasattr(track, "forced") else False,
|
"forced": track.forced if hasattr(track, "forced") else False,
|
||||||
"sdh": track.sdh if hasattr(track, "sdh") else False,
|
"sdh": track.sdh if hasattr(track, "sdh") else False,
|
||||||
"cc": track.cc if hasattr(track, "cc") 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:
|
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
|
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]:
|
def load_cookies_from_content(cookies_content: Optional[str]) -> Optional[http.cookiejar.MozillaCookieJar]:
|
||||||
"""
|
"""
|
||||||
Load cookies from raw cookie file content.
|
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."
|
f"Please resolve the proxy on the client side before sending to server."
|
||||||
}, status=400)
|
}, 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 = 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}
|
ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy}
|
||||||
|
|
||||||
service_module = Services.load(normalized_service)
|
service_module = Services.load(normalized_service)
|
||||||
@@ -771,7 +800,7 @@ async def remote_get_tracks(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
# Add additional parameters
|
# Add additional parameters
|
||||||
for key, value in data.items():
|
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
|
service_kwargs[key] = value
|
||||||
|
|
||||||
# Get service parameters
|
# Get service parameters
|
||||||
@@ -942,14 +971,35 @@ async def remote_get_tracks(request: web.Request) -> web.Response:
|
|||||||
if hasattr(service_module, "GEOFENCE"):
|
if hasattr(service_module, "GEOFENCE"):
|
||||||
geofence = list(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 = {
|
response_data = {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"title": serialize_title(first_title),
|
"title": serialize_title(first_title),
|
||||||
"video": [serialize_video_track(t) for t in video_tracks],
|
"video": [serialize_video_track(t, include_url=True) for t in video_tracks],
|
||||||
"audio": [serialize_audio_track(t) for t in audio_tracks],
|
"audio": [serialize_audio_track(t, include_url=True) for t in audio_tracks],
|
||||||
"subtitles": [serialize_subtitle_track(t) for t in tracks.subtitles],
|
"subtitles": [serialize_subtitle_track(t, include_url=True) for t in tracks.subtitles],
|
||||||
"session": session_data,
|
"session": session_data,
|
||||||
"geofence": geofence
|
"geofence": geofence,
|
||||||
|
"license_url": license_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
return web.json_response(response_data)
|
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)
|
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:
|
async def remote_get_chapters(request: web.Request) -> web.Response:
|
||||||
"""
|
"""
|
||||||
Get chapters from a remote service.
|
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,
|
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)
|
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,
|
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_get_manifest, remote_get_titles, remote_get_tracks,
|
||||||
remote_search)
|
remote_list_services, remote_search)
|
||||||
from unshackle.core.services import Services
|
from unshackle.core.services import Services
|
||||||
from unshackle.core.update_checker import UpdateChecker
|
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}/search", remote_search)
|
||||||
app.router.add_post("/api/remote/{service}/titles", remote_get_titles)
|
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}/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}/chapters", remote_get_chapters)
|
||||||
app.router.add_post("/api/remote/{service}/license", remote_get_license)
|
app.router.add_post("/api/remote/{service}/license", remote_get_license)
|
||||||
app.router.add_post("/api/remote/{service}/decrypt", remote_decrypt)
|
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}/search", remote_search),
|
||||||
web.post("/api/remote/{service}/titles", remote_get_titles),
|
web.post("/api/remote/{service}/titles", remote_get_titles),
|
||||||
web.post("/api/remote/{service}/tracks", remote_get_tracks),
|
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}/chapters", remote_get_chapters),
|
||||||
web.post("/api/remote/{service}/license", remote_get_license),
|
web.post("/api/remote/{service}/license", remote_get_license),
|
||||||
web.post("/api/remote/{service}/decrypt", remote_decrypt),
|
web.post("/api/remote/{service}/decrypt", remote_decrypt),
|
||||||
|
|||||||
@@ -105,8 +105,6 @@ class Config:
|
|||||||
self.debug: bool = kwargs.get("debug", False)
|
self.debug: bool = kwargs.get("debug", False)
|
||||||
self.debug_keys: bool = kwargs.get("debug_keys", False)
|
self.debug_keys: bool = kwargs.get("debug_keys", False)
|
||||||
|
|
||||||
self.remote_services: list[dict] = kwargs.get("remote_services") or []
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_yaml(cls, path: Path) -> Config:
|
def from_yaml(cls, path: Path) -> Config:
|
||||||
if not path.exists():
|
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
|
# 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]:
|
def list_commands(self, ctx: click.Context) -> list[str]:
|
||||||
"""Returns a list of all available Services as command names for Click."""
|
"""Returns a list of all available Services as command names for Click."""
|
||||||
return Services.get_tags()
|
return Services.get_tags()
|
||||||
@@ -62,25 +51,14 @@ class Services(click.MultiCommand):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_tags() -> list[str]:
|
def get_tags() -> list[str]:
|
||||||
"""Returns a list of service tags from all available Services (local + remote)."""
|
"""Returns a list of service tags from all available Services."""
|
||||||
local_tags = [x.parent.stem for x in _SERVICES]
|
return [x.parent.stem for x in _SERVICES]
|
||||||
remote_services = Services._get_remote_services()
|
|
||||||
remote_tags = list(remote_services.keys())
|
|
||||||
return local_tags + remote_tags
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_path(name: str) -> Path:
|
def get_path(name: str) -> Path:
|
||||||
"""Get the directory path of a command."""
|
"""Get the directory path of a command."""
|
||||||
tag = Services.get_tag(name)
|
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:
|
for service in _SERVICES:
|
||||||
if service.parent.stem == tag:
|
if service.parent.stem == tag:
|
||||||
return service.parent
|
return service.parent
|
||||||
@@ -96,36 +74,20 @@ class Services(click.MultiCommand):
|
|||||||
original_value = value
|
original_value = value
|
||||||
value = value.lower()
|
value = value.lower()
|
||||||
|
|
||||||
# Check local services
|
|
||||||
for path in _SERVICES:
|
for path in _SERVICES:
|
||||||
tag = path.parent.stem
|
tag = path.parent.stem
|
||||||
if value in (tag.lower(), *_ALIASES.get(tag, [])):
|
if value in (tag.lower(), *_ALIASES.get(tag, [])):
|
||||||
return 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
|
return original_value
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load(tag: str) -> Service:
|
def load(tag: str) -> Service:
|
||||||
"""Load a Service module by Service tag (local or remote)."""
|
"""Load a Service module by Service tag."""
|
||||||
# Check local services first
|
|
||||||
module = _MODULES.get(tag)
|
module = _MODULES.get(tag)
|
||||||
if module:
|
if module:
|
||||||
return 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}'")
|
raise KeyError(f"There is no Service added by the Tag '{tag}'")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user