From e92ccd757a739bfd4ea33975fd7a476c75238eb5 Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 11 Feb 2026 23:11:41 +0100 Subject: [PATCH] updates --- Dockers/self-hosted-iptv/iptv/schedule.py | 5 +-- Dockers/self-hosted-iptv/iptv/server.py | 20 +++++---- Dockers/self-hosted-iptv/iptv/streamer.py | 54 ++++++++++++++++------- 3 files changed, 52 insertions(+), 27 deletions(-) diff --git a/Dockers/self-hosted-iptv/iptv/schedule.py b/Dockers/self-hosted-iptv/iptv/schedule.py index 317464f..98ea5ba 100644 --- a/Dockers/self-hosted-iptv/iptv/schedule.py +++ b/Dockers/self-hosted-iptv/iptv/schedule.py @@ -118,10 +118,9 @@ def get_duration(path: Path) -> float: ) if out.returncode == 0 and out.stdout.strip(): d = float(out.stdout.strip()) - logger.debug("ffprobe %s -> %.0fs in %.2fs", path.name, d, time.monotonic() - t0) return d - except (subprocess.TimeoutExpired, ValueError, FileNotFoundError) as e: - logger.debug("ffprobe %s failed: %s", path.name, e) + except (subprocess.TimeoutExpired, ValueError, FileNotFoundError): + pass return DEFAULT_DURATION_SEC diff --git a/Dockers/self-hosted-iptv/iptv/server.py b/Dockers/self-hosted-iptv/iptv/server.py index 9cc6f19..bd26e65 100644 --- a/Dockers/self-hosted-iptv/iptv/server.py +++ b/Dockers/self-hosted-iptv/iptv/server.py @@ -158,23 +158,27 @@ class IPTVHandler(BaseHTTPRequestHandler): def send_stream(self, path_param: str): """Stream a video file. path is relative to /movies or /series.""" - from iptv.streamer import ( - path_to_absolute, - get_preferred_audio_stream_index, - stream_file_with_preferred_audio, - ) + from iptv.streamer import path_to_absolute, try_remux_preferred_audio abs_path = path_to_absolute(path_param) if abs_path is None: self.send_error(404, "File not found") return - audio_idx = get_preferred_audio_stream_index(abs_path) - if audio_idx is not None: + remux = try_remux_preferred_audio(abs_path) + if remux is not None: + first, rest = remux self.send_response(200) self.send_header("Content-Type", "video/mp4") self.send_header("Cache-Control", "no-cache") self.end_headers() - stream_file_with_preferred_audio(abs_path, self.wfile) + try: + self.wfile.write(first) + self.wfile.flush() + for chunk in rest: + self.wfile.write(chunk) + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError, OSError): + pass return content_type, _ = mimetypes.guess_type(str(abs_path)) content_type = content_type or "video/mp4" diff --git a/Dockers/self-hosted-iptv/iptv/streamer.py b/Dockers/self-hosted-iptv/iptv/streamer.py index e01bf8f..9f4709f 100644 --- a/Dockers/self-hosted-iptv/iptv/streamer.py +++ b/Dockers/self-hosted-iptv/iptv/streamer.py @@ -1,9 +1,12 @@ """Video streaming: preferred-audio remux and live channel concat.""" +from __future__ import annotations + import json import logging import subprocess import tempfile +from collections.abc import Iterator from pathlib import Path from urllib.parse import unquote @@ -107,45 +110,64 @@ def get_preferred_audio_stream_index(path: Path) -> int | None: return None -def stream_file_with_preferred_audio(path: Path, wfile) -> bool: +def try_remux_preferred_audio(path: Path) -> tuple[bytes, Iterator[bytes]] | None: """ - Stream the file via FFmpeg with only video and the preferred audio track. - Returns True if streaming was started, False if caller should fall back to raw file. + Start FFmpeg remux with preferred audio. Returns (first_chunk, rest_iterator) if + FFmpeg produces output, else None. Caller must send headers only after getting + a non-None result, then write first_chunk and iterate rest. """ audio_idx = get_preferred_audio_stream_index(path) if audio_idx is None: - return False + return None try: proc = subprocess.Popen( [ "ffmpeg", "-i", str(path), - "-map", "0:v", + "-map", "0:v:0", "-map", f"0:{audio_idx}", "-c", "copy", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof", + "-max_muxing_queue_size", "1024", "-", ], stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, start_new_session=True, ) - while True: - chunk = proc.stdout.read(65536) - if not chunk: - break - wfile.write(chunk) - wfile.flush() - proc.wait(timeout=1) - except (OSError, subprocess.TimeoutExpired): + first = proc.stdout.read(65536) + if not first: + proc.wait(timeout=5) + if proc.returncode != 0: + err = proc.stderr.read().decode("utf-8", errors="replace")[-500:] + logger.warning("FFmpeg remux failed (exit %s): %s", proc.returncode, err) + return None + + def rest(): + try: + while True: + chunk = proc.stdout.read(65536) + if not chunk: + break + yield chunk + finally: + proc.wait(timeout=5) + try: + proc.terminate() + proc.wait(timeout=2) + except Exception: + pass + + return (first, rest()) + except (OSError, subprocess.TimeoutExpired) as e: + logger.warning("FFmpeg remux error: %s", e) try: proc.terminate() proc.wait(timeout=2) except Exception: pass - return False - return True + return None def _escape_path(p: Path) -> str: