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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user