This commit is contained in:
@@ -56,7 +56,7 @@ Each channel in the M3U points to a **continuous live stream** (`/live/<id>`): F
|
|||||||
- `GET /epg.xml` or `GET /xmltv.xml` — XMLTV EPG (duration set by `EPG_TIMESPAN`, UTC)
|
- `GET /epg.xml` or `GET /xmltv.xml` — XMLTV EPG (duration set by `EPG_TIMESPAN`, UTC)
|
||||||
- `GET /live/<channel_id>` — continuous MPEG-TS stream for that channel (for tuning/recording)
|
- `GET /live/<channel_id>` — continuous MPEG-TS stream for that channel (for tuning/recording)
|
||||||
- `GET /channel/<id>/playlist.m3u` — single channel M3U (list of individual video URLs)
|
- `GET /channel/<id>/playlist.m3u` — single channel M3U (list of individual video URLs)
|
||||||
- `GET /stream?path=movies/...` or `path=series/...` — stream a single video file
|
- `GET /stream?path=movies/...` or `path=series/...` — stream a single video file (audio track is auto-selected: Dutch/Flemish → English → default)
|
||||||
|
|
||||||
## Shuffle behaviour
|
## Shuffle behaviour
|
||||||
|
|
||||||
|
|||||||
@@ -243,6 +243,96 @@ def build_playlist(path_keys: list[str], base_url: str) -> list[tuple[str, str]]
|
|||||||
return playlist
|
return playlist
|
||||||
|
|
||||||
|
|
||||||
|
# Preferred audio languages for streaming: Dutch/Flemish first, then English, then default (first audio).
|
||||||
|
PREFERRED_AUDIO_LANGUAGES = ("nld", "dut", "eng") # ISO 639-2: nld/dut = Dutch, eng = English
|
||||||
|
|
||||||
|
|
||||||
|
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 = [] # (index, language or "")
|
||||||
|
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 (Dutch/Flemish, else English, else first).
|
||||||
|
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 path_to_absolute(path_param: str) -> Path | None:
|
def path_to_absolute(path_param: str) -> Path | None:
|
||||||
"""
|
"""
|
||||||
Convert a path query parameter to an absolute Path under /movies or /series.
|
Convert a path query parameter to an absolute Path under /movies or /series.
|
||||||
@@ -914,11 +1004,19 @@ class IPTVHandler(BaseHTTPRequestHandler):
|
|||||||
stream_live_channel(paths, self.wfile, durations)
|
stream_live_channel(paths, self.wfile, durations)
|
||||||
|
|
||||||
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. Prefers Dutch/Flemish audio, then English, then default."""
|
||||||
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)
|
||||||
|
if audio_idx is not None:
|
||||||
|
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)
|
||||||
|
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"
|
||||||
size = abs_path.stat().st_size
|
size = abs_path.stat().st_size
|
||||||
|
|||||||
Reference in New Issue
Block a user