diff --git a/CONFIG.md b/CONFIG.md index f777f2d..942657a 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -1182,32 +1182,6 @@ remote_cdm: [pywidevine]: https://github.com/rlaphoenix/pywidevine -## remote_services (list\[dict]) - -Configure connections to remote unshackle REST API servers to access services running on other instances. -This allows you to use services from remote unshackle installations as if they were local. - -Each entry requires: - -- `url` (str): The base URL of the remote unshackle REST API server -- `api_key` (str): API key for authenticating with the remote server -- `name` (str, optional): Friendly name for the remote service (for logging/display purposes) - -For example, - -```yaml -remote_services: - - url: "https://remote-unshackle.example.com" - api_key: "your_api_key_here" - name: "Remote US Server" - - url: "https://remote-unshackle-eu.example.com" - api_key: "another_api_key" - name: "Remote EU Server" -``` - -**Note**: The remote unshackle instances must have the REST API enabled and running. Services from all -configured remote servers will be available alongside your local services. - ## scene_naming (bool) Set scene-style naming for titles. When `true` uses scene naming patterns (e.g., `Prime.Suspect.S07E01...`), when diff --git a/unshackle/commands/remote_auth.py b/unshackle/commands/remote_auth.py deleted file mode 100644 index 23b6de2..0000000 --- a/unshackle/commands/remote_auth.py +++ /dev/null @@ -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 [/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 diff --git a/unshackle/commands/serve.py b/unshackle/commands/serve.py index 5d37057..b510350 100644 --- a/unshackle/commands/serve.py +++ b/unshackle/commands/serve.py @@ -62,29 +62,6 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug tier: "premium" default_cdm: "chromecdm_2101" allowed_cdms: ["*"] # or list specific CDMs: ["chromecdm_2101", "chromecdm_2202"] - - \b - REMOTE SERVICES: - The server exposes endpoints that allow remote unshackle clients to use - your configured services without needing the service implementations. - Remote clients can authenticate, get titles/tracks, and receive session data - for downloading. Configure remote clients in unshackle.yaml: - - \b - remote_services: - - url: "http://your-server:8786" - api_key: "your-api-key" - name: "my-server" - - \b - Available remote endpoints: - - GET /api/remote/services - List available services - - POST /api/remote/{service}/search - Search for content - - POST /api/remote/{service}/titles - Get titles - - POST /api/remote/{service}/tracks - Get tracks - - POST /api/remote/{service}/chapters - Get chapters - - POST /api/remote/{service}/license - Get DRM license (uses client CDM) - - POST /api/remote/{service}/decrypt - Decrypt using server CDM (premium only) """ from pywidevine import serve as pywidevine_serve diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index ba94adb..2d779e2 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -191,12 +191,73 @@ def serialize_title(title: Title_T) -> Dict[str, Any]: return result -def serialize_video_track(track: Video) -> Dict[str, Any]: +def serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]: + """Serialize DRM objects to JSON-serializable list.""" + if not drm_list: + return None + + if not isinstance(drm_list, list): + drm_list = [drm_list] + + result = [] + for drm in drm_list: + drm_info = {} + drm_class = drm.__class__.__name__ + drm_info["type"] = drm_class.lower() + + # Get PSSH - handle both Widevine and PlayReady + if hasattr(drm, "_pssh") and drm._pssh: + try: + pssh_obj = drm._pssh + # Try to get base64 representation + if hasattr(pssh_obj, "dumps"): + # pywidevine PSSH has dumps() method + drm_info["pssh"] = pssh_obj.dumps() + elif hasattr(pssh_obj, "__bytes__"): + # Convert to base64 + import base64 + drm_info["pssh"] = base64.b64encode(bytes(pssh_obj)).decode() + elif hasattr(pssh_obj, "to_base64"): + drm_info["pssh"] = pssh_obj.to_base64() + else: + # Fallback - str() works for pywidevine PSSH + pssh_str = str(pssh_obj) + # Check if it's already base64-like or an object repr + if not pssh_str.startswith("<"): + drm_info["pssh"] = pssh_str + except Exception: + pass + + # Get KIDs + if hasattr(drm, "kids") and drm.kids: + drm_info["kids"] = [str(kid) for kid in drm.kids] + + # Get content keys if available + if hasattr(drm, "content_keys") and drm.content_keys: + drm_info["content_keys"] = {str(k): v for k, v in drm.content_keys.items()} + + # Get license URL - essential for remote licensing + if hasattr(drm, "license_url") and drm.license_url: + drm_info["license_url"] = str(drm.license_url) + elif hasattr(drm, "_license_url") and drm._license_url: + drm_info["license_url"] = str(drm._license_url) + + result.append(drm_info) + + return result if result else None + + +def serialize_video_track(track: Video, include_url: bool = False) -> Dict[str, Any]: """Convert video track to JSON-serializable dict.""" codec_name = track.codec.name if hasattr(track.codec, "name") else str(track.codec) range_name = track.range.name if hasattr(track.range, "name") else str(track.range) - return { + # Get descriptor for N_m3u8DL-RE compatibility (HLS, DASH, URL, etc.) + descriptor_name = None + if hasattr(track, "descriptor") and track.descriptor: + descriptor_name = track.descriptor.name if hasattr(track.descriptor, "name") else str(track.descriptor) + + result = { "id": str(track.id), "codec": codec_name, "codec_display": VIDEO_CODEC_MAP.get(codec_name, codec_name), @@ -208,15 +269,24 @@ def serialize_video_track(track: Video) -> Dict[str, Any]: "range": range_name, "range_display": DYNAMIC_RANGE_MAP.get(range_name, range_name), "language": str(track.language) if track.language else None, - "drm": str(track.drm) if hasattr(track, "drm") and track.drm else None, + "drm": serialize_drm(track.drm) if hasattr(track, "drm") and track.drm else None, + "descriptor": descriptor_name, } + if include_url and hasattr(track, "url") and track.url: + result["url"] = str(track.url) + return result -def serialize_audio_track(track: Audio) -> Dict[str, Any]: +def serialize_audio_track(track: Audio, include_url: bool = False) -> Dict[str, Any]: """Convert audio track to JSON-serializable dict.""" codec_name = track.codec.name if hasattr(track.codec, "name") else str(track.codec) - return { + # Get descriptor for N_m3u8DL-RE compatibility + descriptor_name = None + if hasattr(track, "descriptor") and track.descriptor: + descriptor_name = track.descriptor.name if hasattr(track.descriptor, "name") else str(track.descriptor) + + result = { "id": str(track.id), "codec": codec_name, "codec_display": AUDIO_CODEC_MAP.get(codec_name, codec_name), @@ -225,20 +295,33 @@ def serialize_audio_track(track: Audio) -> Dict[str, Any]: "language": str(track.language) if track.language else None, "atmos": track.atmos if hasattr(track, "atmos") else False, "descriptive": track.descriptive if hasattr(track, "descriptive") else False, - "drm": str(track.drm) if hasattr(track, "drm") and track.drm else None, + "drm": serialize_drm(track.drm) if hasattr(track, "drm") and track.drm else None, + "descriptor": descriptor_name, } + if include_url and hasattr(track, "url") and track.url: + result["url"] = str(track.url) + return result -def serialize_subtitle_track(track: Subtitle) -> Dict[str, Any]: +def serialize_subtitle_track(track: Subtitle, include_url: bool = False) -> Dict[str, Any]: """Convert subtitle track to JSON-serializable dict.""" - return { + # Get descriptor for compatibility + descriptor_name = None + if hasattr(track, "descriptor") and track.descriptor: + descriptor_name = track.descriptor.name if hasattr(track.descriptor, "name") else str(track.descriptor) + + result = { "id": str(track.id), "codec": track.codec.name if hasattr(track.codec, "name") else str(track.codec), "language": str(track.language) if track.language else None, "forced": track.forced if hasattr(track, "forced") else False, "sdh": track.sdh if hasattr(track, "sdh") else False, "cc": track.cc if hasattr(track, "cc") else False, + "descriptor": descriptor_name, } + if include_url and hasattr(track, "url") and track.url: + result["url"] = str(track.url) + return result async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response: diff --git a/unshackle/core/api/remote_handlers.py b/unshackle/core/api/remote_handlers.py index b4a8cc3..db60ee2 100644 --- a/unshackle/core/api/remote_handlers.py +++ b/unshackle/core/api/remote_handlers.py @@ -31,6 +31,31 @@ log = logging.getLogger("api.remote") SESSION_EXPIRY_TIME = 86400 +class CDMProxy: + """ + Lightweight CDM proxy that holds CDM properties sent from client. + + This allows services to check CDM properties (like security_level) + without needing an actual CDM loaded on the server. + """ + + def __init__(self, cdm_info: Dict[str, Any]): + """ + Initialize CDM proxy from client-provided info. + + Args: + cdm_info: Dictionary with CDM properties (type, security_level, etc.) + """ + self.cdm_type = cdm_info.get("type", "widevine") + self.security_level = cdm_info.get("security_level", 3) + self.is_playready = self.cdm_type == "playready" + self.device_type = cdm_info.get("device_type") + self.is_remote = cdm_info.get("is_remote", False) + + def __repr__(self): + return f"CDMProxy(type={self.cdm_type}, L{self.security_level})" + + def load_cookies_from_content(cookies_content: Optional[str]) -> Optional[http.cookiejar.MozillaCookieJar]: """ Load cookies from raw cookie file content. @@ -754,8 +779,12 @@ async def remote_get_tracks(request: web.Request) -> web.Response: f"Please resolve the proxy on the client side before sending to server." }, status=400) + # Create CDM proxy from client-provided info (default to L3 Widevine if not provided) + cdm_info = data.get("cdm_info") or {"type": "widevine", "security_level": 3} + cdm = CDMProxy(cdm_info) + ctx = click.Context(dummy_service) - ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile) + ctx.obj = ContextData(config=service_config, cdm=cdm, proxy_providers=[], profile=profile) ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy} service_module = Services.load(normalized_service) @@ -771,7 +800,7 @@ async def remote_get_tracks(request: web.Request) -> web.Response: # Add additional parameters for key, value in data.items(): - if key not in ["title", "title_id", "url", "profile", "wanted", "season", "episode", "proxy", "no_proxy"]: + if key not in ["title", "title_id", "url", "profile", "wanted", "season", "episode", "proxy", "no_proxy", "cdm_info"]: service_kwargs[key] = value # Get service parameters @@ -942,14 +971,35 @@ async def remote_get_tracks(request: web.Request) -> web.Response: if hasattr(service_module, "GEOFENCE"): geofence = list(service_module.GEOFENCE) + # Try to extract license URL from service (for remote licensing) + license_url = None + title_id = first_title.id if hasattr(first_title, "id") else str(first_title) + + # Check playback_data for license URL + if hasattr(service_instance, "playback_data") and title_id in service_instance.playback_data: + playback_data = service_instance.playback_data[title_id] + # DSNP pattern + if "drm" in playback_data and "licenseServerUrl" in playback_data.get("drm", {}): + license_url = playback_data["drm"]["licenseServerUrl"] + elif "stream" in playback_data and "drm" in playback_data["stream"]: + drm_info = playback_data["stream"]["drm"] + if isinstance(drm_info, dict) and "licenseServerUrl" in drm_info: + license_url = drm_info["licenseServerUrl"] + + # Check service config for license URL + if not license_url and hasattr(service_instance, "config"): + if "license_url" in service_instance.config: + license_url = service_instance.config["license_url"] + response_data = { "status": "success", "title": serialize_title(first_title), - "video": [serialize_video_track(t) for t in video_tracks], - "audio": [serialize_audio_track(t) for t in audio_tracks], - "subtitles": [serialize_subtitle_track(t) for t in tracks.subtitles], + "video": [serialize_video_track(t, include_url=True) for t in video_tracks], + "audio": [serialize_audio_track(t, include_url=True) for t in audio_tracks], + "subtitles": [serialize_subtitle_track(t, include_url=True) for t in tracks.subtitles], "session": session_data, - "geofence": geofence + "geofence": geofence, + "license_url": license_url, } return web.json_response(response_data) @@ -959,6 +1009,272 @@ async def remote_get_tracks(request: web.Request) -> web.Response: return web.json_response({"status": "error", "message": "Internal server error while getting tracks"}, status=500) +async def remote_get_manifest(request: web.Request) -> web.Response: + """ + Get manifest URL and session from a remote service. + + This endpoint returns the manifest URL and authenticated session, + allowing the client to fetch and parse the manifest locally. + --- + summary: Get manifest info from remote service + description: Get manifest URL and session for client-side parsing + parameters: + - name: service + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title + properties: + title: + type: string + description: Title identifier + cdm_info: + type: object + description: Client CDM info (type, security_level) + responses: + '200': + description: Manifest info + """ + service_tag = request.match_info.get("service") + + try: + data = await request.json() + except Exception: + return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400) + + title = data.get("title") or data.get("title_id") or data.get("url") + if not title: + return web.json_response( + {"status": "error", "message": "Missing required parameter: title"}, + status=400, + ) + + normalized_service = validate_service(service_tag) + if not normalized_service: + return web.json_response( + {"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400 + ) + + try: + profile = data.get("profile") + + service_config_path = Services.get_path(normalized_service) / config.filenames.config + if service_config_path.exists(): + service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8")) + else: + service_config = {} + merge_dict(config.services.get(normalized_service), service_config) + + @click.command() + @click.pass_context + def dummy_service(ctx: click.Context) -> None: + pass + + proxy_param = data.get("proxy") + no_proxy = data.get("no_proxy", False) + + if proxy_param and not no_proxy: + import re + if not re.match(r"^https?://", proxy_param): + return web.json_response({ + "status": "error", + "error_code": "INVALID_PROXY", + "message": "Proxy must be a fully resolved URL" + }, status=400) + + # Create CDM proxy from client-provided info + cdm_info = data.get("cdm_info") or {"type": "widevine", "security_level": 3} + cdm = CDMProxy(cdm_info) + + ctx = click.Context(dummy_service) + ctx.obj = ContextData(config=service_config, cdm=cdm, proxy_providers=[], profile=profile) + ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy} + + service_module = Services.load(normalized_service) + + dummy_service.name = normalized_service + dummy_service.params = [click.Argument([title], type=str)] + ctx.invoked_subcommand = normalized_service + + service_ctx = click.Context(dummy_service, parent=ctx) + service_ctx.obj = ctx.obj + + service_kwargs = {"title": title} + + for key, value in data.items(): + if key not in ["title", "title_id", "url", "profile", "proxy", "no_proxy", "cdm_info"]: + service_kwargs[key] = value + + service_init_params = inspect.signature(service_module.__init__).parameters + + if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"): + for param in service_module.cli.params: + if hasattr(param, "name") and param.name not in service_kwargs: + if hasattr(param, "default") and param.default is not None: + service_kwargs[param.name] = param.default + + for param_name, param_info in service_init_params.items(): + if param_name not in service_kwargs and param_name not in ["self", "ctx"]: + if param_info.default is inspect.Parameter.empty: + if param_name == "meta_lang": + service_kwargs[param_name] = None + elif param_name == "movie": + service_kwargs[param_name] = False + + filtered_kwargs = {k: v for k, v in service_kwargs.items() if k in service_init_params} + + service_instance = service_module(service_ctx, **filtered_kwargs) + + # Authenticate + cookies, credential, pre_authenticated_session, session_error = get_auth_from_request(data, normalized_service, profile) + + if session_error == "SESSION_EXPIRED": + return web.json_response({ + "status": "error", + "error_code": "SESSION_EXPIRED", + "message": f"Session expired for {normalized_service}. Please re-authenticate." + }, status=401) + + try: + if pre_authenticated_session: + deserialize_session(pre_authenticated_session, service_instance.session) + else: + if not cookies and not credential: + return web.json_response({ + "status": "error", + "error_code": "AUTH_REQUIRED", + "message": f"Authentication required for {normalized_service}" + }, status=401) + service_instance.authenticate(cookies, credential) + except Exception as e: + log.error(f"Authentication failed: {e}") + return web.json_response({ + "status": "error", + "error_code": "AUTH_REQUIRED", + "message": f"Authentication failed for {normalized_service}" + }, status=401) + + # Get titles + titles = service_instance.get_titles() + + if hasattr(titles, "__iter__") and not isinstance(titles, str): + titles_list = list(titles) + else: + titles_list = [titles] if titles else [] + + if not titles_list: + return web.json_response({"status": "error", "message": "No titles found"}, status=404) + + # Handle episode filtering (wanted parameter) + wanted_param = data.get("wanted") + season = data.get("season") + episode = data.get("episode") + target_title = None + + if wanted_param or (season is not None and episode is not None): + # Filter to matching episode + wanted = None + if wanted_param: + from unshackle.core.utils.click_types import SeasonRange + try: + season_range = SeasonRange() + wanted = season_range.parse_tokens(wanted_param) + except Exception: + pass + elif season is not None and episode is not None: + wanted = [f"{season}x{episode}"] + + if wanted: + for t in titles_list: + if isinstance(t, Episode): + episode_key = f"{t.season}x{t.number}" + if episode_key in wanted: + target_title = t + break + + if not target_title: + target_title = titles_list[0] + + # Now we need to get the manifest URL + # This is service-specific, so we call get_tracks but extract manifest info + + # Call get_tracks to populate playback_data + try: + _ = service_instance.get_tracks(target_title) + except Exception as e: + log.warning(f"get_tracks failed, trying to extract manifest anyway: {e}") + + # Extract manifest URL from service's playback_data + manifest_url = None + manifest_type = "hls" # Default + playback_data = {} + + # Check for playback_data (DSNP, HMAX, etc.) + if hasattr(service_instance, "playback_data"): + title_id = target_title.id if hasattr(target_title, "id") else str(target_title) + if title_id in service_instance.playback_data: + playback_data = service_instance.playback_data[title_id] + + # Try to extract manifest URL from common patterns + # Pattern 1: DSNP style - stream.sources[0].complete.url + if "stream" in playback_data and "sources" in playback_data["stream"]: + sources = playback_data["stream"]["sources"] + if sources and "complete" in sources[0]: + manifest_url = sources[0]["complete"].get("url") + + # Pattern 2: Direct manifest_url field + if not manifest_url and "manifest_url" in playback_data: + manifest_url = playback_data["manifest_url"] + + # Pattern 3: url field at top level + if not manifest_url and "url" in playback_data: + manifest_url = playback_data["url"] + + # Check for manifest attribute on service + if not manifest_url and hasattr(service_instance, "manifest"): + manifest_url = service_instance.manifest + + # Check for manifest_url attribute on service + if not manifest_url and hasattr(service_instance, "manifest_url"): + manifest_url = service_instance.manifest_url + + # Detect manifest type from URL + if manifest_url: + if manifest_url.endswith(".mpd") or "dash" in manifest_url.lower(): + manifest_type = "dash" + elif manifest_url.endswith(".m3u8") or manifest_url.endswith(".m3u"): + manifest_type = "hls" + + # Serialize session + session_data = serialize_session(service_instance.session) + + # Serialize title info + title_info = serialize_title(target_title) + + response_data = { + "status": "success", + "title": title_info, + "manifest_url": manifest_url, + "manifest_type": manifest_type, + "playback_data": playback_data, + "session": session_data, + } + + return web.json_response(response_data) + + except Exception: + log.exception("Error getting remote manifest") + return web.json_response({"status": "error", "message": "Internal server error while getting manifest"}, status=500) + + async def remote_get_chapters(request: web.Request) -> web.Response: """ Get chapters from a remote service. diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index a458dd6..d8ce5a4 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -9,8 +9,8 @@ from unshackle.core.api.errors import APIError, APIErrorCode, build_error_respon from unshackle.core.api.handlers import (cancel_download_job_handler, download_handler, get_download_job_handler, list_download_jobs_handler, list_titles_handler, list_tracks_handler) from unshackle.core.api.remote_handlers import (remote_decrypt, remote_get_chapters, remote_get_license, - remote_get_titles, remote_get_tracks, remote_list_services, - remote_search) + remote_get_manifest, remote_get_titles, remote_get_tracks, + remote_list_services, remote_search) from unshackle.core.services import Services from unshackle.core.update_checker import UpdateChecker @@ -738,6 +738,7 @@ def setup_routes(app: web.Application) -> None: app.router.add_post("/api/remote/{service}/search", remote_search) app.router.add_post("/api/remote/{service}/titles", remote_get_titles) app.router.add_post("/api/remote/{service}/tracks", remote_get_tracks) + app.router.add_post("/api/remote/{service}/manifest", remote_get_manifest) app.router.add_post("/api/remote/{service}/chapters", remote_get_chapters) app.router.add_post("/api/remote/{service}/license", remote_get_license) app.router.add_post("/api/remote/{service}/decrypt", remote_decrypt) @@ -771,6 +772,7 @@ def setup_swagger(app: web.Application) -> None: web.post("/api/remote/{service}/search", remote_search), web.post("/api/remote/{service}/titles", remote_get_titles), web.post("/api/remote/{service}/tracks", remote_get_tracks), + web.post("/api/remote/{service}/manifest", remote_get_manifest), web.post("/api/remote/{service}/chapters", remote_get_chapters), web.post("/api/remote/{service}/license", remote_get_license), web.post("/api/remote/{service}/decrypt", remote_decrypt), diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 19545c2..153d7ce 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -105,8 +105,6 @@ class Config: self.debug: bool = kwargs.get("debug", False) self.debug_keys: bool = kwargs.get("debug_keys", False) - self.remote_services: list[dict] = kwargs.get("remote_services") or [] - @classmethod def from_yaml(cls, path: Path) -> Config: if not path.exists(): diff --git a/unshackle/core/crypto.py b/unshackle/core/crypto.py deleted file mode 100644 index 4bba793..0000000 --- a/unshackle/core/crypto.py +++ /dev/null @@ -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", -] diff --git a/unshackle/core/local_session_cache.py b/unshackle/core/local_session_cache.py deleted file mode 100644 index ae54ade..0000000 --- a/unshackle/core/local_session_cache.py +++ /dev/null @@ -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"] diff --git a/unshackle/core/remote_auth.py b/unshackle/core/remote_auth.py deleted file mode 100644 index d852578..0000000 --- a/unshackle/core/remote_auth.py +++ /dev/null @@ -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"] diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py deleted file mode 100644 index 23d178c..0000000 --- a/unshackle/core/remote_service.py +++ /dev/null @@ -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 diff --git a/unshackle/core/remote_services.py b/unshackle/core/remote_services.py deleted file mode 100644 index cca45a3..0000000 --- a/unshackle/core/remote_services.py +++ /dev/null @@ -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") diff --git a/unshackle/core/services.py b/unshackle/core/services.py index 97f64bf..14b7dc9 100644 --- a/unshackle/core/services.py +++ b/unshackle/core/services.py @@ -25,17 +25,6 @@ class Services(click.MultiCommand): # Click-specific methods - @staticmethod - def _get_remote_services(): - """Get remote services from the manager (lazy import to avoid circular dependency).""" - try: - from unshackle.core.remote_services import get_remote_service_manager - - manager = get_remote_service_manager() - return manager.get_all_services() - except Exception: - return {} - def list_commands(self, ctx: click.Context) -> list[str]: """Returns a list of all available Services as command names for Click.""" return Services.get_tags() @@ -62,25 +51,14 @@ class Services(click.MultiCommand): @staticmethod def get_tags() -> list[str]: - """Returns a list of service tags from all available Services (local + remote).""" - local_tags = [x.parent.stem for x in _SERVICES] - remote_services = Services._get_remote_services() - remote_tags = list(remote_services.keys()) - return local_tags + remote_tags + """Returns a list of service tags from all available Services.""" + return [x.parent.stem for x in _SERVICES] @staticmethod def get_path(name: str) -> Path: """Get the directory path of a command.""" tag = Services.get_tag(name) - # Check if it's a remote service - remote_services = Services._get_remote_services() - if tag in remote_services: - # Remote services don't have local paths - # Return a dummy path or raise an appropriate error - # For now, we'll raise KeyError to indicate no path exists - raise KeyError(f"Remote service '{tag}' has no local path") - for service in _SERVICES: if service.parent.stem == tag: return service.parent @@ -96,36 +74,20 @@ class Services(click.MultiCommand): original_value = value value = value.lower() - # Check local services for path in _SERVICES: tag = path.parent.stem if value in (tag.lower(), *_ALIASES.get(tag, [])): return tag - # Check remote services - remote_services = Services._get_remote_services() - for tag, service_class in remote_services.items(): - if value == tag.lower(): - return tag - if hasattr(service_class, "ALIASES"): - if value in (alias.lower() for alias in service_class.ALIASES): - return tag - return original_value @staticmethod def load(tag: str) -> Service: - """Load a Service module by Service tag (local or remote).""" - # Check local services first + """Load a Service module by Service tag.""" module = _MODULES.get(tag) if module: return module - # Check remote services - remote_services = Services._get_remote_services() - if tag in remote_services: - return remote_services[tag] - raise KeyError(f"There is no Service added by the Tag '{tag}'")