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
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY main.py .
+7 -4
View File
@@ -27,14 +27,17 @@ docker run -d --name iptv -p 8080:8080 \
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
- `GET /` — simple web index with playlist link
- `GET /playlist.m3u` — master M3U (all channels)
- `GET /channel/<id>/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/<channel_id>` — continuous MPEG-TS stream for that channel (for tuning/recording)
- `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
+85 -4
View File
@@ -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)