From c4fcc55a394426232e6962422e3c8ca65817d2c5 Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 10 Feb 2026 23:53:59 +0100 Subject: [PATCH] this would be awsome --- Dockers/self-hosted-iptv/Dockerfile | 3 + Dockers/self-hosted-iptv/README.md | 11 ++-- Dockers/self-hosted-iptv/main.py | 89 +++++++++++++++++++++++++++-- 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/Dockers/self-hosted-iptv/Dockerfile b/Dockers/self-hosted-iptv/Dockerfile index c39e322..f0e37d3 100644 --- a/Dockers/self-hosted-iptv/Dockerfile +++ b/Dockers/self-hosted-iptv/Dockerfile @@ -1,5 +1,8 @@ FROM python:3.12-slim +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app COPY main.py . diff --git a/Dockers/self-hosted-iptv/README.md b/Dockers/self-hosted-iptv/README.md index 10e4fd8..1528ab0 100644 --- a/Dockers/self-hosted-iptv/README.md +++ b/Dockers/self-hosted-iptv/README.md @@ -27,14 +27,17 @@ docker run -d --name iptv -p 8080:8080 \ self-hosted-iptv ``` -3. In your IPTV client, add playlist URL: `http://:8080/playlist.m3u` +3. In your IPTV client or **Plex DVR**, add playlist URL: `http://:8080/playlist.m3u` + +Each channel in the M3U points to a **continuous live stream** (`/live/`): FFmpeg concatenates the channel’s randomized videos into one MPEG-TS stream and loops it, so Plex can tune and record like a normal TV channel. ## Endpoints - `GET /` — simple web index with playlist link -- `GET /playlist.m3u` — master M3U (all channels) -- `GET /channel//playlist.m3u` — single channel M3U (randomized, no adjacent same show) -- `GET /stream?path=movies/...` or `path=series/...` — stream a video file +- `GET /playlist.m3u` — master M3U for Plex DVR (one live stream URL per channel) +- `GET /live/` — continuous MPEG-TS stream for that channel (for tuning/recording) +- `GET /channel//playlist.m3u` — single channel M3U (list of individual video URLs) +- `GET /stream?path=movies/...` or `path=series/...` — stream a single video file ## Shuffle behaviour diff --git a/Dockers/self-hosted-iptv/main.py b/Dockers/self-hosted-iptv/main.py index 367bd79..1e9dfe0 100644 --- a/Dockers/self-hosted-iptv/main.py +++ b/Dockers/self-hosted-iptv/main.py @@ -6,6 +6,8 @@ with randomized order so the same show never appears twice in a row. import json import random +import subprocess +import tempfile from collections import defaultdict from pathlib import Path from http.server import HTTPServer, BaseHTTPRequestHandler @@ -91,13 +93,18 @@ def shuffle_no_adjacent_same_show(items: list[tuple[str, Path]]) -> list[Path]: 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 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). """ - items = collect_channel_videos(path_keys) - paths = shuffle_no_adjacent_same_show(items) + paths = build_channel_path_list(path_keys) playlist = [] for p in paths: try: @@ -147,6 +154,58 @@ def get_base_url(handler: BaseHTTPRequestHandler) -> str: return f"http://{host}" +def write_concat_list(paths: list[Path], fd) -> None: + """Write FFmpeg concat demuxer list to file. Escapes paths for safety.""" + for p in paths: + # Concat format: file 'path' — escape ' as '\'' + path_str = str(p.resolve()).replace("\\", "\\\\").replace("'", "'\\''") + fd.write(f"file '{path_str}'\n") + fd.flush() + + +def stream_live_channel(paths: list[Path], wfile) -> None: + """ + Run FFmpeg to output a continuous MPEG-TS stream from paths, looping forever. + Pipe output to wfile. Stops when wfile write fails (client disconnect). + """ + if not paths: + return + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + write_concat_list(paths, 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 @@ -170,6 +229,11 @@ class IPTVHandler(BaseHTTPRequestHandler): 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 self.send_error(404, "Not found") def send_index(self): @@ -189,7 +253,7 @@ class IPTVHandler(BaseHTTPRequestHandler): self.wfile.write(body.encode("utf-8")) def send_channels_m3u(self): - """Master M3U listing all channels (one URL per channel playlist).""" + """Master M3U for Plex DVR: one live stream URL per channel.""" base = get_base_url(self) channels = get_channels() lines = ["#EXTM3U"] @@ -197,7 +261,7 @@ class IPTVHandler(BaseHTTPRequestHandler): cid = ch.get("id", ch.get("name", "")) name = ch.get("name", cid) lines.append(f'#EXTINF:-1,{name}') - lines.append(f"{base}/channel/{cid}/playlist.m3u") + 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") @@ -226,6 +290,23 @@ class IPTVHandler(BaseHTTPRequestHandler): 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).""" + 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 + paths = build_channel_path_list(channel.get("paths", [])) + 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) + def send_stream(self, path_param: str): """Stream a video file. path is relative to /movies or /series.""" abs_path = path_to_absolute(path_param)