From aec45e30cc2c698d8c7604fd2ca358f922d251cb Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Wed, 16 Sep 2026 15:06:43 -0600 Subject: [PATCH] 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. --- docs/dev/creating-a-service.md | 9 ++++ docs/dev/rest-api/dashboard.md | 2 +- docs/dev/rest-api/endpoints.md | 2 + docs/dev/rest-api/errors.md | 2 +- docs/dev/rest-api/quickstart.md | 1 + docs/dev/service-architecture.md | 6 ++- tests/core/test_geoblock.py | 42 ++++++++++++++++++ tests/remote/unit/test_errors.py | 7 +++ .../remote/unit/test_handler_proxy_policy.py | 10 +++++ unshackle/core/api/errors.py | 16 +++---- unshackle/core/api/handlers.py | 17 ++++++- unshackle/core/api/routes.py | 10 +++++ unshackle/core/import_service.py | 1 + unshackle/core/remote_service.py | 1 + unshackle/core/service.py | 44 ++++++++++++++++--- unshackle/services/EXAMPLE/__init__.py | 2 + unshackle/services/MUSIC_EXAMPLE/__init__.py | 2 + 17 files changed, 154 insertions(+), 20 deletions(-) create mode 100644 tests/core/test_geoblock.py diff --git a/docs/dev/creating-a-service.md b/docs/dev/creating-a-service.md index c5da92a4..c989ec8b 100644 --- a/docs/dev/creating-a-service.md +++ b/docs/dev/creating-a-service.md @@ -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 `()`. | | `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). | | `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. | @@ -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 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 class EXAMPLE(Service): ALIASES = ("EX", "DOMAIN") GEOFENCE = ("US", "UK") VAULT_TAG = "DIFFERENT_NAME" + + +class WORLDWIDE(Service): + GEOBLOCK = ("GB",) ``` ### What `Service.__init__` wires up diff --git a/docs/dev/rest-api/dashboard.md b/docs/dev/rest-api/dashboard.md index 7392cce0..830a932e 100644 --- a/docs/dev/rest-api/dashboard.md +++ b/docs/dev/rest-api/dashboard.md @@ -182,7 +182,7 @@ GET /api/dashboard/services [{"tag": "EXAMPLE", "state": "staged", "error": null, "commit": "9cc230c98f13b83f6c6fe831595b9702d3ba2786", "staged_commit": "5f0aa17...", "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 diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 35a043dd..de83f246 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -79,6 +79,7 @@ Show the streaming services available on this server, filtered by your allowlist "tag": "EXAMPLE", "aliases": ["EX"], "geofence": ["US"], + "geoblock": [], "title_regex": "^https?://example\\.com/.+", "url": "https://example.com", "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. | | `aliases` | Alternate tags the service also answers to. | | `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`). | | `url` | Service URL, taken from the service's CLI short help. | | `help` | The service module's docstring. | diff --git a/docs/dev/rest-api/errors.md b/docs/dev/rest-api/errors.md index c899b5e6..409906d5 100644 --- a/docs/dev/rest-api/errors.md +++ b/docs/dev/rest-api/errors.md @@ -152,7 +152,7 @@ The classification is keyword-based, checked in order: |---|---|---| | `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 | -| `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 | | `rate limit`, `too many requests`, `429`, `throttle` | `RATE_LIMITED` (429) | yes | | `drm`, `license`, `widevine`, `playready`, `decrypt` | `DRM_ERROR` (502) | no | diff --git a/docs/dev/rest-api/quickstart.md b/docs/dev/rest-api/quickstart.md index 567e5dc3..e861421e 100644 --- a/docs/dev/rest-api/quickstart.md +++ b/docs/dev/rest-api/quickstart.md @@ -141,6 +141,7 @@ curl "$BASE/api/services" \ "tag": "EXAMPLE", "aliases": ["EX"], "geofence": [], + "geoblock": [], "title_regex": null, "url": "https://example.com", "help": "EXAMPLE service documentation...", diff --git a/docs/dev/service-architecture.md b/docs/dev/service-architecture.md index bafe2d7c..182e10f3 100644 --- a/docs/dev/service-architecture.md +++ b/docs/dev/service-architecture.md @@ -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`). | | `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. | | `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. | @@ -117,7 +118,10 @@ up the objects your methods rely on: 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_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). !!! note "`TrackRequest`" diff --git a/tests/core/test_geoblock.py b/tests/core/test_geoblock.py new file mode 100644 index 00000000..a8c419b8 --- /dev/null +++ b/tests/core/test_geoblock.py @@ -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) diff --git a/tests/remote/unit/test_errors.py b/tests/remote/unit/test_errors.py index 76a7b2dc..10e185d3 100644 --- a/tests/remote/unit/test_errors.py +++ b/tests/remote/unit/test_errors.py @@ -109,6 +109,13 @@ def test_build_error_response_debug_mode_includes_traceback() -> None: (Exception("Connection refused"), APIErrorCode.NETWORK_ERROR), (TimeoutError("read timeout"), APIErrorCode.NETWORK_ERROR), (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("HTTP 429 too many requests"), APIErrorCode.RATE_LIMITED), (Exception("DRM license fetch failed"), APIErrorCode.DRM_ERROR), diff --git a/tests/remote/unit/test_handler_proxy_policy.py b/tests/remote/unit/test_handler_proxy_policy.py index 9f177cc2..7aa6ae77 100644 --- a/tests/remote/unit/test_handler_proxy_policy.py +++ b/tests/remote/unit/test_handler_proxy_policy.py @@ -48,6 +48,16 @@ def test_region_mismatch_raises(monkeypatch): 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): patch_server_region(monkeypatch, "CA") assert handlers.resolve_handler_proxy({"client_region": "ca"}, "EXAMPLE", request("plainkey")) == (None, []) diff --git a/unshackle/core/api/errors.py b/unshackle/core/api/errors.py index 623e2a32..6a9f9c58 100644 --- a/unshackle/core/api/errors.py +++ b/unshackle/core/api/errors.py @@ -184,6 +184,14 @@ def categorize_exception( exc_str = f"{exc} {root}".lower() 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(): return APIError( error_code=APIErrorCode.INVALID_PROXY, @@ -220,14 +228,6 @@ def categorize_exception( 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"]): return APIError( error_code=APIErrorCode.NOT_FOUND, diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 802a6826..70f51f78 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -207,7 +207,7 @@ def build_parent_ctx( parent = click.Context(dummy) 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: params.update(extra_params) parent.params = params @@ -357,9 +357,11 @@ def run_service_search( ) service_module = Services.load(normalized_service) + from click import ClickException + try: 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 except Exception as exc: raise APIError( @@ -2126,6 +2128,7 @@ async def dashboard_services_handler(request: web.Request) -> web.Response: "jobs": jobs.get(tag, 0), "aliases": list(services_module.ALIASES.get(tag, ())), "geofence": [], + "geoblock": [], } if staged: # 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) if module is not None: row["geofence"] = list(getattr(module, "GEOFENCE", ()) or ()) + row["geoblock"] = list(getattr(module, "GEOBLOCK", ()) or ()) rows.append(row) return web.json_response(rows) @@ -3601,6 +3605,15 @@ def resolve_handler_proxy( log.debug(f"Server region lookup failed: {e!r}") 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() if not allowed: if server_region and not in_client_region: diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index cf8e5342..bbe49ad2 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -201,6 +201,10 @@ async def services(request: web.Request) -> web.Response: type: array items: type: string + geoblock: + type: array + items: + type: string title_regex: oneOf: - type: string @@ -253,6 +257,7 @@ async def services(request: web.Request) -> web.Response: "tag": tag, "aliases": [], "geofence": [], + "geoblock": [], "title_regex": None, "url": None, "help": None, @@ -270,6 +275,7 @@ async def services(request: web.Request) -> web.Response: if hasattr(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"): title_re = service_module.TITLE_RE @@ -2488,6 +2494,10 @@ async def dashboard_services(request: web.Request) -> web.Response: type: array items: type: string + geoblock: + type: array + items: + type: string '401': description: Dashboard key missing or invalid """ diff --git a/unshackle/core/import_service.py b/unshackle/core/import_service.py index 9494fb62..bf809ffa 100644 --- a/unshackle/core/import_service.py +++ b/unshackle/core/import_service.py @@ -106,6 +106,7 @@ class ImportService: ALIASES: tuple[str, ...] = () GEOFENCE: tuple[str, ...] = () + GEOBLOCK: tuple[str, ...] = () NO_SUBTITLES: bool = False def __init__(self, ctx: click.Context, service_tag: str, title: str, import_file: Optional[str]) -> None: diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 9e320ace..5d53d281 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -760,6 +760,7 @@ class RemoteService: ALIASES: tuple[str, ...] = () GEOFENCE: tuple[str, ...] = () + GEOBLOCK: tuple[str, ...] = () NO_SUBTITLES: bool = False ANIME: bool = False DAILY: bool = False diff --git a/unshackle/core/service.py b/unshackle/core/service.py index e1bdd0fb..49246331 100644 --- a/unshackle/core/service.py +++ b/unshackle/core/service.py @@ -150,6 +150,7 @@ class Service(metaclass=ABCMeta): 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. + 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. 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. @@ -185,6 +186,7 @@ class Service(metaclass=ABCMeta): 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 ctx.parent: proxy = ctx.parent.params["proxy"] @@ -248,13 +250,20 @@ class Service(metaclass=ABCMeta): if not proxy: # 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"): - if self.GEOFENCE: + if self.GEOFENCE or self.GEOBLOCK: try: - current_region = get_ip_info(self.session)["country"].lower() - if any(x.lower() == current_region for x in self.GEOFENCE): + current_region = (get_ip_info(self.session) or {}).get("country", "").lower() + 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") + 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: - requested_proxy = self.GEOFENCE[0] # first is likely main region + requested_proxy = fence_targets[0] # first is likely main region self.log.info( 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"Pass --proxy with a proxy in {requested_proxy}, or the request can fail." ) + except click.ClickException: + raise except Exception as e: self.log.warning(f"Failed to check geofence: {e}") - current_region = None else: - self.log.info("Service has no Geofence") + self.log.info("Service has no region restrictions") if proxy: self.session.proxies.update({"all": proxy}) @@ -282,7 +292,14 @@ class Service(metaclass=ABCMeta): # plaintext-http forward-proxy requests with HTTP 407. # 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. - 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: # No proxy, use cached IP info for title caching (non-critical) try: @@ -292,6 +309,19 @@ class Service(metaclass=ABCMeta): self.log.debug(f"Failed to get cached IP info: {e}") 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( self, title: Title_T, diff --git a/unshackle/services/EXAMPLE/__init__.py b/unshackle/services/EXAMPLE/__init__.py index f086b9bc..68a3f543 100644 --- a/unshackle/services/EXAMPLE/__init__.py +++ b/unshackle/services/EXAMPLE/__init__.py @@ -70,6 +70,8 @@ class EXAMPLE(Service): ALIASES = ("EX", "DOMAIN") # GEOFENCE: regions required; the framework warns/blocks if proxy region mismatches. 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 = r"^(?:https?://(?:www\.)?domain\.com/details/)?(?P[^/?#]+)" # NO_SUBTITLES: service-level idiom telling the pipeline subs are handled in-band. diff --git a/unshackle/services/MUSIC_EXAMPLE/__init__.py b/unshackle/services/MUSIC_EXAMPLE/__init__.py index ac80f85e..35186b54 100644 --- a/unshackle/services/MUSIC_EXAMPLE/__init__.py +++ b/unshackle/services/MUSIC_EXAMPLE/__init__.py @@ -74,6 +74,8 @@ class MUSIC_EXAMPLE(Service): ALIASES = ("MUSICEX", "MUSICDOMAIN") # GEOFENCE: regions required; the framework warns/blocks if proxy region mismatches. 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. # A music catalogue is addressed by several kinds of thing, so `kind` # is part of the identifier and decides which endpoint get_titles() asks.