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