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:
imSp4rky
2026-05-04 22:20:43 -06:00
parent 8f4cac6c7b
commit a7af898617
9 changed files with 267 additions and 114 deletions
+19
View File
@@ -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)
Enable/disable caching of title metadata to reduce redundant API calls. Default: `true`.
+2 -2
View File
@@ -1986,9 +1986,9 @@ def _resolve_handler_proxy(data: Dict[str, Any], normalized_service: str) -> tup
client_region = data.get("client_region")
if not proxy_param and not no_proxy and client_region and proxy_providers:
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
except Exception:
server_region = None
+1
View File
@@ -93,6 +93,7 @@ class Config:
self.tmdb_api_key: str = kwargs.get("tmdb_api_key") 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.ipinfo_api_key: str = kwargs.get("ipinfo_api_key") or ""
self.update_checks: bool = kwargs.get("update_checks", True)
self.update_check_interval: int = kwargs.get("update_check_interval", 24)
+2 -1
View File
@@ -13,7 +13,8 @@ import requests
from unshackle.core import binaries
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
_gluetun_instances: list["Gluetun"] = []
+2 -2
View File
@@ -481,9 +481,9 @@ class RemoteService:
if not no_proxy and not proxy:
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"):
create_data["client_region"] = ip_info["country"].lower()
except Exception:
+2 -2
View File
@@ -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.tracks import Chapters, Tracks
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
@@ -232,7 +232,7 @@ class Service(metaclass=ABCMeta):
else:
# No proxy, use cached IP info for title caching (non-critical)
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
except Exception as e:
self.log.debug(f"Failed to get cached IP info: {e}")
-107
View File
@@ -22,14 +22,12 @@ from uuid import uuid4
import chardet
import pycountry
import requests
from construct import ValidationError
from fontTools import ttLib
from langcodes import Language, closest_match
from pymp4.parser import Box
from unidecode import unidecode
from unshackle.core.cacher import Cacher
from unshackle.core.config import config
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
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:
"""
Get time elapsed since a timestamp as a string.
+234
View File
@@ -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
View File
@@ -5,6 +5,11 @@ tmdb_api_key: ""
# Get your free client ID at: https://simkl.com/settings/developer/
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
tag: user_tag