feat(service): add GEOBLOCK negative geofence

Regions in GEOBLOCK refuse the service and everything else is allowed. A hit stops the run with a region error instead of auto-proxying; the check also covers proxy exits and the remote-dl client region.
This commit is contained in:
imSp4rky
2026-09-16 15:06:43 -06:00
parent 1a993f4cb9
commit aec45e30cc
17 changed files with 154 additions and 20 deletions
+9
View File
@@ -97,6 +97,7 @@ Declared at class level to configure framework behaviour:
|---|---|---| |---|---|---|
| `ALIASES` | `tuple[str, ...]` | Extra tags that resolve to this service (e.g. `("EX", "DOMAIN")`). Case does not matter. Default `()`. | | `ALIASES` | `tuple[str, ...]` | Extra tags that resolve to this service (e.g. `("EX", "DOMAIN")`). Case does not matter. Default `()`. |
| `GEOFENCE` | `tuple[str, ...]` | IP region codes the service requires. Empty = no geofence. unshackle treats the **first** entry as the main region for auto-proxy. | | `GEOFENCE` | `tuple[str, ...]` | IP region codes the service requires. Empty = no geofence. unshackle treats the **first** entry as the main region for auto-proxy. |
| `GEOBLOCK` | `tuple[str, ...]` | IP region codes where the service refuses to work. Every other region is allowed. Default `()`. |
| `VAULT_TAG` | `Optional[str]` | Overrides the key-vault namespace so sibling services can share one vault. Default `None` (use the service's own tag). | | `VAULT_TAG` | `Optional[str]` | Overrides the key-vault namespace so sibling services can share one vault. Default `None` (use the service's own tag). |
| `AUTH_METHODS` | `Optional[tuple[str, ...]]` | Auth methods accepted (`"cookies"` / `"credentials"`). When `None`, the REST `/services` endpoint infers them from `authenticate()`. | | `AUTH_METHODS` | `Optional[tuple[str, ...]]` | Auth methods accepted (`"cookies"` / `"credentials"`). When `None`, the REST `/services` endpoint infers them from `authenticate()`. |
| `NO_SUBTITLES` | `bool` | Set `True` on a service with no subtitle tracks to skip subtitle handling entirely. | | `NO_SUBTITLES` | `bool` | Set `True` on a service with no subtitle tracks to skip subtitle handling entirely. |
@@ -113,11 +114,19 @@ Declared at class level to configure framework behaviour:
explicit `--proxy`, the base class does a live IP check and, if your region is explicit `--proxy`, the base class does a live IP check and, if your region is
blocked, fetches a proxy to `GEOFENCE[0]` from your configured proxy providers. blocked, fetches a proxy to `GEOFENCE[0]` from your configured proxy providers.
`GEOBLOCK` is the negative form: the service works everywhere except the listed
regions. With no `GEOFENCE` to name a main region, a `GEOBLOCK` hit stops the run
with a "not available in your region" error; pass `--proxy` yourself.
```python ```python
class EXAMPLE(Service): class EXAMPLE(Service):
ALIASES = ("EX", "DOMAIN") ALIASES = ("EX", "DOMAIN")
GEOFENCE = ("US", "UK") GEOFENCE = ("US", "UK")
VAULT_TAG = "DIFFERENT_NAME" VAULT_TAG = "DIFFERENT_NAME"
class WORLDWIDE(Service):
GEOBLOCK = ("GB",)
``` ```
### What `Service.__init__` wires up ### What `Service.__init__` wires up
+1 -1
View File
@@ -182,7 +182,7 @@ GET /api/dashboard/services
[{"tag": "EXAMPLE", "state": "staged", "error": null, [{"tag": "EXAMPLE", "state": "staged", "error": null,
"commit": "9cc230c98f13b83f6c6fe831595b9702d3ba2786", "staged_commit": "5f0aa17...", "commit": "9cc230c98f13b83f6c6fe831595b9702d3ba2786", "staged_commit": "5f0aa17...",
"staged_since": 1756908900.0, "sessions": 1, "jobs": 1, "staged_since": 1756908900.0, "sessions": 1, "jobs": 1,
"aliases": ["EXMPL"], "geofence": ["US"]}] "aliases": ["EXMPL"], "geofence": ["US"], "geoblock": []}]
``` ```
`state` is `loaded`, `staged` or `failed`. A service that failed to import keeps its error `state` is `loaded`, `staged` or `failed`. A service that failed to import keeps its error
+2
View File
@@ -79,6 +79,7 @@ Show the streaming services available on this server, filtered by your allowlist
"tag": "EXAMPLE", "tag": "EXAMPLE",
"aliases": ["EX"], "aliases": ["EX"],
"geofence": ["US"], "geofence": ["US"],
"geoblock": [],
"title_regex": "^https?://example\\.com/.+", "title_regex": "^https?://example\\.com/.+",
"url": "https://example.com", "url": "https://example.com",
"help": "EXAMPLE\nAuthorization: Cookies\nSecurity: FHD@L3", "help": "EXAMPLE\nAuthorization: Cookies\nSecurity: FHD@L3",
@@ -112,6 +113,7 @@ Field notes:
| `tag` | Canonical service tag; use this as the `service` value on other endpoints. | | `tag` | Canonical service tag; use this as the `service` value on other endpoints. |
| `aliases` | Alternate tags the service also answers to. | | `aliases` | Alternate tags the service also answers to. |
| `geofence` | Region codes the service is geofenced to (may be empty). | | `geofence` | Region codes the service is geofenced to (may be empty). |
| `geoblock` | Region codes where the service refuses to work (may be empty). |
| `title_regex` | URL/ID pattern the service matches, as a string or list of strings (or `null`). | | `title_regex` | URL/ID pattern the service matches, as a string or list of strings (or `null`). |
| `url` | Service URL, taken from the service's CLI short help. | | `url` | Service URL, taken from the service's CLI short help. |
| `help` | The service module's docstring. | | `help` | The service module's docstring. |
+1 -1
View File
@@ -152,7 +152,7 @@ The classification is keyword-based, checked in order:
|---|---|---| |---|---|---|
| `auth`, `login`, `credential`, `unauthorized`, `forbidden`, `token` | `AUTH_FAILED` (401) | no | | `auth`, `login`, `credential`, `unauthorized`, `forbidden`, `token` | `AUTH_FAILED` (401) | no |
| `connection`, `timeout`, `network`, `unreachable`, `socket`, `dns`, `resolve` (or `ConnectionError`, `TimeoutError`, `URLError`, `SSLError`) | `NETWORK_ERROR` (503) | yes | | `connection`, `timeout`, `network`, `unreachable`, `socket`, `dns`, `resolve` (or `ConnectionError`, `TimeoutError`, `URLError`, `SSLError`) | `NETWORK_ERROR` (503) | yes |
| `geofence`, `region`, `not available in`, `territory` | `GEOFENCE` (403) | no | | `geofence`, `geoblock`, `region`, `not available in`, `territory` | `GEOFENCE` (403) | no |
| `not found`, `404`, `does not exist`, `invalid id` | `NOT_FOUND` (404) | no | | `not found`, `404`, `does not exist`, `invalid id` | `NOT_FOUND` (404) | no |
| `rate limit`, `too many requests`, `429`, `throttle` | `RATE_LIMITED` (429) | yes | | `rate limit`, `too many requests`, `429`, `throttle` | `RATE_LIMITED` (429) | yes |
| `drm`, `license`, `widevine`, `playready`, `decrypt` | `DRM_ERROR` (502) | no | | `drm`, `license`, `widevine`, `playready`, `decrypt` | `DRM_ERROR` (502) | no |
+1
View File
@@ -141,6 +141,7 @@ curl "$BASE/api/services" \
"tag": "EXAMPLE", "tag": "EXAMPLE",
"aliases": ["EX"], "aliases": ["EX"],
"geofence": [], "geofence": [],
"geoblock": [],
"title_regex": null, "title_regex": null,
"url": "https://example.com", "url": "https://example.com",
"help": "EXAMPLE service documentation...", "help": "EXAMPLE service documentation...",
+5 -1
View File
@@ -63,6 +63,7 @@ Declared at the top of your service class, these are static descriptors:
|---|---|---| |---|---|---|
| `ALIASES` | `tuple[str, ...]` | Alternate tags the service answers to, in any case (resolved by `Services.get_tag`). | | `ALIASES` | `tuple[str, ...]` | Alternate tags the service answers to, in any case (resolved by `Services.get_tag`). |
| `GEOFENCE` | `tuple[str, ...]` | ISO region codes the service requires; empty means no geofence. unshackle treats the first entry as the "main region" for automatic proxy selection. | | `GEOFENCE` | `tuple[str, ...]` | ISO region codes the service requires; empty means no geofence. unshackle treats the first entry as the "main region" for automatic proxy selection. |
| `GEOBLOCK` | `tuple[str, ...]` | ISO region codes where the service refuses to work; every other region is allowed. Use it for a service that works worldwide except in its home market. |
| `VAULT_TAG` | `Optional[str]` | Overrides the key-vault namespace; defaults to the service's own tag. | | `VAULT_TAG` | `Optional[str]` | Overrides the key-vault namespace; defaults to the service's own tag. |
| `AUTH_METHODS` | `Optional[tuple[str, ...]]` | Accepted auth methods (`"cookies"` / `"credentials"`). When `None`, the REST `/services` endpoint infers them from `authenticate()`. | | `AUTH_METHODS` | `Optional[tuple[str, ...]]` | Accepted auth methods (`"cookies"` / `"credentials"`). When `None`, the REST `/services` endpoint infers them from `authenticate()`. |
| `ANIME` | `bool` | The catalogue is anime, so metadata lookups prefer AniList. A title's own `anime` flag overrides it. | | `ANIME` | `bool` | The catalogue is anime, so metadata lookups prefer AniList. A title's own `anime` flag overrides it. |
@@ -117,7 +118,10 @@ up the objects your methods rely on:
The constructor also resolves proxies: it reads `--proxy` and the `proxy_query` and The constructor also resolves proxies: it reads `--proxy` and the `proxy_query` and
`proxy_provider` context params that `dl` derives from it, consults the per-service `proxy_provider` context params that `dl` derives from it, consults the per-service
`proxy_map`, and, if `GEOFENCE` is set and you did not give an explicit proxy, does a live `proxy_map`, and, if `GEOFENCE` is set and you did not give an explicit proxy, does a live
IP check and auto-fetches a proxy to `GEOFENCE[0]` when it detects a geoblock. IP check and auto-fetches a proxy to `GEOFENCE[0]` when it detects a geoblock. A
`GEOBLOCK` hit with no `GEOFENCE` stops the run with a "not available in your region"
error, because there is no main region to fetch a proxy for; pass `--proxy` yourself.
A proxy whose exit is in a `GEOBLOCK` region stops the run the same way.
See [Proxies and VPN](../guide/proxies-and-vpn.md). See [Proxies and VPN](../guide/proxies-and-vpn.md).
!!! note "`TrackRequest`" !!! note "`TrackRequest`"
+42
View File
@@ -0,0 +1,42 @@
"""Region checks for GEOFENCE and GEOBLOCK on the Service base class."""
from unshackle.core.service import Service
class Blocked(Service):
GEOBLOCK = ("GB",)
class Fenced(Service):
GEOFENCE = ("us",)
class Both(Service):
GEOFENCE = ("us", "gb")
GEOBLOCK = ("GB",)
def test_no_restrictions_allows_everything():
assert Service.is_region_allowed("xx")
assert Service.is_region_allowed(None)
def test_geoblock_refuses_listed_region_any_case():
assert Blocked.is_region_allowed("de")
assert not Blocked.is_region_allowed("gb")
assert not Blocked.is_region_allowed("GB")
def test_geofence_allows_only_listed_regions():
assert Fenced.is_region_allowed("US")
assert not Fenced.is_region_allowed("gb")
def test_geoblock_wins_over_geofence():
assert Both.is_region_allowed("us")
assert not Both.is_region_allowed("gb")
def test_unknown_region_is_allowed():
assert Blocked.is_region_allowed("")
assert Blocked.is_region_allowed(None)
+7
View File
@@ -109,6 +109,13 @@ def test_build_error_response_debug_mode_includes_traceback() -> None:
(Exception("Connection refused"), APIErrorCode.NETWORK_ERROR), (Exception("Connection refused"), APIErrorCode.NETWORK_ERROR),
(TimeoutError("read timeout"), APIErrorCode.NETWORK_ERROR), (TimeoutError("read timeout"), APIErrorCode.NETWORK_ERROR),
(Exception("Not available in your region"), APIErrorCode.GEOFENCE), (Exception("Not available in your region"), APIErrorCode.GEOFENCE),
(Exception("Service is geoblocked in GB"), APIErrorCode.GEOFENCE),
(
Exception(
"Service is not available in your region (GB). Pass --proxy with a proxy outside the blocked regions."
),
APIErrorCode.GEOFENCE,
),
(Exception("Title not found"), APIErrorCode.NOT_FOUND), (Exception("Title not found"), APIErrorCode.NOT_FOUND),
(Exception("HTTP 429 too many requests"), APIErrorCode.RATE_LIMITED), (Exception("HTTP 429 too many requests"), APIErrorCode.RATE_LIMITED),
(Exception("DRM license fetch failed"), APIErrorCode.DRM_ERROR), (Exception("DRM license fetch failed"), APIErrorCode.DRM_ERROR),
@@ -48,6 +48,16 @@ def test_region_mismatch_raises(monkeypatch):
assert exc_info.value.details == {"service": "EXAMPLE"} assert exc_info.value.details == {"service": "EXAMPLE"}
def test_geoblocked_client_region_raises(monkeypatch):
patch_server_region(monkeypatch, "CA")
service_class = handlers.Services.load("EXAMPLE")
monkeypatch.setattr(service_class, "GEOBLOCK", ("GB",), raising=False)
with pytest.raises(APIError) as exc_info:
handlers.resolve_handler_proxy({"client_region": "gb"}, "EXAMPLE", request("plainkey"))
assert exc_info.value.error_code == APIErrorCode.GEOFENCE
assert exc_info.value.details == {"service": "EXAMPLE"}
def test_region_match_no_proxy(monkeypatch): def test_region_match_no_proxy(monkeypatch):
patch_server_region(monkeypatch, "CA") patch_server_region(monkeypatch, "CA")
assert handlers.resolve_handler_proxy({"client_region": "ca"}, "EXAMPLE", request("plainkey")) == (None, []) assert handlers.resolve_handler_proxy({"client_region": "ca"}, "EXAMPLE", request("plainkey")) == (None, [])
+8 -8
View File
@@ -184,6 +184,14 @@ def categorize_exception(
exc_str = f"{exc} {root}".lower() exc_str = f"{exc} {root}".lower()
exc_type = type(root).__name__ exc_type = type(root).__name__
if any(keyword in exc_str for keyword in ["geofence", "geoblock", "region", "not available in", "territory"]):
return APIError(
error_code=APIErrorCode.GEOFENCE,
message=f"Content not available in your region: {redact_secrets(str(exc))}",
details={**context, "reason": "geofence_restriction"},
retryable=False,
)
if "proxy" in str(root).lower(): if "proxy" in str(root).lower():
return APIError( return APIError(
error_code=APIErrorCode.INVALID_PROXY, error_code=APIErrorCode.INVALID_PROXY,
@@ -220,14 +228,6 @@ def categorize_exception(
http_status=503, http_status=503,
) )
if any(keyword in exc_str for keyword in ["geofence", "region", "not available in", "territory"]):
return APIError(
error_code=APIErrorCode.GEOFENCE,
message=f"Content not available in your region: {redact_secrets(str(exc))}",
details={**context, "reason": "geofence_restriction"},
retryable=False,
)
if any(keyword in exc_str for keyword in ["not found", "404", "does not exist", "invalid id"]): if any(keyword in exc_str for keyword in ["not found", "404", "does not exist", "invalid id"]):
return APIError( return APIError(
error_code=APIErrorCode.NOT_FOUND, error_code=APIErrorCode.NOT_FOUND,
+15 -2
View File
@@ -207,7 +207,7 @@ def build_parent_ctx(
parent = click.Context(dummy) parent = click.Context(dummy)
parent.obj = ContextData(config=service_config, cdm=cdm, proxy_providers=proxy_providers, profile=profile) parent.obj = ContextData(config=service_config, cdm=cdm, proxy_providers=proxy_providers, profile=profile)
params = {"proxy": proxy_param, "no_proxy": no_proxy} params = {"proxy": proxy_param, "no_proxy": no_proxy, "served": True}
if extra_params: if extra_params:
params.update(extra_params) params.update(extra_params)
parent.params = params parent.params = params
@@ -357,9 +357,11 @@ def run_service_search(
) )
service_module = Services.load(normalized_service) service_module = Services.load(normalized_service)
from click import ClickException
try: try:
service_instance = instantiate_service(parent_ctx, service_module, query) service_instance = instantiate_service(parent_ctx, service_module, query)
except ConnectionError as exc: except (ConnectionError, ClickException) as exc:
raise categorize_exception(exc, {"service": normalized_service}) from exc raise categorize_exception(exc, {"service": normalized_service}) from exc
except Exception as exc: except Exception as exc:
raise APIError( raise APIError(
@@ -2126,6 +2128,7 @@ async def dashboard_services_handler(request: web.Request) -> web.Response:
"jobs": jobs.get(tag, 0), "jobs": jobs.get(tag, 0),
"aliases": list(services_module.ALIASES.get(tag, ())), "aliases": list(services_module.ALIASES.get(tag, ())),
"geofence": [], "geofence": [],
"geoblock": [],
} }
if staged: if staged:
# Only staged tags need a git call; the working tree already holds the new commit. # Only staged tags need a git call; the working tree already holds the new commit.
@@ -2136,6 +2139,7 @@ async def dashboard_services_handler(request: web.Request) -> web.Response:
module = services_module.MODULES.get(tag) module = services_module.MODULES.get(tag)
if module is not None: if module is not None:
row["geofence"] = list(getattr(module, "GEOFENCE", ()) or ()) row["geofence"] = list(getattr(module, "GEOFENCE", ()) or ())
row["geoblock"] = list(getattr(module, "GEOBLOCK", ()) or ())
rows.append(row) rows.append(row)
return web.json_response(rows) return web.json_response(rows)
@@ -3601,6 +3605,15 @@ def resolve_handler_proxy(
log.debug(f"Server region lookup failed: {e!r}") log.debug(f"Server region lookup failed: {e!r}")
server_region = None server_region = None
geoblock = getattr(Services.load(normalized_service), "GEOBLOCK", ()) or ()
if client_region.lower() in {x.lower() for x in geoblock}:
raise APIError(
APIErrorCode.GEOFENCE,
f"Service is not available in your region ({client_region.upper()}). "
"Pass --proxy with a proxy outside the blocked regions.",
details={"service": normalized_service},
)
in_client_region = bool(server_region) and server_region == client_region.lower() in_client_region = bool(server_region) and server_region == client_region.lower()
if not allowed: if not allowed:
if server_region and not in_client_region: if server_region and not in_client_region:
+10
View File
@@ -201,6 +201,10 @@ async def services(request: web.Request) -> web.Response:
type: array type: array
items: items:
type: string type: string
geoblock:
type: array
items:
type: string
title_regex: title_regex:
oneOf: oneOf:
- type: string - type: string
@@ -253,6 +257,7 @@ async def services(request: web.Request) -> web.Response:
"tag": tag, "tag": tag,
"aliases": [], "aliases": [],
"geofence": [], "geofence": [],
"geoblock": [],
"title_regex": None, "title_regex": None,
"url": None, "url": None,
"help": None, "help": None,
@@ -270,6 +275,7 @@ async def services(request: web.Request) -> web.Response:
if hasattr(service_module, "GEOFENCE"): if hasattr(service_module, "GEOFENCE"):
service_data["geofence"] = list(service_module.GEOFENCE) service_data["geofence"] = list(service_module.GEOFENCE)
service_data["geoblock"] = list(getattr(service_module, "GEOBLOCK", ()) or ())
if hasattr(service_module, "TITLE_RE"): if hasattr(service_module, "TITLE_RE"):
title_re = service_module.TITLE_RE title_re = service_module.TITLE_RE
@@ -2488,6 +2494,10 @@ async def dashboard_services(request: web.Request) -> web.Response:
type: array type: array
items: items:
type: string type: string
geoblock:
type: array
items:
type: string
'401': '401':
description: Dashboard key missing or invalid description: Dashboard key missing or invalid
""" """
+1
View File
@@ -106,6 +106,7 @@ class ImportService:
ALIASES: tuple[str, ...] = () ALIASES: tuple[str, ...] = ()
GEOFENCE: tuple[str, ...] = () GEOFENCE: tuple[str, ...] = ()
GEOBLOCK: tuple[str, ...] = ()
NO_SUBTITLES: bool = False NO_SUBTITLES: bool = False
def __init__(self, ctx: click.Context, service_tag: str, title: str, import_file: Optional[str]) -> None: def __init__(self, ctx: click.Context, service_tag: str, title: str, import_file: Optional[str]) -> None:
+1
View File
@@ -760,6 +760,7 @@ class RemoteService:
ALIASES: tuple[str, ...] = () ALIASES: tuple[str, ...] = ()
GEOFENCE: tuple[str, ...] = () GEOFENCE: tuple[str, ...] = ()
GEOBLOCK: tuple[str, ...] = ()
NO_SUBTITLES: bool = False NO_SUBTITLES: bool = False
ANIME: bool = False ANIME: bool = False
DAILY: bool = False DAILY: bool = False
+37 -7
View File
@@ -150,6 +150,7 @@ class Service(metaclass=ABCMeta):
ALIASES: tuple[str, ...] = () # alternative tags for the service, matched without case. ALIASES: tuple[str, ...] = () # alternative tags for the service, matched without case.
GEOFENCE: tuple[str, ...] = () # list of ip regions required to use the service. empty list == no specific region. GEOFENCE: tuple[str, ...] = () # list of ip regions required to use the service. empty list == no specific region.
GEOBLOCK: tuple[str, ...] = () # ip regions where the service refuses to work; everything else is allowed.
ANIME: bool = False # service catalogue is anime; metadata lookups prefer AniList. Title.anime overrides per title. ANIME: bool = False # service catalogue is anime; metadata lookups prefer AniList. Title.anime overrides per title.
DAILY: bool = False # catalog is daily/date-based. episodes are named by air date. Title.daily overrides per title. DAILY: bool = False # catalog is daily/date-based. episodes are named by air date. Title.daily overrides per title.
# vault namespace override; when set, key vault read/write uses this tag instead of the service's own. # vault namespace override; when set, key vault read/write uses this tag instead of the service's own.
@@ -185,6 +186,7 @@ class Service(metaclass=ABCMeta):
best_available=bool(best_available), best_available=bool(best_available),
) )
whose = "the server's" if ctx.parent and ctx.parent.params.get("served") else "your"
if not ctx.parent or not ctx.parent.params.get("no_proxy"): if not ctx.parent or not ctx.parent.params.get("no_proxy"):
if ctx.parent: if ctx.parent:
proxy = ctx.parent.params["proxy"] proxy = ctx.parent.params["proxy"]
@@ -248,13 +250,20 @@ class Service(metaclass=ABCMeta):
if not proxy: if not proxy:
# don't override the explicit proxy set by the user, even if they may be geoblocked # don't override the explicit proxy set by the user, even if they may be geoblocked
with console.status("Checking if current region is Geoblocked...", spinner="dots"): with console.status("Checking if current region is Geoblocked...", spinner="dots"):
if self.GEOFENCE: if self.GEOFENCE or self.GEOBLOCK:
try: try:
current_region = get_ip_info(self.session)["country"].lower() current_region = (get_ip_info(self.session) or {}).get("country", "").lower()
if any(x.lower() == current_region for x in self.GEOFENCE): if not current_region:
self.log.warning(f"Could not find {whose} region, so the region check does not run")
elif self.is_region_allowed(current_region):
self.log.info("Service is not Geoblocked in your region") self.log.info("Service is not Geoblocked in your region")
elif not (fence_targets := [x for x in self.GEOFENCE if self.is_region_allowed(x)]):
raise click.ClickException(
f"Service is not available in {whose} region ({current_region.upper()}). "
"Pass --proxy with a proxy outside the blocked regions."
)
else: else:
requested_proxy = self.GEOFENCE[0] # first is likely main region requested_proxy = fence_targets[0] # first is likely main region
self.log.info( self.log.info(
f"Service is Geoblocked in your region, getting a Proxy to {requested_proxy}" f"Service is Geoblocked in your region, getting a Proxy to {requested_proxy}"
) )
@@ -268,11 +277,12 @@ class Service(metaclass=ABCMeta):
f"No proxy available for {requested_proxy}. " f"No proxy available for {requested_proxy}. "
f"Pass --proxy with a proxy in {requested_proxy}, or the request can fail." f"Pass --proxy with a proxy in {requested_proxy}, or the request can fail."
) )
except click.ClickException:
raise
except Exception as e: except Exception as e:
self.log.warning(f"Failed to check geofence: {e}") self.log.warning(f"Failed to check geofence: {e}")
current_region = None
else: else:
self.log.info("Service has no Geofence") self.log.info("Service has no region restrictions")
if proxy: if proxy:
self.session.proxies.update({"all": proxy}) self.session.proxies.update({"all": proxy})
@@ -282,7 +292,14 @@ class Service(metaclass=ABCMeta):
# plaintext-http forward-proxy requests with HTTP 407. # plaintext-http forward-proxy requests with HTTP 407.
# Verify the proxy IP every time, because a proxy can change its exit node. A dead # Verify the proxy IP every time, because a proxy can change its exit node. A dead
# proxy fails here, not after every service request has used up its retries. # proxy fails here, not after every service request has used up its retries.
self.current_region = verify_proxy_exit(self.session).get("country") exit_region = verify_proxy_exit(self.session).get("country")
self.current_region = exit_region
# Only GEOBLOCK applies here: an explicit --proxy outside GEOFENCE stays the user's call.
if exit_region and any(x.lower() == exit_region.lower() for x in self.GEOBLOCK):
raise click.ClickException(
f"Service is not available in {whose} proxy's region ({exit_region.upper()}). "
"Pass --proxy with a proxy outside the blocked regions."
)
else: else:
# No proxy, use cached IP info for title caching (non-critical) # No proxy, use cached IP info for title caching (non-critical)
try: try:
@@ -292,6 +309,19 @@ class Service(metaclass=ABCMeta):
self.log.debug(f"Failed to get cached IP info: {e}") self.log.debug(f"Failed to get cached IP info: {e}")
self.current_region = None self.current_region = None
@classmethod
def is_region_allowed(cls, region: Optional[str]) -> bool:
"""True when an exit IP in `region` (two-letter region code, any case) can use the service.
An unknown region (None or empty) is allowed, because there is nothing to compare.
"""
if not region:
return True
region = region.lower()
if any(x.lower() == region for x in cls.GEOBLOCK):
return False
return not cls.GEOFENCE or any(x.lower() == region for x in cls.GEOFENCE)
def get_tracks_for_variants( def get_tracks_for_variants(
self, self,
title: Title_T, title: Title_T,
+2
View File
@@ -70,6 +70,8 @@ class EXAMPLE(Service):
ALIASES = ("EX", "DOMAIN") ALIASES = ("EX", "DOMAIN")
# GEOFENCE: regions required; the framework warns/blocks if proxy region mismatches. # GEOFENCE: regions required; the framework warns/blocks if proxy region mismatches.
GEOFENCE = ("US", "UK") GEOFENCE = ("US", "UK")
# GEOBLOCK: the negative form; regions where the service refuses to work, all others allowed.
# GEOBLOCK = ("GB",)
# TITLE_RE: named groups (?P<...>) parsed in get_titles(). Accepts URL or bare id. # TITLE_RE: named groups (?P<...>) parsed in get_titles(). Accepts URL or bare id.
TITLE_RE = r"^(?:https?://(?:www\.)?domain\.com/details/)?(?P<title_id>[^/?#]+)" TITLE_RE = r"^(?:https?://(?:www\.)?domain\.com/details/)?(?P<title_id>[^/?#]+)"
# NO_SUBTITLES: service-level idiom telling the pipeline subs are handled in-band. # NO_SUBTITLES: service-level idiom telling the pipeline subs are handled in-band.
@@ -74,6 +74,8 @@ class MUSIC_EXAMPLE(Service):
ALIASES = ("MUSICEX", "MUSICDOMAIN") ALIASES = ("MUSICEX", "MUSICDOMAIN")
# GEOFENCE: regions required; the framework warns/blocks if proxy region mismatches. # GEOFENCE: regions required; the framework warns/blocks if proxy region mismatches.
GEOFENCE = ("US", "UK") GEOFENCE = ("US", "UK")
# GEOBLOCK: the negative form; regions where the service refuses to work, all others allowed.
# GEOBLOCK = ("GB",)
# TITLE_RE: named groups (?P<...>) parsed in get_titles(). Accepts URL or bare id. # TITLE_RE: named groups (?P<...>) parsed in get_titles(). Accepts URL or bare id.
# A music catalogue is addressed by several kinds of thing, so `kind` # A music catalogue is addressed by several kinds of thing, so `kind`
# is part of the identifier and decides which endpoint get_titles() asks. # is part of the identifier and decides which endpoint get_titles() asks.