mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 21:15:06 +00:00
feat(ip-info): consolidate IP lookup, add ipinfo.io token support
Replace get_ip_info + get_cached_ip_info pair with a single unshackle.core.utils.ip_info module providing a normalized return shape across providers. Adds optional ipinfo_api_key config for the ipinfo.io Lite endpoint (higher rate limits, ASN/org/continent data), swaps the ipapi.co fallback for ip-api.in, and migrates all callers (service, gluetun, remote_service, api/handlers, DSNP, YT) to the new import path. Auth token is sent per-request and never attached to the shared session headers.
This commit is contained in:
@@ -119,6 +119,25 @@ simkl_client_id: "your_client_id_here"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ipinfo_api_key (str)
|
||||||
|
|
||||||
|
Optional API token for [ipinfo.io](https://ipinfo.io). When set, unshackle uses the free authenticated **Lite** endpoint (`https://api.ipinfo.io/lite/me`), which has substantially higher rate limits than the anonymous endpoint and returns richer fields (ASN, organization name, continent). Leave empty to use the anonymous ipinfo.io endpoint, with [ip-api.in](https://ip-api.in) as a final fallback.
|
||||||
|
|
||||||
|
To obtain an ipinfo.io token:
|
||||||
|
|
||||||
|
1. Sign up for a free account at <https://ipinfo.io/signup>
|
||||||
|
2. Copy the token from your dashboard
|
||||||
|
|
||||||
|
For example,
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ipinfo_api_key: "12a3b45cd678ef" # Not a real key
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The token is only ever sent to `api.ipinfo.io` as a per-request `Authorization` header — it is never attached to your session for service requests. Used by `core/utils/ip_info.py` for region detection and proxy verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## title_cache_enabled (bool)
|
## title_cache_enabled (bool)
|
||||||
|
|
||||||
Enable/disable caching of title metadata to reduce redundant API calls. Default: `true`.
|
Enable/disable caching of title metadata to reduce redundant API calls. Default: `true`.
|
||||||
|
|||||||
@@ -1986,9 +1986,9 @@ def _resolve_handler_proxy(data: Dict[str, Any], normalized_service: str) -> tup
|
|||||||
client_region = data.get("client_region")
|
client_region = data.get("client_region")
|
||||||
if not proxy_param and not no_proxy and client_region and proxy_providers:
|
if not proxy_param and not no_proxy and client_region and proxy_providers:
|
||||||
try:
|
try:
|
||||||
from unshackle.core.utilities import get_cached_ip_info
|
from unshackle.core.utils.ip_info import get_ip_info
|
||||||
|
|
||||||
server_ip_info = get_cached_ip_info(None)
|
server_ip_info = get_ip_info(None, cached=True)
|
||||||
server_region = server_ip_info.get("country", "").lower() if server_ip_info else None
|
server_region = server_ip_info.get("country", "").lower() if server_ip_info else None
|
||||||
except Exception:
|
except Exception:
|
||||||
server_region = None
|
server_region = None
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ class Config:
|
|||||||
self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or ""
|
self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or ""
|
||||||
self.simkl_client_id: str = kwargs.get("simkl_client_id") or ""
|
self.simkl_client_id: str = kwargs.get("simkl_client_id") or ""
|
||||||
self.decrypt_labs_api_key: str = kwargs.get("decrypt_labs_api_key") or ""
|
self.decrypt_labs_api_key: str = kwargs.get("decrypt_labs_api_key") or ""
|
||||||
|
self.ipinfo_api_key: str = kwargs.get("ipinfo_api_key") or ""
|
||||||
self.update_checks: bool = kwargs.get("update_checks", True)
|
self.update_checks: bool = kwargs.get("update_checks", True)
|
||||||
self.update_check_interval: int = kwargs.get("update_check_interval", 24)
|
self.update_check_interval: int = kwargs.get("update_check_interval", 24)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import requests
|
|||||||
|
|
||||||
from unshackle.core import binaries
|
from unshackle.core import binaries
|
||||||
from unshackle.core.proxies.proxy import Proxy
|
from unshackle.core.proxies.proxy import Proxy
|
||||||
from unshackle.core.utilities import get_country_code, get_country_name, get_debug_logger, get_ip_info
|
from unshackle.core.utilities import get_country_code, get_country_name, get_debug_logger
|
||||||
|
from unshackle.core.utils.ip_info import get_ip_info
|
||||||
|
|
||||||
# Global registry for cleanup on exit
|
# Global registry for cleanup on exit
|
||||||
_gluetun_instances: list["Gluetun"] = []
|
_gluetun_instances: list["Gluetun"] = []
|
||||||
|
|||||||
@@ -481,9 +481,9 @@ class RemoteService:
|
|||||||
|
|
||||||
if not no_proxy and not proxy:
|
if not no_proxy and not proxy:
|
||||||
try:
|
try:
|
||||||
from unshackle.core.utilities import get_cached_ip_info
|
from unshackle.core.utils.ip_info import get_ip_info
|
||||||
|
|
||||||
ip_info = get_cached_ip_info(self._session)
|
ip_info = get_ip_info(self._session, cached=True)
|
||||||
if ip_info and ip_info.get("country"):
|
if ip_info and ip_info.get("country"):
|
||||||
create_data["client_region"] = ip_info["country"].lower()
|
create_data["client_region"] = ip_info["country"].lower()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from unshackle.core.title_cacher import TitleCacher, get_account_hash, get_regio
|
|||||||
from unshackle.core.titles import Title_T, Titles_T
|
from unshackle.core.titles import Title_T, Titles_T
|
||||||
from unshackle.core.tracks import Chapters, Tracks
|
from unshackle.core.tracks import Chapters, Tracks
|
||||||
from unshackle.core.tracks.video import Video
|
from unshackle.core.tracks.video import Video
|
||||||
from unshackle.core.utilities import get_cached_ip_info, get_ip_info
|
from unshackle.core.utils.ip_info import get_ip_info
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -232,7 +232,7 @@ class Service(metaclass=ABCMeta):
|
|||||||
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:
|
||||||
ip_info = get_cached_ip_info(self.session)
|
ip_info = get_ip_info(self.session, cached=True)
|
||||||
self.current_region = ip_info.get("country", "").lower() if ip_info else None
|
self.current_region = ip_info.get("country", "").lower() if ip_info else None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.debug(f"Failed to get cached IP info: {e}")
|
self.log.debug(f"Failed to get cached IP info: {e}")
|
||||||
|
|||||||
@@ -22,14 +22,12 @@ from uuid import uuid4
|
|||||||
|
|
||||||
import chardet
|
import chardet
|
||||||
import pycountry
|
import pycountry
|
||||||
import requests
|
|
||||||
from construct import ValidationError
|
from construct import ValidationError
|
||||||
from fontTools import ttLib
|
from fontTools import ttLib
|
||||||
from langcodes import Language, closest_match
|
from langcodes import Language, closest_match
|
||||||
from pymp4.parser import Box
|
from pymp4.parser import Box
|
||||||
from unidecode import unidecode
|
from unidecode import unidecode
|
||||||
|
|
||||||
from unshackle.core.cacher import Cacher
|
|
||||||
from unshackle.core.config import config
|
from unshackle.core.config import config
|
||||||
from unshackle.core.constants import LANGUAGE_EXACT_DISTANCE, LANGUAGE_MAX_DISTANCE
|
from unshackle.core.constants import LANGUAGE_EXACT_DISTANCE, LANGUAGE_MAX_DISTANCE
|
||||||
|
|
||||||
@@ -354,111 +352,6 @@ def get_country_code(name: str) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_ip_info(session: Optional[requests.Session] = None) -> dict:
|
|
||||||
"""
|
|
||||||
Use ipinfo.io to get IP location information.
|
|
||||||
|
|
||||||
If you provide a Requests Session with a Proxy, that proxies IP information
|
|
||||||
is what will be returned.
|
|
||||||
"""
|
|
||||||
return (session or requests.Session()).get("https://ipinfo.io/json").json()
|
|
||||||
|
|
||||||
|
|
||||||
def get_cached_ip_info(session: Optional[requests.Session] = None) -> Optional[dict]:
|
|
||||||
"""
|
|
||||||
Get IP location information with 24-hour caching and fallback providers.
|
|
||||||
|
|
||||||
This function uses a global cache to avoid repeated API calls when the IP
|
|
||||||
hasn't changed. Should only be used for local IP checks, not for proxy verification.
|
|
||||||
Implements smart provider rotation to handle rate limiting (429 errors).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: Optional requests session (usually without proxy for local IP)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with IP info including 'country' key, or None if all providers fail
|
|
||||||
"""
|
|
||||||
|
|
||||||
log = logging.getLogger("get_cached_ip_info")
|
|
||||||
cache = Cacher("global").get("ip_info")
|
|
||||||
|
|
||||||
if cache and not cache.expired:
|
|
||||||
return cache.data
|
|
||||||
|
|
||||||
provider_state_cache = Cacher("global").get("ip_provider_state")
|
|
||||||
provider_state = provider_state_cache.data if provider_state_cache and not provider_state_cache.expired else {}
|
|
||||||
|
|
||||||
providers = {
|
|
||||||
"ipinfo": "https://ipinfo.io/json",
|
|
||||||
"ipapi": "https://ipapi.co/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
session = session or requests.Session()
|
|
||||||
provider_order = ["ipinfo", "ipapi"]
|
|
||||||
|
|
||||||
current_time = time.time()
|
|
||||||
for provider_name in list(provider_order):
|
|
||||||
if provider_name in provider_state:
|
|
||||||
rate_limit_info = provider_state[provider_name]
|
|
||||||
if (current_time - rate_limit_info.get("rate_limited_at", 0)) < 300:
|
|
||||||
log.debug(f"Provider {provider_name} was rate limited recently, trying other provider first")
|
|
||||||
provider_order.remove(provider_name)
|
|
||||||
provider_order.append(provider_name)
|
|
||||||
break
|
|
||||||
|
|
||||||
for provider_name in provider_order:
|
|
||||||
provider_url = providers[provider_name]
|
|
||||||
try:
|
|
||||||
log.debug(f"Trying IP provider: {provider_name}")
|
|
||||||
response = session.get(provider_url, timeout=10)
|
|
||||||
|
|
||||||
if response.status_code == 429:
|
|
||||||
log.warning(f"Provider {provider_name} returned 429 (rate limited), trying next provider")
|
|
||||||
if provider_name not in provider_state:
|
|
||||||
provider_state[provider_name] = {}
|
|
||||||
provider_state[provider_name]["rate_limited_at"] = current_time
|
|
||||||
provider_state[provider_name]["rate_limit_count"] = (
|
|
||||||
provider_state[provider_name].get("rate_limit_count", 0) + 1
|
|
||||||
)
|
|
||||||
|
|
||||||
provider_state_cache.set(provider_state, expiration=300)
|
|
||||||
continue
|
|
||||||
|
|
||||||
elif response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
normalized_data = {}
|
|
||||||
|
|
||||||
if "country" in data:
|
|
||||||
normalized_data = data
|
|
||||||
elif "country_code" in data:
|
|
||||||
normalized_data = {
|
|
||||||
"country": data.get("country_code", "").lower(),
|
|
||||||
"region": data.get("region", ""),
|
|
||||||
"city": data.get("city", ""),
|
|
||||||
"ip": data.get("ip", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
if normalized_data and "country" in normalized_data:
|
|
||||||
log.debug(f"Successfully got IP info from provider: {provider_name}")
|
|
||||||
|
|
||||||
if provider_name in provider_state:
|
|
||||||
provider_state[provider_name].pop("rate_limited_at", None)
|
|
||||||
provider_state_cache.set(provider_state, expiration=300)
|
|
||||||
|
|
||||||
normalized_data["_provider"] = provider_name
|
|
||||||
cache.set(normalized_data, expiration=86400)
|
|
||||||
return normalized_data
|
|
||||||
else:
|
|
||||||
log.debug(f"Provider {provider_name} returned status {response.status_code}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log.debug(f"Provider {provider_name} failed with exception: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
log.warning("All IP geolocation providers failed")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def time_elapsed_since(start: float) -> str:
|
def time_elapsed_since(start: float) -> str:
|
||||||
"""
|
"""
|
||||||
Get time elapsed since a timestamp as a string.
|
Get time elapsed since a timestamp as a string.
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from unshackle.core.cacher import Cacher
|
||||||
|
|
||||||
|
CACHE_KEY = "ip_info_v2"
|
||||||
|
CACHE_TTL = 86400 # 24 hours
|
||||||
|
PROVIDER_STATE_KEY = "ip_provider_state"
|
||||||
|
RATE_LIMIT_COOLDOWN = 300 # 5 minutes
|
||||||
|
REQUEST_TIMEOUT = 10
|
||||||
|
|
||||||
|
Fetcher = Callable[[requests.Session], Optional[dict]]
|
||||||
|
|
||||||
|
|
||||||
|
class _RateLimited(Exception):
|
||||||
|
"""Raised by a provider fetcher when the upstream returns 429."""
|
||||||
|
|
||||||
|
|
||||||
|
def _empty() -> dict:
|
||||||
|
return {
|
||||||
|
"ip": "",
|
||||||
|
"country": "",
|
||||||
|
"country_code": "",
|
||||||
|
"region": "",
|
||||||
|
"city": "",
|
||||||
|
"org": "",
|
||||||
|
"asn": "",
|
||||||
|
"as_name": "",
|
||||||
|
"continent_code": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ipinfo_lite(data: dict) -> Optional[dict]:
|
||||||
|
code = (data.get("country_code") or "").strip()
|
||||||
|
if not code:
|
||||||
|
return None
|
||||||
|
asn = (data.get("asn") or "").strip()
|
||||||
|
as_name = (data.get("as_name") or "").strip()
|
||||||
|
org = f"{asn} {as_name}".strip() if (asn or as_name) else ""
|
||||||
|
out = _empty()
|
||||||
|
out.update(
|
||||||
|
{
|
||||||
|
"ip": data.get("ip") or "",
|
||||||
|
"country": code.lower(),
|
||||||
|
"country_code": code.upper(),
|
||||||
|
"org": org,
|
||||||
|
"asn": asn,
|
||||||
|
"as_name": as_name,
|
||||||
|
"continent_code": (data.get("continent_code") or "").upper(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ipinfo(data: dict) -> Optional[dict]:
|
||||||
|
code = (data.get("country") or "").strip()
|
||||||
|
if not code:
|
||||||
|
return None
|
||||||
|
out = _empty()
|
||||||
|
out.update(
|
||||||
|
{
|
||||||
|
"ip": data.get("ip") or "",
|
||||||
|
"country": code.lower(),
|
||||||
|
"country_code": code.upper(),
|
||||||
|
"region": data.get("region") or "",
|
||||||
|
"city": data.get("city") or "",
|
||||||
|
"org": data.get("org") or "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ip_api_in(data: dict) -> Optional[dict]:
|
||||||
|
code = (data.get("country_code") or "").strip()
|
||||||
|
if not code:
|
||||||
|
return None
|
||||||
|
asn = (data.get("asn") or "").strip()
|
||||||
|
org_name = (data.get("organization") or "").strip()
|
||||||
|
org = f"{asn} {org_name}".strip() if (asn or org_name) else ""
|
||||||
|
out = _empty()
|
||||||
|
out.update(
|
||||||
|
{
|
||||||
|
"ip": data.get("ip") or "",
|
||||||
|
"country": code.lower(),
|
||||||
|
"country_code": code.upper(),
|
||||||
|
"region": data.get("region") or "",
|
||||||
|
"city": data.get("city") or "",
|
||||||
|
"org": org,
|
||||||
|
"asn": asn,
|
||||||
|
"as_name": org_name,
|
||||||
|
"continent_code": (data.get("continent_code") or "").upper(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _check(response: requests.Response) -> Optional[dict]:
|
||||||
|
"""Raise _RateLimited on 429, return parsed JSON on 200, else None."""
|
||||||
|
if response.status_code == 429:
|
||||||
|
raise _RateLimited()
|
||||||
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_ipinfo_lite(token: str) -> Fetcher:
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
def fetch(session: requests.Session) -> Optional[dict]:
|
||||||
|
payload = _check(session.get("https://api.ipinfo.io/lite/me", headers=headers, timeout=REQUEST_TIMEOUT))
|
||||||
|
return _parse_ipinfo_lite(payload) if payload else None
|
||||||
|
|
||||||
|
return fetch
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_ipinfo(session: requests.Session) -> Optional[dict]:
|
||||||
|
payload = _check(session.get("https://ipinfo.io/json", timeout=REQUEST_TIMEOUT))
|
||||||
|
return _parse_ipinfo(payload) if payload else None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_ip_api_in(session: requests.Session) -> Optional[dict]:
|
||||||
|
"""ip-api.in has no /me endpoint — resolve IP via ipify first, then look it up."""
|
||||||
|
ip_resp = session.get("https://api.ipify.org", timeout=REQUEST_TIMEOUT)
|
||||||
|
if ip_resp.status_code == 429:
|
||||||
|
raise _RateLimited()
|
||||||
|
if ip_resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
ip = (ip_resp.text or "").strip()
|
||||||
|
if not ip:
|
||||||
|
return None
|
||||||
|
payload = _check(session.get(f"https://ip-api.in/api/v1/ip/{ip}", timeout=REQUEST_TIMEOUT))
|
||||||
|
if not payload or not payload.get("success"):
|
||||||
|
return None
|
||||||
|
return _parse_ip_api_in(payload.get("data") or {})
|
||||||
|
|
||||||
|
|
||||||
|
def _build_providers() -> list[tuple[str, Fetcher]]:
|
||||||
|
"""Return ordered (name, fetcher) pairs. Token read at call time."""
|
||||||
|
from unshackle.core.config import config
|
||||||
|
|
||||||
|
providers: list[tuple[str, Fetcher]] = []
|
||||||
|
token = (getattr(config, "ipinfo_api_key", "") or "").strip()
|
||||||
|
if token:
|
||||||
|
providers.append(("ipinfo_lite", _fetch_ipinfo_lite(token)))
|
||||||
|
providers.append(("ipinfo", _fetch_ipinfo))
|
||||||
|
providers.append(("ip_api_in", _fetch_ip_api_in))
|
||||||
|
return providers
|
||||||
|
|
||||||
|
|
||||||
|
def get_ip_info(
|
||||||
|
session: Optional[requests.Session] = None,
|
||||||
|
*,
|
||||||
|
cached: bool = False,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Look up IP/geolocation info via ipinfo.io (Lite when `ipinfo_api_key` configured)
|
||||||
|
with fallback to ip-api.in.
|
||||||
|
|
||||||
|
Returns a normalized dict with keys: `ip`, `country` (lowercase ISO2),
|
||||||
|
`country_code` (uppercase ISO2), `region`, `city`, `org`, `asn`, `as_name`,
|
||||||
|
`continent_code`, and `_provider`. Returns None if every provider fails.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Optional requests session. If a proxied session is passed, the
|
||||||
|
returned info reflects the proxy's exit IP. Auth headers for ipinfo
|
||||||
|
are sent per-request; never mutated onto session.headers.
|
||||||
|
cached: When True, read/write a 24h Cacher-backed entry. Use only for
|
||||||
|
local IP lookups — never with a proxied session.
|
||||||
|
"""
|
||||||
|
log = logging.getLogger("ip_info")
|
||||||
|
|
||||||
|
if cached:
|
||||||
|
cache = Cacher("global").get(CACHE_KEY)
|
||||||
|
if cache and not cache.expired and cache.data:
|
||||||
|
return cache.data
|
||||||
|
else:
|
||||||
|
cache = None
|
||||||
|
|
||||||
|
state_cache = Cacher("global").get(PROVIDER_STATE_KEY)
|
||||||
|
state: dict[str, Any] = (
|
||||||
|
state_cache.data if state_cache and not state_cache.expired and isinstance(state_cache.data, dict) else {}
|
||||||
|
)
|
||||||
|
|
||||||
|
providers = _build_providers()
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
def _cooldown_key(item: tuple[str, Fetcher]) -> int:
|
||||||
|
info = state.get(item[0]) or {}
|
||||||
|
return 1 if (now - info.get("rate_limited_at", 0)) < RATE_LIMIT_COOLDOWN else 0
|
||||||
|
|
||||||
|
providers.sort(key=_cooldown_key)
|
||||||
|
|
||||||
|
sess = session or requests.Session()
|
||||||
|
|
||||||
|
for name, fetcher in providers:
|
||||||
|
log.debug(f"Trying IP provider: {name}")
|
||||||
|
try:
|
||||||
|
normalized = fetcher(sess)
|
||||||
|
except _RateLimited:
|
||||||
|
log.warning(f"Provider {name} returned 429 (rate limited), trying next provider")
|
||||||
|
entry = state.setdefault(name, {})
|
||||||
|
entry["rate_limited_at"] = now
|
||||||
|
entry["rate_limit_count"] = entry.get("rate_limit_count", 0) + 1
|
||||||
|
state_cache.set(state, expiration=RATE_LIMIT_COOLDOWN)
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
log.debug(f"Provider {name} failed with exception: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not normalized:
|
||||||
|
log.debug(f"Provider {name} returned no usable data")
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized["_provider"] = name
|
||||||
|
log.debug(f"Successfully got IP info from provider: {name}")
|
||||||
|
|
||||||
|
if name in state and state[name].pop("rate_limited_at", None) is not None:
|
||||||
|
state_cache.set(state, expiration=RATE_LIMIT_COOLDOWN)
|
||||||
|
|
||||||
|
if cached and cache is not None:
|
||||||
|
cache.set(normalized, expiration=CACHE_TTL)
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
log.warning("All IP geolocation providers failed")
|
||||||
|
return None
|
||||||
@@ -5,6 +5,11 @@ tmdb_api_key: ""
|
|||||||
# Get your free client ID at: https://simkl.com/settings/developer/
|
# Get your free client ID at: https://simkl.com/settings/developer/
|
||||||
simkl_client_id: ""
|
simkl_client_id: ""
|
||||||
|
|
||||||
|
# Optional ipinfo.io API token. When set, unshackle uses the free Lite endpoint
|
||||||
|
# which has higher rate limits and richer IP info (ASN, org, continent).
|
||||||
|
# Get a free token at: https://ipinfo.io/signup
|
||||||
|
ipinfo_api_key: ""
|
||||||
|
|
||||||
# Group or Username to postfix to the end of all download filenames following a dash
|
# Group or Username to postfix to the end of all download filenames following a dash
|
||||||
tag: user_tag
|
tag: user_tag
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user