mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
perf(proxies): cache expressvpn and proton catalogues across requests
A config reload clears the cache; a failed answer is never kept.
This commit is contained in:
@@ -35,3 +35,33 @@ def test_empty_catalogue_is_not_kept() -> None:
|
||||
fetch()
|
||||
fetch()
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_login_bound_providers_fetch_once_across_builds(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
from types import SimpleNamespace
|
||||
|
||||
from unshackle.core.proxies.expressvpn import ExpressVPN
|
||||
from unshackle.core.proxies.proton import ProtonVPN
|
||||
|
||||
requests_made: list[str] = []
|
||||
|
||||
def express_request(self: ExpressVPN, method: str, url: str, **_: object) -> SimpleNamespace:
|
||||
requests_made.append("express")
|
||||
return SimpleNamespace(json=lambda: {"locations": [{"country_code": "ca"}]})
|
||||
|
||||
def proton_api(self: ProtonVPN, method: str, path: str) -> SimpleNamespace:
|
||||
requests_made.append(path)
|
||||
body = {"LogicalServers": [{"ExitCountry": "CA"}]} if path.endswith("logicals") else {"VPN": {"MaxTier": 0}}
|
||||
return SimpleNamespace(json=lambda: body)
|
||||
|
||||
monkeypatch.setattr(ExpressVPN, "get_srt", lambda self: "srt")
|
||||
monkeypatch.setattr(ExpressVPN, "request", express_request)
|
||||
monkeypatch.setattr(ProtonVPN, "api", proton_api)
|
||||
|
||||
for _ in range(2):
|
||||
assert ExpressVPN(cache_path=str(tmp_path / "e.json")).get_locations() == [{"country_code": "ca"}]
|
||||
proton = ProtonVPN(cookie_path=str(tmp_path / "c.txt"), cache_path=str(tmp_path / "p.json"))
|
||||
assert proton.get_logicals() == [{"ExitCountry": "CA"}]
|
||||
assert proton.get_max_tier() == 0
|
||||
|
||||
assert sorted(requests_made) == ["/vpn", "/vpn/v1/logicals", "express"]
|
||||
|
||||
@@ -272,8 +272,10 @@ def reload_config(app: web.Application, *, no_key: bool, serve_widevine: bool, s
|
||||
old_devices, new_devices = _device_stems(config.serve), _device_stems(serve)
|
||||
vars(config).update(vars(fresh))
|
||||
from unshackle.core.api.handlers import reset_remote_pool
|
||||
from unshackle.core.proxies.proxy import CATALOGUES
|
||||
|
||||
reset_remote_pool()
|
||||
CATALOGUES.clear()
|
||||
app["config"]["users"] = serve_config["users"]
|
||||
if serve_widevine:
|
||||
app["config"]["devices"] = _device_map(serve_config["devices"])
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import Any, Optional
|
||||
import requests
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.proxies.proxy import Proxy
|
||||
from unshackle.core.proxies.proxy import Proxy, cached_catalogue
|
||||
|
||||
log = logging.getLogger("proxies.expressvpn")
|
||||
|
||||
@@ -248,15 +248,19 @@ class ExpressVPN(Proxy):
|
||||
|
||||
def get_locations(self) -> list[dict]:
|
||||
if self.locations is None:
|
||||
srt = self.get_srt()
|
||||
if not srt:
|
||||
return []
|
||||
response = self.request(
|
||||
"GET", f"{self.API_BASE}/ids2/locations", headers=self.api_headers(srt), params={"protocols": "proxy"}
|
||||
)
|
||||
self.locations = response.json().get("locations", [])
|
||||
self.locations = self.fetch_locations()
|
||||
return self.locations
|
||||
|
||||
@cached_catalogue
|
||||
def fetch_locations(self) -> list[dict]:
|
||||
srt = self.get_srt()
|
||||
if not srt:
|
||||
return []
|
||||
response = self.request(
|
||||
"GET", f"{self.API_BASE}/ids2/locations", headers=self.api_headers(srt), params={"protocols": "proxy"}
|
||||
)
|
||||
return response.json().get("locations", [])
|
||||
|
||||
def get_endpoints(self, location: dict) -> list[dict]:
|
||||
srt = self.get_srt()
|
||||
if not srt:
|
||||
|
||||
@@ -15,7 +15,7 @@ import requests
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import prompt_user
|
||||
from unshackle.core.proxies.proxy import Proxy
|
||||
from unshackle.core.proxies.proxy import Proxy, cached_catalogue
|
||||
|
||||
log = logging.getLogger("proxies.proton")
|
||||
|
||||
@@ -195,22 +195,29 @@ class ProtonVPN(Proxy):
|
||||
return chosen
|
||||
|
||||
def get_logicals(self) -> list[dict]:
|
||||
"""Get Proton's server list. Only a list that loaded is cached, so a later session can still fetch it."""
|
||||
if self.logicals is None:
|
||||
response = self.api("GET", "/vpn/v1/logicals")
|
||||
if response is None:
|
||||
return []
|
||||
self.logicals = response.json().get("LogicalServers") or []
|
||||
"""Get Proton's server list. Only a list that loaded is kept, so a later call can still fetch it."""
|
||||
if not self.logicals:
|
||||
self.logicals = self.fetch_logicals()
|
||||
return self.logicals
|
||||
|
||||
@cached_catalogue
|
||||
def fetch_logicals(self) -> list[dict]:
|
||||
response = self.api("GET", "/vpn/v1/logicals")
|
||||
return (response.json().get("LogicalServers") or []) if response is not None else []
|
||||
|
||||
def get_max_tier(self) -> int:
|
||||
"""The account's plan tier (0=free, 2=plus). Caps which servers it can connect to."""
|
||||
if self.max_tier is None:
|
||||
response = self.api("GET", "/vpn")
|
||||
vpn = (response.json().get("VPN") or {}) if response is not None else {}
|
||||
self.max_tier = int(vpn.get("MaxTier") or 0)
|
||||
self.max_tier = int(self.fetch_vpn().get("MaxTier") or 0)
|
||||
return self.max_tier
|
||||
|
||||
@cached_catalogue
|
||||
def fetch_vpn(self) -> dict:
|
||||
"""The account's VPN section, kept only when it names the plan tier."""
|
||||
response = self.api("GET", "/vpn")
|
||||
vpn = (response.json().get("VPN") or {}) if response is not None else {}
|
||||
return vpn if "MaxTier" in vpn else {}
|
||||
|
||||
def get_browser_token(self) -> Optional[tuple[str, str]]:
|
||||
response = self.api("GET", f"/vpn/v1/browser/token?Duration={self.TOKEN_DURATION}")
|
||||
if response is None:
|
||||
|
||||
@@ -10,9 +10,10 @@ F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def cached_catalogue(fetch: F) -> F:
|
||||
"""Keep a proxy provider's server catalogue for ``CATALOGUE_TTL`` seconds; the next call fetches an empty answer again.
|
||||
"""Keep a proxy provider's catalogue for ``CATALOGUE_TTL`` seconds; a failed (empty) answer is fetched again.
|
||||
|
||||
A server builds its proxy providers on every request, and each build fetches the catalogue.
|
||||
One config builds every provider, so a config reload clears the cache.
|
||||
"""
|
||||
|
||||
@functools.wraps(fetch)
|
||||
|
||||
Reference in New Issue
Block a user