poahh
Build and Push Docker Images / build-and-push (push) Successful in 18s

This commit is contained in:
2026-02-11 21:06:16 +01:00
parent a641c06e6a
commit 9f87297d35
19 changed files with 1288 additions and 1024 deletions
+1
View File
@@ -7,6 +7,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
WORKDIR /app WORKDIR /app
COPY main.py . COPY main.py .
COPY iptv/ iptv/
# /movies and /series are mounted by the user (video libraries) # /movies and /series are mounted by the user (video libraries)
# /data is mounted for channels.json # /data is mounted for channels.json
+3 -3
View File
@@ -12,9 +12,9 @@ Serves M3U playlists that randomly schedule video files from `/movies` and `/ser
- Each channel: - Each channel:
- `id`: unique id (used in URLs) - `id`: unique id (used in URLs)
- `name`: display name - `name`: display name
- `paths`: list of path keys to include: - `paths`: list of path entries. Each entry can be:
- `"movies"` — all videos under `/movies` - **String:** `"movies"` or `"series/Show Name"` — path to videos; EPG title and TMDB search use the folder name.
- `"series/Show Name"` — all videos under `/series/Show 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: 2. Run the container with volumes:
@@ -3,7 +3,20 @@
{ {
"id": "comedy", "id": "comedy",
"name": "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", "id": "drama",
@@ -0,0 +1 @@
"""Self-hosted IPTV: M3U playlists and EPG with randomized video scheduling."""
+70
View File
@@ -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
+58
View File
@@ -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")
@@ -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 '<?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_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 (
'<?xml version="1.0" encoding="UTF-8"?>\n'
+ ET.tostring(root, encoding="unicode", default_namespace=None)
)
+161
View File
@@ -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
+185
View File
@@ -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 = "<!DOCTYPE html><html><head><meta charset='utf-8'><title>Self-hosted IPTV</title></head><body>"
body += "<h1>Self-hosted IPTV</h1><p>Playlist (M3U):</p>"
body += f"<p><code>{get_base_url(self)}/playlist.m3u</code></p>"
body += "<p>EPG (XMLTV, for Plex DVR):</p>"
body += f"<p><code>{get_base_url(self)}/epg.xml</code></p>"
body += "<h2>Channels</h2><ul>"
for ch in channels:
cid = ch.get("id", ch.get("name", ""))
body += f"<li><a href='/channel/{cid}/playlist.m3u'>{ch.get('name', cid)}</a></li>"
body += "</ul></body></html>"
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
+246
View File
@@ -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)
@@ -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},
}
File diff suppressed because it is too large Load Diff