diff --git a/docs/SERVICE_CONFIG.md b/docs/SERVICE_CONFIG.md index bf2634f..936bc5d 100644 --- a/docs/SERVICE_CONFIG.md +++ b/docs/SERVICE_CONFIG.md @@ -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 +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`. diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index c576e31..e0da5eb 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -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 diff --git a/unshackle/core/config.py b/unshackle/core/config.py index f80497a..06d5fe3 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -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) diff --git a/unshackle/core/proxies/gluetun.py b/unshackle/core/proxies/gluetun.py index a33c9c3..fceeb04 100644 --- a/unshackle/core/proxies/gluetun.py +++ b/unshackle/core/proxies/gluetun.py @@ -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"] = [] diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 99e56be..7a8d6c7 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -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: diff --git a/unshackle/core/service.py b/unshackle/core/service.py index f15c2d4..36b1376 100644 --- a/unshackle/core/service.py +++ b/unshackle/core/service.py @@ -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}") diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index fad62d9..0c3b435 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -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. diff --git a/unshackle/core/utils/ip_info.py b/unshackle/core/utils/ip_info.py new file mode 100644 index 0000000..24caa3b --- /dev/null +++ b/unshackle/core/utils/ip_info.py @@ -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 diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index 6cd2b8c..de59b32 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -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