diff --git a/Dockers/self-hosted-iptv/main.py b/Dockers/self-hosted-iptv/main.py index ffdde53..c08b5ec 100644 --- a/Dockers/self-hosted-iptv/main.py +++ b/Dockers/self-hosted-iptv/main.py @@ -272,12 +272,14 @@ def build_epg_xml() -> str: return '\n' + ET.tostring(root, encoding="unicode", default_namespace=None) +def _escape_path(p: Path) -> str: + return str(p.resolve()).replace("\\", "\\\\").replace("'", "'\\''") + + 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.write(f"file '{_escape_path(p)}'\n") fd.flush() @@ -286,18 +288,57 @@ def _schedule_epoch_utc() -> datetime: return datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) +def _current_position_in_cycle(durations: list[float]) -> tuple[int, float]: + """ + Return (file_index, offset_sec_in_file) for 'now' in the schedule cycle. + Schedule epoch = midnight UTC; cycle = one full playlist. + """ + epoch = _schedule_epoch_utc() + now = datetime.now(timezone.utc) + cycle_duration = sum(durations) + if cycle_duration <= 0: + return 0, 0.0 + offset_sec = (now - epoch).total_seconds() % cycle_duration + cumul = 0.0 + for i, dur in enumerate(durations): + if offset_sec < cumul + dur: + return i, offset_sec - cumul + cumul += dur + return len(durations) - 1, durations[-1] + + +def write_concat_list_from_current(paths: list[Path], durations: list[float], fd) -> None: + """ + Write a concat list that starts at the current position in the schedule and + loops seamlessly. Uses inpoint/outpoint so we join mid-file, then repeat. + Cycle: current file (offset→end), next files…, start…current-1, current file (0→offset). + """ + n = len(paths) + if n == 0: + return + idx, offset_in_file = _current_position_in_cycle(durations) + for i in range(n): + j = (idx + i) % n + fd.write(f"file '{_escape_path(paths[j])}'\n") + if i == 0 and offset_in_file >= 0.5: + fd.write(f"inpoint {offset_in_file:.2f}\n") + if offset_in_file >= 0.5: + fd.write(f"file '{_escape_path(paths[idx])}'\n") + fd.write(f"outpoint {offset_in_file:.2f}\n") + fd.flush() + + 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. - Stops when wfile write fails (client disconnect). - (No -ss seek: seeking into a looping concat input is unreliable, so stream - always starts from playlist start; EPG order and durations still match.) + Run FFmpeg to output a continuous MPEG-TS stream. Concat list starts at + current position in schedule (midnight UTC cycle) so each new client joins + where the 'channel' is now; then the list loops. EPG and stream stay in sync. """ if not paths: return with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: - write_concat_list(paths, f) + write_concat_list_from_current(paths, durations, f) list_path = f.name try: proc = subprocess.Popen(