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:
Andy
2025-11-25 20:14:48 +00:00
parent 2d4bf140fa
commit 965482a1e4
27 changed files with 6678 additions and 98 deletions

View File

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

View File

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

View 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

View File

@@ -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:

View File

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