This commit is contained in:
@@ -5,6 +5,7 @@ with randomized order so the same show never appears twice in a row.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
@@ -22,12 +23,48 @@ from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, parse_qs, unquote, urlencode
|
||||
import mimetypes
|
||||
|
||||
# Per-channel schedule cache: channel_id -> (paths, durations). TTL 24h so EPG and live stay in sync.
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Per-channel schedule cache: channel_id -> (paths, durations). TTL = (timespan - 3h) so a new span is ready before the current one ends.
|
||||
_schedule_cache: dict[str, tuple[list[Path], list[float], float]] = {}
|
||||
SCHEDULE_CACHE_TTL_SEC = 24 * 3600
|
||||
EPG_DAYS = 14
|
||||
# Parsed from EPG_TIMESPAN (e.g. "2d", "14d", "48h"). Default 14d. Schedule refreshes after (timespan - 3h).
|
||||
EPG_TIMESPAN_DAYS = 14.0
|
||||
EPG_TIMESPAN_SEC = 14.0 * 24 * 3600
|
||||
SCHEDULE_CACHE_TTL_SEC = (14 * 24 - 3) * 3600 # 14d - 3h
|
||||
DEFAULT_DURATION_SEC = 3600
|
||||
|
||||
|
||||
def _parse_epg_timespan(value: str) -> tuple[float, float]:
|
||||
"""Parse EPG_TIMESPAN env (e.g. '2d', '14d', '48h'). Returns (days, seconds). Default (14, 14*24*3600) on error."""
|
||||
value = (value or "").strip().lower()
|
||||
if not value:
|
||||
return 14.0, 14.0 * 24 * 3600
|
||||
m = re.match(r"^(\d+(?:\.\d+)?)\s*([dh])?$", value)
|
||||
if not m:
|
||||
return 14.0, 14.0 * 24 * 3600
|
||||
num = float(m.group(1))
|
||||
unit = (m.group(2) or "d")
|
||||
if unit == "h":
|
||||
days = num / 24.0
|
||||
else:
|
||||
days = num
|
||||
sec = days * 24 * 3600
|
||||
return days, sec
|
||||
|
||||
|
||||
def _init_epg_timespan() -> None:
|
||||
"""Load EPG_TIMESPAN from env and set EPG_* and SCHEDULE_CACHE_TTL_SEC."""
|
||||
global EPG_TIMESPAN_DAYS, EPG_TIMESPAN_SEC, SCHEDULE_CACHE_TTL_SEC
|
||||
days, sec = _parse_epg_timespan(os.environ.get("EPG_TIMESPAN", ""))
|
||||
EPG_TIMESPAN_DAYS = days
|
||||
EPG_TIMESPAN_SEC = sec
|
||||
# New schedule is generated after (timespan - 3 hours); minimum 1 hour TTL
|
||||
schedule_ttl = sec - 3 * 3600
|
||||
SCHEDULE_CACHE_TTL_SEC = max(3600, schedule_ttl)
|
||||
|
||||
|
||||
_init_epg_timespan()
|
||||
|
||||
MOVIES_ROOT = Path("/movies")
|
||||
SERIES_ROOT = Path("/series")
|
||||
DATA_ROOT = Path("/data")
|
||||
@@ -131,6 +168,7 @@ def build_channel_path_list(path_keys: list[str]) -> list[Path]:
|
||||
|
||||
def get_duration(path: Path) -> float:
|
||||
"""Return duration in seconds via ffprobe. Uses DEFAULT_DURATION_SEC on failure."""
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[
|
||||
@@ -145,9 +183,11 @@ def get_duration(path: Path) -> float:
|
||||
timeout=30,
|
||||
)
|
||||
if out.returncode == 0 and out.stdout.strip():
|
||||
return float(out.stdout.strip())
|
||||
except (subprocess.TimeoutExpired, ValueError, FileNotFoundError):
|
||||
pass
|
||||
d = float(out.stdout.strip())
|
||||
logger.debug("ffprobe %s -> %.0fs in %.2fs", path.name, d, time.monotonic() - t0)
|
||||
return d
|
||||
except (subprocess.TimeoutExpired, ValueError, FileNotFoundError) as e:
|
||||
logger.debug("ffprobe %s failed: %s", path.name, e)
|
||||
return DEFAULT_DURATION_SEC
|
||||
|
||||
|
||||
@@ -160,7 +200,10 @@ def get_or_build_schedule(channel_id: str) -> tuple[list[Path], list[float]] | N
|
||||
if channel_id in _schedule_cache:
|
||||
paths, durations, ts = _schedule_cache[channel_id]
|
||||
if now - ts < SCHEDULE_CACHE_TTL_SEC:
|
||||
logger.debug("schedule cache hit for channel %s (%s videos)", channel_id, len(paths))
|
||||
return paths, durations
|
||||
logger.info("Building schedule for channel %s ...", channel_id)
|
||||
t0 = time.monotonic()
|
||||
channels = get_channels()
|
||||
channel = next((c for c in channels if c.get("id") == channel_id), None)
|
||||
if not channel:
|
||||
@@ -169,7 +212,9 @@ def get_or_build_schedule(channel_id: str) -> tuple[list[Path], list[float]] | N
|
||||
if not paths:
|
||||
return None
|
||||
durations = [get_duration(p) for p in paths]
|
||||
elapsed = time.monotonic() - t0
|
||||
_schedule_cache[channel_id] = (paths, durations, now)
|
||||
logger.info("Schedule for channel %s built in %.1fs (%s videos)", channel_id, elapsed, len(paths))
|
||||
return paths, durations
|
||||
|
||||
|
||||
@@ -311,6 +356,7 @@ def _tmdb_wait_rate_limit() -> None:
|
||||
break
|
||||
wait = TMDB_RATE_LIMIT_WINDOW_SEC - (now - _tmdb_request_times[0])
|
||||
if wait > 0:
|
||||
logger.debug("TMDB rate limit: waiting %.1fs (%s/40 in window)", wait, len(_tmdb_request_times))
|
||||
time.sleep(min(0.5, wait))
|
||||
|
||||
|
||||
@@ -331,12 +377,14 @@ def _tmdb_request(path: str, params: dict | None = None) -> dict | None:
|
||||
q.update(params)
|
||||
url = f"{TMDB_API_BASE}{path}?{urlencode(q)}"
|
||||
req = urllib.request.Request(url)
|
||||
logger.debug("TMDB request: %s", path)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
data = json.load(resp)
|
||||
_tmdb_record_request()
|
||||
return data
|
||||
except (urllib.error.HTTPError, urllib.error.URLError, OSError, json.JSONDecodeError):
|
||||
except (urllib.error.HTTPError, urllib.error.URLError, OSError, json.JSONDecodeError) as e:
|
||||
logger.debug("TMDB request failed %s: %s", path, e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -346,6 +394,7 @@ def _tmdb_search_series(name: str) -> tuple[int, str] | None:
|
||||
if name in _tmdb_series_cache:
|
||||
sid, sname, ts = _tmdb_series_cache[name]
|
||||
if now - ts < TMDB_CACHE_TTL_SEC:
|
||||
logger.debug("TMDB series_search %s: memory hit -> %s", name, sid)
|
||||
return (sid, sname)
|
||||
key = _tmdb_sanitize_key(name)
|
||||
cached = _tmdb_read_disk_cache("series_search", key)
|
||||
@@ -353,7 +402,9 @@ def _tmdb_search_series(name: str) -> tuple[int, str] | None:
|
||||
sid, sname = cached.get("series_id"), cached.get("series_name_nl")
|
||||
if sid is not None and sname is not None:
|
||||
_tmdb_series_cache[name] = (int(sid), sname, now)
|
||||
logger.debug("TMDB series_search %s: disk hit -> %s", name, sid)
|
||||
return (int(sid), sname)
|
||||
logger.debug("TMDB series_search %s: API request", name)
|
||||
data = _tmdb_request("/search/tv", {"query": name})
|
||||
if not data:
|
||||
return None
|
||||
@@ -376,12 +427,15 @@ def _tmdb_get_series_details(series_id: int) -> dict | None:
|
||||
if series_id in _tmdb_series_details_cache:
|
||||
details, ts = _tmdb_series_details_cache[series_id]
|
||||
if now - ts < TMDB_CACHE_TTL_SEC:
|
||||
logger.debug("TMDB series_details %s: memory hit", series_id)
|
||||
return details
|
||||
key = str(series_id)
|
||||
cached = _tmdb_read_disk_cache("series_details", key)
|
||||
if cached is not None:
|
||||
_tmdb_series_details_cache[series_id] = (cached, now)
|
||||
logger.debug("TMDB series_details %s: disk hit", series_id)
|
||||
return cached
|
||||
logger.debug("TMDB series_details %s: API request", series_id)
|
||||
data = _tmdb_request(f"/tv/{series_id}")
|
||||
if not data:
|
||||
return None
|
||||
@@ -401,12 +455,15 @@ def _tmdb_get_episode(series_id: int, season: int, episode: int) -> dict | None:
|
||||
if key_tuple in _tmdb_episode_cache:
|
||||
meta, ts = _tmdb_episode_cache[key_tuple]
|
||||
if now - ts < TMDB_CACHE_TTL_SEC:
|
||||
logger.debug("TMDB episode %s s%s e%s: memory hit", series_id, season, episode)
|
||||
return meta
|
||||
key = f"{series_id}_{season}_{episode}"
|
||||
cached = _tmdb_read_disk_cache("episode", key)
|
||||
if cached is not None:
|
||||
_tmdb_episode_cache[key_tuple] = (cached, now)
|
||||
logger.debug("TMDB episode %s s%s e%s: disk hit", series_id, season, episode)
|
||||
return cached
|
||||
logger.debug("TMDB episode %s s%s e%s: API request", series_id, season, episode)
|
||||
data = _tmdb_request(f"/tv/{series_id}/season/{season}/episode/{episode}")
|
||||
if not data:
|
||||
return None
|
||||
@@ -426,12 +483,15 @@ def _tmdb_get_episode_credits(series_id: int, season: int, episode: int) -> dict
|
||||
if key_tuple in _tmdb_episode_credits_cache:
|
||||
cred, ts = _tmdb_episode_credits_cache[key_tuple]
|
||||
if now - ts < TMDB_CACHE_TTL_SEC:
|
||||
logger.debug("TMDB episode_credits %s s%s e%s: memory hit", series_id, season, episode)
|
||||
return cred
|
||||
key = f"{series_id}_{season}_{episode}"
|
||||
cached = _tmdb_read_disk_cache("episode_credits", key)
|
||||
if cached is not None:
|
||||
_tmdb_episode_credits_cache[key_tuple] = (cached, now)
|
||||
logger.debug("TMDB episode_credits %s s%s e%s: disk hit", series_id, season, episode)
|
||||
return cached
|
||||
logger.debug("TMDB episode_credits %s s%s e%s: API request", series_id, season, episode)
|
||||
data = _tmdb_request(f"/tv/{series_id}/season/{season}/episode/{episode}/credits")
|
||||
if not data:
|
||||
return None
|
||||
@@ -518,15 +578,17 @@ def get_episode_metadata(programme_name: str, path: Path) -> dict | None:
|
||||
def build_epg_xml() -> str:
|
||||
"""
|
||||
Build XMLTV EPG for all channels. Schedule repeats from midnight UTC (same
|
||||
epoch as live stream). Programmes generated for EPG_DAYS. Uses cached schedule.
|
||||
epoch as live stream). Programmes generated for EPG_TIMESPAN_DAYS. Uses cached schedule.
|
||||
"""
|
||||
epg_start = time.monotonic()
|
||||
channels = get_channels()
|
||||
if not channels:
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>\n<tv></tv>'
|
||||
|
||||
logger.info("EPG build started (%s channels)", len(channels))
|
||||
now = datetime.now(timezone.utc)
|
||||
epoch = _schedule_epoch_utc()
|
||||
end_epoch = epoch + timedelta(days=EPG_DAYS)
|
||||
end_epoch = epoch + timedelta(days=EPG_TIMESPAN_DAYS)
|
||||
|
||||
root = ET.Element("tv")
|
||||
root.set("source-info-name", "self-hosted-iptv")
|
||||
@@ -538,10 +600,13 @@ def build_epg_xml() -> str:
|
||||
chan_el = ET.SubElement(root, "channel", id=cid)
|
||||
ET.SubElement(chan_el, "display-name").text = name
|
||||
|
||||
total_programmes = 0
|
||||
for ch in channels:
|
||||
cid = ch.get("id", ch.get("name", ""))
|
||||
ch_start = time.monotonic()
|
||||
schedule = get_or_build_schedule(cid)
|
||||
if not schedule:
|
||||
logger.debug("EPG channel %s: no schedule", cid)
|
||||
continue
|
||||
paths, durations = schedule
|
||||
cycle_duration_sec = sum(durations)
|
||||
@@ -552,6 +617,7 @@ def build_epg_xml() -> str:
|
||||
for d in durations[:-1]:
|
||||
offsets_sec.append(offsets_sec[-1] + d)
|
||||
|
||||
channel_programmes = 0
|
||||
cycle_start = epoch
|
||||
while cycle_start < end_epoch:
|
||||
for i, (path, dur) in enumerate(zip(paths, durations)):
|
||||
@@ -561,6 +627,7 @@ def build_epg_xml() -> str:
|
||||
continue
|
||||
if start_dt >= end_epoch:
|
||||
break
|
||||
channel_programmes += 1
|
||||
prog = ET.SubElement(root, "programme",
|
||||
start=_format_xmltv_time(start_dt),
|
||||
stop=_format_xmltv_time(stop_dt),
|
||||
@@ -606,7 +673,13 @@ def build_epg_xml() -> str:
|
||||
ET.SubElement(prog, "episode-num", system="onscreen").text = f"S{s:02d}E{e:02d}"
|
||||
cycle_start += cycle_delta
|
||||
|
||||
ch_elapsed = time.monotonic() - ch_start
|
||||
total_programmes += channel_programmes
|
||||
logger.info("EPG channel %s: %s programmes in %.1fs", cid, channel_programmes, ch_elapsed)
|
||||
|
||||
ET.indent(root, space=" ")
|
||||
epg_elapsed = time.monotonic() - epg_start
|
||||
logger.info("EPG build finished in %.1fs (%s programmes total)", epg_elapsed, total_programmes)
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding="unicode", default_namespace=None)
|
||||
|
||||
|
||||
@@ -845,8 +918,19 @@ class IPTVHandler(BaseHTTPRequestHandler):
|
||||
|
||||
|
||||
def main():
|
||||
log_level = getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO)
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
if not CHANNELS_FILE.exists():
|
||||
print(f"Warning: {CHANNELS_FILE} not found. Create it from data/channels.json.example")
|
||||
logger.info(
|
||||
"EPG timespan: %s days, schedule refresh after: %.1f h",
|
||||
EPG_TIMESPAN_DAYS,
|
||||
SCHEDULE_CACHE_TTL_SEC / 3600,
|
||||
)
|
||||
port = 8080
|
||||
server = HTTPServer(("0.0.0.0", port), IPTVHandler)
|
||||
print(f"Serving at http://0.0.0.0:{port}/ (playlist: http://<host>:{port}/playlist.m3u)")
|
||||
|
||||
Reference in New Issue
Block a user