diff --git a/Dockers/self-hosted-iptv/README.md b/Dockers/self-hosted-iptv/README.md index 728943d..eff12e0 100644 --- a/Dockers/self-hosted-iptv/README.md +++ b/Dockers/self-hosted-iptv/README.md @@ -56,7 +56,7 @@ Each channel in the M3U points to a **continuous live stream** (`/live/`): F - `GET /epg.xml` or `GET /xmltv.xml` — XMLTV EPG (duration set by `EPG_TIMESPAN`, UTC) - `GET /live/` — continuous MPEG-TS stream for that channel (for tuning/recording) - `GET /channel//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 diff --git a/Dockers/self-hosted-iptv/main.py b/Dockers/self-hosted-iptv/main.py index 087329a..6861aa6 100644 --- a/Dockers/self-hosted-iptv/main.py +++ b/Dockers/self-hosted-iptv/main.py @@ -243,6 +243,96 @@ def build_playlist(path_keys: list[str], base_url: str) -> list[tuple[str, str]] 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: """ 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) 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) if abs_path is None: self.send_error(404, "File not found") 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 = content_type or "video/mp4" size = abs_path.stat().st_size