mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-13 12:35:05 +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)
|
||||
|
||||
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")
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"] = []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user