2 Commits

Author SHA1 Message Date
imSp4rky
af46dd5fc9 fix(tracks): treat ccextractor signal-crash as non-fatal
A crash (negative return code, e.g. SIGABRT) during best-effort CC extraction now logs a warning instead of aborting a completed download.
2026-07-09 22:49:32 -06:00
imSp4rky
847696b301 feat(proxies)!: rewrite ExpressVPN provider to Android device login
Swap cookie/PKCE auth for the OAuth 2.0 device authorization grant with a cached rotating refresh token. Fix the connection-token key (`cat`) and move the cache to {cache}/vpn/ to match ProtonVPN.

BREAKING CHANGE: cookie-file auth removed; run once with `enable: true` to approve a device code.
2026-07-09 11:20:38 -06:00
7 changed files with 311 additions and 629 deletions

View File

@@ -107,8 +107,8 @@ provider, prefix the query (`--proxy nordvpn:us`).
!!! note "Auto-loading providers"
Most providers only load when you configure them under `proxy_providers:`. Three are
special: **ExpressVPN** and **Proton VPN** also load automatically if their cookie
file already exists on disk (even with no YAML), and **Hola** loads automatically
special: **ExpressVPN** and **Proton VPN** also load automatically once their cached
session file exists on disk (even with no YAML), and **Hola** loads automatically
whenever the `hola-proxy` binary is found on your `PATH`.
### Connection feedback
@@ -347,24 +347,24 @@ Understanding what Gluetun does behind the scenes helps when something goes wron
## ExpressVPN
ExpressVPN emulates the flow used by the ExpressVPN browser extension: it authenticates
with cookies (or a cached token), obtains proxy-capable locations from ExpressVPN's API,
and returns an authenticated HTTPS proxy. Because it relies on browser cookies rather than
service credentials, setup is slightly more involved than the credential-based providers.
ExpressVPN mirrors the ExpressVPN Android TV app: it signs in through Express's OAuth 2.0
device-login flow, obtains proxy-capable locations from Express's API, and returns an
authenticated HTTPS proxy. The device login runs once; after that the cached refresh token
keeps the session alive headlessly.
ExpressVPN **auto-loads** if its cookie file exists, so once the cookie is in place you do
not strictly need a YAML block, but you can add one to pin regions or servers.
Enable it and run a download interactively once:
**Default file locations:**
```yaml title="unshackle.yaml"
proxy_providers:
expressvpn:
enable: true
```
| File | Default path |
|---|---|
| Browser cookies | `{cookies}/vpn/expressvpn.txt` |
| Token cache | `{cache}/global/expressvpn_tokens.json` |
Where `{cookies}` and `{cache}` are your configured cookie and cache directories. The
cookie file may be a Netscape `cookies.txt`, a JSON cookie array, or a JSON dict. Export
your ExpressVPN session cookies from a logged-in browser into one of those forms.
With `enable: true` and an interactive terminal, unshackle prints a code and a URL to
enter it at. Once approved, the tokens are cached and reused automatically, so later runs
need no terminal. ExpressVPN also **auto-loads** once its token cache exists, so you can
drop `enable` after the first login, but you can keep a YAML block to pin regions or
servers:
```yaml title="unshackle.yaml (optional)"
proxy_providers:
@@ -376,6 +376,14 @@ proxy_providers:
myserver: usny-newyork-2
```
**Default file location:**
| File | Default path |
|---|---|
| Token cache | `{cache}/vpn/expressvpn_tokens.json` |
Where `{cache}` is your configured cache directory.
**Query forms:**
| Query | Meaning |

View File

@@ -668,7 +668,7 @@ the full provider guide. Recognised providers and their exit ports:
| NordVPN | `nordvpn` | 48-char **service** credentials | `https://...:89` |
| Surfshark | `surfsharkvpn` | 48-char **service** credentials | `https://...:443` |
| Windscribe | `windscribevpn` | service credentials | `https://...:443` |
| ExpressVPN | `expressvpn` | browser cookies / token cache | `https://cat:...@...:443` |
| ExpressVPN | `expressvpn` | device login (`enable: true`) / token cache | `https://cat:...@...:443` |
| ProtonVPN | `protonvpn` | TV login or exported cookies | `https://...:4443` (or `:443` Secure Core) |
| Gluetun | `gluetun` | per-VPN keys/creds | `http://localhost:{port}` (local Docker) |
| Hola | *(none, auto)* | none | `http://...:{peer}` |
@@ -688,7 +688,7 @@ proxy_providers:
!!! note "Provider loading differs between CLI and REST server"
The `dl` CLI loads all providers, including `windscribevpn` and `gluetun`. The REST API /
remote-client path uses a separate resolver that does **not** load `windscribevpn` or
`gluetun`. ExpressVPN and ProtonVPN also auto-load when their cookie file exists, and Hola
`gluetun`. ExpressVPN and ProtonVPN also auto-load when their cached session exists, and Hola
auto-loads whenever the `hola-proxy` binary is present.
---

View File

@@ -1049,7 +1049,7 @@ class dl:
self.proxy_providers.append(Basic(**config.proxy_providers["basic"]))
# ExpressVPN/ProtonVPN auto-load when their default cookie file exists (no yaml needed)
expressvpn = ExpressVPN(**(config.proxy_providers.get("expressvpn") or {}))
if config.proxy_providers.get("expressvpn") or expressvpn.cookie_path.is_file():
if config.proxy_providers.get("expressvpn") or expressvpn.cache_path.is_file():
self.proxy_providers.append(expressvpn)
if config.proxy_providers.get("nordvpn"):
self.proxy_providers.append(NordVPN(**config.proxy_providers["nordvpn"]))

View File

@@ -68,7 +68,7 @@ def search(ctx: click.Context, no_proxy: bool, profile: Optional[str] = None, pr
if config.proxy_providers.get("basic"):
proxy_providers.append(Basic(**config.proxy_providers["basic"]))
expressvpn = ExpressVPN(**(config.proxy_providers.get("expressvpn") or {}))
if config.proxy_providers.get("expressvpn") or expressvpn.cookie_path.is_file():
if config.proxy_providers.get("expressvpn") or expressvpn.cache_path.is_file():
proxy_providers.append(expressvpn)
if config.proxy_providers.get("nordvpn"):
proxy_providers.append(NordVPN(**config.proxy_providers["nordvpn"]))

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,7 @@ def initialize_proxy_providers() -> List[Any]:
proxy_providers.append(Basic(**proxy_config["basic"]))
# ExpressVPN/ProtonVPN auto-load when their default cookie file exists (no yaml needed)
expressvpn = ExpressVPN(**(proxy_config.get("expressvpn") or {}))
if proxy_config.get("expressvpn") or expressvpn.cookie_path.is_file():
if proxy_config.get("expressvpn") or expressvpn.cache_path.is_file():
proxy_providers.append(expressvpn)
if proxy_config.get("nordvpn"):
proxy_providers.append(NordVPN(**proxy_config["nordvpn"]))

View File

@@ -543,6 +543,11 @@ class Video(Track):
e.returncode,
duration_ms=round((time.monotonic() - cc_start) * 1000, 1),
)
if e.returncode < 0:
logging.getLogger("Video").warning(
f"ccextractor crashed (signal {-e.returncode}) on {self.path.name}; skipping CC extraction"
)
return out_path.exists()
if e.returncode != 10: # 10 = No captions found
raise
return out_path.exists()