this would be awsome
Build and Push Docker Images / build-and-push (push) Successful in 2m7s

This commit is contained in:
2026-02-10 23:53:59 +01:00
parent a71d52b426
commit c4fcc55a39
3 changed files with 95 additions and 8 deletions
+3
View File
@@ -1,5 +1,8 @@
FROM python:3.12-slim 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 WORKDIR /app
COPY main.py . COPY main.py .
+7 -4
View File
@@ -27,14 +27,17 @@ docker run -d --name iptv -p 8080:8080 \
self-hosted-iptv self-hosted-iptv
``` ```
3. In your IPTV client, add playlist URL: `http://<host>:8080/playlist.m3u` 3. In your IPTV client or **Plex DVR**, add playlist URL: `http://<host>:8080/playlist.m3u`
Each channel in the M3U points to a **continuous live stream** (`/live/<id>`): FFmpeg concatenates the channels randomized videos into one MPEG-TS stream and loops it, so Plex can tune and record like a normal TV channel.
## Endpoints ## Endpoints
- `GET /` — simple web index with playlist link - `GET /` — simple web index with playlist link
- `GET /playlist.m3u` — master M3U (all channels) - `GET /playlist.m3u` — master M3U for Plex DVR (one live stream URL per channel)
- `GET /channel/<id>/playlist.m3u` — single channel M3U (randomized, no adjacent same show) - `GET /live/<channel_id>` — continuous MPEG-TS stream for that channel (for tuning/recording)
- `GET /stream?path=movies/...` or `path=series/...` — stream a video file - `GET /channel/<id>/playlist.m3u` — single channel M3U (list of individual video URLs)
- `GET /stream?path=movies/...` or `path=series/...` — stream a single video file
## Shuffle behaviour ## Shuffle behaviour
+85 -4
View File
@@ -6,6 +6,8 @@ with randomized order so the same show never appears twice in a row.
import json import json
import random import random
import subprocess
import tempfile
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
from http.server import HTTPServer, BaseHTTPRequestHandler 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 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]]: def build_playlist(path_keys: list[str], base_url: str) -> list[tuple[str, str]]:
""" """
Build playlist as list of (display_name, stream_url). Build playlist as list of (display_name, stream_url).
base_url is the base URL for stream links (e.g. http://host:port). base_url is the base URL for stream links (e.g. http://host:port).
""" """
items = collect_channel_videos(path_keys) paths = build_channel_path_list(path_keys)
paths = shuffle_no_adjacent_same_show(items)
playlist = [] playlist = []
for p in paths: for p in paths:
try: try:
@@ -147,6 +154,58 @@ def get_base_url(handler: BaseHTTPRequestHandler) -> str:
return f"http://{host}" 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): class IPTVHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args): def log_message(self, format, *args):
pass # quiet by default; set to super().log_message for debugging pass # quiet by default; set to super().log_message for debugging
@@ -170,6 +229,11 @@ class IPTVHandler(BaseHTTPRequestHandler):
path_param = qs.get("path", [""])[0] path_param = qs.get("path", [""])[0]
self.send_stream(path_param) self.send_stream(path_param)
return 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") self.send_error(404, "Not found")
def send_index(self): def send_index(self):
@@ -189,7 +253,7 @@ class IPTVHandler(BaseHTTPRequestHandler):
self.wfile.write(body.encode("utf-8")) self.wfile.write(body.encode("utf-8"))
def send_channels_m3u(self): 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) base = get_base_url(self)
channels = get_channels() channels = get_channels()
lines = ["#EXTM3U"] lines = ["#EXTM3U"]
@@ -197,7 +261,7 @@ class IPTVHandler(BaseHTTPRequestHandler):
cid = ch.get("id", ch.get("name", "")) cid = ch.get("id", ch.get("name", ""))
name = ch.get("name", cid) name = ch.get("name", cid)
lines.append(f'#EXTINF:-1,{name}') 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" body = "\n".join(lines) + "\n"
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "application/x-mpegURL; charset=utf-8") self.send_header("Content-Type", "application/x-mpegURL; charset=utf-8")
@@ -226,6 +290,23 @@ class IPTVHandler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body.encode("utf-8")) 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): def send_stream(self, path_param: str):
"""Stream a video file. path is relative to /movies or /series.""" """Stream a video file. path is relative to /movies or /series."""
abs_path = path_to_absolute(path_param) abs_path = path_to_absolute(path_param)