This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
while True:
|
first = proc.stdout.read(65536)
|
||||||
chunk = proc.stdout.read(65536)
|
if not first:
|
||||||
if not chunk:
|
proc.wait(timeout=5)
|
||||||
break
|
if proc.returncode != 0:
|
||||||
wfile.write(chunk)
|
err = proc.stderr.read().decode("utf-8", errors="replace")[-500:]
|
||||||
wfile.flush()
|
logger.warning("FFmpeg remux failed (exit %s): %s", proc.returncode, err)
|
||||||
proc.wait(timeout=1)
|
return None
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
|
||||||
|
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:
|
try:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
proc.wait(timeout=2)
|
proc.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return False
|
return None
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _escape_path(p: Path) -> str:
|
def _escape_path(p: Path) -> str:
|
||||||
|
|||||||
Reference in New Issue
Block a user