mirror of
https://github.com/UrloMythus/UnHided.git
synced 2026-06-10 09:10:23 +00:00
204 lines
7.5 KiB
Python
204 lines
7.5 KiB
Python
"""
|
|
EPG Proxy — XMLTV/EPG pass-through with caching.
|
|
|
|
Supports Channels DVR, Plex, Emby, and any XMLTV-compatible EPG client.
|
|
|
|
Usage:
|
|
GET /proxy/epg?d=<epg_url>&api_password=<key>
|
|
|
|
With custom headers for protected sources:
|
|
GET /proxy/epg?d=<url>&h_Authorization=Bearer+<token>&api_password=<key>
|
|
|
|
With cache TTL override:
|
|
GET /proxy/epg?d=<url>&cache_ttl=7200&api_password=<key>
|
|
"""
|
|
|
|
import hashlib
|
|
import logging
|
|
import time
|
|
from typing import Optional
|
|
|
|
import aiohttp
|
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
from fastapi.responses import Response
|
|
|
|
from mediaflow_proxy.configs import settings
|
|
from mediaflow_proxy.utils.base64_utils import process_potential_base64_url
|
|
from mediaflow_proxy.utils.http_client import create_aiohttp_session
|
|
|
|
logger = logging.getLogger(__name__)
|
|
epg_router = APIRouter()
|
|
|
|
# In-memory EPG cache: {cache_key: (content_bytes, content_type, fetch_timestamp)}
|
|
# EPG data rarely changes; default TTL is 1 hour.
|
|
_epg_cache: dict[str, tuple[bytes, str, float]] = {}
|
|
|
|
|
|
def _get_cached_epg(cache_key: str, ttl: int) -> Optional[tuple[bytes, str]]:
|
|
"""Return (content, content_type) from cache if the entry exists and has not expired."""
|
|
entry = _epg_cache.get(cache_key)
|
|
if entry is None:
|
|
return None
|
|
content, content_type, ts = entry
|
|
if time.monotonic() - ts < ttl:
|
|
return content, content_type
|
|
# Expired — evict lazily
|
|
del _epg_cache[cache_key]
|
|
return None
|
|
|
|
|
|
def _set_cached_epg(cache_key: str, content: bytes, content_type: str) -> None:
|
|
_epg_cache[cache_key] = (content, content_type, time.monotonic())
|
|
|
|
|
|
def _build_cache_key(destination: str, request_headers: dict[str, str]) -> str:
|
|
"""
|
|
Incorporate auth-bearing headers into the cache key so that different
|
|
credentials don't serve each other's cached EPG data.
|
|
"""
|
|
if not request_headers:
|
|
return destination
|
|
header_hash = hashlib.md5(str(sorted(request_headers.items())).encode()).hexdigest()[:8]
|
|
return f"{destination}|{header_hash}"
|
|
|
|
|
|
@epg_router.get("/epg")
|
|
@epg_router.head("/epg")
|
|
async def epg_proxy(
|
|
request: Request,
|
|
destination: str = Query(
|
|
...,
|
|
alias="d",
|
|
description="URL of the XMLTV/EPG source. Supports plain URLs and base64-encoded URLs.",
|
|
),
|
|
cache_ttl: Optional[int] = Query(
|
|
None,
|
|
description=(
|
|
"Cache lifetime in seconds. 0 disables caching. Defaults to the EPG_CACHE_TTL setting (3600 s = 1 h)."
|
|
),
|
|
),
|
|
):
|
|
"""
|
|
Proxy EPG / XMLTV data from any upstream source with optional caching.
|
|
|
|
**Channels DVR setup:** enter this URL as your custom EPG source:
|
|
|
|
http://<proxy-host>:<port>/proxy/epg?d=<epg_url>&api_password=<key>
|
|
|
|
**Protected EPG sources** — pass authentication via `h_` header params:
|
|
|
|
?d=<url>&h_Authorization=Bearer+<token>&api_password=<key>
|
|
|
|
Base64-encoded destination URLs are automatically decoded.
|
|
|
|
Returns the XMLTV XML with `Content-Type: application/xml`.
|
|
"""
|
|
# Resolve base64-encoded destination URLs
|
|
destination = process_potential_base64_url(destination)
|
|
|
|
if not destination.startswith(("http://", "https://")):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Destination must be an http:// or https:// URL.",
|
|
)
|
|
|
|
# Collect upstream request headers from h_<name> query params
|
|
request_headers: dict[str, str] = {
|
|
key[2:]: value for key, value in request.query_params.items() if key.startswith("h_")
|
|
}
|
|
|
|
# Effective TTL — per-request override or global config
|
|
effective_ttl: int = cache_ttl if cache_ttl is not None else settings.epg_cache_ttl
|
|
cache_key = _build_cache_key(destination, request_headers)
|
|
|
|
# --- Cache read -------------------------------------------------------
|
|
if effective_ttl > 0:
|
|
cached = _get_cached_epg(cache_key, effective_ttl)
|
|
if cached is not None:
|
|
content, content_type = cached
|
|
logger.debug("[epg_proxy] Cache HIT: %s", destination)
|
|
if request.method == "HEAD":
|
|
return Response(
|
|
status_code=200,
|
|
headers={
|
|
"Content-Type": content_type,
|
|
"Content-Length": str(len(content)),
|
|
"X-EPG-Cache": "HIT",
|
|
"Cache-Control": f"public, max-age={effective_ttl}",
|
|
},
|
|
)
|
|
return Response(
|
|
content=content,
|
|
media_type=content_type,
|
|
headers={
|
|
"X-EPG-Cache": "HIT",
|
|
"Cache-Control": f"public, max-age={effective_ttl}",
|
|
},
|
|
)
|
|
|
|
# --- Upstream fetch ---------------------------------------------------
|
|
logger.info("[epg_proxy] Fetching EPG from: %s", destination)
|
|
|
|
async with create_aiohttp_session(destination, timeout=120) as (session, proxy_url):
|
|
try:
|
|
async with session.get(
|
|
destination,
|
|
headers=request_headers,
|
|
proxy=proxy_url,
|
|
allow_redirects=True,
|
|
) as response:
|
|
response.raise_for_status()
|
|
content = await response.read()
|
|
content_type = response.headers.get("content-type", "application/xml; charset=utf-8")
|
|
|
|
# Normalise to XML content type if upstream returns something unexpected
|
|
if not any(t in content_type.lower() for t in ("xml", "text")):
|
|
content_type = "application/xml; charset=utf-8"
|
|
|
|
if effective_ttl > 0:
|
|
_set_cached_epg(cache_key, content, content_type)
|
|
logger.info(
|
|
"[epg_proxy] Cached %d bytes from %s (TTL=%ds)",
|
|
len(content),
|
|
destination,
|
|
effective_ttl,
|
|
)
|
|
|
|
if request.method == "HEAD":
|
|
return Response(
|
|
status_code=200,
|
|
headers={
|
|
"Content-Type": content_type,
|
|
"Content-Length": str(len(content)),
|
|
"X-EPG-Cache": "MISS",
|
|
"Cache-Control": f"public, max-age={effective_ttl}",
|
|
},
|
|
)
|
|
return Response(
|
|
content=content,
|
|
media_type=content_type,
|
|
headers={
|
|
"X-EPG-Cache": "MISS",
|
|
"Cache-Control": f"public, max-age={effective_ttl}",
|
|
},
|
|
)
|
|
|
|
except aiohttp.ClientResponseError as e:
|
|
logger.warning("[epg_proxy] Upstream HTTP %s for %s", e.status, destination)
|
|
raise HTTPException(
|
|
status_code=e.status,
|
|
detail=f"Upstream EPG error: HTTP {e.status}",
|
|
)
|
|
except aiohttp.ClientConnectorError as e:
|
|
logger.error("[epg_proxy] Cannot connect to %s: %s", destination, e)
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"Cannot connect to EPG source: {e}",
|
|
)
|
|
except TimeoutError:
|
|
logger.error("[epg_proxy] Timeout fetching %s", destination)
|
|
raise HTTPException(status_code=504, detail="EPG source timed out")
|
|
except aiohttp.ClientError as e:
|
|
logger.error("[epg_proxy] Fetch error for %s: %s", destination, e)
|
|
raise HTTPException(status_code=502, detail=f"EPG fetch failed: {e}")
|