feat(gluetun): improve VPN connection display and Windscribe support

This commit is contained in:
Andy
2026-01-24 13:36:04 -07:00
parent e77f000494
commit 4b30090d87
10 changed files with 1198 additions and 1460 deletions

View File

@@ -97,11 +97,7 @@ class dl:
return None
def prepare_temp_font(
self,
font_name: str,
matched_font: Path,
system_fonts: dict[str, Path],
temp_font_files: list[Path]
self, font_name: str, matched_font: Path, system_fonts: dict[str, Path], temp_font_files: list[Path]
) -> Path:
"""
Copy system font to temp and log if using fallback.
@@ -116,10 +112,7 @@ class dl:
Path to temp font file
"""
# Find the matched name for logging
matched_name = next(
(name for name, path in system_fonts.items() if path == matched_font),
None
)
matched_name = next((name for name, path in system_fonts.items() if path == matched_font), None)
if matched_name and matched_name.lower() != font_name.lower():
self.log.info(f"Using '{matched_name}' as fallback for '{font_name}'")
@@ -136,10 +129,7 @@ class dl:
return temp_path
def attach_subtitle_fonts(
self,
font_names: list[str],
title: Title_T,
temp_font_files: list[Path]
self, font_names: list[str], title: Title_T, temp_font_files: list[Path]
) -> tuple[int, list[str]]:
"""
Attach fonts for subtitle rendering.
@@ -672,16 +662,21 @@ class dl:
self.log.info(f"Loaded {proxy_provider.__class__.__name__}: {proxy_provider}")
if proxy:
# Store original proxy query for service-specific proxy_map
original_proxy_query = proxy
requested_provider = None
if re.match(r"^[a-z]+:.+$", proxy, re.IGNORECASE):
# requesting proxy from a specific proxy provider
requested_provider, proxy = proxy.split(":", maxsplit=1)
# 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):
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"):
status_msg = (
f"Connecting to VPN ({proxy})..."
if requested_provider == "gluetun"
else f"Getting a Proxy to {proxy}..."
)
with console.status(status_msg, spinner="dots"):
if requested_provider:
proxy_provider = next(
(x for x in self.proxy_providers if x.__class__.__name__.lower() == requested_provider),
@@ -690,18 +685,40 @@ class dl:
if not proxy_provider:
self.log.error(f"The proxy provider '{requested_provider}' was not recognised.")
sys.exit(1)
proxy_query = proxy # Save query before overwriting with URI
proxy_uri = proxy_provider.get_proxy(proxy)
if not proxy_uri:
self.log.error(f"The proxy provider {requested_provider} had no proxy for {proxy}")
sys.exit(1)
proxy = ctx.params["proxy"] = proxy_uri
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
# Show connection info for Gluetun (IP, location) instead of proxy URL
if hasattr(proxy_provider, "get_connection_info"):
conn_info = proxy_provider.get_connection_info(proxy_query)
if conn_info and conn_info.get("public_ip"):
location_parts = [conn_info.get("city"), conn_info.get("country")]
location = ", ".join(p for p in location_parts if p)
self.log.info(f"VPN Connected: {conn_info['public_ip']} ({location})")
else:
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
else:
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
else:
for proxy_provider in self.proxy_providers:
proxy_query = proxy # Save query before overwriting with URI
proxy_uri = proxy_provider.get_proxy(proxy)
if proxy_uri:
proxy = ctx.params["proxy"] = proxy_uri
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
# Show connection info for Gluetun (IP, location) instead of proxy URL
if hasattr(proxy_provider, "get_connection_info"):
conn_info = proxy_provider.get_connection_info(proxy_query)
if conn_info and conn_info.get("public_ip"):
location_parts = [conn_info.get("city"), conn_info.get("country")]
location = ", ".join(p for p in location_parts if p)
self.log.info(f"VPN Connected: {conn_info['public_ip']} ({location})")
else:
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
else:
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
@@ -1069,7 +1086,9 @@ class dl:
title.tracks.add(non_sdh_sub)
events.subscribe(
events.Types.TRACK_MULTIPLEX,
lambda track, sub_id=non_sdh_sub.id: (track.strip_hearing_impaired()) if track.id == sub_id else None,
lambda track, sub_id=non_sdh_sub.id: (track.strip_hearing_impaired())
if track.id == sub_id
else None,
)
with console.status("Sorting tracks by language and bitrate...", spinner="dots"):
@@ -1339,7 +1358,16 @@ class dl:
self.log.error(f"There's no {processed_lang} Audio Track, cannot continue...")
sys.exit(1)
if video_only or audio_only or subs_only or chapters_only or no_subs or no_audio or no_chapters or no_video:
if (
video_only
or audio_only
or subs_only
or chapters_only
or no_subs
or no_audio
or no_chapters
or no_video
):
keep_videos = False
keep_audio = False
keep_subtitles = False
@@ -1579,9 +1607,7 @@ class dl:
if line.startswith("Style: "):
font_names.append(line.removeprefix("Style: ").split(",")[1].strip())
font_count, missing_fonts = self.attach_subtitle_fonts(
font_names, title, temp_font_files
)
font_count, missing_fonts = self.attach_subtitle_fonts(font_names, title, temp_font_files)
if font_count:
self.log.info(f"Attached {font_count} fonts for the Subtitles")

View File

@@ -80,7 +80,6 @@ def status_command(remote: Optional[str]) -> None:
from unshackle.core.local_session_cache import get_local_session_cache
# Get local session cache
cache = get_local_session_cache()

View File

@@ -1,18 +1,13 @@
"""Cryptographic utilities for secure remote service authentication."""
import base64
import hashlib
import json
import logging
import secrets
import time
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
try:
from nacl.public import Box, PrivateKey, PublicKey
from nacl.secret import SecretBox
from nacl.utils import random
NACL_AVAILABLE = True
except ImportError:

View File

@@ -10,7 +10,7 @@ import requests
from requests.cookies import cookiejar_from_dict, get_cookie_header
from unshackle.core import binaries
from unshackle.core.binaries import FFMPEG, ShakaPackager, Mp4decrypt
from unshackle.core.binaries import FFMPEG, Mp4decrypt, ShakaPackager
from unshackle.core.config import config
from unshackle.core.console import console
from unshackle.core.constants import DOWNLOAD_CANCELLED

View File

@@ -102,6 +102,62 @@ class Gluetun(Proxy):
"purevpn": "purevpn",
}
# Windscribe uses specific region names instead of country codes
# See: https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/windscribe.md
WINDSCRIBE_REGION_MAP = {
# Country codes to Windscribe region names
"us": "US East",
"us-east": "US East",
"us-west": "US West",
"us-central": "US Central",
"ca": "Canada East",
"ca-east": "Canada East",
"ca-west": "Canada West",
"uk": "United Kingdom",
"gb": "United Kingdom",
"de": "Germany",
"fr": "France",
"nl": "Netherlands",
"au": "Australia",
"jp": "Japan",
"sg": "Singapore",
"hk": "Hong Kong",
"kr": "South Korea",
"in": "India",
"it": "Italy",
"es": "Spain",
"ch": "Switzerland",
"se": "Sweden",
"no": "Norway",
"dk": "Denmark",
"fi": "Finland",
"at": "Austria",
"be": "Belgium",
"ie": "Ireland",
"pl": "Poland",
"pt": "Portugal",
"cz": "Czech Republic",
"ro": "Romania",
"hu": "Hungary",
"gr": "Greece",
"tr": "Turkey",
"ru": "Russia",
"ua": "Ukraine",
"br": "Brazil",
"mx": "Mexico",
"ar": "Argentina",
"za": "South Africa",
"nz": "New Zealand",
"th": "Thailand",
"ph": "Philippines",
"id": "Indonesia",
"my": "Malaysia",
"vn": "Vietnam",
"tw": "Taiwan",
"ae": "United Arab Emirates",
"il": "Israel",
}
def __init__(
self,
providers: Optional[dict] = None,
@@ -196,9 +252,7 @@ class Gluetun(Proxy):
# Parse query
parts = query.split(":")
if len(parts) != 2:
raise ValueError(
f"Invalid query format: '{query}'. Expected 'provider:region' (e.g., 'windscribe:us')"
)
raise ValueError(f"Invalid query format: '{query}'. Expected 'provider:region' (e.g., 'windscribe:us')")
provider_name = parts[0].lower()
region = parts[1].lower()
@@ -206,9 +260,7 @@ class Gluetun(Proxy):
# Check if provider is configured
if provider_name not in self.providers:
available = ", ".join(self.providers.keys())
raise ValueError(
f"VPN provider '{provider_name}' not configured. Available providers: {available}"
)
raise ValueError(f"VPN provider '{provider_name}' not configured. Available providers: {available}")
# Create query key for tracking
query_key = f"{provider_name}:{region}"
@@ -333,11 +385,11 @@ class Gluetun(Proxy):
# Get container logs for better error message
logs = self._get_container_logs(container_name, tail=30)
error_msg = f"Gluetun container '{container_name}' failed to start"
if hasattr(self, '_last_wait_error') and self._last_wait_error:
if hasattr(self, "_last_wait_error") and self._last_wait_error:
error_msg += f": {self._last_wait_error}"
if logs:
# Extract last few relevant lines
log_lines = [line for line in logs.strip().split('\n') if line.strip()][-5:]
log_lines = [line for line in logs.strip().split("\n") if line.strip()][-5:]
error_msg += "\nRecent logs:\n" + "\n".join(log_lines)
raise RuntimeError(error_msg)
@@ -396,22 +448,49 @@ class Gluetun(Proxy):
success=True,
)
def get_connection_info(self, query: str) -> Optional[dict]:
"""
Get connection info for a proxy query.
Args:
query: Query format "provider:region" (e.g., "windscribe:us")
Returns:
Dict with connection info including public_ip, country, city, or None if not found.
"""
parts = query.split(":")
if len(parts) != 2:
return None
provider_name = parts[0].lower()
region = parts[1].lower()
query_key = f"{provider_name}:{region}"
container = self.active_containers.get(query_key)
if not container:
return None
return {
"provider": container.get("provider"),
"region": container.get("region"),
"public_ip": container.get("public_ip"),
"country": container.get("ip_country"),
"city": container.get("ip_city"),
"org": container.get("ip_org"),
}
def _validate_provider_config(self, provider_name: str, config: dict):
"""Validate a provider's configuration."""
vpn_type = config.get("vpn_type", "wireguard").lower()
credentials = config.get("credentials", {})
if vpn_type not in ["wireguard", "openvpn"]:
raise ValueError(
f"Provider '{provider_name}': Invalid vpn_type '{vpn_type}'. Use 'wireguard' or 'openvpn'"
)
raise ValueError(f"Provider '{provider_name}': Invalid vpn_type '{vpn_type}'. Use 'wireguard' or 'openvpn'")
if vpn_type == "wireguard":
# private_key is always required for WireGuard
if "private_key" not in credentials:
raise ValueError(
f"Provider '{provider_name}': WireGuard requires 'private_key' in credentials"
)
raise ValueError(f"Provider '{provider_name}': WireGuard requires 'private_key' in credentials")
# Provider-specific WireGuard requirements based on Gluetun wiki:
# - NordVPN, ProtonVPN: only private_key required
@@ -435,9 +514,7 @@ class Gluetun(Proxy):
# Providers that require addresses (but not preshared_key)
elif provider_lower in ["surfshark", "mullvad", "ivpn"]:
if "addresses" not in credentials:
raise ValueError(
f"Provider '{provider_name}': WireGuard requires 'addresses' in credentials"
)
raise ValueError(f"Provider '{provider_name}': WireGuard requires 'addresses' in credentials")
elif vpn_type == "openvpn":
if "username" not in credentials or "password" not in credentials:
@@ -651,7 +728,12 @@ class Gluetun(Proxy):
# Use country/city selection
if country:
if uses_regions:
env_vars["SERVER_REGIONS"] = country
# Convert country code to provider-specific region name
if gluetun_provider == "windscribe":
region_name = self.WINDSCRIBE_REGION_MAP.get(country.lower(), country)
env_vars["SERVER_REGIONS"] = region_name
else:
env_vars["SERVER_REGIONS"] = country
else:
env_vars["SERVER_COUNTRIES"] = country
if city:
@@ -666,6 +748,16 @@ class Gluetun(Proxy):
# Merge extra environment variables
env_vars.update(extra_env)
# Debug log environment variables (redact sensitive values)
if debug_logger:
safe_env = {k: ("***" if "KEY" in k or "PASSWORD" in k else v) for k, v in env_vars.items()}
debug_logger.log(
level="DEBUG",
operation="gluetun_env_vars",
message=f"Environment variables for {container_name}",
context={"env_vars": safe_env, "gluetun_provider": gluetun_provider},
)
# Build docker run command
cmd = [
"docker",
@@ -791,7 +883,7 @@ class Gluetun(Proxy):
return None
# Parse port from output like "map[8888/tcp:[{127.0.0.1 8888}]]"
port_match = re.search(r'127\.0\.0\.1\s+(\d+)', result.stdout)
port_match = re.search(r"127\.0\.0\.1\s+(\d+)", result.stdout)
if not port_match:
return None
@@ -854,11 +946,9 @@ class Gluetun(Proxy):
Returns:
True if container is ready, False if it failed or timed out
"""
log = logging.getLogger("Gluetun")
debug_logger = get_debug_logger()
start_time = time.time()
last_error = None
last_status = None
if debug_logger:
debug_logger.log(
@@ -900,21 +990,6 @@ class Gluetun(Proxy):
proxy_ready = "[http proxy] listening" in all_logs
vpn_ready = "initialization sequence completed" in all_logs
# Log status changes to help debug slow connections
current_status = None
if vpn_ready:
current_status = "VPN connected"
elif "peer connection initiated" in all_logs:
current_status = "VPN connecting..."
elif "[openvpn]" in all_logs or "[wireguard]" in all_logs:
current_status = "Starting VPN..."
elif "[firewall]" in all_logs:
current_status = "Configuring firewall..."
if current_status and current_status != last_status:
log.info(current_status)
last_status = current_status
if proxy_ready and vpn_ready:
# Give a brief moment for the proxy to fully initialize
time.sleep(1)
@@ -947,7 +1022,7 @@ class Gluetun(Proxy):
for error in error_indicators:
if error in all_logs:
# Extract the error line for better messaging
for line in (stdout + stderr).split('\n'):
for line in (stdout + stderr).split("\n"):
if error in line.lower():
last_error = line.strip()
break
@@ -975,7 +1050,6 @@ class Gluetun(Proxy):
"container_name": container_name,
"timeout": timeout,
"last_error": last_error,
"last_status": last_status,
},
success=False,
duration_ms=duration_ms,
@@ -986,10 +1060,7 @@ class Gluetun(Proxy):
"""Get exit information for a stopped container."""
try:
result = subprocess.run(
[
"docker", "inspect", container_name,
"--format", "{{.State.ExitCode}}:{{.State.Error}}"
],
["docker", "inspect", container_name, "--format", "{{.State.ExitCode}}:{{.State.Error}}"],
capture_output=True,
text=True,
timeout=5,
@@ -998,7 +1069,7 @@ class Gluetun(Proxy):
parts = result.stdout.strip().split(":", 1)
return {
"exit_code": int(parts[0]) if parts[0].isdigit() else -1,
"error": parts[1] if len(parts) > 1 else ""
"error": parts[1] if len(parts) > 1 else "",
}
return None
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
@@ -1104,7 +1175,13 @@ class Gluetun(Proxy):
f"(IP: {ip_info.get('ip')}, City: {ip_info.get('city')})"
)
# Verification successful
# Verification successful - store IP info in container record
if query_key in self.active_containers:
self.active_containers[query_key]["public_ip"] = ip_info.get("ip")
self.active_containers[query_key]["ip_country"] = actual_country
self.active_containers[query_key]["ip_city"] = ip_info.get("city")
self.active_containers[query_key]["ip_org"] = ip_info.get("org")
duration_ms = (time.time() - start_time) * 1000
if debug_logger:
debug_logger.log(
@@ -1145,7 +1222,7 @@ class Gluetun(Proxy):
# Wait before retry (exponential backoff)
if attempt < max_retries - 1:
wait_time = 2 ** attempt # 1, 2, 4 seconds
wait_time = 2**attempt # 1, 2, 4 seconds
time.sleep(wait_time)
# All retries exhausted
@@ -1253,9 +1330,9 @@ class Gluetun(Proxy):
def __del__(self):
"""Cleanup containers on object destruction."""
if hasattr(self, 'auto_cleanup') and self.auto_cleanup:
if hasattr(self, "auto_cleanup") and self.auto_cleanup:
try:
if hasattr(self, 'active_containers') and self.active_containers:
if hasattr(self, "active_containers") and self.active_containers:
self.cleanup()
except Exception:
pass

View File

@@ -112,18 +112,18 @@ class Episode(Title):
if config.dash_naming:
# Format: Title - SXXEXX - Episode Name
name = self.title.replace("$", "S") # e.g., Arli$$
# Add year if configured
if self.year and config.series_year:
name += f" {self.year}"
# Add season and episode
name += f" - S{self.season:02}E{self.number:02}"
# Add episode name with dash separator
if self.name:
name += f" - {self.name}"
name = name.strip()
else:
# Standard format without extra dashes