"""Video streaming: preferred-audio remux and live channel concat.""" import json import logging import os import subprocess import tempfile from pathlib import Path from urllib.parse import unquote from .config import ( VIDEO_EXTENSIONS, MOVIES_ROOT, SERIES_ROOT, PREFERRED_AUDIO_LANGUAGES, ) logger = logging.getLogger(__name__) def path_to_absolute(path_param: str) -> Path | None: """ Convert a path query parameter to an absolute Path under /movies or /series. Path must start with 'movies/' or 'series/'. Prevents path traversal. """ path_param = unquote(path_param).strip("/") if ".." in path_param or path_param.startswith("/"): return None if path_param.startswith("movies/"): sub = path_param[7:] root = MOVIES_ROOT elif path_param.startswith("series/"): sub = path_param[7:] root = SERIES_ROOT else: return None candidate = (root / sub).resolve() try: candidate.relative_to(root) except ValueError: return None if ( candidate.exists() and candidate.is_file() and candidate.suffix.lower() in VIDEO_EXTENSIONS ): return candidate return None def get_preferred_audio_stream_index(path: Path) -> int | None: """ Probe the file with ffprobe and return the 0-based audio stream index to use: first Dutch/Flemish (nld/dut), else first English (eng), else first audio stream. Returns None on probe failure or no audio streams. """ try: out = subprocess.run( [ "ffprobe", "-v", "quiet", "-print_format", "json", "-show_entries", "stream=index,codec_type", "-show_entries", "stream_tags=language", str(path), ], capture_output=True, text=True, timeout=10, ) if out.returncode != 0 or not out.stdout: return None data = json.loads(out.stdout) streams = data.get("streams") or [] audio_streams = [] for s in streams: if s.get("codec_type") != "audio": continue idx = s.get("index") if idx is None: continue tags = s.get("tags") or {} lang = (tags.get("language") or "").strip().lower()[:3] audio_streams.append((idx, lang)) if not audio_streams: return None for pref in PREFERRED_AUDIO_LANGUAGES: for idx, lang in audio_streams: if lang == pref: return idx return audio_streams[0][0] except ( subprocess.TimeoutExpired, ValueError, FileNotFoundError, json.JSONDecodeError, ): return None def stream_file_with_preferred_audio(path: Path, wfile) -> bool: """ 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. """ audio_idx = get_preferred_audio_stream_index(path) if audio_idx is None: return False try: proc = subprocess.Popen( [ "ffmpeg", "-i", str(path), "-map", "0:v", "-map", f"0:{audio_idx}", "-c", "copy", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof", "-", ], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, 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): try: proc.terminate() proc.wait(timeout=2) except Exception: pass return False return True def _escape_path(p: Path) -> str: return str(p.resolve()).replace("\\", "\\\\").replace("'", "'\\''") def _get_keyframe_at_or_before(path: Path, time_sec: float) -> float | None: """ Return the PTS (seconds) of the keyframe at or just before time_sec. Returns None on failure (probe error, timeout, no keyframes). Aligning cuts to keyframes avoids corrupt output at concat transitions. """ try: out = subprocess.run( [ "ffprobe", "-v", "error", "-select_streams", "v:0", "-skip_frame", "nokey", "-show_entries", "frame=pkt_pts_time", "-of", "csv=p=0", str(path), ], capture_output=True, text=True, timeout=15, ) if out.returncode != 0 or not out.stdout: return None keyframes = [] for line in out.stdout.strip().splitlines(): line = line.strip() if line: try: t = float(line) keyframes.append(t) except ValueError: continue if not keyframes: return None keyframes = sorted(set(keyframes)) # Last keyframe at or before time_sec best = 0.0 for kf in keyframes: if kf <= time_sec: best = kf else: break return best except (subprocess.TimeoutExpired, ValueError, FileNotFoundError): return None def write_concat_list(paths: list[Path], fd) -> None: """Write FFmpeg concat demuxer list to file.""" for p in paths: fd.write(f"file '{_escape_path(p)}'\n") fd.flush() def _schedule_epoch_utc(): from datetime import datetime, timezone 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. """ from datetime import datetime, timezone 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 aligned to keyframes so we never cut mid-GOP (which causes corrupt packets and stream crashes at transitions). """ n = len(paths) if n == 0: return idx, offset_in_file = _current_position_in_cycle(durations) # For testing: start first episode at last N minutes (env TEST_START_LAST_MINUTES=3) try: test_min = int(os.environ.get("TEST_START_LAST_MINUTES", "0") or "0") if test_min > 0 and idx < len(durations): offset_in_file = max(0.0, durations[idx] - test_min * 60) except (ValueError, TypeError): pass # Align cuts to keyframes to avoid corrupt output at file boundaries inpoint_sec = None outpoint_sec = None if offset_in_file >= 0.5: kf = _get_keyframe_at_or_before(paths[idx], offset_in_file) if kf is not None and kf >= 0.5: inpoint_sec = kf outpoint_sec = kf # If keyframe probe failed, skip inpoint/outpoint — start from file start # to avoid mid-GOP cuts that crash the stream for i in range(n): j = (idx + i) % n fd.write(f"file '{_escape_path(paths[j])}'\n") if i == 0 and inpoint_sec is not None: fd.write(f"inpoint {inpoint_sec:.2f}\n") if outpoint_sec is not None: fd.write(f"file '{_escape_path(paths[idx])}'\n") fd.write(f"outpoint {outpoint_sec:.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. 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_from_current(paths, durations, f) list_path = f.name try: proc = subprocess.Popen( [ "ffmpeg", "-fflags", "+nobuffer+flush_packets+genpts", "-stream_loop", "-1", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", "-avoid_negative_ts", "make_zero", "-muxdelay", "0", "-muxpreload", "0", "-max_muxing_queue_size", "2048", "-f", "mpegts", "-", ], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, start_new_session=True, ) try: while True: chunk = proc.stdout.read(65536) if not chunk: break wfile.write(chunk) wfile.flush() except (BrokenPipeError, ConnectionResetError, OSError): pass finally: proc.terminate() try: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() finally: Path(list_path).unlink(missing_ok=True)