mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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`"
|
||||
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<title_id>[^/?#]+)"
|
||||
# NO_SUBTITLES: service-level idiom telling the pipeline subs are handled in-band.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user