This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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},
|
||||||
|
}
|
||||||
+22
-1020
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user