This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# ffmpeg = live stream; ffprobe = video duration for EPG (both in same package)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
|
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -215,15 +215,15 @@ def _format_xmltv_time(dt: datetime) -> str:
|
|||||||
|
|
||||||
def build_epg_xml() -> str:
|
def build_epg_xml() -> str:
|
||||||
"""
|
"""
|
||||||
Build XMLTV EPG for all channels. Schedule repeats from midnight UTC;
|
Build XMLTV EPG for all channels. Schedule repeats from midnight UTC (same
|
||||||
programmes generated for EPG_DAYS. Uses cached schedule per channel.
|
epoch as live stream). Programmes generated for EPG_DAYS. Uses cached schedule.
|
||||||
"""
|
"""
|
||||||
channels = get_channels()
|
channels = get_channels()
|
||||||
if not channels:
|
if not channels:
|
||||||
return '<?xml version="1.0" encoding="UTF-8"?>\n<tv></tv>'
|
return '<?xml version="1.0" encoding="UTF-8"?>\n<tv></tv>'
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
epoch = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
epoch = _schedule_epoch_utc()
|
||||||
end_epoch = epoch + timedelta(days=EPG_DAYS)
|
end_epoch = epoch + timedelta(days=EPG_DAYS)
|
||||||
|
|
||||||
root = ET.Element("tv")
|
root = ET.Element("tv")
|
||||||
@@ -281,26 +281,43 @@ def write_concat_list(paths: list[Path], fd) -> None:
|
|||||||
fd.flush()
|
fd.flush()
|
||||||
|
|
||||||
|
|
||||||
def stream_live_channel(paths: list[Path], wfile) -> None:
|
def _schedule_epoch_utc() -> datetime:
|
||||||
|
"""Midnight UTC today — same reference used by EPG and live stream."""
|
||||||
|
return datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def stream_live_channel(paths: list[Path], wfile, durations: list[float]) -> None:
|
||||||
"""
|
"""
|
||||||
Run FFmpeg to output a continuous MPEG-TS stream from paths, looping forever.
|
Run FFmpeg to output a continuous MPEG-TS stream from paths, looping forever.
|
||||||
Pipe output to wfile. Stops when wfile write fails (client disconnect).
|
start_offset_sec aligns the stream with the EPG (schedule started at midnight UTC).
|
||||||
|
Stops when wfile write fails (client disconnect).
|
||||||
"""
|
"""
|
||||||
if not paths:
|
if not paths:
|
||||||
return
|
return
|
||||||
|
cycle_duration = sum(durations)
|
||||||
|
if cycle_duration <= 0:
|
||||||
|
return
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
epoch = _schedule_epoch_utc()
|
||||||
|
start_offset_sec = (now - epoch).total_seconds() % cycle_duration
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||||
write_concat_list(paths, f)
|
write_concat_list(paths, f)
|
||||||
list_path = f.name
|
list_path = f.name
|
||||||
try:
|
try:
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-stream_loop", "-1",
|
||||||
|
"-f", "concat", "-safe", "0", "-i", list_path,
|
||||||
|
"-c", "copy",
|
||||||
|
"-f", "mpegts",
|
||||||
|
"-",
|
||||||
|
]
|
||||||
|
if start_offset_sec >= 0.5:
|
||||||
|
# Seek so "now playing" matches EPG (schedule epoch = midnight UTC)
|
||||||
|
cmd = cmd[:9] + ["-ss", str(round(start_offset_sec, 2))] + cmd[9:]
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[
|
cmd,
|
||||||
"ffmpeg",
|
|
||||||
"-stream_loop", "-1",
|
|
||||||
"-f", "concat", "-safe", "0", "-i", list_path,
|
|
||||||
"-c", "copy",
|
|
||||||
"-f", "mpegts",
|
|
||||||
"-",
|
|
||||||
],
|
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
@@ -424,12 +441,12 @@ class IPTVHandler(BaseHTTPRequestHandler):
|
|||||||
self.wfile.write(body.encode("utf-8"))
|
self.wfile.write(body.encode("utf-8"))
|
||||||
|
|
||||||
def send_live_stream(self, channel_id: str):
|
def send_live_stream(self, channel_id: str):
|
||||||
"""Stream channel as continuous MPEG-TS (for Plex DVR). Uses cached schedule so EPG matches."""
|
"""Stream channel as continuous MPEG-TS (for Plex DVR). Starts at current position in schedule so EPG matches."""
|
||||||
schedule = get_or_build_schedule(channel_id)
|
schedule = get_or_build_schedule(channel_id)
|
||||||
if not schedule:
|
if not schedule:
|
||||||
self.send_error(404, "Channel not found or no videos")
|
self.send_error(404, "Channel not found or no videos")
|
||||||
return
|
return
|
||||||
paths, _ = schedule
|
paths, durations = schedule
|
||||||
if not paths:
|
if not paths:
|
||||||
self.send_error(404, "No videos in channel")
|
self.send_error(404, "No videos in channel")
|
||||||
return
|
return
|
||||||
@@ -437,7 +454,7 @@ class IPTVHandler(BaseHTTPRequestHandler):
|
|||||||
self.send_header("Content-Type", "video/MP2T")
|
self.send_header("Content-Type", "video/MP2T")
|
||||||
self.send_header("Cache-Control", "no-cache, no-store")
|
self.send_header("Cache-Control", "no-cache, no-store")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
stream_live_channel(paths, self.wfile)
|
stream_live_channel(paths, self.wfile, durations)
|
||||||
|
|
||||||
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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user