forked from kenzuya/unshackle
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
This commit is contained in:
159
docs/GLUETUN.md
Normal file
159
docs/GLUETUN.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Gluetun VPN Proxy
|
||||
|
||||
Gluetun provides Docker-managed VPN proxies supporting 50+ VPN providers.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Docker must be installed and running.**
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $USER # Then log out/in
|
||||
|
||||
# Windows/Mac
|
||||
# Install Docker Desktop: https://www.docker.com/products/docker-desktop/
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Configuration
|
||||
|
||||
Add to `~/.config/unshackle/unshackle.yaml`:
|
||||
|
||||
```yaml
|
||||
proxy_providers:
|
||||
gluetun:
|
||||
providers:
|
||||
nordvpn:
|
||||
vpn_type: wireguard
|
||||
credentials:
|
||||
private_key: YOUR_PRIVATE_KEY
|
||||
```
|
||||
|
||||
### 2. Usage
|
||||
|
||||
Use 2-letter country codes directly:
|
||||
|
||||
```bash
|
||||
uv run unshackle dl SERVICE CONTENT --proxy gluetun:nordvpn:us
|
||||
uv run unshackle dl SERVICE CONTENT --proxy gluetun:nordvpn:uk
|
||||
```
|
||||
|
||||
Format: `gluetun:provider:region`
|
||||
|
||||
## Provider Credential Requirements
|
||||
|
||||
Each provider has different credential requirements. See the [Gluetun Wiki](https://github.com/qdm12/gluetun-wiki/tree/main/setup/providers) for complete details.
|
||||
|
||||
| Provider | VPN Type | Required Credentials |
|
||||
|----------|----------|---------------------|
|
||||
| NordVPN | WireGuard | `private_key` only |
|
||||
| ProtonVPN | WireGuard | `private_key` only |
|
||||
| Windscribe | WireGuard | `private_key`, `addresses`, `preshared_key` (all required) |
|
||||
| Surfshark | WireGuard | `private_key`, `addresses` |
|
||||
| Mullvad | WireGuard | `private_key`, `addresses` |
|
||||
| IVPN | WireGuard | `private_key`, `addresses` |
|
||||
| ExpressVPN | OpenVPN | `username`, `password` (no WireGuard support) |
|
||||
| Any | OpenVPN | `username`, `password` |
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
**NordVPN/ProtonVPN** (only private_key needed):
|
||||
```yaml
|
||||
providers:
|
||||
nordvpn:
|
||||
vpn_type: wireguard
|
||||
credentials:
|
||||
private_key: YOUR_PRIVATE_KEY
|
||||
```
|
||||
|
||||
**Windscribe** (all three credentials required):
|
||||
```yaml
|
||||
providers:
|
||||
windscribe:
|
||||
vpn_type: wireguard
|
||||
credentials:
|
||||
private_key: YOUR_PRIVATE_KEY
|
||||
addresses: 10.x.x.x/32
|
||||
preshared_key: YOUR_PRESHARED_KEY # Required, can be empty string
|
||||
```
|
||||
|
||||
**OpenVPN** (any provider):
|
||||
```yaml
|
||||
providers:
|
||||
expressvpn:
|
||||
vpn_type: openvpn
|
||||
credentials:
|
||||
username: YOUR_USERNAME
|
||||
password: YOUR_PASSWORD
|
||||
```
|
||||
|
||||
## Server Selection
|
||||
|
||||
Most providers use `SERVER_COUNTRIES`, but some use `SERVER_REGIONS`:
|
||||
|
||||
| Variable | Providers |
|
||||
|----------|-----------|
|
||||
| `SERVER_COUNTRIES` | NordVPN, ProtonVPN, Surfshark, Mullvad, ExpressVPN, and most others |
|
||||
| `SERVER_REGIONS` | Windscribe, VyprVPN, VPN Secure |
|
||||
|
||||
Unshackle handles this automatically - just use 2-letter country codes.
|
||||
|
||||
## Global Settings
|
||||
|
||||
```yaml
|
||||
proxy_providers:
|
||||
gluetun:
|
||||
providers: {...}
|
||||
base_port: 8888 # Starting port (default: 8888)
|
||||
auto_cleanup: true # Remove containers on exit (default: true)
|
||||
verify_ip: true # Verify IP matches region (default: true)
|
||||
container_prefix: "unshackle-gluetun"
|
||||
auth_user: username # Proxy auth (optional)
|
||||
auth_password: password
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Container Reuse**: First request takes 10-30s; subsequent requests are instant
|
||||
- **IP Verification**: Automatically verifies VPN exit IP matches requested region
|
||||
- **Concurrent Sessions**: Multiple downloads share the same container
|
||||
- **Specific Servers**: Use `--proxy gluetun:nordvpn:us1239` for specific server selection
|
||||
|
||||
## Container Management
|
||||
|
||||
```bash
|
||||
# View containers
|
||||
docker ps | grep unshackle-gluetun
|
||||
|
||||
# Check logs
|
||||
docker logs unshackle-gluetun-nordvpn-us
|
||||
|
||||
# Remove all containers
|
||||
docker ps -a | grep unshackle-gluetun | awk '{print $1}' | xargs docker rm -f
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Docker Permission Denied (Linux)
|
||||
```bash
|
||||
sudo usermod -aG docker $USER
|
||||
# Then log out and log back in
|
||||
```
|
||||
|
||||
### VPN Connection Failed
|
||||
Check container logs for specific errors:
|
||||
```bash
|
||||
docker logs unshackle-gluetun-nordvpn-us
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- Invalid/missing credentials
|
||||
- Windscribe requires `preshared_key` (can be empty string)
|
||||
- VPN provider server issues
|
||||
|
||||
## Resources
|
||||
|
||||
- [Gluetun Wiki](https://github.com/qdm12/gluetun-wiki) - Official provider documentation
|
||||
- [Gluetun GitHub](https://github.com/qdm12/gluetun)
|
||||
@@ -62,6 +62,7 @@ dependencies = [
|
||||
"aiohttp-swagger3>=0.9.0,<1",
|
||||
"pysubs2>=1.7.0,<2",
|
||||
"PyExecJS>=1.5.1,<2",
|
||||
"pycountry>=24.6.1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -48,7 +48,7 @@ from unshackle.core.constants import DOWNLOAD_LICENCE_ONLY, AnyTrack, context_se
|
||||
from unshackle.core.credential import Credential
|
||||
from unshackle.core.drm import DRM_T, PlayReady, Widevine
|
||||
from unshackle.core.events import events
|
||||
from unshackle.core.proxies import Basic, Hola, NordVPN, SurfsharkVPN, WindscribeVPN
|
||||
from unshackle.core.proxies import Basic, Gluetun, Hola, NordVPN, SurfsharkVPN, WindscribeVPN
|
||||
from unshackle.core.service import Service
|
||||
from unshackle.core.services import Services
|
||||
from unshackle.core.title_cacher import get_account_hash
|
||||
@@ -261,13 +261,6 @@ class dl:
|
||||
default=None,
|
||||
help="Wanted episodes, e.g. `S01-S05,S07`, `S01E01-S02E03`, `S02-S02E03`, e.t.c, defaults to all.",
|
||||
)
|
||||
@click.option(
|
||||
"-le",
|
||||
"--latest-episode",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Download only the single most recent episode available.",
|
||||
)
|
||||
@click.option(
|
||||
"-l",
|
||||
"--lang",
|
||||
@@ -275,6 +268,12 @@ class dl:
|
||||
default="orig",
|
||||
help="Language wanted for Video and Audio. Use 'orig' to select the original language, e.g. 'orig,en' for both original and English.",
|
||||
)
|
||||
@click.option(
|
||||
"--latest-episode",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Download only the single most recent episode available.",
|
||||
)
|
||||
@click.option(
|
||||
"-vl",
|
||||
"--v-lang",
|
||||
@@ -665,6 +664,8 @@ class dl:
|
||||
self.proxy_providers.append(SurfsharkVPN(**config.proxy_providers["surfsharkvpn"]))
|
||||
if config.proxy_providers.get("windscribevpn"):
|
||||
self.proxy_providers.append(WindscribeVPN(**config.proxy_providers["windscribevpn"]))
|
||||
if config.proxy_providers.get("gluetun"):
|
||||
self.proxy_providers.append(Gluetun(**config.proxy_providers["gluetun"]))
|
||||
if binaries.HolaProxy:
|
||||
self.proxy_providers.append(Hola())
|
||||
for proxy_provider in self.proxy_providers:
|
||||
@@ -675,7 +676,8 @@ class dl:
|
||||
if re.match(r"^[a-z]+:.+$", proxy, re.IGNORECASE):
|
||||
# requesting proxy from a specific proxy provider
|
||||
requested_provider, proxy = proxy.split(":", maxsplit=1)
|
||||
if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE):
|
||||
# Match simple region codes (us, ca, uk1) or provider:region format (nordvpn:ca, windscribe:us)
|
||||
if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE) or re.match(r"^[a-z]+:[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE):
|
||||
proxy = proxy.lower()
|
||||
with console.status(f"Getting a Proxy to {proxy}...", spinner="dots"):
|
||||
if requested_provider:
|
||||
@@ -699,8 +701,14 @@ class dl:
|
||||
proxy = ctx.params["proxy"] = proxy_uri
|
||||
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
||||
break
|
||||
# Store proxy query info for service-specific overrides
|
||||
ctx.params["proxy_query"] = proxy
|
||||
ctx.params["proxy_provider"] = requested_provider
|
||||
else:
|
||||
self.log.info(f"Using explicit Proxy: {proxy}")
|
||||
# For explicit proxies, store None for query/provider
|
||||
ctx.params["proxy_query"] = None
|
||||
ctx.params["proxy_provider"] = None
|
||||
|
||||
ctx.obj = ContextData(
|
||||
config=self.service_config, cdm=self.cdm, proxy_providers=self.proxy_providers, profile=self.profile
|
||||
|
||||
@@ -97,6 +97,7 @@ def check() -> None:
|
||||
"cat": "Network",
|
||||
},
|
||||
{"name": "Caddy", "binary": binaries.Caddy, "required": False, "desc": "Web server", "cat": "Network"},
|
||||
{"name": "Docker", "binary": binaries.Docker, "required": False, "desc": "Gluetun VPN", "cat": "Network"},
|
||||
]
|
||||
|
||||
# Track overall status
|
||||
|
||||
225
unshackle/commands/remote_auth.py
Normal file
225
unshackle/commands/remote_auth.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""CLI command for authenticating remote services."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
from rich.table import Table
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.constants import context_settings
|
||||
from unshackle.core.remote_auth import RemoteAuthenticator
|
||||
|
||||
|
||||
@click.group(short_help="Manage remote service authentication.", context_settings=context_settings)
|
||||
def remote_auth() -> None:
|
||||
"""Authenticate and manage sessions for remote services."""
|
||||
pass
|
||||
|
||||
|
||||
@remote_auth.command(name="authenticate")
|
||||
@click.argument("service", type=str)
|
||||
@click.option(
|
||||
"-r", "--remote", type=str, help="Remote server name or URL (from config)", required=False
|
||||
)
|
||||
@click.option("-p", "--profile", type=str, help="Profile to use for authentication")
|
||||
def authenticate_command(service: str, remote: Optional[str], profile: Optional[str]) -> None:
|
||||
"""
|
||||
Authenticate a service locally and upload session to remote server.
|
||||
|
||||
This command:
|
||||
1. Authenticates the service locally (shows browser, handles 2FA, etc.)
|
||||
2. Extracts the authenticated session
|
||||
3. Uploads the session to the remote server
|
||||
|
||||
The server will use this pre-authenticated session for all requests.
|
||||
|
||||
Examples:
|
||||
unshackle remote-auth authenticate DSNP
|
||||
unshackle remote-auth authenticate NF --profile john
|
||||
unshackle remote-auth auth AMZN --remote my-server
|
||||
"""
|
||||
# Get remote server config
|
||||
remote_config = _get_remote_config(remote)
|
||||
if not remote_config:
|
||||
return
|
||||
|
||||
remote_url = remote_config["url"]
|
||||
api_key = remote_config["api_key"]
|
||||
server_name = remote_config.get("name", remote_url)
|
||||
|
||||
console.print(f"\n[bold cyan]Authenticating {service} for remote server:[/bold cyan] {server_name}")
|
||||
console.print(f"[dim]Server: {remote_url}[/dim]\n")
|
||||
|
||||
# Create authenticator
|
||||
authenticator = RemoteAuthenticator(remote_url, api_key)
|
||||
|
||||
# Authenticate and save locally
|
||||
success = authenticator.authenticate_and_save(service, profile)
|
||||
|
||||
if success:
|
||||
console.print(f"\n[bold green]✓ Success![/bold green] Session saved locally. You can now use remote_{service} service.")
|
||||
else:
|
||||
console.print(f"\n[bold red]✗ Failed to authenticate {service}[/bold red]")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@remote_auth.command(name="status")
|
||||
@click.option(
|
||||
"-r", "--remote", type=str, help="Remote server name or URL (from config)", required=False
|
||||
)
|
||||
def status_command(remote: Optional[str]) -> None:
|
||||
"""
|
||||
Show status of all authenticated sessions in local cache.
|
||||
|
||||
Examples:
|
||||
unshackle remote-auth status
|
||||
unshackle remote-auth status --remote my-server
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from unshackle.core.local_session_cache import get_local_session_cache
|
||||
|
||||
# Get local session cache
|
||||
cache = get_local_session_cache()
|
||||
|
||||
# Get remote server config (optional filter)
|
||||
remote_url = None
|
||||
if remote:
|
||||
remote_config = _get_remote_config(remote)
|
||||
if remote_config:
|
||||
remote_url = remote_config["url"]
|
||||
server_name = remote_config.get("name", remote_url)
|
||||
else:
|
||||
server_name = "All Remotes"
|
||||
|
||||
# Get sessions (filtered by remote if specified)
|
||||
sessions = cache.list_sessions(remote_url)
|
||||
|
||||
if not sessions:
|
||||
if remote_url:
|
||||
console.print(f"\n[yellow]No authenticated sessions for {server_name}[/yellow]")
|
||||
else:
|
||||
console.print("\n[yellow]No authenticated sessions in local cache[/yellow]")
|
||||
console.print("\nUse [cyan]unshackle remote-auth authenticate <SERVICE>[/cyan] to add sessions")
|
||||
return
|
||||
|
||||
# Display sessions in table
|
||||
table = Table(title=f"Local Authenticated Sessions - {server_name}")
|
||||
table.add_column("Remote", style="magenta")
|
||||
table.add_column("Service", style="cyan")
|
||||
table.add_column("Profile", style="green")
|
||||
table.add_column("Cached", style="dim")
|
||||
table.add_column("Age", style="yellow")
|
||||
table.add_column("Status", style="bold")
|
||||
|
||||
for session in sessions:
|
||||
cached_time = datetime.datetime.fromtimestamp(session["cached_at"]).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# Format age
|
||||
age_seconds = session["age_seconds"]
|
||||
if age_seconds < 3600:
|
||||
age_str = f"{age_seconds // 60}m"
|
||||
elif age_seconds < 86400:
|
||||
age_str = f"{age_seconds // 3600}h"
|
||||
else:
|
||||
age_str = f"{age_seconds // 86400}d"
|
||||
|
||||
# Status
|
||||
status = "[red]Expired" if session["expired"] else "[green]Valid"
|
||||
|
||||
# Short remote URL for display
|
||||
remote_display = session["remote_url"].replace("https://", "").replace("http://", "")
|
||||
if len(remote_display) > 30:
|
||||
remote_display = remote_display[:27] + "..."
|
||||
|
||||
table.add_row(
|
||||
remote_display,
|
||||
session["service_tag"],
|
||||
session["profile"],
|
||||
cached_time,
|
||||
age_str,
|
||||
status
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print("\n[dim]Sessions are stored locally and expire after 24 hours[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
@remote_auth.command(name="delete")
|
||||
@click.argument("service", type=str)
|
||||
@click.option(
|
||||
"-r", "--remote", type=str, help="Remote server name or URL (from config)", required=False
|
||||
)
|
||||
@click.option("-p", "--profile", type=str, default="default", help="Profile name")
|
||||
def delete_command(service: str, remote: Optional[str], profile: str) -> None:
|
||||
"""
|
||||
Delete an authenticated session from local cache.
|
||||
|
||||
Examples:
|
||||
unshackle remote-auth delete DSNP
|
||||
unshackle remote-auth delete NF --profile john
|
||||
"""
|
||||
from unshackle.core.local_session_cache import get_local_session_cache
|
||||
|
||||
# Get remote server config
|
||||
remote_config = _get_remote_config(remote)
|
||||
if not remote_config:
|
||||
return
|
||||
|
||||
remote_url = remote_config["url"]
|
||||
|
||||
cache = get_local_session_cache()
|
||||
|
||||
console.print(f"\n[yellow]Deleting local session for {service} (profile: {profile})...[/yellow]")
|
||||
|
||||
deleted = cache.delete_session(remote_url, service, profile)
|
||||
|
||||
if deleted:
|
||||
console.print("[green]✓ Session deleted from local cache[/green]")
|
||||
else:
|
||||
console.print(f"[red]✗ No session found for {service} (profile: {profile})[/red]")
|
||||
|
||||
|
||||
def _get_remote_config(remote: Optional[str]) -> Optional[dict]:
|
||||
"""
|
||||
Get remote server configuration.
|
||||
|
||||
Args:
|
||||
remote: Remote server name or URL, or None for first configured remote
|
||||
|
||||
Returns:
|
||||
Remote config dict or None
|
||||
"""
|
||||
if not config.remote_services:
|
||||
console.print("[red]No remote services configured in unshackle.yaml[/red]")
|
||||
console.print("\nAdd a remote service to your config:")
|
||||
console.print("[dim]remote_services:")
|
||||
console.print(" - url: https://your-server.com")
|
||||
console.print(" api_key: your-api-key")
|
||||
console.print(" name: my-server[/dim]")
|
||||
return None
|
||||
|
||||
# If no remote specified, use the first one
|
||||
if not remote:
|
||||
return config.remote_services[0]
|
||||
|
||||
# Check if remote is a name
|
||||
for remote_config in config.remote_services:
|
||||
if remote_config.get("name") == remote:
|
||||
return remote_config
|
||||
|
||||
# Check if remote is a URL
|
||||
for remote_config in config.remote_services:
|
||||
if remote_config.get("url") == remote:
|
||||
return remote_config
|
||||
|
||||
console.print(f"[red]Remote server '{remote}' not found in config[/red]")
|
||||
console.print("\nAvailable remotes:")
|
||||
for remote_config in config.remote_services:
|
||||
name = remote_config.get("name", remote_config.get("url"))
|
||||
console.print(f" - {name}")
|
||||
|
||||
return None
|
||||
@@ -16,7 +16,7 @@ from unshackle.core import binaries
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.constants import context_settings
|
||||
from unshackle.core.proxies import Basic, Hola, NordVPN, SurfsharkVPN
|
||||
from unshackle.core.proxies import Basic, Gluetun, Hola, NordVPN, SurfsharkVPN, WindscribeVPN
|
||||
from unshackle.core.service import Service
|
||||
from unshackle.core.services import Services
|
||||
from unshackle.core.utils.click_types import ContextData
|
||||
@@ -71,6 +71,10 @@ def search(ctx: click.Context, no_proxy: bool, profile: Optional[str] = None, pr
|
||||
proxy_providers.append(NordVPN(**config.proxy_providers["nordvpn"]))
|
||||
if config.proxy_providers.get("surfsharkvpn"):
|
||||
proxy_providers.append(SurfsharkVPN(**config.proxy_providers["surfsharkvpn"]))
|
||||
if config.proxy_providers.get("windscribevpn"):
|
||||
proxy_providers.append(WindscribeVPN(**config.proxy_providers["windscribevpn"]))
|
||||
if config.proxy_providers.get("gluetun"):
|
||||
proxy_providers.append(Gluetun(**config.proxy_providers["gluetun"]))
|
||||
if binaries.HolaProxy:
|
||||
proxy_providers.append(Hola())
|
||||
for proxy_provider in proxy_providers:
|
||||
@@ -81,7 +85,8 @@ def search(ctx: click.Context, no_proxy: bool, profile: Optional[str] = None, pr
|
||||
if re.match(r"^[a-z]+:.+$", proxy, re.IGNORECASE):
|
||||
# requesting proxy from a specific proxy provider
|
||||
requested_provider, proxy = proxy.split(":", maxsplit=1)
|
||||
if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE):
|
||||
# Match simple region codes (us, ca, uk1) or provider:region format (nordvpn:ca, windscribe:us)
|
||||
if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE) or re.match(r"^[a-z]+:[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE):
|
||||
proxy = proxy.lower()
|
||||
with console.status(f"Getting a Proxy to {proxy}...", spinner="dots"):
|
||||
if requested_provider:
|
||||
|
||||
@@ -24,7 +24,13 @@ from unshackle.core.constants import context_settings
|
||||
default=False,
|
||||
help="Include technical debug information (tracebacks, stderr) in API error responses.",
|
||||
)
|
||||
def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug_api: bool) -> None:
|
||||
@click.option(
|
||||
"--debug",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Enable debug logging for API operations.",
|
||||
)
|
||||
def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug_api: bool, debug: bool) -> None:
|
||||
"""
|
||||
Serve your Local Widevine Devices and REST API for Remote Access.
|
||||
|
||||
@@ -39,12 +45,60 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug
|
||||
|
||||
\b
|
||||
The REST API provides programmatic access to unshackle functionality.
|
||||
Configure authentication in your config under serve.users and serve.api_secret.
|
||||
Configure authentication in your config under serve.api_secret and serve.api_keys.
|
||||
|
||||
\b
|
||||
API KEY TIERS:
|
||||
Premium API keys can use server-side CDM for decryption. Configure in unshackle.yaml:
|
||||
|
||||
\b
|
||||
serve:
|
||||
api_secret: "your-api-secret"
|
||||
api_keys:
|
||||
- key: "basic-user-key"
|
||||
tier: "basic"
|
||||
allowed_cdms: []
|
||||
- key: "premium-user-key"
|
||||
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
|
||||
|
||||
log = logging.getLogger("serve")
|
||||
|
||||
# Configure logging level based on --debug flag
|
||||
if debug:
|
||||
logging.basicConfig(level=logging.DEBUG, format="%(name)s - %(levelname)s - %(message)s")
|
||||
log.info("Debug logging enabled for API operations")
|
||||
else:
|
||||
# Set API loggers to WARNING to reduce noise unless --debug is used
|
||||
logging.getLogger("api").setLevel(logging.WARNING)
|
||||
logging.getLogger("api.remote").setLevel(logging.WARNING)
|
||||
|
||||
# Validate API secret for REST API routes (unless --no-key is used)
|
||||
if not no_key:
|
||||
api_secret = config.serve.get("api_secret")
|
||||
|
||||
137
unshackle/core/api/api_keys.py
Normal file
137
unshackle/core/api/api_keys.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""API key tier management for remote services."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
log = logging.getLogger("api.keys")
|
||||
|
||||
|
||||
def get_api_key_from_request(request: web.Request) -> Optional[str]:
|
||||
"""
|
||||
Extract API key from request headers.
|
||||
|
||||
Args:
|
||||
request: aiohttp request object
|
||||
|
||||
Returns:
|
||||
API key string or None
|
||||
"""
|
||||
return request.headers.get("X-API-Key") or request.headers.get("Authorization", "").replace("Bearer ", "")
|
||||
|
||||
|
||||
def get_api_key_config(app: web.Application, api_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get configuration for a specific API key.
|
||||
|
||||
Args:
|
||||
app: aiohttp application
|
||||
api_key: API key to look up
|
||||
|
||||
Returns:
|
||||
API key configuration dict or None if not found
|
||||
"""
|
||||
config = app.get("config", {})
|
||||
|
||||
# Check new-style tiered API keys
|
||||
api_keys = config.get("api_keys", [])
|
||||
for key_config in api_keys:
|
||||
if isinstance(key_config, dict) and key_config.get("key") == api_key:
|
||||
return key_config
|
||||
|
||||
# Check legacy users list (backward compatibility)
|
||||
users = config.get("users", [])
|
||||
if api_key in users:
|
||||
return {
|
||||
"key": api_key,
|
||||
"tier": "basic",
|
||||
"allowed_cdms": []
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_premium_user(app: web.Application, api_key: str) -> bool:
|
||||
"""
|
||||
Check if an API key belongs to a premium user.
|
||||
|
||||
Premium users can use server-side CDM for decryption.
|
||||
|
||||
Args:
|
||||
app: aiohttp application
|
||||
api_key: API key to check
|
||||
|
||||
Returns:
|
||||
True if premium user, False otherwise
|
||||
"""
|
||||
key_config = get_api_key_config(app, api_key)
|
||||
if not key_config:
|
||||
return False
|
||||
|
||||
tier = key_config.get("tier", "basic")
|
||||
return tier == "premium"
|
||||
|
||||
|
||||
def get_allowed_cdms(app: web.Application, api_key: str) -> List[str]:
|
||||
"""
|
||||
Get list of CDMs that an API key is allowed to use.
|
||||
|
||||
Args:
|
||||
app: aiohttp application
|
||||
api_key: API key to check
|
||||
|
||||
Returns:
|
||||
List of allowed CDM names, or empty list if not premium
|
||||
"""
|
||||
key_config = get_api_key_config(app, api_key)
|
||||
if not key_config:
|
||||
return []
|
||||
|
||||
allowed_cdms = key_config.get("allowed_cdms", [])
|
||||
|
||||
# Handle wildcard
|
||||
if allowed_cdms == "*" or allowed_cdms == ["*"]:
|
||||
return ["*"]
|
||||
|
||||
return allowed_cdms if isinstance(allowed_cdms, list) else []
|
||||
|
||||
|
||||
def get_default_cdm(app: web.Application, api_key: str) -> Optional[str]:
|
||||
"""
|
||||
Get default CDM for an API key.
|
||||
|
||||
Args:
|
||||
app: aiohttp application
|
||||
api_key: API key to check
|
||||
|
||||
Returns:
|
||||
Default CDM name or None
|
||||
"""
|
||||
key_config = get_api_key_config(app, api_key)
|
||||
if not key_config:
|
||||
return None
|
||||
|
||||
return key_config.get("default_cdm")
|
||||
|
||||
|
||||
def can_use_cdm(app: web.Application, api_key: str, cdm_name: str) -> bool:
|
||||
"""
|
||||
Check if an API key can use a specific CDM.
|
||||
|
||||
Args:
|
||||
app: aiohttp application
|
||||
api_key: API key to check
|
||||
cdm_name: CDM name to check access for
|
||||
|
||||
Returns:
|
||||
True if allowed, False otherwise
|
||||
"""
|
||||
allowed_cdms = get_allowed_cdms(app, api_key)
|
||||
|
||||
# Wildcard access
|
||||
if "*" in allowed_cdms:
|
||||
return True
|
||||
|
||||
# Specific CDM access
|
||||
return cdm_name in allowed_cdms
|
||||
1879
unshackle/core/api/remote_handlers.py
Normal file
1879
unshackle/core/api/remote_handlers.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,9 @@ from unshackle.core import __version__
|
||||
from unshackle.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception
|
||||
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)
|
||||
from unshackle.core.services import Services
|
||||
from unshackle.core.update_checker import UpdateChecker
|
||||
|
||||
@@ -730,6 +733,15 @@ def setup_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/download/jobs/{job_id}", download_job_detail)
|
||||
app.router.add_delete("/api/download/jobs/{job_id}", cancel_download_job)
|
||||
|
||||
# Remote service endpoints
|
||||
app.router.add_get("/api/remote/services", remote_list_services)
|
||||
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}/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)
|
||||
|
||||
|
||||
def setup_swagger(app: web.Application) -> None:
|
||||
"""Setup Swagger UI documentation."""
|
||||
@@ -754,5 +766,13 @@ def setup_swagger(app: web.Application) -> None:
|
||||
web.get("/api/download/jobs", download_jobs),
|
||||
web.get("/api/download/jobs/{job_id}", download_job_detail),
|
||||
web.delete("/api/download/jobs/{job_id}", cancel_download_job),
|
||||
# Remote service routes
|
||||
web.get("/api/remote/services", remote_list_services),
|
||||
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}/chapters", remote_get_chapters),
|
||||
web.post("/api/remote/{service}/license", remote_get_license),
|
||||
web.post("/api/remote/{service}/decrypt", remote_decrypt),
|
||||
]
|
||||
)
|
||||
|
||||
236
unshackle/core/api/session_serializer.py
Normal file
236
unshackle/core/api/session_serializer.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""Session serialization helpers for remote services."""
|
||||
|
||||
from http.cookiejar import CookieJar
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from unshackle.core.credential import Credential
|
||||
|
||||
|
||||
def serialize_session(session: requests.Session) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize a requests.Session into a JSON-serializable dictionary.
|
||||
|
||||
Extracts cookies, headers, and other session data that can be
|
||||
transferred to a remote client for downloading.
|
||||
|
||||
Args:
|
||||
session: The requests.Session to serialize
|
||||
|
||||
Returns:
|
||||
Dictionary containing serialized session data
|
||||
"""
|
||||
session_data = {
|
||||
"cookies": {},
|
||||
"headers": {},
|
||||
"proxies": session.proxies.copy() if session.proxies else {},
|
||||
}
|
||||
|
||||
# Serialize cookies
|
||||
if session.cookies:
|
||||
for cookie in session.cookies:
|
||||
session_data["cookies"][cookie.name] = {
|
||||
"value": cookie.value,
|
||||
"domain": cookie.domain,
|
||||
"path": cookie.path,
|
||||
"secure": cookie.secure,
|
||||
"expires": cookie.expires,
|
||||
}
|
||||
|
||||
# Serialize headers (exclude proxy-authorization for security)
|
||||
if session.headers:
|
||||
for key, value in session.headers.items():
|
||||
# Skip proxy-related headers as they're server-specific
|
||||
if key.lower() not in ["proxy-authorization"]:
|
||||
session_data["headers"][key] = value
|
||||
|
||||
return session_data
|
||||
|
||||
|
||||
def deserialize_session(
|
||||
session_data: Dict[str, Any], target_session: Optional[requests.Session] = None
|
||||
) -> requests.Session:
|
||||
"""
|
||||
Deserialize session data into a requests.Session.
|
||||
|
||||
Applies cookies, headers, and other session data from a remote server
|
||||
to a local session for downloading.
|
||||
|
||||
Args:
|
||||
session_data: Dictionary containing serialized session data
|
||||
target_session: Optional existing session to update (creates new if None)
|
||||
|
||||
Returns:
|
||||
requests.Session with applied session data
|
||||
"""
|
||||
if target_session is None:
|
||||
target_session = requests.Session()
|
||||
|
||||
# Apply cookies
|
||||
if "cookies" in session_data:
|
||||
for cookie_name, cookie_data in session_data["cookies"].items():
|
||||
target_session.cookies.set(
|
||||
name=cookie_name,
|
||||
value=cookie_data["value"],
|
||||
domain=cookie_data.get("domain"),
|
||||
path=cookie_data.get("path", "/"),
|
||||
secure=cookie_data.get("secure", False),
|
||||
expires=cookie_data.get("expires"),
|
||||
)
|
||||
|
||||
# Apply headers
|
||||
if "headers" in session_data:
|
||||
target_session.headers.update(session_data["headers"])
|
||||
|
||||
# Note: We don't apply proxies from remote as the local client
|
||||
# should use its own proxy configuration
|
||||
|
||||
return target_session
|
||||
|
||||
|
||||
def extract_session_tokens(session: requests.Session) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract authentication tokens and similar data from a session.
|
||||
|
||||
Looks for common authentication patterns like Bearer tokens,
|
||||
API keys in headers, etc.
|
||||
|
||||
Args:
|
||||
session: The requests.Session to extract tokens from
|
||||
|
||||
Returns:
|
||||
Dictionary containing extracted tokens
|
||||
"""
|
||||
tokens = {}
|
||||
|
||||
# Check for Authorization header
|
||||
if "Authorization" in session.headers:
|
||||
tokens["authorization"] = session.headers["Authorization"]
|
||||
|
||||
# Check for common API key headers
|
||||
for key in ["X-API-Key", "Api-Key", "X-Auth-Token"]:
|
||||
if key in session.headers:
|
||||
tokens[key.lower().replace("-", "_")] = session.headers[key]
|
||||
|
||||
return tokens
|
||||
|
||||
|
||||
def apply_session_tokens(tokens: Dict[str, Any], target_session: requests.Session) -> None:
|
||||
"""
|
||||
Apply authentication tokens to a session.
|
||||
|
||||
Args:
|
||||
tokens: Dictionary containing tokens to apply
|
||||
target_session: Session to apply tokens to
|
||||
"""
|
||||
# Apply Authorization header
|
||||
if "authorization" in tokens:
|
||||
target_session.headers["Authorization"] = tokens["authorization"]
|
||||
|
||||
# Apply other token headers
|
||||
token_header_map = {
|
||||
"x_api_key": "X-API-Key",
|
||||
"api_key": "Api-Key",
|
||||
"x_auth_token": "X-Auth-Token",
|
||||
}
|
||||
|
||||
for token_key, header_name in token_header_map.items():
|
||||
if token_key in tokens:
|
||||
target_session.headers[header_name] = tokens[token_key]
|
||||
|
||||
|
||||
def serialize_cookies(cookie_jar: Optional[CookieJar]) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize a CookieJar into a JSON-serializable dictionary.
|
||||
|
||||
Args:
|
||||
cookie_jar: The CookieJar to serialize
|
||||
|
||||
Returns:
|
||||
Dictionary containing serialized cookies
|
||||
"""
|
||||
if not cookie_jar:
|
||||
return {}
|
||||
|
||||
cookies = {}
|
||||
for cookie in cookie_jar:
|
||||
cookies[cookie.name] = {
|
||||
"value": cookie.value,
|
||||
"domain": cookie.domain,
|
||||
"path": cookie.path,
|
||||
"secure": cookie.secure,
|
||||
"expires": cookie.expires,
|
||||
}
|
||||
|
||||
return cookies
|
||||
|
||||
|
||||
def deserialize_cookies(cookies_data: Dict[str, Any]) -> CookieJar:
|
||||
"""
|
||||
Deserialize cookies into a CookieJar.
|
||||
|
||||
Args:
|
||||
cookies_data: Dictionary containing serialized cookies
|
||||
|
||||
Returns:
|
||||
CookieJar with cookies
|
||||
"""
|
||||
import http.cookiejar
|
||||
|
||||
cookie_jar = http.cookiejar.CookieJar()
|
||||
|
||||
for cookie_name, cookie_data in cookies_data.items():
|
||||
cookie = http.cookiejar.Cookie(
|
||||
version=0,
|
||||
name=cookie_name,
|
||||
value=cookie_data["value"],
|
||||
port=None,
|
||||
port_specified=False,
|
||||
domain=cookie_data.get("domain", ""),
|
||||
domain_specified=bool(cookie_data.get("domain")),
|
||||
domain_initial_dot=cookie_data.get("domain", "").startswith("."),
|
||||
path=cookie_data.get("path", "/"),
|
||||
path_specified=True,
|
||||
secure=cookie_data.get("secure", False),
|
||||
expires=cookie_data.get("expires"),
|
||||
discard=False,
|
||||
comment=None,
|
||||
comment_url=None,
|
||||
rest={},
|
||||
)
|
||||
cookie_jar.set_cookie(cookie)
|
||||
|
||||
return cookie_jar
|
||||
|
||||
|
||||
def serialize_credential(credential: Optional[Credential]) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Serialize a Credential into a JSON-serializable dictionary.
|
||||
|
||||
Args:
|
||||
credential: The Credential to serialize
|
||||
|
||||
Returns:
|
||||
Dictionary containing username and password, or None
|
||||
"""
|
||||
if not credential:
|
||||
return None
|
||||
|
||||
return {"username": credential.username, "password": credential.password}
|
||||
|
||||
|
||||
def deserialize_credential(credential_data: Optional[Dict[str, str]]) -> Optional[Credential]:
|
||||
"""
|
||||
Deserialize credential data into a Credential object.
|
||||
|
||||
Args:
|
||||
credential_data: Dictionary containing username and password
|
||||
|
||||
Returns:
|
||||
Credential object or None
|
||||
"""
|
||||
if not credential_data:
|
||||
return None
|
||||
|
||||
return Credential(username=credential_data["username"], password=credential_data["password"])
|
||||
@@ -52,6 +52,7 @@ Mkvpropedit = find("mkvpropedit")
|
||||
DoviTool = find("dovi_tool")
|
||||
HDR10PlusTool = find("hdr10plus_tool", "HDR10Plus_tool")
|
||||
Mp4decrypt = find("mp4decrypt")
|
||||
Docker = find("docker")
|
||||
|
||||
|
||||
__all__ = (
|
||||
@@ -71,5 +72,6 @@ __all__ = (
|
||||
"DoviTool",
|
||||
"HDR10PlusTool",
|
||||
"Mp4decrypt",
|
||||
"Docker",
|
||||
"find",
|
||||
)
|
||||
|
||||
@@ -103,6 +103,8 @@ 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():
|
||||
|
||||
274
unshackle/core/local_session_cache.py
Normal file
274
unshackle/core/local_session_cache.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""Local client-side session cache for remote services.
|
||||
|
||||
Sessions are stored ONLY on the client machine, never on the server.
|
||||
The server is completely stateless and receives session data with each request.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
log = logging.getLogger("LocalSessionCache")
|
||||
|
||||
|
||||
class LocalSessionCache:
|
||||
"""
|
||||
Client-side session cache.
|
||||
|
||||
Stores authenticated sessions locally (similar to cookies/cache).
|
||||
Server never stores sessions - client sends session with each request.
|
||||
"""
|
||||
|
||||
def __init__(self, cache_dir: Path):
|
||||
"""
|
||||
Initialize local session cache.
|
||||
|
||||
Args:
|
||||
cache_dir: Directory to store session cache files
|
||||
"""
|
||||
self.cache_dir = cache_dir
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.sessions_file = cache_dir / "remote_sessions.json"
|
||||
|
||||
# Load existing sessions
|
||||
self.sessions: Dict[str, Dict[str, Dict[str, Any]]] = self._load_sessions()
|
||||
|
||||
def _load_sessions(self) -> Dict[str, Dict[str, Dict[str, Any]]]:
|
||||
"""Load sessions from cache file."""
|
||||
if not self.sessions_file.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
data = json.loads(self.sessions_file.read_text(encoding="utf-8"))
|
||||
log.debug(f"Loaded {len(data)} remote sessions from cache")
|
||||
return data
|
||||
except Exception as e:
|
||||
log.error(f"Failed to load sessions cache: {e}")
|
||||
return {}
|
||||
|
||||
def _save_sessions(self) -> None:
|
||||
"""Save sessions to cache file."""
|
||||
try:
|
||||
self.sessions_file.write_text(
|
||||
json.dumps(self.sessions, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8"
|
||||
)
|
||||
log.debug(f"Saved {len(self.sessions)} remote sessions to cache")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to save sessions cache: {e}")
|
||||
|
||||
def store_session(
|
||||
self,
|
||||
remote_url: str,
|
||||
service_tag: str,
|
||||
profile: str,
|
||||
session_data: Dict[str, Any]
|
||||
) -> None:
|
||||
"""
|
||||
Store an authenticated session locally.
|
||||
|
||||
Args:
|
||||
remote_url: Remote server URL (as key)
|
||||
service_tag: Service tag
|
||||
profile: Profile name
|
||||
session_data: Authenticated session data
|
||||
"""
|
||||
# Create nested structure
|
||||
if remote_url not in self.sessions:
|
||||
self.sessions[remote_url] = {}
|
||||
if service_tag not in self.sessions[remote_url]:
|
||||
self.sessions[remote_url][service_tag] = {}
|
||||
|
||||
# Store session with metadata
|
||||
self.sessions[remote_url][service_tag][profile] = {
|
||||
"session_data": session_data,
|
||||
"cached_at": time.time(),
|
||||
"service_tag": service_tag,
|
||||
"profile": profile,
|
||||
}
|
||||
|
||||
self._save_sessions()
|
||||
log.info(f"Cached session for {service_tag} (profile: {profile}, remote: {remote_url})")
|
||||
|
||||
def get_session(
|
||||
self,
|
||||
remote_url: str,
|
||||
service_tag: str,
|
||||
profile: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Retrieve a cached session.
|
||||
|
||||
Args:
|
||||
remote_url: Remote server URL
|
||||
service_tag: Service tag
|
||||
profile: Profile name
|
||||
|
||||
Returns:
|
||||
Session data or None if not found/expired
|
||||
"""
|
||||
try:
|
||||
session_entry = self.sessions[remote_url][service_tag][profile]
|
||||
|
||||
# Check if expired (24 hours)
|
||||
age = time.time() - session_entry["cached_at"]
|
||||
if age > 86400: # 24 hours
|
||||
log.info(f"Session expired for {service_tag} (age: {age:.0f}s)")
|
||||
self.delete_session(remote_url, service_tag, profile)
|
||||
return None
|
||||
|
||||
log.debug(f"Using cached session for {service_tag} (profile: {profile})")
|
||||
return session_entry["session_data"]
|
||||
|
||||
except KeyError:
|
||||
log.debug(f"No cached session for {service_tag} (profile: {profile})")
|
||||
return None
|
||||
|
||||
def has_session(
|
||||
self,
|
||||
remote_url: str,
|
||||
service_tag: str,
|
||||
profile: str
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a valid session exists.
|
||||
|
||||
Args:
|
||||
remote_url: Remote server URL
|
||||
service_tag: Service tag
|
||||
profile: Profile name
|
||||
|
||||
Returns:
|
||||
True if valid session exists
|
||||
"""
|
||||
session = self.get_session(remote_url, service_tag, profile)
|
||||
return session is not None
|
||||
|
||||
def delete_session(
|
||||
self,
|
||||
remote_url: str,
|
||||
service_tag: str,
|
||||
profile: str
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a cached session.
|
||||
|
||||
Args:
|
||||
remote_url: Remote server URL
|
||||
service_tag: Service tag
|
||||
profile: Profile name
|
||||
|
||||
Returns:
|
||||
True if session was deleted
|
||||
"""
|
||||
try:
|
||||
del self.sessions[remote_url][service_tag][profile]
|
||||
|
||||
# Clean up empty nested dicts
|
||||
if not self.sessions[remote_url][service_tag]:
|
||||
del self.sessions[remote_url][service_tag]
|
||||
if not self.sessions[remote_url]:
|
||||
del self.sessions[remote_url]
|
||||
|
||||
self._save_sessions()
|
||||
log.info(f"Deleted cached session for {service_tag} (profile: {profile})")
|
||||
return True
|
||||
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
def list_sessions(self, remote_url: Optional[str] = None) -> list[Dict[str, Any]]:
|
||||
"""
|
||||
List all cached sessions.
|
||||
|
||||
Args:
|
||||
remote_url: Optional filter by remote URL
|
||||
|
||||
Returns:
|
||||
List of session metadata
|
||||
"""
|
||||
sessions = []
|
||||
|
||||
remotes = [remote_url] if remote_url else self.sessions.keys()
|
||||
|
||||
for remote in remotes:
|
||||
if remote not in self.sessions:
|
||||
continue
|
||||
|
||||
for service_tag, profiles in self.sessions[remote].items():
|
||||
for profile, entry in profiles.items():
|
||||
age = time.time() - entry["cached_at"]
|
||||
|
||||
sessions.append({
|
||||
"remote_url": remote,
|
||||
"service_tag": service_tag,
|
||||
"profile": profile,
|
||||
"cached_at": entry["cached_at"],
|
||||
"age_seconds": int(age),
|
||||
"expired": age > 86400,
|
||||
"has_cookies": bool(entry["session_data"].get("cookies")),
|
||||
"has_headers": bool(entry["session_data"].get("headers")),
|
||||
})
|
||||
|
||||
return sessions
|
||||
|
||||
def cleanup_expired(self) -> int:
|
||||
"""
|
||||
Remove expired sessions (older than 24 hours).
|
||||
|
||||
Returns:
|
||||
Number of sessions removed
|
||||
"""
|
||||
removed = 0
|
||||
current_time = time.time()
|
||||
|
||||
for remote_url in list(self.sessions.keys()):
|
||||
for service_tag in list(self.sessions[remote_url].keys()):
|
||||
for profile in list(self.sessions[remote_url][service_tag].keys()):
|
||||
entry = self.sessions[remote_url][service_tag][profile]
|
||||
age = current_time - entry["cached_at"]
|
||||
|
||||
if age > 86400: # 24 hours
|
||||
del self.sessions[remote_url][service_tag][profile]
|
||||
removed += 1
|
||||
log.info(f"Removed expired session for {service_tag} (age: {age:.0f}s)")
|
||||
|
||||
# Clean up empty dicts
|
||||
if not self.sessions[remote_url][service_tag]:
|
||||
del self.sessions[remote_url][service_tag]
|
||||
if not self.sessions[remote_url]:
|
||||
del self.sessions[remote_url]
|
||||
|
||||
if removed > 0:
|
||||
self._save_sessions()
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
# Global instance
|
||||
_local_session_cache: Optional[LocalSessionCache] = None
|
||||
|
||||
|
||||
def get_local_session_cache() -> LocalSessionCache:
|
||||
"""
|
||||
Get the global local session cache instance.
|
||||
|
||||
Returns:
|
||||
LocalSessionCache instance
|
||||
"""
|
||||
global _local_session_cache
|
||||
|
||||
if _local_session_cache is None:
|
||||
from unshackle.core.config import config
|
||||
cache_dir = config.directories.cache / "remote_sessions"
|
||||
_local_session_cache = LocalSessionCache(cache_dir)
|
||||
|
||||
# Clean up expired sessions on init
|
||||
_local_session_cache.cleanup_expired()
|
||||
|
||||
return _local_session_cache
|
||||
|
||||
|
||||
__all__ = ["LocalSessionCache", "get_local_session_cache"]
|
||||
@@ -1,7 +1,8 @@
|
||||
from .basic import Basic
|
||||
from .gluetun import Gluetun
|
||||
from .hola import Hola
|
||||
from .nordvpn import NordVPN
|
||||
from .surfsharkvpn import SurfsharkVPN
|
||||
from .windscribevpn import WindscribeVPN
|
||||
|
||||
__all__ = ("Basic", "Hola", "NordVPN", "SurfsharkVPN", "WindscribeVPN")
|
||||
__all__ = ("Basic", "Gluetun", "Hola", "NordVPN", "SurfsharkVPN", "WindscribeVPN")
|
||||
|
||||
1261
unshackle/core/proxies/gluetun.py
Normal file
1261
unshackle/core/proxies/gluetun.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
@@ -46,8 +47,21 @@ class NordVPN(Proxy):
|
||||
|
||||
HTTP proxies under port 80 were disabled on the 15th of Feb, 2021:
|
||||
https://nordvpn.com/blog/removing-http-proxies
|
||||
|
||||
Supports:
|
||||
- Country code: "us", "ca", "gb"
|
||||
- Country ID: "228"
|
||||
- Specific server: "us1234"
|
||||
- City selection: "us:seattle", "ca:calgary"
|
||||
"""
|
||||
query = query.lower()
|
||||
city = None
|
||||
|
||||
# Check if query includes city specification (e.g., "ca:calgary")
|
||||
if ":" in query:
|
||||
query, city = query.split(":", maxsplit=1)
|
||||
city = city.strip()
|
||||
|
||||
if re.match(r"^[a-z]{2}\d+$", query):
|
||||
# country and nordvpn server id, e.g., us1, fr1234
|
||||
hostname = f"{query}.nordvpn.com"
|
||||
@@ -64,7 +78,12 @@ class NordVPN(Proxy):
|
||||
# NordVPN doesnt have servers in this region
|
||||
return
|
||||
|
||||
server_mapping = self.server_map.get(country["code"].lower())
|
||||
# Check server_map for pinned servers (can include city)
|
||||
server_map_key = f"{country['code'].lower()}:{city}" if city else country["code"].lower()
|
||||
server_mapping = self.server_map.get(server_map_key) or (
|
||||
self.server_map.get(country["code"].lower()) if not city else None
|
||||
)
|
||||
|
||||
if server_mapping:
|
||||
# country was set to a specific server ID in config
|
||||
hostname = f"{country['code'].lower()}{server_mapping}.nordvpn.com"
|
||||
@@ -76,7 +95,19 @@ class NordVPN(Proxy):
|
||||
f"The NordVPN Country {query} currently has no recommended servers. "
|
||||
"Try again later. If the issue persists, double-check the query."
|
||||
)
|
||||
hostname = recommended_servers[0]["hostname"]
|
||||
|
||||
# Filter by city if specified
|
||||
if city:
|
||||
city_servers = self.filter_servers_by_city(recommended_servers, city)
|
||||
if not city_servers:
|
||||
raise ValueError(
|
||||
f"No servers found in city '{city}' for country '{country['name']}'. "
|
||||
"Try a different city or check the city name spelling."
|
||||
)
|
||||
recommended_servers = city_servers
|
||||
|
||||
# Pick a random server from the filtered list
|
||||
hostname = random.choice(recommended_servers)["hostname"]
|
||||
|
||||
if hostname.startswith("gb"):
|
||||
# NordVPN uses the alpha2 of 'GB' in API responses, but 'UK' in the hostname
|
||||
@@ -95,6 +126,41 @@ class NordVPN(Proxy):
|
||||
):
|
||||
return country
|
||||
|
||||
@staticmethod
|
||||
def filter_servers_by_city(servers: list[dict], city: str) -> list[dict]:
|
||||
"""
|
||||
Filter servers by city name.
|
||||
|
||||
The API returns servers with location data that includes city information.
|
||||
This method filters servers to only those in the specified city.
|
||||
|
||||
Args:
|
||||
servers: List of server dictionaries from the NordVPN API
|
||||
city: City name to filter by (case-insensitive)
|
||||
|
||||
Returns:
|
||||
List of servers in the specified city
|
||||
"""
|
||||
city_lower = city.lower()
|
||||
filtered = []
|
||||
|
||||
for server in servers:
|
||||
# Each server has a 'locations' list with location data
|
||||
locations = server.get("locations", [])
|
||||
for location in locations:
|
||||
# City data can be in different formats:
|
||||
# - {"city": {"name": "Seattle", ...}}
|
||||
# - {"city": "Seattle"}
|
||||
city_data = location.get("city")
|
||||
if city_data:
|
||||
# Handle both dict and string formats
|
||||
city_name = city_data.get("name") if isinstance(city_data, dict) else city_data
|
||||
if city_name and city_name.lower() == city_lower:
|
||||
filtered.append(server)
|
||||
break # Found a match, no need to check other locations for this server
|
||||
|
||||
return filtered
|
||||
|
||||
@staticmethod
|
||||
def get_recommended_servers(country_id: int) -> list[dict]:
|
||||
"""
|
||||
|
||||
@@ -44,8 +44,21 @@ class SurfsharkVPN(Proxy):
|
||||
def get_proxy(self, query: str) -> Optional[str]:
|
||||
"""
|
||||
Get an HTTP(SSL) proxy URI for a SurfsharkVPN server.
|
||||
|
||||
Supports:
|
||||
- Country code: "us", "ca", "gb"
|
||||
- Country ID: "228"
|
||||
- Specific server: "us-bos" (Boston)
|
||||
- City selection: "us:seattle", "ca:toronto"
|
||||
"""
|
||||
query = query.lower()
|
||||
city = None
|
||||
|
||||
# Check if query includes city specification (e.g., "us:seattle")
|
||||
if ":" in query:
|
||||
query, city = query.split(":", maxsplit=1)
|
||||
city = city.strip()
|
||||
|
||||
if re.match(r"^[a-z]{2}\d+$", query):
|
||||
# country and surfsharkvpn server id, e.g., au-per, be-anr, us-bos
|
||||
hostname = f"{query}.prod.surfshark.com"
|
||||
@@ -62,13 +75,18 @@ class SurfsharkVPN(Proxy):
|
||||
# SurfsharkVPN doesnt have servers in this region
|
||||
return
|
||||
|
||||
server_mapping = self.server_map.get(country["countryCode"].lower())
|
||||
# Check server_map for pinned servers (can include city)
|
||||
server_map_key = f"{country['countryCode'].lower()}:{city}" if city else country["countryCode"].lower()
|
||||
server_mapping = self.server_map.get(server_map_key) or (
|
||||
self.server_map.get(country["countryCode"].lower()) if not city else None
|
||||
)
|
||||
|
||||
if server_mapping:
|
||||
# country was set to a specific server ID in config
|
||||
hostname = f"{country['code'].lower()}{server_mapping}.prod.surfshark.com"
|
||||
else:
|
||||
# get the random server ID
|
||||
random_server = self.get_random_server(country["countryCode"])
|
||||
random_server = self.get_random_server(country["countryCode"], city)
|
||||
if not random_server:
|
||||
raise ValueError(
|
||||
f"The SurfsharkVPN Country {query} currently has no random servers. "
|
||||
@@ -92,18 +110,44 @@ class SurfsharkVPN(Proxy):
|
||||
):
|
||||
return country
|
||||
|
||||
def get_random_server(self, country_id: str):
|
||||
def get_random_server(self, country_id: str, city: Optional[str] = None):
|
||||
"""
|
||||
Get the list of random Server for a Country.
|
||||
Get a random server for a Country, optionally filtered by city.
|
||||
|
||||
Note: There may not always be more than one recommended server.
|
||||
Args:
|
||||
country_id: The country code (e.g., "US", "CA")
|
||||
city: Optional city name to filter by (case-insensitive)
|
||||
|
||||
Note: The API may include a 'location' field with city information.
|
||||
If not available, this will return any server from the country.
|
||||
"""
|
||||
country = [x["connectionName"] for x in self.countries if x["countryCode"].lower() == country_id.lower()]
|
||||
servers = [x for x in self.countries if x["countryCode"].lower() == country_id.lower()]
|
||||
|
||||
# Filter by city if specified
|
||||
if city:
|
||||
city_lower = city.lower()
|
||||
# Check if servers have a 'location' field for city filtering
|
||||
city_servers = [
|
||||
x
|
||||
for x in servers
|
||||
if x.get("location", "").lower() == city_lower or x.get("city", "").lower() == city_lower
|
||||
]
|
||||
|
||||
if city_servers:
|
||||
servers = city_servers
|
||||
else:
|
||||
raise ValueError(
|
||||
f"No servers found in city '{city}' for country '{country_id}'. "
|
||||
"Try a different city or check the city name spelling."
|
||||
)
|
||||
|
||||
# Get connection names from filtered servers
|
||||
connection_names = [x["connectionName"] for x in servers]
|
||||
|
||||
try:
|
||||
country = random.choice(country)
|
||||
return country
|
||||
except Exception:
|
||||
raise ValueError("Could not get random countrycode from the countries list.")
|
||||
return random.choice(connection_names)
|
||||
except (IndexError, KeyError):
|
||||
raise ValueError(f"Could not get random server for country '{country_id}'.")
|
||||
|
||||
@staticmethod
|
||||
def get_countries() -> list[dict]:
|
||||
|
||||
279
unshackle/core/remote_auth.py
Normal file
279
unshackle/core/remote_auth.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""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 requests
|
||||
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
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"X-API-Key": self.api_key, "Content-Type": "application/json"})
|
||||
|
||||
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"]
|
||||
593
unshackle/core/remote_service.py
Normal file
593
unshackle/core/remote_service.py
Normal file
@@ -0,0 +1,593 @@
|
||||
"""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
|
||||
245
unshackle/core/remote_services.py
Normal file
245
unshackle/core/remote_services.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""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")
|
||||
@@ -53,8 +53,55 @@ class Service(metaclass=ABCMeta):
|
||||
if not ctx.parent or not ctx.parent.params.get("no_proxy"):
|
||||
if ctx.parent:
|
||||
proxy = ctx.parent.params["proxy"]
|
||||
proxy_query = ctx.parent.params.get("proxy_query")
|
||||
proxy_provider_name = ctx.parent.params.get("proxy_provider")
|
||||
else:
|
||||
proxy = None
|
||||
proxy_query = None
|
||||
proxy_provider_name = None
|
||||
|
||||
# Check for service-specific proxy mapping
|
||||
service_name = self.__class__.__name__
|
||||
service_config_dict = config.services.get(service_name, {})
|
||||
proxy_map = service_config_dict.get("proxy_map", {})
|
||||
|
||||
if proxy_map and proxy_query:
|
||||
# Build the full proxy query key (e.g., "nordvpn:ca" or "us")
|
||||
if proxy_provider_name:
|
||||
full_proxy_key = f"{proxy_provider_name}:{proxy_query}"
|
||||
else:
|
||||
full_proxy_key = proxy_query
|
||||
|
||||
# Check if there's a mapping for this query
|
||||
mapped_value = proxy_map.get(full_proxy_key)
|
||||
if mapped_value:
|
||||
self.log.info(f"Found service-specific proxy mapping: {full_proxy_key} -> {mapped_value}")
|
||||
# Query the proxy provider with the mapped value
|
||||
if proxy_provider_name:
|
||||
# Specific provider requested
|
||||
proxy_provider = next(
|
||||
(x for x in ctx.obj.proxy_providers if x.__class__.__name__.lower() == proxy_provider_name),
|
||||
None,
|
||||
)
|
||||
if proxy_provider:
|
||||
mapped_proxy_uri = proxy_provider.get_proxy(mapped_value)
|
||||
if mapped_proxy_uri:
|
||||
proxy = mapped_proxy_uri
|
||||
self.log.info(f"Using mapped proxy from {proxy_provider.__class__.__name__}: {proxy}")
|
||||
else:
|
||||
self.log.warning(f"Failed to get proxy for mapped value '{mapped_value}', using default")
|
||||
else:
|
||||
self.log.warning(f"Proxy provider '{proxy_provider_name}' not found, using default proxy")
|
||||
else:
|
||||
# No specific provider, try all providers
|
||||
for proxy_provider in ctx.obj.proxy_providers:
|
||||
mapped_proxy_uri = proxy_provider.get_proxy(mapped_value)
|
||||
if mapped_proxy_uri:
|
||||
proxy = mapped_proxy_uri
|
||||
self.log.info(f"Using mapped proxy from {proxy_provider.__class__.__name__}: {proxy}")
|
||||
break
|
||||
else:
|
||||
self.log.warning(f"No provider could resolve mapped value '{mapped_value}', using default")
|
||||
|
||||
if not proxy:
|
||||
# don't override the explicit proxy set by the user, even if they may be geoblocked
|
||||
|
||||
@@ -25,6 +25,17 @@ 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()
|
||||
@@ -51,13 +62,25 @@ class Services(click.MultiCommand):
|
||||
|
||||
@staticmethod
|
||||
def get_tags() -> list[str]:
|
||||
"""Returns a list of service tags from all available Services."""
|
||||
return [x.parent.stem for x in _SERVICES]
|
||||
"""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
|
||||
|
||||
@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
|
||||
@@ -72,19 +95,38 @@ 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."""
|
||||
"""Load a Service module by Service tag (local or remote)."""
|
||||
# Check local services first
|
||||
module = _MODULES.get(tag)
|
||||
if not module:
|
||||
raise KeyError(f"There is no Service added by the Tag '{tag}'")
|
||||
return module
|
||||
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}'")
|
||||
|
||||
|
||||
__all__ = ("Services",)
|
||||
|
||||
@@ -19,6 +19,7 @@ from urllib.parse import ParseResult, urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
import chardet
|
||||
import pycountry
|
||||
import requests
|
||||
from construct import ValidationError
|
||||
from fontTools import ttLib
|
||||
@@ -272,6 +273,80 @@ def ap_case(text: str, keep_spaces: bool = False, stop_words: tuple[str] = None)
|
||||
)
|
||||
|
||||
|
||||
# Common country code aliases that differ from ISO 3166-1 alpha-2
|
||||
COUNTRY_CODE_ALIASES = {
|
||||
"uk": "gb", # United Kingdom -> Great Britain
|
||||
}
|
||||
|
||||
|
||||
def get_country_name(code: str) -> Optional[str]:
|
||||
"""
|
||||
Convert a 2-letter country code to full country name.
|
||||
|
||||
Args:
|
||||
code: ISO 3166-1 alpha-2 country code (e.g., 'ca', 'us', 'gb', 'uk')
|
||||
|
||||
Returns:
|
||||
Full country name (e.g., 'Canada', 'United States', 'United Kingdom') or None if not found
|
||||
|
||||
Examples:
|
||||
>>> get_country_name('ca')
|
||||
'Canada'
|
||||
>>> get_country_name('US')
|
||||
'United States'
|
||||
>>> get_country_name('uk')
|
||||
'United Kingdom'
|
||||
"""
|
||||
# Handle common aliases
|
||||
code = COUNTRY_CODE_ALIASES.get(code.lower(), code.lower())
|
||||
|
||||
try:
|
||||
country = pycountry.countries.get(alpha_2=code.upper())
|
||||
if country:
|
||||
return country.name
|
||||
except (KeyError, LookupError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_country_code(name: str) -> Optional[str]:
|
||||
"""
|
||||
Convert a country name to its 2-letter ISO 3166-1 alpha-2 code.
|
||||
|
||||
Args:
|
||||
name: Full country name (e.g., 'Canada', 'United States', 'United Kingdom')
|
||||
|
||||
Returns:
|
||||
2-letter country code in uppercase (e.g., 'CA', 'US', 'GB') or None if not found
|
||||
|
||||
Examples:
|
||||
>>> get_country_code('Canada')
|
||||
'CA'
|
||||
>>> get_country_code('united states')
|
||||
'US'
|
||||
>>> get_country_code('United Kingdom')
|
||||
'GB'
|
||||
"""
|
||||
try:
|
||||
# Try exact name match first
|
||||
country = pycountry.countries.get(name=name.title())
|
||||
if country:
|
||||
return country.alpha_2.upper()
|
||||
|
||||
# Try common name (e.g., "Bolivia" vs "Bolivia, Plurinational State of")
|
||||
country = pycountry.countries.get(common_name=name.title())
|
||||
if country:
|
||||
return country.alpha_2.upper()
|
||||
|
||||
# Try fuzzy search as fallback
|
||||
results = pycountry.countries.search_fuzzy(name)
|
||||
if results:
|
||||
return results[0].alpha_2.upper()
|
||||
except (KeyError, LookupError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_ip_info(session: Optional[requests.Session] = None) -> dict:
|
||||
"""
|
||||
Use ipinfo.io to get IP location information.
|
||||
|
||||
@@ -408,6 +408,19 @@ services:
|
||||
app_name: "AIV"
|
||||
device_model: "Fire TV Stick 4K"
|
||||
|
||||
# Service-specific proxy mappings
|
||||
# Override global proxy selection with specific servers for this service
|
||||
# When --proxy matches a key in proxy_map, the mapped server will be used
|
||||
# instead of the default/random server selection
|
||||
proxy_map:
|
||||
nordvpn:ca: ca1577 # Use ca1577 when --proxy nordvpn:ca is specified
|
||||
nordvpn:us: us9842 # Use us9842 when --proxy nordvpn:us is specified
|
||||
us: 123 # Use server 123 (from any provider) when --proxy us is specified
|
||||
gb: 456 # Use server 456 (from any provider) when --proxy gb is specified
|
||||
# Without this service, --proxy nordvpn:ca picks a random CA server
|
||||
# With this config, --proxy nordvpn:ca EXAMPLE uses ca1577 specifically
|
||||
# Other services or no service specified will still use random selection
|
||||
|
||||
# NEW: Configuration overrides (can be combined with profiles and certificates)
|
||||
# Override dl command defaults for this service
|
||||
dl:
|
||||
@@ -478,8 +491,15 @@ proxy_providers:
|
||||
nordvpn:
|
||||
username: username_from_service_credentials
|
||||
password: password_from_service_credentials
|
||||
# server_map: global mapping that applies to ALL services
|
||||
# Difference from service-specific proxy_map:
|
||||
# - server_map: applies to ALL services when --proxy nordvpn:us is used
|
||||
# - proxy_map: only applies to the specific service configured (see services: EXAMPLE: proxy_map above)
|
||||
# - proxy_map takes precedence over server_map for that service
|
||||
server_map:
|
||||
us: 12 # force US server #12 for US proxies
|
||||
ca:calgary: 2534 # force CA server #2534 for Calgary proxies
|
||||
us:seattle: 7890 # force US server #7890 for Seattle proxies
|
||||
surfsharkvpn:
|
||||
username: your_surfshark_service_username # Service credentials from https://my.surfshark.com/vpn/manual-setup/main/openvpn
|
||||
password: your_surfshark_service_password # Service credentials (not your login password)
|
||||
@@ -487,12 +507,81 @@ proxy_providers:
|
||||
us: 3844 # force US server #3844 for US proxies
|
||||
gb: 2697 # force GB server #2697 for GB proxies
|
||||
au: 4621 # force AU server #4621 for AU proxies
|
||||
us:seattle: 5678 # force US server #5678 for Seattle proxies
|
||||
ca:toronto: 1234 # force CA server #1234 for Toronto proxies
|
||||
windscribevpn:
|
||||
username: your_windscribe_username # Service credentials from https://windscribe.com/getconfig/openvpn
|
||||
password: your_windscribe_password # Service credentials (not your login password)
|
||||
server_map:
|
||||
us: "us-central-096.totallyacdn.com" # force US server
|
||||
gb: "uk-london-055.totallyacdn.com" # force GB server
|
||||
us:seattle: "us-west-011.totallyacdn.com" # force US Seattle server
|
||||
ca:toronto: "ca-toronto-012.totallyacdn.com" # force CA Toronto server
|
||||
|
||||
# Gluetun: Dynamic Docker-based VPN proxy (supports 50+ VPN providers)
|
||||
# Creates Docker containers running Gluetun to bridge VPN connections to HTTP proxies
|
||||
# Requires Docker to be installed and running
|
||||
# Usage: --proxy gluetun:windscribe:us or --proxy gluetun:nordvpn:de
|
||||
gluetun:
|
||||
# Global settings
|
||||
base_port: 8888 # Starting port for HTTP proxies (increments for each container)
|
||||
auto_cleanup: true # Automatically remove containers when done
|
||||
container_prefix: "unshackle-gluetun" # Docker container name prefix
|
||||
verify_ip: true # Verify VPN IP matches expected region
|
||||
# Optional HTTP proxy authentication (for the proxy itself, not VPN)
|
||||
# auth_user: proxy_user
|
||||
# auth_password: proxy_password
|
||||
|
||||
# VPN provider configurations
|
||||
providers:
|
||||
# Windscribe (WireGuard) - Get credentials from https://windscribe.com/getconfig/wireguard
|
||||
windscribe:
|
||||
vpn_type: wireguard
|
||||
credentials:
|
||||
private_key: "YOUR_WIREGUARD_PRIVATE_KEY"
|
||||
addresses: "YOUR_WIREGUARD_ADDRESS" # e.g., "10.x.x.x/32"
|
||||
# Map friendly names to country codes
|
||||
server_countries:
|
||||
us: US
|
||||
uk: GB
|
||||
ca: CA
|
||||
de: DE
|
||||
|
||||
# NordVPN (OpenVPN) - Get service credentials from https://my.nordaccount.com/dashboard/nordvpn/manual-configuration/
|
||||
# Note: Service credentials are NOT your email+password - generate them from the link above
|
||||
# nordvpn:
|
||||
# vpn_type: openvpn
|
||||
# credentials:
|
||||
# username: "YOUR_NORDVPN_SERVICE_USERNAME"
|
||||
# password: "YOUR_NORDVPN_SERVICE_PASSWORD"
|
||||
# server_countries:
|
||||
# us: US
|
||||
# uk: GB
|
||||
|
||||
# ExpressVPN (OpenVPN) - Get credentials from ExpressVPN setup page
|
||||
# expressvpn:
|
||||
# vpn_type: openvpn
|
||||
# credentials:
|
||||
# username: "YOUR_EXPRESSVPN_USERNAME"
|
||||
# password: "YOUR_EXPRESSVPN_PASSWORD"
|
||||
# server_countries:
|
||||
# us: US
|
||||
# uk: GB
|
||||
|
||||
# Surfshark (WireGuard) - Get credentials from https://my.surfshark.com/vpn/manual-setup/main/wireguard
|
||||
# surfshark:
|
||||
# vpn_type: wireguard
|
||||
# credentials:
|
||||
# private_key: "YOUR_SURFSHARK_PRIVATE_KEY"
|
||||
# addresses: "YOUR_SURFSHARK_ADDRESS"
|
||||
# server_countries:
|
||||
# us: US
|
||||
# uk: GB
|
||||
|
||||
# Specific server selection: Use format like "us1239" to select specific servers
|
||||
# Example: --proxy gluetun:nordvpn:us1239 connects to us1239.nordvpn.com
|
||||
# Supported providers: nordvpn, surfshark, expressvpn, cyberghost
|
||||
|
||||
basic:
|
||||
GB:
|
||||
- "socks5://username:password@bhx.socks.ipvanish.com:1080" # 1 (Birmingham)
|
||||
|
||||
11
uv.lock
generated
11
uv.lock
generated
@@ -1070,6 +1070,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/35/c7/d13c57e5a3408df2e5d910853e957ec8e253b41ba531e0f32036c8321240/pycaption-2.2.19-py3-none-any.whl", hash = "sha256:7eb84a05d40bb80400689f9431d05d8b77dec6535938b419ebed2c9d67283a4f", size = 124970, upload-time = "2025-09-30T07:15:21.945Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycountry"
|
||||
version = "24.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/57/c389fa68c50590881a75b7883eeb3dc15e9e73a0fdc001cdd45c13290c92/pycountry-24.6.1.tar.gz", hash = "sha256:b61b3faccea67f87d10c1f2b0fc0be714409e8fcdcc1315613174f6466c10221", size = 6043910, upload-time = "2024-06-01T04:12:15.05Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/ec/1fb891d8a2660716aadb2143235481d15ed1cbfe3ad669194690b0604492/pycountry-24.6.1-py3-none-any.whl", hash = "sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f", size = 6335189, upload-time = "2024-06-01T04:11:49.711Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.22"
|
||||
@@ -1585,6 +1594,7 @@ dependencies = [
|
||||
{ name = "pproxy" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "pycaption" },
|
||||
{ name = "pycountry" },
|
||||
{ name = "pycryptodomex" },
|
||||
{ name = "pyexecjs" },
|
||||
{ name = "pyjwt" },
|
||||
@@ -1638,6 +1648,7 @@ requires-dist = [
|
||||
{ name = "pproxy", specifier = ">=2.7.9,<3" },
|
||||
{ name = "protobuf", specifier = ">=4.25.3,<7" },
|
||||
{ name = "pycaption", specifier = ">=2.2.6,<3" },
|
||||
{ name = "pycountry", specifier = ">=24.6.1" },
|
||||
{ name = "pycryptodomex", specifier = ">=3.20.0,<4" },
|
||||
{ name = "pyexecjs", specifier = ">=1.5.1,<2" },
|
||||
{ name = "pyjwt", specifier = ">=2.8.0,<3" },
|
||||
|
||||
Reference in New Issue
Block a user