diff --git a/Dockers/self-hosted-iptv/Dockerfile b/Dockers/self-hosted-iptv/Dockerfile
index 4f8643c..a9890b4 100644
--- a/Dockers/self-hosted-iptv/Dockerfile
+++ b/Dockers/self-hosted-iptv/Dockerfile
@@ -7,6 +7,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
WORKDIR /app
COPY main.py .
+COPY iptv/ iptv/
# /movies and /series are mounted by the user (video libraries)
# /data is mounted for channels.json
diff --git a/Dockers/self-hosted-iptv/README.md b/Dockers/self-hosted-iptv/README.md
index eff12e0..cc61e95 100644
--- a/Dockers/self-hosted-iptv/README.md
+++ b/Dockers/self-hosted-iptv/README.md
@@ -12,9 +12,9 @@ Serves M3U playlists that randomly schedule video files from `/movies` and `/ser
- Each channel:
- `id`: unique id (used in URLs)
- `name`: display name
- - `paths`: list of path keys to include:
- - `"movies"` — all videos under `/movies`
- - `"series/Show Name"` — all videos under `/series/Show Name`
+ - `paths`: list of path entries. Each entry can be:
+ - **String:** `"movies"` or `"series/Show Name"` — path to videos; EPG title and TMDB search use the folder name.
+ - **Object:** `{"path": "series/W817", "display_name": "Phineas en Ferb", "tmdb_search": "Phineas and Ferb"}` — `path` is required; `display_name` is shown in the EPG; `tmdb_search` is used when querying TMDB. You can use `tmdb_id` instead of (or to override) `tmdb_search` for a direct lookup, e.g. `97596` or `"97596-kika-bob"` (the numeric ID is extracted).
2. Run the container with volumes:
diff --git a/Dockers/self-hosted-iptv/data/channels.json.example b/Dockers/self-hosted-iptv/data/channels.json.example
index ad5e085..2941d81 100644
--- a/Dockers/self-hosted-iptv/data/channels.json.example
+++ b/Dockers/self-hosted-iptv/data/channels.json.example
@@ -3,7 +3,20 @@
{
"id": "comedy",
"name": "Comedy",
- "paths": ["movies", "series/The Office", "series/Parks and Recreation"]
+ "paths": [
+ "movies",
+ "series/The Office",
+ {
+ "path": "series/W817",
+ "display_name": "Phineas en Ferb",
+ "tmdb_search": "Phineas and Ferb"
+ },
+ {
+ "path": "series/kika-bob",
+ "display_name": "Kika & Bob",
+ "tmdb_id": "97596-kika-bob"
+ }
+ ]
},
{
"id": "drama",
diff --git a/Dockers/self-hosted-iptv/iptv/__init__.py b/Dockers/self-hosted-iptv/iptv/__init__.py
new file mode 100644
index 0000000..ebdfe07
--- /dev/null
+++ b/Dockers/self-hosted-iptv/iptv/__init__.py
@@ -0,0 +1 @@
+"""Self-hosted IPTV: M3U playlists and EPG with randomized video scheduling."""
diff --git a/Dockers/self-hosted-iptv/iptv/__pycache__/__init__.cpython-313.pyc b/Dockers/self-hosted-iptv/iptv/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 0000000..e8a14bb
Binary files /dev/null and b/Dockers/self-hosted-iptv/iptv/__pycache__/__init__.cpython-313.pyc differ
diff --git a/Dockers/self-hosted-iptv/iptv/__pycache__/channel.cpython-313.pyc b/Dockers/self-hosted-iptv/iptv/__pycache__/channel.cpython-313.pyc
new file mode 100644
index 0000000..4c3ade0
Binary files /dev/null and b/Dockers/self-hosted-iptv/iptv/__pycache__/channel.cpython-313.pyc differ
diff --git a/Dockers/self-hosted-iptv/iptv/__pycache__/config.cpython-313.pyc b/Dockers/self-hosted-iptv/iptv/__pycache__/config.cpython-313.pyc
new file mode 100644
index 0000000..080ebcc
Binary files /dev/null and b/Dockers/self-hosted-iptv/iptv/__pycache__/config.cpython-313.pyc differ
diff --git a/Dockers/self-hosted-iptv/iptv/__pycache__/epg_builder.cpython-313.pyc b/Dockers/self-hosted-iptv/iptv/__pycache__/epg_builder.cpython-313.pyc
new file mode 100644
index 0000000..6bcbf63
Binary files /dev/null and b/Dockers/self-hosted-iptv/iptv/__pycache__/epg_builder.cpython-313.pyc differ
diff --git a/Dockers/self-hosted-iptv/iptv/__pycache__/schedule.cpython-313.pyc b/Dockers/self-hosted-iptv/iptv/__pycache__/schedule.cpython-313.pyc
new file mode 100644
index 0000000..5a61cae
Binary files /dev/null and b/Dockers/self-hosted-iptv/iptv/__pycache__/schedule.cpython-313.pyc differ
diff --git a/Dockers/self-hosted-iptv/iptv/__pycache__/server.cpython-313.pyc b/Dockers/self-hosted-iptv/iptv/__pycache__/server.cpython-313.pyc
new file mode 100644
index 0000000..5950769
Binary files /dev/null and b/Dockers/self-hosted-iptv/iptv/__pycache__/server.cpython-313.pyc differ
diff --git a/Dockers/self-hosted-iptv/iptv/__pycache__/tmdb_client.cpython-313.pyc b/Dockers/self-hosted-iptv/iptv/__pycache__/tmdb_client.cpython-313.pyc
new file mode 100644
index 0000000..25816db
Binary files /dev/null and b/Dockers/self-hosted-iptv/iptv/__pycache__/tmdb_client.cpython-313.pyc differ
diff --git a/Dockers/self-hosted-iptv/iptv/channel.py b/Dockers/self-hosted-iptv/iptv/channel.py
new file mode 100644
index 0000000..4ae6aa9
--- /dev/null
+++ b/Dockers/self-hosted-iptv/iptv/channel.py
@@ -0,0 +1,70 @@
+"""Channel loading and path configuration."""
+
+import json
+import re
+from pathlib import Path
+
+from .config import CHANNELS_FILE, MOVIES_ROOT, SERIES_ROOT
+
+
+def resolve_path(path_key: str) -> Path | None:
+ """Resolve a channel path key to an absolute Path. Returns None if invalid."""
+ path_key = path_key.strip("/")
+ if path_key == "movies":
+ return MOVIES_ROOT
+ if path_key.startswith("series/"):
+ subpath = path_key[7:]
+ return SERIES_ROOT / subpath
+ return None
+
+
+def normalize_path_config(entry) -> dict:
+ """
+ Normalize a path entry from channels.json. Entry can be:
+ - String: "series/W817" -> {path_key, display_name, tmdb_search, tmdb_id}
+ - Object: {"path": "series/W817", "display_name": "...", "tmdb_search": "...", "tmdb_id": ...}
+ """
+ if isinstance(entry, str):
+ return {"path_key": entry.strip(), "display_name": None, "tmdb_search": None, "tmdb_id": None}
+ if isinstance(entry, dict):
+ path_key = (entry.get("path") or entry.get("path_key") or "").strip()
+ if not path_key:
+ return {"path_key": "", "display_name": None, "tmdb_search": None, "tmdb_id": None}
+ raw_id = entry.get("tmdb_id")
+ tmdb_id = None
+ if raw_id is not None:
+ if isinstance(raw_id, int) and raw_id > 0:
+ tmdb_id = raw_id
+ else:
+ m = re.match(r"^(\d+)", str(raw_id).strip())
+ if m:
+ tmdb_id = int(m.group(1))
+ return {
+ "path_key": path_key,
+ "display_name": (entry.get("display_name") or "").strip() or None,
+ "tmdb_search": (entry.get("tmdb_search") or "").strip() or None,
+ "tmdb_id": tmdb_id,
+ }
+ return {"path_key": "", "display_name": None, "tmdb_search": None, "tmdb_id": None}
+
+
+class ChannelRepository:
+ """Loads and provides channel configuration from channels.json."""
+
+ def __init__(self, channels_file: Path = CHANNELS_FILE):
+ self._channels_file = channels_file
+
+ def get_all(self) -> list[dict]:
+ """Load and return all channels."""
+ if not self._channels_file.exists():
+ return []
+ with open(self._channels_file, encoding="utf-8") as f:
+ data = json.load(f)
+ return data.get("channels", [])
+
+ def get_by_id(self, channel_id: str) -> dict | None:
+ """Return the channel with the given id, or None."""
+ for ch in self.get_all():
+ if ch.get("id") == channel_id:
+ return ch
+ return None
diff --git a/Dockers/self-hosted-iptv/iptv/config.py b/Dockers/self-hosted-iptv/iptv/config.py
new file mode 100644
index 0000000..e2a071c
--- /dev/null
+++ b/Dockers/self-hosted-iptv/iptv/config.py
@@ -0,0 +1,58 @@
+"""Configuration constants and EPG timespan parsing."""
+
+import os
+import re
+from pathlib import Path
+
+DATA_ROOT = Path("/data")
+MOVIES_ROOT = Path("/movies")
+SERIES_ROOT = Path("/series")
+CHANNELS_FILE = DATA_ROOT / "channels.json"
+VIDEO_EXTENSIONS = {".mkv", ".mp4", ".avi", ".mov", ".m4v", ".webm", ".wmv"}
+DEFAULT_DURATION_SEC = 3600
+
+# EPG timespan (parsed from EPG_TIMESPAN env)
+EPG_TIMESPAN_DAYS = 14.0
+EPG_TIMESPAN_SEC = 14.0 * 24 * 3600
+SCHEDULE_CACHE_TTL_SEC = (14 * 24 - 3) * 3600
+
+
+def _parse_epg_timespan(value: str) -> tuple[float, float]:
+ """Parse EPG_TIMESPAN env (e.g. '2d', '14d', '48h'). Returns (days, seconds)."""
+ 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"
+ days = num / 24.0 if unit == "h" else 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
+ schedule_ttl = sec - 3 * 3600
+ SCHEDULE_CACHE_TTL_SEC = max(3600, schedule_ttl)
+
+
+init_epg_timespan()
+
+# TMDB (The Movie Database) for EPG metadata — Dutch (nl). Set TMDB_API_KEY.
+TMDB_API_BASE = "https://api.themoviedb.org/3"
+TMDB_LANGUAGE = "nl"
+TMDB_CACHE_TTL_SEC = 24 * 3600
+TMDB_RATE_LIMIT_REQUESTS = 40
+TMDB_RATE_LIMIT_WINDOW_SEC = 10.0
+TMDB_CACHE_DIR = DATA_ROOT / "tmdb_cache"
+TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"
+TMDB_IMAGE_STILL_SIZE = "w500"
+
+# Preferred audio languages for streaming
+PREFERRED_AUDIO_LANGUAGES = ("nld", "dut", "eng")
diff --git a/Dockers/self-hosted-iptv/iptv/epg_builder.py b/Dockers/self-hosted-iptv/iptv/epg_builder.py
new file mode 100644
index 0000000..d2aa9e0
--- /dev/null
+++ b/Dockers/self-hosted-iptv/iptv/epg_builder.py
@@ -0,0 +1,187 @@
+"""XMLTV EPG generation."""
+
+import logging
+import time
+import xml.etree.ElementTree as ET
+from datetime import datetime, timezone, timedelta
+from pathlib import Path
+
+from .config import EPG_TIMESPAN_DAYS, MOVIES_ROOT, SERIES_ROOT
+from .tmdb_client import parse_season_episode
+
+logger = logging.getLogger(__name__)
+
+
+def format_xmltv_time(dt: datetime) -> str:
+ """Format as XMLTV: YYYYMMDDHHmmss +0000"""
+ return dt.strftime("%Y%m%d%H%M%S ") + (dt.strftime("%z") if dt.tzinfo else "+0000")
+
+
+def get_programme_name_from_path(path: Path) -> str:
+ """
+ Return the series/movie name from the folder (e.g. series/W817/... -> W817,
+ movies/MyMovie/... -> MyMovie).
+ """
+ try:
+ if SERIES_ROOT in path.parents:
+ rel = path.relative_to(SERIES_ROOT)
+ parts = rel.parts
+ if len(parts) > 1:
+ return parts[0]
+ return path.stem
+ if MOVIES_ROOT in path.parents:
+ rel = path.relative_to(MOVIES_ROOT)
+ parts = rel.parts
+ if len(parts) > 1:
+ return parts[0]
+ return path.stem
+ except ValueError:
+ pass
+ return path.stem
+
+
+def schedule_epoch_utc() -> datetime:
+ """Midnight UTC today — same reference used by EPG and live stream."""
+ return datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
+
+
+class EPGBuilder:
+ """Builds XMLTV EPG for all channels."""
+
+ def __init__(self, schedule_builder, tmdb_client, channel_repo):
+ self._schedule = schedule_builder
+ self._tmdb = tmdb_client
+ self._channels = channel_repo
+
+ def build_xml(self) -> str:
+ """
+ Build XMLTV EPG for all channels. Schedule repeats from midnight UTC.
+ Programmes generated for EPG_TIMESPAN_DAYS.
+ """
+ epg_start = time.monotonic()
+ channels = self._channels.get_all()
+ if not channels:
+ return '\n'
+
+ logger.info("EPG build started (%s channels)", len(channels))
+ now = datetime.now(timezone.utc)
+ epoch = schedule_epoch_utc()
+ end_epoch = epoch + timedelta(days=EPG_TIMESPAN_DAYS)
+
+ root = ET.Element("tv")
+ root.set("source-info-name", "self-hosted-iptv")
+ root.set("generator-info-name", "self-hosted-iptv")
+
+ for ch in channels:
+ cid = ch.get("id", ch.get("name", ""))
+ name = ch.get("name", cid)
+ 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 = self._schedule.get_or_build(cid)
+ if not schedule:
+ logger.debug("EPG channel %s: no schedule", cid)
+ continue
+ paths, durations, path_configs = schedule
+ cycle_duration_sec = sum(durations)
+ if cycle_duration_sec <= 0:
+ continue
+ cycle_delta = timedelta(seconds=cycle_duration_sec)
+ offsets_sec = [0.0]
+ 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)):
+ start_dt = cycle_start + timedelta(seconds=offsets_sec[i])
+ stop_dt = start_dt + timedelta(seconds=dur)
+ if stop_dt <= now:
+ 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),
+ channel=cid,
+ )
+ cfg = path_configs[i] if i < len(path_configs) else {}
+ programme_name = get_programme_name_from_path(path)
+ meta = self._tmdb.get_episode_metadata(
+ programme_name,
+ path,
+ display_name=cfg.get("display_name"),
+ tmdb_search=cfg.get("tmdb_search"),
+ tmdb_id=cfg.get("tmdb_id"),
+ series_root=SERIES_ROOT,
+ )
+ lang = (meta.get("lang") or "en") if meta else "en"
+ if meta:
+ ET.SubElement(prog, "title", lang=lang).text = meta["title"]
+ ET.SubElement(prog, "sub-title", lang=lang).text = meta["sub_title"]
+ if meta.get("desc"):
+ ET.SubElement(prog, "desc", lang=lang).text = meta["desc"]
+ if meta.get("icon"):
+ ET.SubElement(prog, "icon", attrib={"src": meta["icon"]})
+ directors = meta.get("directors") or []
+ actors = meta.get("actors") or []
+ producers = meta.get("producers") or []
+ if directors or actors or producers:
+ credits_el = ET.SubElement(prog, "credits")
+ for d in directors:
+ ET.SubElement(credits_el, "director").text = d
+ for a in actors:
+ ET.SubElement(credits_el, "actor").text = a
+ for p in producers:
+ ET.SubElement(credits_el, "producer").text = p
+ if meta.get("date"):
+ ET.SubElement(prog, "date").text = meta["date"]
+ for cat in meta.get("categories") or []:
+ ET.SubElement(prog, "category", lang=lang).text = cat
+ if meta.get("country"):
+ ET.SubElement(prog, "country", lang=lang).text = meta["country"]
+ nums = meta.get("episode_nums") or {}
+ if nums.get("xmltv_ns"):
+ ET.SubElement(
+ prog, "episode-num", system="xmltv_ns"
+ ).text = nums["xmltv_ns"]
+ if nums.get("onscreen"):
+ ET.SubElement(
+ prog, "episode-num", system="onscreen"
+ ).text = nums["onscreen"]
+ else:
+ fallback_title = (
+ (cfg.get("display_name") or programme_name).strip() or programme_name
+ )
+ ET.SubElement(prog, "title", lang="en").text = fallback_title
+ ET.SubElement(prog, "sub-title", lang="en").text = path.stem
+ se = parse_season_episode(path.stem)
+ if se:
+ s, e = se
+ ET.SubElement(
+ prog, "episode-num", system="xmltv_ns"
+ ).text = f"{s}.{e}.0/1"
+ 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 (
+ '\n'
+ + ET.tostring(root, encoding="unicode", default_namespace=None)
+ )
diff --git a/Dockers/self-hosted-iptv/iptv/schedule.py b/Dockers/self-hosted-iptv/iptv/schedule.py
new file mode 100644
index 0000000..a847dd2
--- /dev/null
+++ b/Dockers/self-hosted-iptv/iptv/schedule.py
@@ -0,0 +1,161 @@
+"""Schedule building: video collection, shuffle, durations, and caching."""
+
+import logging
+import random
+import subprocess
+import time
+from collections import defaultdict
+from pathlib import Path
+
+from .channel import normalize_path_config, resolve_path
+from .config import (
+ VIDEO_EXTENSIONS,
+ DEFAULT_DURATION_SEC,
+ SCHEDULE_CACHE_TTL_SEC,
+ MOVIES_ROOT,
+ SERIES_ROOT,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def collect_videos(root: Path) -> list[Path]:
+ """Collect all video files under root."""
+ if not root.exists() or not root.is_dir():
+ return []
+ videos = []
+ try:
+ for p in root.rglob("*"):
+ if p.is_file() and p.suffix.lower() in VIDEO_EXTENSIONS:
+ videos.append(p)
+ except OSError:
+ pass
+ return videos
+
+
+def collect_channel_videos(path_configs: list[dict]) -> list[tuple[dict, Path]]:
+ """For each path config, collect videos. Returns [(path_config, absolute_path)]."""
+ out = []
+ for cfg in path_configs:
+ path_key = cfg.get("path_key") or ""
+ root = resolve_path(path_key)
+ if root is None:
+ continue
+ for vid in collect_videos(root):
+ out.append((cfg, vid))
+ return out
+
+
+def shuffle_no_adjacent_same_show(
+ items: list[tuple[dict, Path]],
+) -> list[tuple[Path, dict]]:
+ """Shuffle so same path_key never appears twice in a row. Returns [(path, path_config)]."""
+ if not items:
+ return []
+ by_key: dict[str, list[tuple[Path, dict]]] = defaultdict(list)
+ for cfg, path in items:
+ by_key[cfg.get("path_key") or ""].append((path, cfg))
+ for key in by_key:
+ random.shuffle(by_key[key])
+ groups = list(by_key.values())
+ result = []
+ n = max(len(g) for g in groups)
+ for i in range(n):
+ for g in groups:
+ if i < len(g):
+ result.append(g[i])
+ return result
+
+
+def build_channel_path_list(path_entries: list) -> tuple[list[Path], list[dict]]:
+ """Build randomized list of (path, path_config) for a channel."""
+ configs = [normalize_path_config(e) for e in path_entries]
+ configs = [c for c in configs if c.get("path_key")]
+ items = collect_channel_videos(configs)
+ pairs = shuffle_no_adjacent_same_show(items)
+ return [p for p, _ in pairs], [c for _, c in pairs]
+
+
+def build_playlist(path_entries: list, base_url: str) -> list[tuple[str, str]]:
+ """Build playlist as list of (display_name, stream_url)."""
+ paths, _ = build_channel_path_list(path_entries)
+ playlist = []
+ for p in paths:
+ try:
+ if MOVIES_ROOT in p.parents:
+ rel = p.relative_to(MOVIES_ROOT)
+ path_str = "movies/" + str(rel).replace("\\", "/")
+ else:
+ rel = p.relative_to(SERIES_ROOT)
+ path_str = "series/" + str(rel).replace("\\", "/")
+ except ValueError:
+ path_str = "movies/" + p.name
+ stream_url = f"{base_url}/stream?path={path_str}"
+ playlist.append((p.stem, stream_url))
+ return playlist
+
+
+def get_duration(path: Path) -> float:
+ """Return duration in seconds via ffprobe."""
+ t0 = time.monotonic()
+ try:
+ out = subprocess.run(
+ [
+ "ffprobe",
+ "-v", "error",
+ "-show_entries", "format=duration",
+ "-of", "default=noprint_wrappers=1:nokey=1",
+ str(path),
+ ],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if out.returncode == 0 and out.stdout.strip():
+ 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
+
+
+class ScheduleBuilder:
+ """Builds and caches video schedules per channel."""
+
+ def __init__(self):
+ self._cache: dict[str, tuple[list[Path], list[float], list[dict], float]] = {}
+ self._ttl_sec = SCHEDULE_CACHE_TTL_SEC
+ self._channel_repo = None # injected
+
+ def set_channel_repo(self, repo) -> None:
+ """Set the channel repository for schedule building."""
+ self._channel_repo = repo
+
+ def get_or_build(
+ self,
+ channel_id: str,
+ ) -> tuple[list[Path], list[float], list[dict]] | None:
+ """Return (paths, durations, path_configs) for the channel. Cached per TTL."""
+ now = time.time()
+ if channel_id in self._cache:
+ paths, durations, path_configs, ts = self._cache[channel_id]
+ if now - ts < self._ttl_sec:
+ logger.debug("schedule cache hit for channel %s (%s videos)", channel_id, len(paths))
+ return paths, durations, path_configs
+
+ logger.info("Building schedule for channel %s ...", channel_id)
+ t0 = time.monotonic()
+ if not self._channel_repo:
+ return None
+ channel = self._channel_repo.get_by_id(channel_id)
+ if not channel:
+ return None
+ paths, path_configs = build_channel_path_list(channel.get("paths", []))
+ if not paths:
+ return None
+ durations = [get_duration(p) for p in paths]
+ elapsed = time.monotonic() - t0
+ self._cache[channel_id] = (paths, durations, path_configs, now)
+ logger.info("Schedule for channel %s built in %.1fs (%s videos)", channel_id, elapsed, len(paths))
+ return paths, durations, path_configs
diff --git a/Dockers/self-hosted-iptv/iptv/server.py b/Dockers/self-hosted-iptv/iptv/server.py
new file mode 100644
index 0000000..d17cb03
--- /dev/null
+++ b/Dockers/self-hosted-iptv/iptv/server.py
@@ -0,0 +1,185 @@
+"""HTTP server and request handler for IPTV endpoints."""
+
+import mimetypes
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from urllib.parse import parse_qs, urlparse
+
+from .schedule import build_playlist
+
+
+def get_base_url(handler: BaseHTTPRequestHandler) -> str:
+ """Build base URL for playlist links from request."""
+ host = handler.headers.get("Host", "localhost:8080")
+ return f"http://{host}"
+
+
+class IPTVHandler(BaseHTTPRequestHandler):
+ """Handles M3U playlists, EPG, live streams, and on-demand video."""
+
+ def __init__(self, request, client_address, server):
+ # Dependencies injected by server_factory
+ self._channel_repo = getattr(server, "channel_repo", None)
+ self._schedule_builder = getattr(server, "schedule_builder", None)
+ self._epg_builder = getattr(server, "epg_builder", None)
+ super().__init__(request, client_address, server)
+
+ def log_message(self, format, *args):
+ pass
+
+ def do_GET(self):
+ parsed = urlparse(self.path)
+ path = parsed.path.rstrip("/") or "/"
+ qs = parse_qs(parsed.query)
+
+ if path == "/" or path == "/index.html":
+ self.send_index()
+ return
+ if path == "/playlist.m3u" or path == "/channels.m3u":
+ self.send_channels_m3u()
+ return
+ if path.startswith("/channel/") and path.endswith("/playlist.m3u"):
+ channel_id = path.split("/")[2]
+ self.send_channel_m3u(channel_id)
+ return
+ if path == "/stream":
+ path_param = qs.get("path", [""])[0]
+ self.send_stream(path_param)
+ return
+ if path.startswith("/live/"):
+ parts = path.split("/")
+ if len(parts) == 3 and parts[1] == "live":
+ self.send_live_stream(parts[2])
+ return
+ if path == "/epg.xml" or path == "/xmltv.xml":
+ self.send_epg()
+ return
+ self.send_error(404, "Not found")
+
+ def send_index(self):
+ channels = self._channel_repo.get_all()
+ body = "
Self-hosted IPTV"
+ body += "Self-hosted IPTV
Playlist (M3U):
"
+ body += f"{get_base_url(self)}/playlist.m3u
"
+ body += "EPG (XMLTV, for Plex DVR):
"
+ body += f"{get_base_url(self)}/epg.xml
"
+ body += "Channels
"
+ for ch in channels:
+ cid = ch.get("id", ch.get("name", ""))
+ body += f"- {ch.get('name', cid)}
"
+ body += "
"
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(body.encode("utf-8"))))
+ self.end_headers()
+ self.wfile.write(body.encode("utf-8"))
+
+ def send_channels_m3u(self):
+ """Master M3U for Plex DVR: one live stream URL per channel."""
+ base = get_base_url(self)
+ channels = self._channel_repo.get_all()
+ lines = ["#EXTM3U"]
+ for ch in channels:
+ cid = ch.get("id", ch.get("name", ""))
+ name = ch.get("name", cid)
+ lines.append(f"#EXTINF:-1,{name}")
+ lines.append(f"{base}/live/{cid}")
+ body = "\n".join(lines) + "\n"
+ self.send_response(200)
+ self.send_header("Content-Type", "application/x-mpegURL; charset=utf-8")
+ self.send_header("Content-Length", str(len(body.encode("utf-8"))))
+ self.end_headers()
+ self.wfile.write(body.encode("utf-8"))
+
+ def send_channel_m3u(self, channel_id: str):
+ """Single channel M3U with randomized video entries."""
+ channel = self._channel_repo.get_by_id(channel_id)
+ if not channel:
+ self.send_error(404, "Channel not found")
+ return
+ path_keys = channel.get("paths", [])
+ base = get_base_url(self)
+ playlist = build_playlist(path_keys, base)
+ lines = ["#EXTM3U"]
+ for name, url in playlist:
+ lines.append(f"#EXTINF:-1,{name}")
+ lines.append(url)
+ body = "\n".join(lines) + "\n"
+ self.send_response(200)
+ self.send_header("Content-Type", "application/x-mpegURL; charset=utf-8")
+ self.send_header("Content-Length", str(len(body.encode("utf-8"))))
+ self.end_headers()
+ self.wfile.write(body.encode("utf-8"))
+
+ def send_epg(self):
+ """Serve XMLTV EPG (programme schedule for Plex DVR)."""
+ body = self._epg_builder.build_xml()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/xml; charset=utf-8")
+ self.send_header("Content-Length", str(len(body.encode("utf-8"))))
+ self.send_header("Cache-Control", "public, max-age=300")
+ self.end_headers()
+ self.wfile.write(body.encode("utf-8"))
+
+ def send_live_stream(self, channel_id: str):
+ """Stream channel as continuous MPEG-TS (for Plex DVR)."""
+ from iptv.streamer import stream_live_channel
+
+ schedule = self._schedule_builder.get_or_build(channel_id)
+ if not schedule:
+ self.send_error(404, "Channel not found or no videos")
+ return
+ paths, durations, _ = schedule
+ if not paths:
+ self.send_error(404, "No videos in channel")
+ return
+ self.send_response(200)
+ self.send_header("Content-Type", "video/MP2T")
+ self.send_header("Cache-Control", "no-cache, no-store")
+ self.end_headers()
+ stream_live_channel(paths, self.wfile, durations)
+
+ def send_stream(self, path_param: str):
+ """Stream a video file. path is relative to /movies or /series."""
+ from iptv.streamer import (
+ path_to_absolute,
+ get_preferred_audio_stream_index,
+ stream_file_with_preferred_audio,
+ )
+
+ abs_path = path_to_absolute(path_param)
+ if abs_path is None:
+ self.send_error(404, "File not found")
+ return
+ audio_idx = get_preferred_audio_stream_index(abs_path)
+ if audio_idx is not None:
+ self.send_response(200)
+ self.send_header("Content-Type", "video/mp4")
+ self.send_header("Cache-Control", "no-cache")
+ self.end_headers()
+ stream_file_with_preferred_audio(abs_path, self.wfile)
+ return
+ content_type, _ = mimetypes.guess_type(str(abs_path))
+ content_type = content_type or "video/mp4"
+ size = abs_path.stat().st_size
+ self.send_response(200)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(size))
+ self.send_header("Accept-Ranges", "bytes")
+ self.end_headers()
+ with open(abs_path, "rb") as f:
+ self.wfile.write(f.read())
+
+
+def create_server(
+ channel_repo,
+ schedule_builder,
+ epg_builder,
+ host: str = "0.0.0.0",
+ port: int = 8080,
+) -> HTTPServer:
+ """Create HTTP server with injected dependencies."""
+ server = HTTPServer((host, port), IPTVHandler)
+ server.channel_repo = channel_repo
+ server.schedule_builder = schedule_builder
+ server.epg_builder = epg_builder
+ return server
diff --git a/Dockers/self-hosted-iptv/iptv/streamer.py b/Dockers/self-hosted-iptv/iptv/streamer.py
new file mode 100644
index 0000000..ff9d3d8
--- /dev/null
+++ b/Dockers/self-hosted-iptv/iptv/streamer.py
@@ -0,0 +1,246 @@
+"""Video streaming: preferred-audio remux and live channel concat."""
+
+import json
+import logging
+import subprocess
+import tempfile
+from pathlib import Path
+from urllib.parse import unquote
+
+from .config import (
+ VIDEO_EXTENSIONS,
+ MOVIES_ROOT,
+ SERIES_ROOT,
+ PREFERRED_AUDIO_LANGUAGES,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def path_to_absolute(path_param: str) -> Path | None:
+ """
+ Convert a path query parameter to an absolute Path under /movies or /series.
+ Path must start with 'movies/' or 'series/'. Prevents path traversal.
+ """
+ path_param = unquote(path_param).strip("/")
+ if ".." in path_param or path_param.startswith("/"):
+ return None
+ if path_param.startswith("movies/"):
+ sub = path_param[7:]
+ root = MOVIES_ROOT
+ elif path_param.startswith("series/"):
+ sub = path_param[7:]
+ root = SERIES_ROOT
+ else:
+ return None
+ candidate = (root / sub).resolve()
+ try:
+ candidate.relative_to(root)
+ except ValueError:
+ return None
+ if (
+ candidate.exists()
+ and candidate.is_file()
+ and candidate.suffix.lower() in VIDEO_EXTENSIONS
+ ):
+ return candidate
+ return None
+
+
+def get_preferred_audio_stream_index(path: Path) -> int | None:
+ """
+ Probe the file with ffprobe and return the 0-based audio stream index to use:
+ first Dutch/Flemish (nld/dut), else first English (eng), else first audio stream.
+ Returns None on probe failure or no audio streams.
+ """
+ try:
+ out = subprocess.run(
+ [
+ "ffprobe",
+ "-v", "quiet",
+ "-print_format", "json",
+ "-show_entries", "stream=index,codec_type",
+ "-show_entries", "stream_tags=language",
+ str(path),
+ ],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ if out.returncode != 0 or not out.stdout:
+ return None
+ data = json.loads(out.stdout)
+ streams = data.get("streams") or []
+ audio_streams = []
+ for s in streams:
+ if s.get("codec_type") != "audio":
+ continue
+ idx = s.get("index")
+ if idx is None:
+ continue
+ tags = s.get("tags") or {}
+ lang = (tags.get("language") or "").strip().lower()[:3]
+ audio_streams.append((idx, lang))
+ if not audio_streams:
+ return None
+ for pref in PREFERRED_AUDIO_LANGUAGES:
+ for idx, lang in audio_streams:
+ if lang == pref:
+ return idx
+ return audio_streams[0][0]
+ except (
+ subprocess.TimeoutExpired,
+ ValueError,
+ FileNotFoundError,
+ json.JSONDecodeError,
+ ):
+ return None
+
+
+def stream_file_with_preferred_audio(path: Path, wfile) -> bool:
+ """
+ Stream the file via FFmpeg with only video and the preferred audio track.
+ Returns True if streaming was started, False if caller should fall back to raw file.
+ """
+ audio_idx = get_preferred_audio_stream_index(path)
+ if audio_idx is None:
+ return False
+ try:
+ proc = subprocess.Popen(
+ [
+ "ffmpeg",
+ "-i", str(path),
+ "-map", "0:v",
+ "-map", f"0:{audio_idx}",
+ "-c", "copy",
+ "-f", "mp4",
+ "-movflags", "frag_keyframe+empty_moov+default_base_moof",
+ "-",
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+ while True:
+ chunk = proc.stdout.read(65536)
+ if not chunk:
+ break
+ wfile.write(chunk)
+ wfile.flush()
+ proc.wait(timeout=1)
+ except (OSError, subprocess.TimeoutExpired):
+ try:
+ proc.terminate()
+ proc.wait(timeout=2)
+ except Exception:
+ pass
+ return False
+ return True
+
+
+def _escape_path(p: Path) -> str:
+ return str(p.resolve()).replace("\\", "\\\\").replace("'", "'\\''")
+
+
+def write_concat_list(paths: list[Path], fd) -> None:
+ """Write FFmpeg concat demuxer list to file."""
+ for p in paths:
+ fd.write(f"file '{_escape_path(p)}'\n")
+ fd.flush()
+
+
+def _schedule_epoch_utc():
+ from datetime import datetime, timezone
+
+ return datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
+
+
+def _current_position_in_cycle(durations: list[float]) -> tuple[int, float]:
+ """
+ Return (file_index, offset_sec_in_file) for 'now' in the schedule cycle.
+ Schedule epoch = midnight UTC; cycle = one full playlist.
+ """
+ from datetime import datetime, timezone
+
+ epoch = _schedule_epoch_utc()
+ now = datetime.now(timezone.utc)
+ cycle_duration = sum(durations)
+ if cycle_duration <= 0:
+ return 0, 0.0
+ offset_sec = (now - epoch).total_seconds() % cycle_duration
+ cumul = 0.0
+ for i, dur in enumerate(durations):
+ if offset_sec < cumul + dur:
+ return i, offset_sec - cumul
+ cumul += dur
+ return len(durations) - 1, durations[-1]
+
+
+def write_concat_list_from_current(paths: list[Path], durations: list[float], fd) -> None:
+ """
+ Write a concat list that starts at the current position in the schedule and
+ loops seamlessly. Uses inpoint/outpoint so we join mid-file, then repeat.
+ """
+ n = len(paths)
+ if n == 0:
+ return
+ idx, offset_in_file = _current_position_in_cycle(durations)
+ for i in range(n):
+ j = (idx + i) % n
+ fd.write(f"file '{_escape_path(paths[j])}'\n")
+ if i == 0 and offset_in_file >= 0.5:
+ fd.write(f"inpoint {offset_in_file:.2f}\n")
+ if offset_in_file >= 0.5:
+ fd.write(f"file '{_escape_path(paths[idx])}'\n")
+ fd.write(f"outpoint {offset_in_file:.2f}\n")
+ fd.flush()
+
+
+def stream_live_channel(paths: list[Path], wfile, durations: list[float]) -> None:
+ """
+ Run FFmpeg to output a continuous MPEG-TS stream. Concat list starts at
+ current position in schedule (midnight UTC cycle) so each new client joins
+ where the 'channel' is now; then the list loops. EPG and stream stay in sync.
+ """
+ if not paths:
+ return
+
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
+ write_concat_list_from_current(paths, durations, f)
+ list_path = f.name
+ try:
+ proc = subprocess.Popen(
+ [
+ "ffmpeg",
+ "-fflags", "+nobuffer+flush_packets",
+ "-stream_loop", "-1",
+ "-f", "concat", "-safe", "0", "-i", list_path,
+ "-c", "copy",
+ "-avoid_negative_ts", "make_zero",
+ "-muxdelay", "0",
+ "-muxpreload", "0",
+ "-max_muxing_queue_size", "1024",
+ "-f", "mpegts",
+ "-",
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+ try:
+ while True:
+ chunk = proc.stdout.read(65536)
+ if not chunk:
+ break
+ wfile.write(chunk)
+ wfile.flush()
+ except (BrokenPipeError, ConnectionResetError, OSError):
+ pass
+ finally:
+ proc.terminate()
+ try:
+ proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ finally:
+ Path(list_path).unlink(missing_ok=True)
diff --git a/Dockers/self-hosted-iptv/iptv/tmdb_client.py b/Dockers/self-hosted-iptv/iptv/tmdb_client.py
new file mode 100644
index 0000000..30719cc
--- /dev/null
+++ b/Dockers/self-hosted-iptv/iptv/tmdb_client.py
@@ -0,0 +1,340 @@
+"""TMDB API client: rate limiting, disk cache, episode metadata."""
+
+import json
+import logging
+import os
+import re
+import threading
+import time
+import urllib.error
+import urllib.request
+from collections import deque
+from pathlib import Path
+from urllib.parse import urlencode
+
+from .config import (
+ TMDB_API_BASE,
+ TMDB_LANGUAGE,
+ TMDB_CACHE_TTL_SEC,
+ TMDB_RATE_LIMIT_REQUESTS,
+ TMDB_RATE_LIMIT_WINDOW_SEC,
+ TMDB_CACHE_DIR,
+ TMDB_IMAGE_BASE,
+ TMDB_IMAGE_STILL_SIZE,
+)
+
+logger = logging.getLogger(__name__)
+
+_EPISODE_PATTERN = re.compile(r"(?i)s(\d+)e(\d+)")
+
+
+def parse_season_episode(stem: str) -> tuple[int, int] | None:
+ """Parse S01E01-style from filename stem. Returns (season, episode) or None."""
+ m = _EPISODE_PATTERN.search(stem)
+ if m:
+ return int(m.group(1)), int(m.group(2))
+ return None
+
+
+def _sanitize_key(name: str) -> str:
+ """Sanitize a string for use as a cache filename."""
+ s = re.sub(r"[^\w\-.\s]", "", str(name))
+ return re.sub(r"\s+", "_", s).strip("_") or "unknown"
+
+
+class TMDBClient:
+ """TMDB API client with rate limiting and disk cache."""
+
+ def __init__(self):
+ self._series_cache: dict[str, tuple[int, str, float]] = {}
+ self._series_details_cache: dict[int, tuple[dict, float]] = {}
+ self._episode_cache: dict[tuple[int, int, int], tuple[dict, float]] = {}
+ self._episode_credits_cache: dict[tuple[int, int, int], tuple[dict, float]] = {}
+ self._request_times: deque = deque(maxlen=TMDB_RATE_LIMIT_REQUESTS + 10)
+ self._rate_limit_lock = threading.Lock()
+ self._ttl_sec = TMDB_CACHE_TTL_SEC
+
+ def _cache_path(self, subdir: str, key: str) -> Path:
+ """Path to a cache file."""
+ TMDB_CACHE_DIR.mkdir(parents=True, exist_ok=True)
+ (TMDB_CACHE_DIR / subdir).mkdir(exist_ok=True)
+ return TMDB_CACHE_DIR / subdir / f"{key}.json"
+
+ def _read_disk_cache(self, subdir: str, key: str) -> dict | None:
+ path = self._cache_path(subdir, key)
+ if not path.exists():
+ return None
+ try:
+ with open(path, encoding="utf-8") as f:
+ return json.load(f)
+ except (OSError, json.JSONDecodeError):
+ return None
+
+ def _write_disk_cache(self, subdir: str, key: str, data: dict) -> None:
+ path = self._cache_path(subdir, key)
+ try:
+ with open(path, "w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=0)
+ except OSError:
+ pass
+
+ def _wait_rate_limit(self) -> None:
+ with self._rate_limit_lock:
+ while True:
+ now = time.monotonic()
+ while self._request_times and now - self._request_times[0] >= TMDB_RATE_LIMIT_WINDOW_SEC:
+ self._request_times.popleft()
+ if len(self._request_times) < TMDB_RATE_LIMIT_REQUESTS:
+ break
+ wait = TMDB_RATE_LIMIT_WINDOW_SEC - (now - self._request_times[0])
+ if wait > 0:
+ logger.debug(
+ "TMDB rate limit: waiting %.1fs (%s/40 in window)",
+ wait,
+ len(self._request_times),
+ )
+ time.sleep(min(0.5, wait))
+
+ def _record_request(self) -> None:
+ with self._rate_limit_lock:
+ self._request_times.append(time.monotonic())
+
+ def _request(self, path: str, params: dict | None = None) -> dict | None:
+ api_key = os.environ.get("TMDB_API_KEY", "").strip()
+ if not api_key:
+ return None
+ self._wait_rate_limit()
+ q = {"api_key": api_key, "language": TMDB_LANGUAGE}
+ if params:
+ 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)
+ self._record_request()
+ return data
+ except (
+ urllib.error.HTTPError,
+ urllib.error.URLError,
+ OSError,
+ json.JSONDecodeError,
+ ) as e:
+ logger.debug("TMDB request failed %s: %s", path, e)
+ return None
+
+ def search_series(self, name: str) -> tuple[int, str] | None:
+ """Search for a TV series by name. Returns (series_id, series_name_nl) or None."""
+ now = time.time()
+ if name in self._series_cache:
+ sid, sname, ts = self._series_cache[name]
+ if now - ts < self._ttl_sec:
+ logger.debug("TMDB series_search %s: memory hit -> %s", name, sid)
+ return (sid, sname)
+ key = _sanitize_key(name)
+ cached = self._read_disk_cache("series_search", key)
+ if cached is not None:
+ sid, sname = cached.get("series_id"), cached.get("series_name_nl")
+ if sid is not None and sname is not None:
+ self._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 = self._request("/search/tv", {"query": name})
+ if not data:
+ return None
+ results = data.get("results") or []
+ if not results:
+ return None
+ first = results[0]
+ sid = first.get("id")
+ sname = (first.get("name") or name).strip() or name
+ if sid is not None:
+ self._series_cache[name] = (int(sid), sname, now)
+ self._write_disk_cache("series_search", key, {"series_id": sid, "series_name_nl": sname})
+ return (int(sid), sname)
+ return None
+
+ def get_series_details(self, series_id: int) -> dict | None:
+ """Fetch series details in Dutch. Returns {genres, country, name} or None."""
+ now = time.time()
+ if series_id in self._series_details_cache:
+ details, ts = self._series_details_cache[series_id]
+ if now - ts < self._ttl_sec:
+ logger.debug("TMDB series_details %s: memory hit", series_id)
+ return details
+ key = str(series_id)
+ cached = self._read_disk_cache("series_details", key)
+ if cached is not None:
+ self._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 = self._request(f"/tv/{series_id}")
+ if not data:
+ return None
+ genres = [g.get("name") for g in (data.get("genres") or []) if g.get("name")]
+ countries = data.get("production_countries") or []
+ country = (countries[0].get("iso_3166_1") or "").upper() if countries else ""
+ name = (data.get("name") or "").strip() or ""
+ details = {"genres": genres, "country": country, "name": name}
+ self._series_details_cache[series_id] = (details, now)
+ self._write_disk_cache("series_details", key, details)
+ return details
+
+ def get_episode(self, series_id: int, season: int, episode: int) -> dict | None:
+ """Fetch one episode in Dutch."""
+ now = time.time()
+ key_tuple = (series_id, season, episode)
+ if key_tuple in self._episode_cache:
+ meta, ts = self._episode_cache[key_tuple]
+ if now - ts < self._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 = self._read_disk_cache("episode", key)
+ if cached is not None:
+ self._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 = self._request(f"/tv/{series_id}/season/{season}/episode/{episode}")
+ if not data:
+ return None
+ name = (data.get("name") or "").strip()
+ overview = (data.get("overview") or "").strip()
+ air_date = (data.get("air_date") or "").strip()
+ still_path = (data.get("still_path") or "").strip()
+ if still_path and not still_path.startswith("/"):
+ still_path = "/" + still_path
+ meta = {
+ "name": name,
+ "overview": overview,
+ "air_date": air_date,
+ "still_path": still_path or None,
+ }
+ self._episode_cache[key_tuple] = (meta, now)
+ self._write_disk_cache("episode", key, meta)
+ return meta
+
+ def get_episode_credits(self, series_id: int, season: int, episode: int) -> dict | None:
+ """Fetch episode credits."""
+ now = time.time()
+ key_tuple = (series_id, season, episode)
+ if key_tuple in self._episode_credits_cache:
+ cred, ts = self._episode_credits_cache[key_tuple]
+ if now - ts < self._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 = self._read_disk_cache("episode_credits", key)
+ if cached is not None:
+ self._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 = self._request(f"/tv/{series_id}/season/{season}/episode/{episode}/credits")
+ if not data:
+ return None
+ directors = []
+ producers = []
+ for c in data.get("crew") or []:
+ job = (c.get("job") or "").strip()
+ name = (c.get("name") or "").strip()
+ if not name:
+ continue
+ if job == "Director":
+ directors.append(name)
+ elif job in (
+ "Producer",
+ "Executive Producer",
+ "Co-Executive Producer",
+ "Supervising Producer",
+ "Co-Producer",
+ ):
+ producers.append(name)
+ actors = [
+ (c.get("name") or "").strip()
+ for c in (data.get("cast") or [])
+ if (c.get("name") or "").strip()
+ ]
+ directors = list(dict.fromkeys(directors))
+ producers = list(dict.fromkeys(producers))
+ cred = {"directors": directors, "actors": actors, "producers": producers}
+ self._episode_credits_cache[key_tuple] = (cred, now)
+ self._write_disk_cache("episode_credits", key, cred)
+ return cred
+
+ def get_episode_metadata(
+ self,
+ programme_name: str,
+ path: Path,
+ *,
+ display_name: str | None = None,
+ tmdb_search: str | None = None,
+ tmdb_id: int | None = None,
+ series_root: Path,
+ ) -> dict | None:
+ """
+ Return TMDB metadata for this series episode in Dutch.
+ Only for series under series_root; parses S01E01 from path.stem.
+ """
+ if series_root not in path.parents:
+ return None
+ if not os.environ.get("TMDB_API_KEY", "").strip():
+ return None
+ se = parse_season_episode(path.stem)
+ if not se:
+ return None
+ season_num, episode_num = se
+
+ if tmdb_id is not None:
+ series_id = tmdb_id
+ details = self.get_series_details(series_id)
+ series_name_nl = (details.get("name") or programme_name).strip() or programme_name
+ else:
+ search_term = (tmdb_search or programme_name).strip() or programme_name
+ hit = self.search_series(search_term)
+ if not hit:
+ return None
+ series_id, series_name_nl = hit
+
+ ep = self.get_episode(series_id, season_num, episode_num)
+ if not ep:
+ return None
+
+ sub_title = ep.get("name") or path.stem
+ overview = (ep.get("overview") or "").strip() or None
+ air_date = ep.get("air_date") or ""
+ date_str = (air_date.replace("-", "")[:8]) if air_date and len(air_date) >= 10 else "20090101"
+ still_path = ep.get("still_path") or ""
+ icon_url = f"{TMDB_IMAGE_BASE}/{TMDB_IMAGE_STILL_SIZE}{still_path}" if still_path and still_path.startswith("/") else None
+
+ details = self.get_series_details(series_id)
+ genres = (details.get("genres") or []) if details else []
+ country = (details.get("country") or "") if details else ""
+
+ credits = self.get_episode_credits(series_id, season_num, episode_num)
+ directors = (credits.get("directors") or []) if credits else []
+ actors = (credits.get("actors") or []) if credits else []
+ producers = (credits.get("producers") or []) if credits else []
+
+ xmltv_ns = f"{season_num}.{episode_num}.0/1"
+ onscreen = f"S{season_num:02d}E{episode_num:02d}"
+ epg_title = (display_name or series_name_nl).strip() or series_name_nl
+
+ return {
+ "title": epg_title,
+ "sub_title": sub_title,
+ "desc": overview,
+ "lang": TMDB_LANGUAGE,
+ "icon": icon_url,
+ "directors": directors,
+ "actors": actors,
+ "producers": producers,
+ "date": date_str,
+ "categories": genres,
+ "country": country,
+ "episode_nums": {"xmltv_ns": xmltv_ns, "onscreen": onscreen},
+ }
diff --git a/Dockers/self-hosted-iptv/main.py b/Dockers/self-hosted-iptv/main.py
index 6861aa6..598f8cb 100644
--- a/Dockers/self-hosted-iptv/main.py
+++ b/Dockers/self-hosted-iptv/main.py
@@ -4,1032 +4,21 @@ Self-hosted IPTV: serves M3U playlists that schedule video files from /movies an
with randomized order so the same show never appears twice in a row.
"""
-import json
import logging
import os
-import random
-import re
-import subprocess
-import tempfile
-import threading
-import time
-import urllib.error
-import urllib.request
-import xml.etree.ElementTree as ET
-from collections import defaultdict, deque
-from datetime import datetime, timezone, timedelta
-from pathlib import Path
-from http.server import HTTPServer, BaseHTTPRequestHandler
-from urllib.parse import urlparse, parse_qs, unquote, urlencode
-import mimetypes
+
+from iptv.config import CHANNELS_FILE, EPG_TIMESPAN_DAYS, SCHEDULE_CACHE_TTL_SEC, init_epg_timespan
+from iptv.channel import ChannelRepository
+from iptv.schedule import ScheduleBuilder
+from iptv.tmdb_client import TMDBClient
+from iptv.epg_builder import EPGBuilder
+from iptv.server import create_server
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]] = {}
-# 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")
-CHANNELS_FILE = DATA_ROOT / "channels.json"
-VIDEO_EXTENSIONS = {".mkv", ".mp4", ".avi", ".mov", ".m4v", ".webm", ".wmv"}
-
-# TMDB (The Movie Database) for EPG metadata — Dutch (nl). Set TMDB_API_KEY.
-TMDB_API_BASE = "https://api.themoviedb.org/3"
-TMDB_LANGUAGE = "nl"
-TMDB_CACHE_TTL_SEC = 24 * 3600
-TMDB_RATE_LIMIT_REQUESTS = 40
-TMDB_RATE_LIMIT_WINDOW_SEC = 10.0
-TMDB_CACHE_DIR = DATA_ROOT / "tmdb_cache"
-TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"
-TMDB_IMAGE_STILL_SIZE = "w500" # episode still width for EPG icon
-_tmdb_series_cache: dict[str, tuple[int, str, float]] = {} # programme_name -> (series_id, series_name_nl, ts)
-_tmdb_series_details_cache: dict[int, tuple[dict, float]] = {} # series_id -> ({genres, country}, ts)
-_tmdb_episode_cache: dict[tuple[int, int, int], tuple[dict, float]] = {} # (series_id, s, e) -> (meta, ts)
-_tmdb_episode_credits_cache: dict[tuple[int, int, int], tuple[dict, float]] = {} # (series_id, s, e) -> (credits, ts)
-_tmdb_request_times: deque = deque(maxlen=TMDB_RATE_LIMIT_REQUESTS + 10)
-_tmdb_rate_limit_lock = threading.Lock()
-# S01E01-style pattern for matching episode numbers
-_episode_pattern = re.compile(r"(?i)s(\d+)e(\d+)")
-
-
-def get_channels():
- """Load channels from /data/channels.json."""
- if not CHANNELS_FILE.exists():
- return []
- with open(CHANNELS_FILE, encoding="utf-8") as f:
- data = json.load(f)
- return data.get("channels", [])
-
-
-def resolve_path(path_key: str) -> Path | None:
- """Resolve a channel path key to an absolute Path. Returns None if invalid."""
- path_key = path_key.strip("/")
- if path_key == "movies":
- return MOVIES_ROOT
- if path_key.startswith("series/"):
- subpath = path_key[7:] # len("series/")
- return SERIES_ROOT / subpath
- return None
-
-
-def collect_videos(root: Path) -> list[tuple[str, Path]]:
- """Collect all video files under root. Returns list of (path_key, Path)."""
- if not root.exists() or not root.is_dir():
- return []
- videos = []
- try:
- for p in root.rglob("*"):
- if p.is_file() and p.suffix.lower() in VIDEO_EXTENSIONS:
- videos.append(p)
- except OSError:
- pass
- return videos
-
-
-def collect_channel_videos(path_keys: list[str]) -> list[tuple[str, Path]]:
- """
- For each path key (e.g. 'movies', 'series/Breaking Bad'), collect videos.
- Returns list of (path_key, absolute_path) so we can group by path_key (show).
- """
- out = []
- for key in path_keys:
- root = resolve_path(key)
- if root is None:
- continue
- for vid in collect_videos(root):
- out.append((key, vid))
- return out
-
-
-def shuffle_no_adjacent_same_show(items: list[tuple[str, Path]]) -> list[Path]:
- """
- Shuffle so that the same path_key (show) never appears twice in a row.
- Groups by path_key, shuffles each group, then interleaves round-robin.
- """
- if not items:
- return []
- by_key = defaultdict(list)
- for key, path in items:
- by_key[key].append(path)
- for key in by_key:
- random.shuffle(by_key[key])
- # Round-robin interleave so no two from same key are adjacent
- groups = list(by_key.values())
- result = []
- n = max(len(g) for g in groups)
- for i in range(n):
- for g in groups:
- if i < len(g):
- result.append(g[i])
- return result
-
-
-def build_channel_path_list(path_keys: list[str]) -> list[Path]:
- """Build randomized list of absolute video paths for a channel (no same show adjacent)."""
- items = collect_channel_videos(path_keys)
- return shuffle_no_adjacent_same_show(items)
-
-
-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(
- [
- "ffprobe",
- "-v", "error",
- "-show_entries", "format=duration",
- "-of", "default=noprint_wrappers=1:nokey=1",
- str(path),
- ],
- capture_output=True,
- text=True,
- timeout=30,
- )
- if out.returncode == 0 and out.stdout.strip():
- 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
-
-
-def get_or_build_schedule(channel_id: str) -> tuple[list[Path], list[float]] | None:
- """
- Return (paths, durations) for the channel, so live stream and EPG use the same schedule.
- Cached for SCHEDULE_CACHE_TTL_SEC.
- """
- now = time.time()
- 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:
- return None
- paths = build_channel_path_list(channel.get("paths", []))
- 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
-
-
-def build_playlist(path_keys: list[str], base_url: str) -> list[tuple[str, str]]:
- """
- Build playlist as list of (display_name, stream_url).
- base_url is the base URL for stream links (e.g. http://host:port).
- """
- paths = build_channel_path_list(path_keys)
- playlist = []
- for p in paths:
- try:
- if MOVIES_ROOT in p.parents:
- rel = p.relative_to(MOVIES_ROOT)
- path_str = "movies/" + str(rel).replace("\\", "/")
- else:
- rel = p.relative_to(SERIES_ROOT)
- path_str = "series/" + str(rel).replace("\\", "/")
- except ValueError:
- path_str = "movies/" + p.name
- stream_url = f"{base_url}/stream?path={path_str}"
- display = p.stem
- playlist.append((display, stream_url))
- return playlist
-
-
-# Preferred audio languages for streaming: Dutch/Flemish first, then English, then default (first audio).
-PREFERRED_AUDIO_LANGUAGES = ("nld", "dut", "eng") # ISO 639-2: nld/dut = Dutch, eng = English
-
-
-def get_preferred_audio_stream_index(path: Path) -> int | None:
- """
- Probe the file with ffprobe and return the 0-based audio stream index to use:
- first Dutch/Flemish (nld/dut), else first English (eng), else first audio stream.
- Returns None on probe failure or no audio streams.
- """
- try:
- out = subprocess.run(
- [
- "ffprobe",
- "-v", "quiet",
- "-print_format", "json",
- "-show_entries", "stream=index,codec_type",
- "-show_entries", "stream_tags=language",
- str(path),
- ],
- capture_output=True,
- text=True,
- timeout=10,
- )
- if out.returncode != 0 or not out.stdout:
- return None
- data = json.loads(out.stdout)
- streams = data.get("streams") or []
- audio_streams = [] # (index, language or "")
- for s in streams:
- if s.get("codec_type") != "audio":
- continue
- idx = s.get("index")
- if idx is None:
- continue
- tags = s.get("tags") or {}
- lang = (tags.get("language") or "").strip().lower()[:3]
- audio_streams.append((idx, lang))
- if not audio_streams:
- return None
- for pref in PREFERRED_AUDIO_LANGUAGES:
- for idx, lang in audio_streams:
- if lang == pref:
- return idx
- return audio_streams[0][0]
- except (subprocess.TimeoutExpired, ValueError, FileNotFoundError, json.JSONDecodeError):
- return None
-
-
-def stream_file_with_preferred_audio(path: Path, wfile) -> bool:
- """
- Stream the file via FFmpeg with only video and the preferred audio track (Dutch/Flemish, else English, else first).
- Returns True if streaming was started, False if caller should fall back to raw file.
- """
- audio_idx = get_preferred_audio_stream_index(path)
- if audio_idx is None:
- return False
- try:
- proc = subprocess.Popen(
- [
- "ffmpeg",
- "-i", str(path),
- "-map", "0:v",
- "-map", f"0:{audio_idx}",
- "-c", "copy",
- "-f", "mp4",
- "-movflags", "frag_keyframe+empty_moov+default_base_moof",
- "-",
- ],
- stdout=subprocess.PIPE,
- stderr=subprocess.DEVNULL,
- start_new_session=True,
- )
- while True:
- chunk = proc.stdout.read(65536)
- if not chunk:
- break
- wfile.write(chunk)
- wfile.flush()
- proc.wait(timeout=1)
- except (OSError, subprocess.TimeoutExpired):
- try:
- proc.terminate()
- proc.wait(timeout=2)
- except Exception:
- pass
- return False
- return True
-
-
-def path_to_absolute(path_param: str) -> Path | None:
- """
- Convert a path query parameter to an absolute Path under /movies or /series.
- Path must start with 'movies/' or 'series/'. Prevents path traversal.
- """
- path_param = unquote(path_param).strip("/")
- if ".." in path_param or path_param.startswith("/"):
- return None
- if path_param.startswith("movies/"):
- sub = path_param[7:]
- root = MOVIES_ROOT
- elif path_param.startswith("series/"):
- sub = path_param[7:]
- root = SERIES_ROOT
- else:
- return None
- candidate = (root / sub).resolve()
- try:
- candidate.relative_to(root)
- except ValueError:
- return None
- if candidate.exists() and candidate.is_file() and candidate.suffix.lower() in VIDEO_EXTENSIONS:
- return candidate
- return None
-
-
-def get_base_url(handler: BaseHTTPRequestHandler) -> str:
- """Build base URL for playlist links from request."""
- host = handler.headers.get("Host", "localhost:8080")
- return f"http://{host}"
-
-
-def _format_xmltv_time(dt: datetime) -> str:
- """Format as XMLTV: YYYYMMDDHHmmss +0000"""
- tz = dt.strftime("%z") if dt.tzinfo else "+0000"
- return dt.strftime("%Y%m%d%H%M%S ") + tz
-
-
-def get_programme_name_from_path(path: Path) -> str:
- """
- Return the series/movie name from the folder (e.g. series/W817/... -> W817,
- movies/MyMovie/... -> MyMovie). If the file is directly under root, use stem.
- """
- try:
- if SERIES_ROOT in path.parents:
- rel = path.relative_to(SERIES_ROOT)
- parts = rel.parts
- if len(parts) > 1:
- return parts[0]
- return path.stem
- if MOVIES_ROOT in path.parents:
- rel = path.relative_to(MOVIES_ROOT)
- parts = rel.parts
- if len(parts) > 1:
- return parts[0]
- return path.stem
- except ValueError:
- pass
- return path.stem
-
-
-def _parse_season_episode(stem: str) -> tuple[int, int] | None:
- """Parse S01E01-style from filename stem. Returns (season, episode) or None."""
- m = _episode_pattern.search(stem)
- if m:
- return int(m.group(1)), int(m.group(2))
- return None
-
-
-def _tmdb_sanitize_key(name: str) -> str:
- """Sanitize a string for use as a cache filename (no path separators or problematic chars)."""
- s = re.sub(r'[^\w\-.\s]', "", str(name))
- return re.sub(r"\s+", "_", s).strip("_") or "unknown"
-
-
-def _tmdb_cache_path(subdir: str, key: str) -> Path:
- """Path to a cache file: TMDB_CACHE_DIR/subdir/key.json."""
- TMDB_CACHE_DIR.mkdir(parents=True, exist_ok=True)
- (TMDB_CACHE_DIR / subdir).mkdir(exist_ok=True)
- return TMDB_CACHE_DIR / subdir / f"{key}.json"
-
-
-def _tmdb_read_disk_cache(subdir: str, key: str) -> dict | None:
- """Read cached JSON from disk. Returns None if missing or invalid."""
- path = _tmdb_cache_path(subdir, key)
- if not path.exists():
- return None
- try:
- with open(path, encoding="utf-8") as f:
- return json.load(f)
- except (OSError, json.JSONDecodeError):
- return None
-
-
-def _tmdb_write_disk_cache(subdir: str, key: str, data: dict) -> None:
- """Write JSON to disk cache."""
- path = _tmdb_cache_path(subdir, key)
- try:
- with open(path, "w", encoding="utf-8") as f:
- json.dump(data, f, ensure_ascii=False, indent=0)
- except OSError:
- pass
-
-
-def _tmdb_wait_rate_limit() -> None:
- """Block until we are allowed to make another TMDB request (40 per 10s)."""
- with _tmdb_rate_limit_lock:
- while True:
- now = time.monotonic()
- while _tmdb_request_times and now - _tmdb_request_times[0] >= TMDB_RATE_LIMIT_WINDOW_SEC:
- _tmdb_request_times.popleft()
- if len(_tmdb_request_times) < TMDB_RATE_LIMIT_REQUESTS:
- 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))
-
-
-def _tmdb_record_request() -> None:
- """Record that a TMDB request was just made."""
- with _tmdb_rate_limit_lock:
- _tmdb_request_times.append(time.monotonic())
-
-
-def _tmdb_request(path: str, params: dict | None = None) -> dict | None:
- """GET TMDB API with API key and Dutch language. Rate-limited to 40 requests per 10s. Returns parsed JSON or None."""
- api_key = os.environ.get("TMDB_API_KEY", "").strip()
- if not api_key:
- return None
- _tmdb_wait_rate_limit()
- q = {"api_key": api_key, "language": TMDB_LANGUAGE}
- if params:
- 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) as e:
- logger.debug("TMDB request failed %s: %s", path, e)
- return None
-
-
-def _tmdb_search_series(name: str) -> tuple[int, str] | None:
- """Search for a TV series by name. Returns (series_id, series_name_nl) or None. Uses memory, disk, then API (rate-limited)."""
- now = time.time()
- 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)
- if cached is not 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
- results = (data.get("results") or [])
- if not results:
- return None
- first = results[0]
- sid = first.get("id")
- sname = (first.get("name") or name).strip() or name
- if sid is not None:
- _tmdb_series_cache[name] = (int(sid), sname, now)
- _tmdb_write_disk_cache("series_search", key, {"series_id": sid, "series_name_nl": sname})
- return (int(sid), sname)
- return None
-
-
-def _tmdb_get_series_details(series_id: int) -> dict | None:
- """Fetch series details in Dutch. Returns {genres: [names], country: iso} or None. Uses memory, disk, then API."""
- now = time.time()
- 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
- genres = [g.get("name") for g in (data.get("genres") or []) if g.get("name")]
- countries = data.get("production_countries") or []
- country = (countries[0].get("iso_3166_1") or "").upper() if countries else ""
- details = {"genres": genres, "country": country}
- _tmdb_series_details_cache[series_id] = (details, now)
- _tmdb_write_disk_cache("series_details", key, details)
- return details
-
-
-def _tmdb_get_episode(series_id: int, season: int, episode: int) -> dict | None:
- """Fetch one episode in Dutch. Returns dict with name, overview, air_date or None. Uses memory, disk, then API."""
- now = time.time()
- key_tuple = (series_id, season, episode)
- 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
- name = (data.get("name") or "").strip()
- overview = (data.get("overview") or "").strip()
- air_date = (data.get("air_date") or "").strip() # YYYY-MM-DD
- still_path = (data.get("still_path") or "").strip()
- if still_path and not still_path.startswith("/"):
- still_path = "/" + still_path
- meta = {"name": name, "overview": overview, "air_date": air_date, "still_path": still_path or None}
- _tmdb_episode_cache[key_tuple] = (meta, now)
- _tmdb_write_disk_cache("episode", key, meta)
- return meta
-
-
-def _tmdb_get_episode_credits(series_id: int, season: int, episode: int) -> dict | None:
- """Fetch episode credits. Returns {directors, actors, producers} or None. Uses memory, disk, then API."""
- now = time.time()
- key_tuple = (series_id, season, episode)
- 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
- directors = []
- producers = []
- for c in data.get("crew") or []:
- job = (c.get("job") or "").strip()
- name = (c.get("name") or "").strip()
- if not name:
- continue
- if job == "Director":
- directors.append(name)
- elif job in ("Producer", "Executive Producer", "Co-Executive Producer", "Supervising Producer", "Co-Producer"):
- producers.append(name)
- actors = [(c.get("name") or "").strip() for c in (data.get("cast") or []) if (c.get("name") or "").strip()]
- directors = list(dict.fromkeys(directors))
- producers = list(dict.fromkeys(producers))
- cred = {"directors": directors, "actors": actors, "producers": producers}
- _tmdb_episode_credits_cache[key_tuple] = (cred, now)
- _tmdb_write_disk_cache("episode_credits", key, cred)
- return cred
-
-
-def _air_date_to_xmltv_date(air_date: str) -> str:
- """Convert TMDB air_date (YYYY-MM-DD) to XMLTV date (YYYYMMDD)."""
- if not air_date or len(air_date) < 10:
- return ""
- return air_date.replace("-", "")[:8] # YYYYMMDD
-
-
-def get_episode_metadata(programme_name: str, path: Path) -> dict | None:
- """
- Return TMDB metadata for this series episode in Dutch: title, sub_title, desc, credits, date, categories, country, episode_nums.
- Only for series under SERIES_ROOT; parses S01E01 from path.stem. Returns None if disabled or not found.
- """
- if SERIES_ROOT not in path.parents:
- return None
- if not os.environ.get("TMDB_API_KEY", "").strip():
- return None
- se = _parse_season_episode(path.stem)
- if not se:
- return None
- season_num, episode_num = se
- hit = _tmdb_search_series(programme_name)
- if not hit:
- return None
- series_id, series_name_nl = hit
- ep = _tmdb_get_episode(series_id, season_num, episode_num)
- if not ep:
- return None
- sub_title = ep.get("name") or path.stem
- overview = (ep.get("overview") or "").strip() or None
- air_date = ep.get("air_date") or ""
- date_str = _air_date_to_xmltv_date(air_date) or "20090101" # fallback if missing
-
- still_path = ep.get("still_path") or ""
- if still_path and still_path.startswith("/"):
- icon_url = f"{TMDB_IMAGE_BASE}/{TMDB_IMAGE_STILL_SIZE}{still_path}"
- else:
- icon_url = None
-
- details = _tmdb_get_series_details(series_id)
- genres = (details.get("genres") or []) if details else []
- country = (details.get("country") or "") if details else ""
-
- credits = _tmdb_get_episode_credits(series_id, season_num, episode_num)
- directors = (credits.get("directors") or []) if credits else []
- actors = (credits.get("actors") or []) if credits else []
- producers = (credits.get("producers") or []) if credits else []
-
- # xmltv_ns: season.episode.part/parts (single part = 0/1)
- xmltv_ns = f"{season_num}.{episode_num}.0/1"
- onscreen = f"S{season_num:02d}E{episode_num:02d}"
-
- return {
- "title": series_name_nl,
- "sub_title": sub_title,
- "desc": overview,
- "lang": TMDB_LANGUAGE,
- "icon": icon_url,
- "directors": directors,
- "actors": actors,
- "producers": producers,
- "date": date_str,
- "categories": genres,
- "country": country,
- "episode_nums": {"xmltv_ns": xmltv_ns, "onscreen": onscreen},
- }
-
-
-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_TIMESPAN_DAYS. Uses cached schedule.
- """
- epg_start = time.monotonic()
- channels = get_channels()
- if not channels:
- return '\n'
-
- logger.info("EPG build started (%s channels)", len(channels))
- now = datetime.now(timezone.utc)
- epoch = _schedule_epoch_utc()
- end_epoch = epoch + timedelta(days=EPG_TIMESPAN_DAYS)
-
- root = ET.Element("tv")
- root.set("source-info-name", "self-hosted-iptv")
- root.set("generator-info-name", "self-hosted-iptv")
-
- for ch in channels:
- cid = ch.get("id", ch.get("name", ""))
- name = ch.get("name", cid)
- 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)
- if cycle_duration_sec <= 0:
- continue
- cycle_delta = timedelta(seconds=cycle_duration_sec)
- offsets_sec = [0.0]
- 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)):
- start_dt = cycle_start + timedelta(seconds=offsets_sec[i])
- stop_dt = start_dt + timedelta(seconds=dur)
- if stop_dt <= now:
- 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),
- channel=cid,
- )
- programme_name = get_programme_name_from_path(path)
- meta = get_episode_metadata(programme_name, path)
- lang = (meta.get("lang") or "en") if meta else "en"
- if meta:
- ET.SubElement(prog, "title", lang=lang).text = meta["title"]
- ET.SubElement(prog, "sub-title", lang=lang).text = meta["sub_title"]
- if meta.get("desc"):
- ET.SubElement(prog, "desc", lang=lang).text = meta["desc"]
- if meta.get("icon"):
- ET.SubElement(prog, "icon", attrib={"src": meta["icon"]})
- directors = meta.get("directors") or []
- actors = meta.get("actors") or []
- producers = meta.get("producers") or []
- if directors or actors or producers:
- credits_el = ET.SubElement(prog, "credits")
- for d in directors:
- ET.SubElement(credits_el, "director").text = d
- for a in actors:
- ET.SubElement(credits_el, "actor").text = a
- for p in producers:
- ET.SubElement(credits_el, "producer").text = p
- if meta.get("date"):
- ET.SubElement(prog, "date").text = meta["date"]
- for cat in meta.get("categories") or []:
- ET.SubElement(prog, "category", lang=lang).text = cat
- if meta.get("country"):
- ET.SubElement(prog, "country", lang=lang).text = meta["country"]
- nums = meta.get("episode_nums") or {}
- if nums.get("xmltv_ns"):
- ET.SubElement(prog, "episode-num", system="xmltv_ns").text = nums["xmltv_ns"]
- if nums.get("onscreen"):
- ET.SubElement(prog, "episode-num", system="onscreen").text = nums["onscreen"]
- else:
- ET.SubElement(prog, "title", lang="en").text = programme_name
- ET.SubElement(prog, "sub-title", lang="en").text = path.stem
- se = _parse_season_episode(path.stem)
- if se:
- s, e = se
- ET.SubElement(prog, "episode-num", system="xmltv_ns").text = f"{s}.{e}.0/1"
- 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 '\n' + ET.tostring(root, encoding="unicode", default_namespace=None)
-
-
-def _escape_path(p: Path) -> str:
- return str(p.resolve()).replace("\\", "\\\\").replace("'", "'\\''")
-
-
-def write_concat_list(paths: list[Path], fd) -> None:
- """Write FFmpeg concat demuxer list to file. Escapes paths for safety."""
- for p in paths:
- fd.write(f"file '{_escape_path(p)}'\n")
- fd.flush()
-
-
-def _schedule_epoch_utc() -> datetime:
- """Midnight UTC today — same reference used by EPG and live stream."""
- return datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
-
-
-def _current_position_in_cycle(durations: list[float]) -> tuple[int, float]:
- """
- Return (file_index, offset_sec_in_file) for 'now' in the schedule cycle.
- Schedule epoch = midnight UTC; cycle = one full playlist.
- """
- epoch = _schedule_epoch_utc()
- now = datetime.now(timezone.utc)
- cycle_duration = sum(durations)
- if cycle_duration <= 0:
- return 0, 0.0
- offset_sec = (now - epoch).total_seconds() % cycle_duration
- cumul = 0.0
- for i, dur in enumerate(durations):
- if offset_sec < cumul + dur:
- return i, offset_sec - cumul
- cumul += dur
- return len(durations) - 1, durations[-1]
-
-
-def write_concat_list_from_current(paths: list[Path], durations: list[float], fd) -> None:
- """
- Write a concat list that starts at the current position in the schedule and
- loops seamlessly. Uses inpoint/outpoint so we join mid-file, then repeat.
- Cycle: current file (offset→end), next files…, start…current-1, current file (0→offset).
- """
- n = len(paths)
- if n == 0:
- return
- idx, offset_in_file = _current_position_in_cycle(durations)
- for i in range(n):
- j = (idx + i) % n
- fd.write(f"file '{_escape_path(paths[j])}'\n")
- if i == 0 and offset_in_file >= 0.5:
- fd.write(f"inpoint {offset_in_file:.2f}\n")
- if offset_in_file >= 0.5:
- fd.write(f"file '{_escape_path(paths[idx])}'\n")
- fd.write(f"outpoint {offset_in_file:.2f}\n")
- fd.flush()
-
-
-def stream_live_channel(paths: list[Path], wfile, durations: list[float]) -> None:
- """
- Run FFmpeg to output a continuous MPEG-TS stream. Concat list starts at
- current position in schedule (midnight UTC cycle) so each new client joins
- where the 'channel' is now; then the list loops. EPG and stream stay in sync.
- """
- if not paths:
- return
-
- with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
- write_concat_list_from_current(paths, durations, f)
- list_path = f.name
- try:
- proc = subprocess.Popen(
- [
- "ffmpeg",
- "-stream_loop", "-1",
- "-f", "concat", "-safe", "0", "-i", list_path,
- "-c", "copy",
- "-f", "mpegts",
- "-",
- ],
- stdout=subprocess.PIPE,
- stderr=subprocess.DEVNULL,
- start_new_session=True,
- )
- try:
- while True:
- chunk = proc.stdout.read(65536)
- if not chunk:
- break
- wfile.write(chunk)
- wfile.flush()
- except (BrokenPipeError, ConnectionResetError, OSError):
- pass
- finally:
- proc.terminate()
- try:
- proc.wait(timeout=5)
- except subprocess.TimeoutExpired:
- proc.kill()
- finally:
- Path(list_path).unlink(missing_ok=True)
-
-
-class IPTVHandler(BaseHTTPRequestHandler):
- def log_message(self, format, *args):
- pass # quiet by default; set to super().log_message for debugging
-
- def do_GET(self):
- parsed = urlparse(self.path)
- path = parsed.path.rstrip("/") or "/"
- qs = parse_qs(parsed.query)
-
- if path == "/" or path == "/index.html":
- self.send_index()
- return
- if path == "/playlist.m3u" or path == "/channels.m3u":
- self.send_channels_m3u()
- return
- if path.startswith("/channel/") and path.endswith("/playlist.m3u"):
- channel_id = path.split("/")[2]
- self.send_channel_m3u(channel_id)
- return
- if path == "/stream":
- path_param = qs.get("path", [""])[0]
- self.send_stream(path_param)
- return
- if path.startswith("/live/"):
- parts = path.split("/")
- if len(parts) == 3 and parts[1] == "live":
- self.send_live_stream(parts[2])
- return
- if path == "/epg.xml" or path == "/xmltv.xml":
- self.send_epg()
- return
- self.send_error(404, "Not found")
-
- def send_index(self):
- channels = get_channels()
- body = "Self-hosted IPTV"
- body += "Self-hosted IPTV
Playlist (M3U):
"
- body += f"{get_base_url(self)}/playlist.m3u
"
- body += "EPG (XMLTV, for Plex DVR):
"
- body += f"{get_base_url(self)}/epg.xml
"
- body += "Channels
"
- for ch in channels:
- cid = ch.get("id", ch.get("name", ""))
- body += f"- {ch.get('name', cid)}
"
- body += "
"
- self.send_response(200)
- self.send_header("Content-Type", "text/html; charset=utf-8")
- self.send_header("Content-Length", str(len(body.encode("utf-8"))))
- self.end_headers()
- self.wfile.write(body.encode("utf-8"))
-
- def send_channels_m3u(self):
- """Master M3U for Plex DVR: one live stream URL per channel."""
- base = get_base_url(self)
- channels = get_channels()
- lines = ["#EXTM3U"]
- for ch in channels:
- cid = ch.get("id", ch.get("name", ""))
- name = ch.get("name", cid)
- lines.append(f'#EXTINF:-1,{name}')
- lines.append(f"{base}/live/{cid}")
- body = "\n".join(lines) + "\n"
- self.send_response(200)
- self.send_header("Content-Type", "application/x-mpegURL; charset=utf-8")
- self.send_header("Content-Length", str(len(body.encode("utf-8"))))
- self.end_headers()
- self.wfile.write(body.encode("utf-8"))
-
- def send_channel_m3u(self, channel_id: str):
- """Single channel M3U with randomized video entries (no same show adjacent)."""
- channels = get_channels()
- channel = next((c for c in channels if c.get("id") == channel_id), None)
- if not channel:
- self.send_error(404, "Channel not found")
- return
- path_keys = channel.get("paths", [])
- base = get_base_url(self)
- playlist = build_playlist(path_keys, base)
- lines = ["#EXTM3U"]
- for name, url in playlist:
- lines.append(f"#EXTINF:-1,{name}")
- lines.append(url)
- body = "\n".join(lines) + "\n"
- self.send_response(200)
- self.send_header("Content-Type", "application/x-mpegURL; charset=utf-8")
- self.send_header("Content-Length", str(len(body.encode("utf-8"))))
- self.end_headers()
- self.wfile.write(body.encode("utf-8"))
-
- def send_epg(self):
- """Serve XMLTV EPG (programme schedule for Plex DVR)."""
- body = build_epg_xml()
- self.send_response(200)
- self.send_header("Content-Type", "application/xml; charset=utf-8")
- self.send_header("Content-Length", str(len(body.encode("utf-8"))))
- self.send_header("Cache-Control", "public, max-age=300")
- self.end_headers()
- self.wfile.write(body.encode("utf-8"))
-
- def send_live_stream(self, channel_id: str):
- """Stream channel as continuous MPEG-TS (for Plex DVR). Starts at current position in schedule so EPG matches."""
- schedule = get_or_build_schedule(channel_id)
- if not schedule:
- self.send_error(404, "Channel not found or no videos")
- return
- paths, durations = schedule
- if not paths:
- self.send_error(404, "No videos in channel")
- return
- self.send_response(200)
- self.send_header("Content-Type", "video/MP2T")
- self.send_header("Cache-Control", "no-cache, no-store")
- self.end_headers()
- stream_live_channel(paths, self.wfile, durations)
-
- def send_stream(self, path_param: str):
- """Stream a video file. path is relative to /movies or /series. Prefers Dutch/Flemish audio, then English, then default."""
- abs_path = path_to_absolute(path_param)
- if abs_path is None:
- self.send_error(404, "File not found")
- return
- audio_idx = get_preferred_audio_stream_index(abs_path)
- if audio_idx is not None:
- self.send_response(200)
- self.send_header("Content-Type", "video/mp4")
- self.send_header("Cache-Control", "no-cache")
- self.end_headers()
- stream_file_with_preferred_audio(abs_path, self.wfile)
- return
- content_type, _ = mimetypes.guess_type(str(abs_path))
- content_type = content_type or "video/mp4"
- size = abs_path.stat().st_size
- self.send_response(200)
- self.send_header("Content-Type", content_type)
- self.send_header("Content-Length", str(size))
- self.send_header("Accept-Ranges", "bytes")
- self.end_headers()
- with open(abs_path, "rb") as f:
- self.wfile.write(f.read())
-
def main():
+ init_epg_timespan()
log_level = getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO)
logging.basicConfig(
level=log_level,
@@ -1043,8 +32,21 @@ def main():
EPG_TIMESPAN_DAYS,
SCHEDULE_CACHE_TTL_SEC / 3600,
)
+
+ channel_repo = ChannelRepository()
+ schedule_builder = ScheduleBuilder()
+ schedule_builder.set_channel_repo(channel_repo)
+ tmdb_client = TMDBClient()
+ epg_builder = EPGBuilder(schedule_builder, tmdb_client, channel_repo)
+
port = 8080
- server = HTTPServer(("0.0.0.0", port), IPTVHandler)
+ server = create_server(
+ channel_repo=channel_repo,
+ schedule_builder=schedule_builder,
+ epg_builder=epg_builder,
+ host="0.0.0.0",
+ port=port,
+ )
print(f"Serving at http://0.0.0.0:{port}/ (playlist: http://:{port}/playlist.m3u)")
server.serve_forever()