Files
unshackle/unshackle/core/remote_service.py
Andy 965482a1e4 feat: merge upstream dev branch
- Add Gluetun dynamic VPN-to-HTTP proxy provider
   - Add remote services and authentication system
   - Add country code utilities
   - Add Docker binary detection
   - Update proxy providers
2025-11-25 20:23:06 +00:00

594 lines
22 KiB
Python

"""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