#!/usr/bin/env python3
"""
Self-hosted IPTV: serves M3U playlists that schedule video files from /movies and /series
with randomized order so the same show never appears twice in a row.
"""
import json
import random
import subprocess
import tempfile
import time
import xml.etree.ElementTree as ET
from collections import defaultdict
from datetime import datetime, timezone, timedelta
from pathlib import Path
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs, unquote
import mimetypes
# Per-channel schedule cache: channel_id -> (paths, durations). TTL 24h so EPG and live stay in sync.
_schedule_cache: dict[str, tuple[list[Path], list[float], float]] = {}
SCHEDULE_CACHE_TTL_SEC = 24 * 3600
EPG_DAYS = 14
DEFAULT_DURATION_SEC = 3600
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"}
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."""
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():
return float(out.stdout.strip())
except (subprocess.TimeoutExpired, ValueError, FileNotFoundError):
pass
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:
return paths, durations
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]
_schedule_cache[channel_id] = (paths, durations, now)
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
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 build_epg_xml() -> str:
"""
Build XMLTV EPG for all channels. Schedule repeats from midnight UTC;
programmes generated for EPG_DAYS. Uses cached schedule per channel.
"""
channels = get_channels()
if not channels:
return '\n
Playlist (M3U):
" body += f"{get_base_url(self)}/playlist.m3u
EPG (XMLTV, for Plex DVR):
" body += f"{get_base_url(self)}/epg.xml