updates
Build and Push Docker Images / build-and-push (push) Successful in 22s

This commit is contained in:
2026-02-11 23:11:41 +01:00
parent 016e0cee0c
commit e92ccd757a
3 changed files with 52 additions and 27 deletions
+2 -3
View File
@@ -118,10 +118,9 @@ def get_duration(path: Path) -> float:
) )
if out.returncode == 0 and out.stdout.strip(): if out.returncode == 0 and out.stdout.strip():
d = float(out.stdout.strip()) d = float(out.stdout.strip())
logger.debug("ffprobe %s -> %.0fs in %.2fs", path.name, d, time.monotonic() - t0)
return d return d
except (subprocess.TimeoutExpired, ValueError, FileNotFoundError) as e: except (subprocess.TimeoutExpired, ValueError, FileNotFoundError):
logger.debug("ffprobe %s failed: %s", path.name, e) pass
return DEFAULT_DURATION_SEC return DEFAULT_DURATION_SEC
+12 -8
View File
@@ -158,23 +158,27 @@ class IPTVHandler(BaseHTTPRequestHandler):
def send_stream(self, path_param: str): def send_stream(self, path_param: str):
"""Stream a video file. path is relative to /movies or /series.""" """Stream a video file. path is relative to /movies or /series."""
from iptv.streamer import ( from iptv.streamer import path_to_absolute, try_remux_preferred_audio
path_to_absolute,
get_preferred_audio_stream_index,
stream_file_with_preferred_audio,
)
abs_path = path_to_absolute(path_param) abs_path = path_to_absolute(path_param)
if abs_path is None: if abs_path is None:
self.send_error(404, "File not found") self.send_error(404, "File not found")
return return
audio_idx = get_preferred_audio_stream_index(abs_path) remux = try_remux_preferred_audio(abs_path)
if audio_idx is not None: if remux is not None:
first, rest = remux
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "video/mp4") self.send_header("Content-Type", "video/mp4")
self.send_header("Cache-Control", "no-cache") self.send_header("Cache-Control", "no-cache")
self.end_headers() 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 return
content_type, _ = mimetypes.guess_type(str(abs_path)) content_type, _ = mimetypes.guess_type(str(abs_path))
content_type = content_type or "video/mp4" content_type = content_type or "video/mp4"
+34 -12
View File
@@ -1,9 +1,12 @@
"""Video streaming: preferred-audio remux and live channel concat.""" """Video streaming: preferred-audio remux and live channel concat."""
from __future__ import annotations
import json import json
import logging import logging
import subprocess import subprocess
import tempfile import tempfile
from collections.abc import Iterator
from pathlib import Path from pathlib import Path
from urllib.parse import unquote from urllib.parse import unquote
@@ -107,45 +110,64 @@ def get_preferred_audio_stream_index(path: Path) -> int | None:
return 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. Start FFmpeg remux with preferred audio. Returns (first_chunk, rest_iterator) if
Returns True if streaming was started, False if caller should fall back to raw file. 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) audio_idx = get_preferred_audio_stream_index(path)
if audio_idx is None: if audio_idx is None:
return False return None
try: try:
proc = subprocess.Popen( proc = subprocess.Popen(
[ [
"ffmpeg", "ffmpeg",
"-i", str(path), "-i", str(path),
"-map", "0:v", "-map", "0:v:0",
"-map", f"0:{audio_idx}", "-map", f"0:{audio_idx}",
"-c", "copy", "-c", "copy",
"-f", "mp4", "-f", "mp4",
"-movflags", "frag_keyframe+empty_moov+default_base_moof", "-movflags", "frag_keyframe+empty_moov+default_base_moof",
"-max_muxing_queue_size", "1024",
"-", "-",
], ],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, stderr=subprocess.PIPE,
start_new_session=True, start_new_session=True,
) )
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: while True:
chunk = proc.stdout.read(65536) chunk = proc.stdout.read(65536)
if not chunk: if not chunk:
break break
wfile.write(chunk) yield chunk
wfile.flush() finally:
proc.wait(timeout=1) proc.wait(timeout=5)
except (OSError, subprocess.TimeoutExpired):
try: try:
proc.terminate() proc.terminate()
proc.wait(timeout=2) proc.wait(timeout=2)
except Exception: except Exception:
pass pass
return False
return True 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 None
def _escape_path(p: Path) -> str: def _escape_path(p: Path) -> str: