From 9f87297d35322f73875ae72ca84c16c822050cff Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 11 Feb 2026 21:06:16 +0100 Subject: [PATCH] poahh --- Dockers/self-hosted-iptv/Dockerfile | 1 + Dockers/self-hosted-iptv/README.md | 6 +- .../data/channels.json.example | 15 +- Dockers/self-hosted-iptv/iptv/__init__.py | 1 + .../iptv/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 262 bytes .../iptv/__pycache__/channel.cpython-313.pyc | Bin 0 -> 4018 bytes .../iptv/__pycache__/config.cpython-313.pyc | Bin 0 -> 2478 bytes .../__pycache__/epg_builder.cpython-313.pyc | Bin 0 -> 10110 bytes .../iptv/__pycache__/schedule.cpython-313.pyc | Bin 0 -> 9043 bytes .../iptv/__pycache__/server.cpython-313.pyc | Bin 0 -> 11377 bytes .../__pycache__/tmdb_client.cpython-313.pyc | Bin 0 -> 19413 bytes Dockers/self-hosted-iptv/iptv/channel.py | 70 ++ Dockers/self-hosted-iptv/iptv/config.py | 58 + Dockers/self-hosted-iptv/iptv/epg_builder.py | 187 +++ Dockers/self-hosted-iptv/iptv/schedule.py | 161 +++ Dockers/self-hosted-iptv/iptv/server.py | 185 +++ Dockers/self-hosted-iptv/iptv/streamer.py | 246 ++++ Dockers/self-hosted-iptv/iptv/tmdb_client.py | 340 ++++++ Dockers/self-hosted-iptv/main.py | 1042 +---------------- 19 files changed, 1288 insertions(+), 1024 deletions(-) create mode 100644 Dockers/self-hosted-iptv/iptv/__init__.py create mode 100644 Dockers/self-hosted-iptv/iptv/__pycache__/__init__.cpython-313.pyc create mode 100644 Dockers/self-hosted-iptv/iptv/__pycache__/channel.cpython-313.pyc create mode 100644 Dockers/self-hosted-iptv/iptv/__pycache__/config.cpython-313.pyc create mode 100644 Dockers/self-hosted-iptv/iptv/__pycache__/epg_builder.cpython-313.pyc create mode 100644 Dockers/self-hosted-iptv/iptv/__pycache__/schedule.cpython-313.pyc create mode 100644 Dockers/self-hosted-iptv/iptv/__pycache__/server.cpython-313.pyc create mode 100644 Dockers/self-hosted-iptv/iptv/__pycache__/tmdb_client.cpython-313.pyc create mode 100644 Dockers/self-hosted-iptv/iptv/channel.py create mode 100644 Dockers/self-hosted-iptv/iptv/config.py create mode 100644 Dockers/self-hosted-iptv/iptv/epg_builder.py create mode 100644 Dockers/self-hosted-iptv/iptv/schedule.py create mode 100644 Dockers/self-hosted-iptv/iptv/server.py create mode 100644 Dockers/self-hosted-iptv/iptv/streamer.py create mode 100644 Dockers/self-hosted-iptv/iptv/tmdb_client.py diff --git a/Dockers/self-hosted-iptv/Dockerfile b/Dockers/self-hosted-iptv/Dockerfile index 4f8643c..a9890b4 100644 --- a/Dockers/self-hosted-iptv/Dockerfile +++ b/Dockers/self-hosted-iptv/Dockerfile @@ -7,6 +7,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \ WORKDIR /app COPY main.py . +COPY iptv/ iptv/ # /movies and /series are mounted by the user (video libraries) # /data is mounted for channels.json diff --git a/Dockers/self-hosted-iptv/README.md b/Dockers/self-hosted-iptv/README.md index eff12e0..cc61e95 100644 --- a/Dockers/self-hosted-iptv/README.md +++ b/Dockers/self-hosted-iptv/README.md @@ -12,9 +12,9 @@ Serves M3U playlists that randomly schedule video files from `/movies` and `/ser - Each channel: - `id`: unique id (used in URLs) - `name`: display name - - `paths`: list of path keys to include: - - `"movies"` — all videos under `/movies` - - `"series/Show Name"` — all videos under `/series/Show Name` + - `paths`: list of path entries. Each entry can be: + - **String:** `"movies"` or `"series/Show Name"` — path to videos; EPG title and TMDB search use the folder name. + - **Object:** `{"path": "series/W817", "display_name": "Phineas en Ferb", "tmdb_search": "Phineas and Ferb"}` — `path` is required; `display_name` is shown in the EPG; `tmdb_search` is used when querying TMDB. You can use `tmdb_id` instead of (or to override) `tmdb_search` for a direct lookup, e.g. `97596` or `"97596-kika-bob"` (the numeric ID is extracted). 2. Run the container with volumes: diff --git a/Dockers/self-hosted-iptv/data/channels.json.example b/Dockers/self-hosted-iptv/data/channels.json.example index ad5e085..2941d81 100644 --- a/Dockers/self-hosted-iptv/data/channels.json.example +++ b/Dockers/self-hosted-iptv/data/channels.json.example @@ -3,7 +3,20 @@ { "id": "comedy", "name": "Comedy", - "paths": ["movies", "series/The Office", "series/Parks and Recreation"] + "paths": [ + "movies", + "series/The Office", + { + "path": "series/W817", + "display_name": "Phineas en Ferb", + "tmdb_search": "Phineas and Ferb" + }, + { + "path": "series/kika-bob", + "display_name": "Kika & Bob", + "tmdb_id": "97596-kika-bob" + } + ] }, { "id": "drama", diff --git a/Dockers/self-hosted-iptv/iptv/__init__.py b/Dockers/self-hosted-iptv/iptv/__init__.py new file mode 100644 index 0000000..ebdfe07 --- /dev/null +++ b/Dockers/self-hosted-iptv/iptv/__init__.py @@ -0,0 +1 @@ +"""Self-hosted IPTV: M3U playlists and EPG with randomized video scheduling.""" diff --git a/Dockers/self-hosted-iptv/iptv/__pycache__/__init__.cpython-313.pyc b/Dockers/self-hosted-iptv/iptv/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e8a14bbc3019f048f12819dec53c7270dc25f665 GIT binary patch literal 262 zcmey&%ge<81RW20G6R70V-N=h7@>^M96-iYhG2#whIB?vrYg_i)SNWkjQrw~)D#8J zfRHdN1z+P(g@T;K%ACyNl46C#yc7l30C$D*%#sX+A|Nk6H?s<;wk$IxHD94PIU_Zt zG$%7JUC&RG@fLf0d`f6(#2CyW}UA=BDPA6zjWZmZT=? z7Zl}Zr6vQpK#A literal 0 HcmV?d00001 diff --git a/Dockers/self-hosted-iptv/iptv/__pycache__/channel.cpython-313.pyc b/Dockers/self-hosted-iptv/iptv/__pycache__/channel.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4c3ade0440e0466d75c64772c26a75dc0f71f6ef GIT binary patch literal 4018 zcmb_fTTC0-89p@iH%o}p(3 zHn80&Pb;FW6p}W@(NVh31bURhl-1IhhSo9a zxWHhBM*<_TqwEDIc3$8xcfo~SFz1|d3)~bV&|Mrt-HhOBKvx;;fjMtCEx4^w#Ss)l zf~Nrq-jj6kPFy)dhborZ2WBNzl~a65lM;$L!%J#{pOcJPKCY=rWo8~rhN7wAXXMma zh%%XXVDT9RqmaX_92o8&8ymVPi04Kw4w>Fd8ZZK9sQ@(dAUKYgW?@Inkxe84D4sDsdE^TQnnIbZO`aDnr!oX&BLo4$q(R|AalTtk*?$n@m7wk+3HsAxQef;Pq_-Ewo4FgkMYL{|27!qRFztW4jb;+iF>Dhl$z_Qt?T00Tl2S%X7j^ z>t32=74N5co{~;kRKYj*mmR}-i?ZL4XaE(ER~)pTu?=n4e&7$hl>NR}t5j@w|^gB_Pr_-V{G|JAiqg)vJxF8T5b1tTET5ow_D(kdW{@B_MZPu z=CAC29ewZrjlM&miCym1E@1Fe&zOeO02NCFYb=ZbhsKM15^HH2d-U*C9dMW*vR2}N zKGSlqr;8T=KGd0BK3D>liPLZVILLRMHA+Dfv3QY^+t+y-mpEqXV&-VOAk0=a5d+J58DbIn<lZgF#6orLYW?l{Oi!-5ZP{6HX z*8#%h$i0!Zbgrp8-_-k%&NudF8~byO1Np|$M@FIU=yNw)<=H~4+p|@H>KoSw9|bm| z?`6Fc8_WdugY@F?FBdz6Z$pHK*f5148v+v$C=4EinJKm#a-u0GWH$`SZmv?86WA%& zl$#ucS?5j$1oLjeRmzM?xsc$2+{YF2n$@M;Co0crx?*T}@!6X|VX5yTo}XpDc$aSSD?5WCb&G;S%pn4R)cDrKu& z4==IvMzZTPB)=|MjcX)L9y49Go{++5A4_9hl=E6{=6@k^#$|3P}Ud9`%YwiCkp<< z%OjhW)hn&}nzn3B+XL>K%F_?~H~n?1J$HICKmBFj^59DcI@ANFZ(X~2E#p~xJLhZP zaJ9c=VfJz5>3``&n18D4Z+HB;-P3=PGd(390Tsvg7&1X%%a9Y$d0oP8+lC(vHzB0b zG5R12+DYqU6iMlc1zmz1Ax7(4Q5IyCR}!7P zhOPWNY|`*(NT7+_c548ifeDk2&+gTLn}~UO5jZs)57vRT*bj$wqHfSaP3>yu?ap6^ zm)|b!EZ?z2EX-)Vp>bg zr%1m-6n{1^rAjM45wHX6MoLj-RfAv>r)3?PbVKAMRwXq&wAl7>5&*w!o9wt(9Ysfn4{pYpMz@4K~dDVsOu^6 z{sp=JijMppoynpzTTT~Mm3eauL9^Dq-umU47o@vY+elre)&^f7(!T8TQ-OaVsH}Vb E3zQRSNdN!< literal 0 HcmV?d00001 diff --git a/Dockers/self-hosted-iptv/iptv/__pycache__/config.cpython-313.pyc b/Dockers/self-hosted-iptv/iptv/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..080ebccad5c00292889b105ed386cd694ab23000 GIT binary patch literal 2478 zcmZ`)-ESMm5#J+^q{I)2Uy>-wN@!bwbhbs>j#MW?Ynv9&3T28?xV*%z*d9*gnLL}k zqxOz+BlLj zX7*-gcjh7J4cduDwIk%0>c58UC%ARZ)M?A`QX-=-h?Hv>3O z8p1=R;Zm>^oO8G!@Enha$H+L2kvJYF6IF*Wv3+6D&~Itg7EvtC&=X}tH!VfC%!Hy>65`rY!qOV5*;MpI zQz53NSCa=URbJpIzouBVZC9$ISjx61)iAa+)day*2@?Y4nxgA!-Avv#4gE3{7yZm9 zl`Rc;V{VJ_J2p3L-kbx=-Nx3>uH;>Hqj3-%Lg-(Iw>o$AP-tCDYn|mVqOL8a-cqTX zs8);U|U6OnTqm(c`jk90Hum4Bq~W~>Ol+^!b&8# zsmrv8)}*ewaaSeOK~(B$C|0>fUAO9nVo_I>7_Fw@CIBD^)Syni)u4P!*DT7j!%-(3 z&SVWs*hFunYDPm%Z4jlA${6KVLxllIEoqjjq?*LIt(HNBjXNqa0p0amvo*uC)XJ>Z zw6;<mDOfd?#~W64auVLGye>%jeZD(@2~G{Jn_H2=YM_QfAWcc zde1+-?-$zQ_r73v_>FG|y!Qt_9_WNpk4N?gF16F&`~1x89p2&o>iN{O%l*Z>@11Bb zc76W)mp;0*bK%~?6W@v5NITOVpL`hk+r)=&?nFOccyt;J-l3iFN5W2J&l_hB!ymrg z;iJ#sAgIh(pIAZc$@voq577G2l&tp)4`@y9@W}(5c6CHk}I~Iqwb|a&^u}?3z zbKg!T9!4MjVSjRNkB^fnh_Zd6W?4;hA(c{^TGFbi_Jdv7NE)P?dXqZzy4HS|LkH}M z>rLL&RqC}Gl?}P9z~Q&!{Qz1+sj5k~V>?@^CgtzWpFb}+Vc6;w>a4UZ>QwbAjQ%i$ z%L^x{N0uu_S(eE(7|1v<>S@9sNL||?tQvGMlP;#^!s=>~`c_uo%Zj+CV6kwd1Qy>( z=ksC?%h$6xkp|z(X2exlyjc|UIJ=t1G@22wr=?s`&PauH5ljpM1iFpLS;l5rDknT4 z)Md4rb(KsoD=Rf&HK&2%O6HoJUdzhY(pc=NxpaO>N-v2tZ127VFB-FLkW7?w*_CWj zE{N|+A}(SYe|6{mY(BI4K06T|Iu%s0@-aogw((<8wLa0WpV><#YWlu2e7A3j^n;YmwrNn-=M+oQ0zy4 zaA)k^N_+6@q43Vz_l&mhNazN~cUSjaM&PZbT literal 0 HcmV?d00001 diff --git a/Dockers/self-hosted-iptv/iptv/__pycache__/epg_builder.cpython-313.pyc b/Dockers/self-hosted-iptv/iptv/__pycache__/epg_builder.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6bcbf6380864feb5ee114649efd402dbd9290498 GIT binary patch literal 10110 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 literal 0 HcmV?d00001 diff --git a/Dockers/self-hosted-iptv/iptv/__pycache__/schedule.cpython-313.pyc b/Dockers/self-hosted-iptv/iptv/__pycache__/schedule.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5a61caeb4dd701c0e0d5e71ac7eea809ee5d8aa2 GIT binary patch literal 9043 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 literal 0 HcmV?d00001 diff --git a/Dockers/self-hosted-iptv/iptv/__pycache__/server.cpython-313.pyc b/Dockers/self-hosted-iptv/iptv/__pycache__/server.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..59507693a4c3310821dbdff428a5e972ce0b5d3b GIT binary patch literal 11377 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 E0W4h+q5uE@ literal 0 HcmV?d00001 diff --git a/Dockers/self-hosted-iptv/iptv/__pycache__/tmdb_client.cpython-313.pyc b/Dockers/self-hosted-iptv/iptv/__pycache__/tmdb_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..25816db98a1d0ef5178e6d56f1ed67c0fc8ad231 GIT binary patch literal 19413 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,u|+Vpycc=@>p%K>y7kb8 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 n58qgK2(8B-VcfPwr8p7;Isq@PdW7C*##vgS|CB Path | None: + """Resolve a channel path key to an absolute Path. Returns None if invalid.""" + path_key = path_key.strip("/") + if path_key == "movies": + return MOVIES_ROOT + if path_key.startswith("series/"): + subpath = path_key[7:] + return SERIES_ROOT / subpath + return None + + +def normalize_path_config(entry) -> dict: + """ + Normalize a path entry from channels.json. Entry can be: + - String: "series/W817" -> {path_key, display_name, tmdb_search, tmdb_id} + - Object: {"path": "series/W817", "display_name": "...", "tmdb_search": "...", "tmdb_id": ...} + """ + if isinstance(entry, str): + return {"path_key": entry.strip(), "display_name": None, "tmdb_search": None, "tmdb_id": None} + if isinstance(entry, dict): + path_key = (entry.get("path") or entry.get("path_key") or "").strip() + if not path_key: + return {"path_key": "", "display_name": None, "tmdb_search": None, "tmdb_id": None} + raw_id = entry.get("tmdb_id") + tmdb_id = None + if raw_id is not None: + if isinstance(raw_id, int) and raw_id > 0: + tmdb_id = raw_id + else: + m = re.match(r"^(\d+)", str(raw_id).strip()) + if m: + tmdb_id = int(m.group(1)) + return { + "path_key": path_key, + "display_name": (entry.get("display_name") or "").strip() or None, + "tmdb_search": (entry.get("tmdb_search") or "").strip() or None, + "tmdb_id": tmdb_id, + } + return {"path_key": "", "display_name": None, "tmdb_search": None, "tmdb_id": None} + + +class ChannelRepository: + """Loads and provides channel configuration from channels.json.""" + + def __init__(self, channels_file: Path = CHANNELS_FILE): + self._channels_file = channels_file + + def get_all(self) -> list[dict]: + """Load and return all channels.""" + if not self._channels_file.exists(): + return [] + with open(self._channels_file, encoding="utf-8") as f: + data = json.load(f) + return data.get("channels", []) + + def get_by_id(self, channel_id: str) -> dict | None: + """Return the channel with the given id, or None.""" + for ch in self.get_all(): + if ch.get("id") == channel_id: + return ch + return None diff --git a/Dockers/self-hosted-iptv/iptv/config.py b/Dockers/self-hosted-iptv/iptv/config.py new file mode 100644 index 0000000..e2a071c --- /dev/null +++ b/Dockers/self-hosted-iptv/iptv/config.py @@ -0,0 +1,58 @@ +"""Configuration constants and EPG timespan parsing.""" + +import os +import re +from pathlib import Path + +DATA_ROOT = Path("/data") +MOVIES_ROOT = Path("/movies") +SERIES_ROOT = Path("/series") +CHANNELS_FILE = DATA_ROOT / "channels.json" +VIDEO_EXTENSIONS = {".mkv", ".mp4", ".avi", ".mov", ".m4v", ".webm", ".wmv"} +DEFAULT_DURATION_SEC = 3600 + +# EPG timespan (parsed from EPG_TIMESPAN env) +EPG_TIMESPAN_DAYS = 14.0 +EPG_TIMESPAN_SEC = 14.0 * 24 * 3600 +SCHEDULE_CACHE_TTL_SEC = (14 * 24 - 3) * 3600 + + +def _parse_epg_timespan(value: str) -> tuple[float, float]: + """Parse EPG_TIMESPAN env (e.g. '2d', '14d', '48h'). Returns (days, seconds).""" + value = (value or "").strip().lower() + if not value: + return 14.0, 14.0 * 24 * 3600 + m = re.match(r"^(\d+(?:\.\d+)?)\s*([dh])?$", value) + if not m: + return 14.0, 14.0 * 24 * 3600 + num = float(m.group(1)) + unit = m.group(2) or "d" + days = num / 24.0 if unit == "h" else num + sec = days * 24 * 3600 + return days, sec + + +def init_epg_timespan() -> None: + """Load EPG_TIMESPAN from env and set EPG_* and SCHEDULE_CACHE_TTL_SEC.""" + global EPG_TIMESPAN_DAYS, EPG_TIMESPAN_SEC, SCHEDULE_CACHE_TTL_SEC + days, sec = _parse_epg_timespan(os.environ.get("EPG_TIMESPAN", "")) + EPG_TIMESPAN_DAYS = days + EPG_TIMESPAN_SEC = sec + schedule_ttl = sec - 3 * 3600 + SCHEDULE_CACHE_TTL_SEC = max(3600, schedule_ttl) + + +init_epg_timespan() + +# TMDB (The Movie Database) for EPG metadata — Dutch (nl). Set TMDB_API_KEY. +TMDB_API_BASE = "https://api.themoviedb.org/3" +TMDB_LANGUAGE = "nl" +TMDB_CACHE_TTL_SEC = 24 * 3600 +TMDB_RATE_LIMIT_REQUESTS = 40 +TMDB_RATE_LIMIT_WINDOW_SEC = 10.0 +TMDB_CACHE_DIR = DATA_ROOT / "tmdb_cache" +TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p" +TMDB_IMAGE_STILL_SIZE = "w500" + +# Preferred audio languages for streaming +PREFERRED_AUDIO_LANGUAGES = ("nld", "dut", "eng") diff --git a/Dockers/self-hosted-iptv/iptv/epg_builder.py b/Dockers/self-hosted-iptv/iptv/epg_builder.py new file mode 100644 index 0000000..d2aa9e0 --- /dev/null +++ b/Dockers/self-hosted-iptv/iptv/epg_builder.py @@ -0,0 +1,187 @@ +"""XMLTV EPG generation.""" + +import logging +import time +import xml.etree.ElementTree as ET +from datetime import datetime, timezone, timedelta +from pathlib import Path + +from .config import EPG_TIMESPAN_DAYS, MOVIES_ROOT, SERIES_ROOT +from .tmdb_client import parse_season_episode + +logger = logging.getLogger(__name__) + + +def format_xmltv_time(dt: datetime) -> str: + """Format as XMLTV: YYYYMMDDHHmmss +0000""" + return dt.strftime("%Y%m%d%H%M%S ") + (dt.strftime("%z") if dt.tzinfo else "+0000") + + +def get_programme_name_from_path(path: Path) -> str: + """ + Return the series/movie name from the folder (e.g. series/W817/... -> W817, + movies/MyMovie/... -> MyMovie). + """ + try: + if SERIES_ROOT in path.parents: + rel = path.relative_to(SERIES_ROOT) + parts = rel.parts + if len(parts) > 1: + return parts[0] + return path.stem + if MOVIES_ROOT in path.parents: + rel = path.relative_to(MOVIES_ROOT) + parts = rel.parts + if len(parts) > 1: + return parts[0] + return path.stem + except ValueError: + pass + return path.stem + + +def schedule_epoch_utc() -> datetime: + """Midnight UTC today — same reference used by EPG and live stream.""" + return datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + + +class EPGBuilder: + """Builds XMLTV EPG for all channels.""" + + def __init__(self, schedule_builder, tmdb_client, channel_repo): + self._schedule = schedule_builder + self._tmdb = tmdb_client + self._channels = channel_repo + + def build_xml(self) -> str: + """ + Build XMLTV EPG for all channels. Schedule repeats from midnight UTC. + Programmes generated for EPG_TIMESPAN_DAYS. + """ + epg_start = time.monotonic() + channels = self._channels.get_all() + if not channels: + return '\n' + + logger.info("EPG build started (%s channels)", len(channels)) + now = datetime.now(timezone.utc) + epoch = schedule_epoch_utc() + end_epoch = epoch + timedelta(days=EPG_TIMESPAN_DAYS) + + root = ET.Element("tv") + root.set("source-info-name", "self-hosted-iptv") + root.set("generator-info-name", "self-hosted-iptv") + + for ch in channels: + cid = ch.get("id", ch.get("name", "")) + name = ch.get("name", cid) + chan_el = ET.SubElement(root, "channel", id=cid) + ET.SubElement(chan_el, "display-name").text = name + + total_programmes = 0 + for ch in channels: + cid = ch.get("id", ch.get("name", "")) + ch_start = time.monotonic() + schedule = self._schedule.get_or_build(cid) + if not schedule: + logger.debug("EPG channel %s: no schedule", cid) + continue + paths, durations, path_configs = schedule + cycle_duration_sec = sum(durations) + if cycle_duration_sec <= 0: + continue + cycle_delta = timedelta(seconds=cycle_duration_sec) + offsets_sec = [0.0] + for d in durations[:-1]: + offsets_sec.append(offsets_sec[-1] + d) + + channel_programmes = 0 + cycle_start = epoch + while cycle_start < end_epoch: + for i, (path, dur) in enumerate(zip(paths, durations)): + start_dt = cycle_start + timedelta(seconds=offsets_sec[i]) + stop_dt = start_dt + timedelta(seconds=dur) + if stop_dt <= now: + continue + if start_dt >= end_epoch: + break + channel_programmes += 1 + prog = ET.SubElement( + root, + "programme", + start=format_xmltv_time(start_dt), + stop=format_xmltv_time(stop_dt), + channel=cid, + ) + cfg = path_configs[i] if i < len(path_configs) else {} + programme_name = get_programme_name_from_path(path) + meta = self._tmdb.get_episode_metadata( + programme_name, + path, + display_name=cfg.get("display_name"), + tmdb_search=cfg.get("tmdb_search"), + tmdb_id=cfg.get("tmdb_id"), + series_root=SERIES_ROOT, + ) + lang = (meta.get("lang") or "en") if meta else "en" + if meta: + ET.SubElement(prog, "title", lang=lang).text = meta["title"] + ET.SubElement(prog, "sub-title", lang=lang).text = meta["sub_title"] + if meta.get("desc"): + ET.SubElement(prog, "desc", lang=lang).text = meta["desc"] + if meta.get("icon"): + ET.SubElement(prog, "icon", attrib={"src": meta["icon"]}) + directors = meta.get("directors") or [] + actors = meta.get("actors") or [] + producers = meta.get("producers") or [] + if directors or actors or producers: + credits_el = ET.SubElement(prog, "credits") + for d in directors: + ET.SubElement(credits_el, "director").text = d + for a in actors: + ET.SubElement(credits_el, "actor").text = a + for p in producers: + ET.SubElement(credits_el, "producer").text = p + if meta.get("date"): + ET.SubElement(prog, "date").text = meta["date"] + for cat in meta.get("categories") or []: + ET.SubElement(prog, "category", lang=lang).text = cat + if meta.get("country"): + ET.SubElement(prog, "country", lang=lang).text = meta["country"] + nums = meta.get("episode_nums") or {} + if nums.get("xmltv_ns"): + ET.SubElement( + prog, "episode-num", system="xmltv_ns" + ).text = nums["xmltv_ns"] + if nums.get("onscreen"): + ET.SubElement( + prog, "episode-num", system="onscreen" + ).text = nums["onscreen"] + else: + fallback_title = ( + (cfg.get("display_name") or programme_name).strip() or programme_name + ) + ET.SubElement(prog, "title", lang="en").text = fallback_title + ET.SubElement(prog, "sub-title", lang="en").text = path.stem + se = parse_season_episode(path.stem) + if se: + s, e = se + ET.SubElement( + prog, "episode-num", system="xmltv_ns" + ).text = f"{s}.{e}.0/1" + ET.SubElement( + prog, "episode-num", system="onscreen" + ).text = f"S{s:02d}E{e:02d}" + cycle_start += cycle_delta + + ch_elapsed = time.monotonic() - ch_start + total_programmes += channel_programmes + logger.info("EPG channel %s: %s programmes in %.1fs", cid, channel_programmes, ch_elapsed) + + ET.indent(root, space=" ") + epg_elapsed = time.monotonic() - epg_start + logger.info("EPG build finished in %.1fs (%s programmes total)", epg_elapsed, total_programmes) + return ( + '\n' + + ET.tostring(root, encoding="unicode", default_namespace=None) + ) diff --git a/Dockers/self-hosted-iptv/iptv/schedule.py b/Dockers/self-hosted-iptv/iptv/schedule.py new file mode 100644 index 0000000..a847dd2 --- /dev/null +++ b/Dockers/self-hosted-iptv/iptv/schedule.py @@ -0,0 +1,161 @@ +"""Schedule building: video collection, shuffle, durations, and caching.""" + +import logging +import random +import subprocess +import time +from collections import defaultdict +from pathlib import Path + +from .channel import normalize_path_config, resolve_path +from .config import ( + VIDEO_EXTENSIONS, + DEFAULT_DURATION_SEC, + SCHEDULE_CACHE_TTL_SEC, + MOVIES_ROOT, + SERIES_ROOT, +) + +logger = logging.getLogger(__name__) + + +def collect_videos(root: Path) -> list[Path]: + """Collect all video files under root.""" + if not root.exists() or not root.is_dir(): + return [] + videos = [] + try: + for p in root.rglob("*"): + if p.is_file() and p.suffix.lower() in VIDEO_EXTENSIONS: + videos.append(p) + except OSError: + pass + return videos + + +def collect_channel_videos(path_configs: list[dict]) -> list[tuple[dict, Path]]: + """For each path config, collect videos. Returns [(path_config, absolute_path)].""" + out = [] + for cfg in path_configs: + path_key = cfg.get("path_key") or "" + root = resolve_path(path_key) + if root is None: + continue + for vid in collect_videos(root): + out.append((cfg, vid)) + return out + + +def shuffle_no_adjacent_same_show( + items: list[tuple[dict, Path]], +) -> list[tuple[Path, dict]]: + """Shuffle so same path_key never appears twice in a row. Returns [(path, path_config)].""" + if not items: + return [] + by_key: dict[str, list[tuple[Path, dict]]] = defaultdict(list) + for cfg, path in items: + by_key[cfg.get("path_key") or ""].append((path, cfg)) + for key in by_key: + random.shuffle(by_key[key]) + groups = list(by_key.values()) + result = [] + n = max(len(g) for g in groups) + for i in range(n): + for g in groups: + if i < len(g): + result.append(g[i]) + return result + + +def build_channel_path_list(path_entries: list) -> tuple[list[Path], list[dict]]: + """Build randomized list of (path, path_config) for a channel.""" + configs = [normalize_path_config(e) for e in path_entries] + configs = [c for c in configs if c.get("path_key")] + items = collect_channel_videos(configs) + pairs = shuffle_no_adjacent_same_show(items) + return [p for p, _ in pairs], [c for _, c in pairs] + + +def build_playlist(path_entries: list, base_url: str) -> list[tuple[str, str]]: + """Build playlist as list of (display_name, stream_url).""" + paths, _ = build_channel_path_list(path_entries) + playlist = [] + for p in paths: + try: + if MOVIES_ROOT in p.parents: + rel = p.relative_to(MOVIES_ROOT) + path_str = "movies/" + str(rel).replace("\\", "/") + else: + rel = p.relative_to(SERIES_ROOT) + path_str = "series/" + str(rel).replace("\\", "/") + except ValueError: + path_str = "movies/" + p.name + stream_url = f"{base_url}/stream?path={path_str}" + playlist.append((p.stem, stream_url)) + return playlist + + +def get_duration(path: Path) -> float: + """Return duration in seconds via ffprobe.""" + t0 = time.monotonic() + try: + out = subprocess.run( + [ + "ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + str(path), + ], + capture_output=True, + text=True, + timeout=30, + ) + if out.returncode == 0 and out.stdout.strip(): + d = float(out.stdout.strip()) + logger.debug("ffprobe %s -> %.0fs in %.2fs", path.name, d, time.monotonic() - t0) + return d + except (subprocess.TimeoutExpired, ValueError, FileNotFoundError) as e: + logger.debug("ffprobe %s failed: %s", path.name, e) + return DEFAULT_DURATION_SEC + + +class ScheduleBuilder: + """Builds and caches video schedules per channel.""" + + def __init__(self): + self._cache: dict[str, tuple[list[Path], list[float], list[dict], float]] = {} + self._ttl_sec = SCHEDULE_CACHE_TTL_SEC + self._channel_repo = None # injected + + def set_channel_repo(self, repo) -> None: + """Set the channel repository for schedule building.""" + self._channel_repo = repo + + def get_or_build( + self, + channel_id: str, + ) -> tuple[list[Path], list[float], list[dict]] | None: + """Return (paths, durations, path_configs) for the channel. Cached per TTL.""" + now = time.time() + if channel_id in self._cache: + paths, durations, path_configs, ts = self._cache[channel_id] + if now - ts < self._ttl_sec: + logger.debug("schedule cache hit for channel %s (%s videos)", channel_id, len(paths)) + return paths, durations, path_configs + + logger.info("Building schedule for channel %s ...", channel_id) + t0 = time.monotonic() + if not self._channel_repo: + return None + channel = self._channel_repo.get_by_id(channel_id) + if not channel: + return None + paths, path_configs = build_channel_path_list(channel.get("paths", [])) + if not paths: + return None + durations = [get_duration(p) for p in paths] + elapsed = time.monotonic() - t0 + self._cache[channel_id] = (paths, durations, path_configs, now) + logger.info("Schedule for channel %s built in %.1fs (%s videos)", channel_id, elapsed, len(paths)) + return paths, durations, path_configs diff --git a/Dockers/self-hosted-iptv/iptv/server.py b/Dockers/self-hosted-iptv/iptv/server.py new file mode 100644 index 0000000..d17cb03 --- /dev/null +++ b/Dockers/self-hosted-iptv/iptv/server.py @@ -0,0 +1,185 @@ +"""HTTP server and request handler for IPTV endpoints.""" + +import mimetypes +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs, urlparse + +from .schedule import build_playlist + + +def get_base_url(handler: BaseHTTPRequestHandler) -> str: + """Build base URL for playlist links from request.""" + host = handler.headers.get("Host", "localhost:8080") + return f"http://{host}" + + +class IPTVHandler(BaseHTTPRequestHandler): + """Handles M3U playlists, EPG, live streams, and on-demand video.""" + + def __init__(self, request, client_address, server): + # Dependencies injected by server_factory + self._channel_repo = getattr(server, "channel_repo", None) + self._schedule_builder = getattr(server, "schedule_builder", None) + self._epg_builder = getattr(server, "epg_builder", None) + super().__init__(request, client_address, server) + + def log_message(self, format, *args): + pass + + def do_GET(self): + parsed = urlparse(self.path) + path = parsed.path.rstrip("/") or "/" + qs = parse_qs(parsed.query) + + if path == "/" or path == "/index.html": + self.send_index() + return + if path == "/playlist.m3u" or path == "/channels.m3u": + self.send_channels_m3u() + return + if path.startswith("/channel/") and path.endswith("/playlist.m3u"): + channel_id = path.split("/")[2] + self.send_channel_m3u(channel_id) + return + if path == "/stream": + path_param = qs.get("path", [""])[0] + self.send_stream(path_param) + return + if path.startswith("/live/"): + parts = path.split("/") + if len(parts) == 3 and parts[1] == "live": + self.send_live_stream(parts[2]) + return + if path == "/epg.xml" or path == "/xmltv.xml": + self.send_epg() + return + self.send_error(404, "Not found") + + def send_index(self): + channels = self._channel_repo.get_all() + body = "Self-hosted IPTV" + body += "

Self-hosted IPTV

Playlist (M3U):

" + body += f"

{get_base_url(self)}/playlist.m3u

" + body += "

EPG (XMLTV, for Plex DVR):

" + body += f"

{get_base_url(self)}/epg.xml

" + body += "

Channels

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

Self-hosted IPTV

Playlist (M3U):

" - body += f"

{get_base_url(self)}/playlist.m3u

" - body += "

EPG (XMLTV, for Plex DVR):

" - body += f"

{get_base_url(self)}/epg.xml

" - body += "

Channels

    " - for ch in channels: - cid = ch.get("id", ch.get("name", "")) - body += f"
  • {ch.get('name', cid)}
  • " - body += "
" - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(body.encode("utf-8")))) - self.end_headers() - self.wfile.write(body.encode("utf-8")) - - def send_channels_m3u(self): - """Master M3U for Plex DVR: one live stream URL per channel.""" - base = get_base_url(self) - channels = get_channels() - lines = ["#EXTM3U"] - for ch in channels: - cid = ch.get("id", ch.get("name", "")) - name = ch.get("name", cid) - lines.append(f'#EXTINF:-1,{name}') - lines.append(f"{base}/live/{cid}") - body = "\n".join(lines) + "\n" - self.send_response(200) - self.send_header("Content-Type", "application/x-mpegURL; charset=utf-8") - self.send_header("Content-Length", str(len(body.encode("utf-8")))) - self.end_headers() - self.wfile.write(body.encode("utf-8")) - - def send_channel_m3u(self, channel_id: str): - """Single channel M3U with randomized video entries (no same show adjacent).""" - channels = get_channels() - channel = next((c for c in channels if c.get("id") == channel_id), None) - if not channel: - self.send_error(404, "Channel not found") - return - path_keys = channel.get("paths", []) - base = get_base_url(self) - playlist = build_playlist(path_keys, base) - lines = ["#EXTM3U"] - for name, url in playlist: - lines.append(f"#EXTINF:-1,{name}") - lines.append(url) - body = "\n".join(lines) + "\n" - self.send_response(200) - self.send_header("Content-Type", "application/x-mpegURL; charset=utf-8") - self.send_header("Content-Length", str(len(body.encode("utf-8")))) - self.end_headers() - self.wfile.write(body.encode("utf-8")) - - def send_epg(self): - """Serve XMLTV EPG (programme schedule for Plex DVR).""" - body = build_epg_xml() - self.send_response(200) - self.send_header("Content-Type", "application/xml; charset=utf-8") - self.send_header("Content-Length", str(len(body.encode("utf-8")))) - self.send_header("Cache-Control", "public, max-age=300") - self.end_headers() - self.wfile.write(body.encode("utf-8")) - - def send_live_stream(self, channel_id: str): - """Stream channel as continuous MPEG-TS (for Plex DVR). Starts at current position in schedule so EPG matches.""" - schedule = get_or_build_schedule(channel_id) - if not schedule: - self.send_error(404, "Channel not found or no videos") - return - paths, durations = schedule - if not paths: - self.send_error(404, "No videos in channel") - return - self.send_response(200) - self.send_header("Content-Type", "video/MP2T") - self.send_header("Cache-Control", "no-cache, no-store") - self.end_headers() - stream_live_channel(paths, self.wfile, durations) - - def send_stream(self, path_param: str): - """Stream a video file. path is relative to /movies or /series. Prefers Dutch/Flemish audio, then English, then default.""" - abs_path = path_to_absolute(path_param) - if abs_path is None: - self.send_error(404, "File not found") - return - audio_idx = get_preferred_audio_stream_index(abs_path) - if audio_idx is not None: - self.send_response(200) - self.send_header("Content-Type", "video/mp4") - self.send_header("Cache-Control", "no-cache") - self.end_headers() - stream_file_with_preferred_audio(abs_path, self.wfile) - return - content_type, _ = mimetypes.guess_type(str(abs_path)) - content_type = content_type or "video/mp4" - size = abs_path.stat().st_size - self.send_response(200) - self.send_header("Content-Type", content_type) - self.send_header("Content-Length", str(size)) - self.send_header("Accept-Ranges", "bytes") - self.end_headers() - with open(abs_path, "rb") as f: - self.wfile.write(f.read()) - def main(): + init_epg_timespan() log_level = getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO) logging.basicConfig( level=log_level, @@ -1043,8 +32,21 @@ def main(): EPG_TIMESPAN_DAYS, SCHEDULE_CACHE_TTL_SEC / 3600, ) + + channel_repo = ChannelRepository() + schedule_builder = ScheduleBuilder() + schedule_builder.set_channel_repo(channel_repo) + tmdb_client = TMDBClient() + epg_builder = EPGBuilder(schedule_builder, tmdb_client, channel_repo) + port = 8080 - server = HTTPServer(("0.0.0.0", port), IPTVHandler) + server = create_server( + channel_repo=channel_repo, + schedule_builder=schedule_builder, + epg_builder=epg_builder, + host="0.0.0.0", + port=port, + ) print(f"Serving at http://0.0.0.0:{port}/ (playlist: http://:{port}/playlist.m3u)") server.serve_forever()