diff --git a/Dockers/self-hosted-iptv/Dockerfile b/Dockers/self-hosted-iptv/Dockerfile index f0e37d3..4f8643c 100644 --- a/Dockers/self-hosted-iptv/Dockerfile +++ b/Dockers/self-hosted-iptv/Dockerfile @@ -1,5 +1,6 @@ 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 \ && rm -rf /var/lib/apt/lists/* diff --git a/Dockers/self-hosted-iptv/main.py b/Dockers/self-hosted-iptv/main.py index e271e66..e253fb3 100644 --- a/Dockers/self-hosted-iptv/main.py +++ b/Dockers/self-hosted-iptv/main.py @@ -215,15 +215,15 @@ def _format_xmltv_time(dt: datetime) -> str: def build_epg_xml() -> str: """ - Build XMLTV EPG for all channels. Schedule repeats from midnight UTC; - programmes generated for EPG_DAYS. Uses cached schedule per channel. + Build XMLTV EPG for all channels. Schedule repeats from midnight UTC (same + epoch as live stream). Programmes generated for EPG_DAYS. Uses cached schedule. """ channels = get_channels() if not channels: return '\n' 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) root = ET.Element("tv") @@ -281,26 +281,43 @@ def write_concat_list(paths: list[Path], fd) -> None: 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. - 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: 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: write_concat_list(paths, f) list_path = f.name 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( - [ - "ffmpeg", - "-stream_loop", "-1", - "-f", "concat", "-safe", "0", "-i", list_path, - "-c", "copy", - "-f", "mpegts", - "-", - ], + cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, start_new_session=True, @@ -424,12 +441,12 @@ class IPTVHandler(BaseHTTPRequestHandler): self.wfile.write(body.encode("utf-8")) 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) if not schedule: self.send_error(404, "Channel not found or no videos") return - paths, _ = schedule + paths, durations = schedule if not paths: self.send_error(404, "No videos in channel") return @@ -437,7 +454,7 @@ class IPTVHandler(BaseHTTPRequestHandler): 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) + stream_live_channel(paths, self.wfile, durations) def send_stream(self, path_param: str): """Stream a video file. path is relative to /movies or /series."""