Files
projects/Dockers/self-hosted-iptv/iptv/streamer.py
T
Bram c49574cb93
Build and Push Docker Images / build-and-push (push) Successful in 27s
continuous stream
2026-02-11 21:21:04 +01:00

313 lines
9.8 KiB
Python

"""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)