lets go
Build and Push Docker Images / build-and-push (push) Successful in 23s

This commit is contained in:
2026-02-11 00:04:09 +01:00
parent a097dbb07b
commit f4030b2bdc
2 changed files with 34 additions and 16 deletions
+1
View File
@@ -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/*
+33 -16
View File
@@ -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."""