This commit is contained in:
@@ -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 .
|
||||
|
||||
@@ -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 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/<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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user