From 43d1a96e5ce57c41df0b641a819de0abb8462a9c Mon Sep 17 00:00:00 2001 From: Tristan Teufel Date: Thu, 2 Apr 2026 13:47:30 +0200 Subject: [PATCH 1/4] Add option to prompt user for preferred stream selection and update settings --- core/playback_selection.py | 6 +- core/runtime/playback.py | 48 +++++++++++++++ .../resource.language.en_gb/strings.po | 4 ++ resources/settings.xml | 5 ++ service.py | 3 + tests/test_playback_selection.py | 60 +++++++++++++++++++ 6 files changed, 125 insertions(+), 1 deletion(-) diff --git a/core/playback_selection.py b/core/playback_selection.py index 6f20bc47a..23e420bdf 100644 --- a/core/playback_selection.py +++ b/core/playback_selection.py @@ -300,6 +300,7 @@ def select_playback_source( maxwidth, isa_supports, dashbuilder=None, + preferred_format_url=None, ): dash_manifest_factory = None dash_start_httpd = None @@ -313,7 +314,7 @@ def select_playback_source( isa_supports(guess_manifest_type(result, manifest_url)) if manifest_url is not None else False, result.get('http_headers'), ) - if original_manifest_candidate is not None: + if original_manifest_candidate is not None and preferred_format_url is None: original_manifest_candidate['source'] = 'original_manifest' return original_manifest_candidate @@ -327,6 +328,9 @@ def select_playback_source( if should_skip_manifest_candidate(have_video, vcodec, acodec): continue + if preferred_format_url is not None and format_info.get('url') != preferred_format_url: + continue + manifest_url = format_info.get('manifest_url') if usemanifest else None format_manifest_candidate = resolve_manifest_candidate( manifest_url, diff --git a/core/runtime/playback.py b/core/runtime/playback.py index d8eede92f..4301a7db8 100644 --- a/core/runtime/playback.py +++ b/core/runtime/playback.py @@ -31,12 +31,45 @@ def _resolve_downloaded_file_path(result): return None +def _format_stream_option(format_info): + format_label = format_info.get("format") or format_info.get("format_id") or "unknown" + protocol = format_info.get("protocol") or "?" + vcodec = format_info.get("vcodec") or "?" + acodec = format_info.get("acodec") or "?" + width = format_info.get("width") + height = format_info.get("height") + resolution = "{}x{}".format(width, height) if width and height else "?" + return "{} | {} | {} / {} | {}".format(format_label, protocol, vcodec, acodec, resolution) + + +def _prompt_preferred_stream_url(result): + formats = result.get("formats", []) + entries = [] + seen_urls = set() + for format_info in reversed(formats): + stream_url = format_info.get("url") + if not stream_url or stream_url in seen_urls: + continue + seen_urls.add(stream_url) + entries.append((stream_url, _format_stream_option(format_info))) + + if not entries: + return None + + labels = ["Automatic selection"] + [label for _, label in entries] + selected_index = xbmcgui.Dialog().select("Select stream", labels) + if selected_index <= 0: + return None + return entries[selected_index - 1][0] + + def create_list_item_from_video( result, ydl_opts, usemanifest, usedashbuilder, maxwidth, + askstream, isa_supports, youtube_dl_cls, log, @@ -58,6 +91,7 @@ def resolve_fresh_result(): selection_result = dict(result) selection_result["resolve_fresh_result"] = resolve_fresh_result + preferred_stream_url = _prompt_preferred_stream_url(selection_result) if askstream else None selected_source = select_playback_source( selection_result, usemanifest, @@ -65,8 +99,20 @@ def resolve_fresh_result(): maxwidth, isa_supports, dash_builder, + preferred_stream_url, ) + if selected_source is None and preferred_stream_url is not None: + log("Selected stream is not playable, falling back to automatic selection", xbmc.LOGWARNING) + selected_source = select_playback_source( + selection_result, + usemanifest, + usedashbuilder, + maxwidth, + isa_supports, + dash_builder, + ) + if selected_source is not None: for message in selection_log_messages(selected_source): log(message) @@ -161,6 +207,7 @@ def play_playlist_result( usemanifest, usedashbuilder, maxwidth, + askstream, isa_supports, youtube_dl_cls, log, @@ -185,6 +232,7 @@ def extract_starting_entry(url, _download): usemanifest, usedashbuilder, maxwidth, + askstream, isa_supports, youtube_dl_cls, log, diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 64b8d175d..f3ba73bd8 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -104,6 +104,10 @@ msgctxt "#33040" msgid "DASH MPD server idle timeout (seconds)" msgstr "" +msgctxt "#33045" +msgid "Ask which stream to play" +msgstr "" + msgctxt "#33030" msgid "Maximum resolution" msgstr "" diff --git a/resources/settings.xml b/resources/settings.xml index 1708b7f2f..ccf5eaa95 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -103,6 +103,11 @@ true + + 0 + false + + 0 120 diff --git a/service.py b/service.py index 0bf646f32..8982bf9e8 100644 --- a/service.py +++ b/service.py @@ -194,6 +194,7 @@ def handle_queue_action(paramstring): usemanifest = xbmcplugin.getSetting(__handle__,"usemanifest") == 'true' usedashbuilder = xbmcplugin.getSetting(__handle__,"usedashbuilder") == 'true' +askstream = xbmcplugin.getSetting(__handle__,"askstream") == 'true' dash_httpd_idle_timeout_seconds = resolve_dash_httpd_idle_timeout(__handle__, xbmcplugin.getSetting) dash_builder.DASH_HTTPD_IDLE_TIMEOUT_SECONDS = dash_httpd_idle_timeout_seconds log("DASH MPD server idle timeout: {}s".format(dash_httpd_idle_timeout_seconds)) @@ -223,6 +224,7 @@ def handle_queue_action(paramstring): usemanifest, usedashbuilder, maxwidth, + askstream, isa_supports, YoutubeDL, log, @@ -238,6 +240,7 @@ def handle_queue_action(paramstring): usemanifest, usedashbuilder, maxwidth, + askstream, isa_supports, YoutubeDL, log, diff --git a/tests/test_playback_selection.py b/tests/test_playback_selection.py index 73a280f5e..e879a4633 100644 --- a/tests/test_playback_selection.py +++ b/tests/test_playback_selection.py @@ -515,6 +515,66 @@ def test_select_playback_source_uses_raw_format_when_playable(): assert selected["url"] == "https://example.com/video.mp4" +def test_select_playback_source_prefers_user_selected_stream_url(): + result = { + "manifest_url": "https://example.com/master.m3u8", + "formats": [ + { + "format": "f360", + "url": "https://example.com/360.mp4", + "vcodec": "avc1", + "acodec": "aac", + "width": 640, + }, + { + "format": "f720", + "url": "https://example.com/720.mp4", + "vcodec": "avc1", + "acodec": "aac", + "width": 1280, + }, + ], + } + + selected = select_playback_source( + result=result, + usemanifest=True, + usedashbuilder=False, + maxwidth=1920, + isa_supports=lambda stream: stream == "hls", + preferred_format_url="https://example.com/360.mp4", + ) + + assert selected["source"] == "raw_format" + assert selected["url"] == "https://example.com/360.mp4" + + +def test_select_playback_source_returns_none_for_unplayable_user_selected_stream_url(): + result = { + "manifest_url": "https://example.com/master.m3u8", + "formats": [ + { + "format": "f720", + "url": "https://example.com/720.mp4", + "vcodec": "avc1", + "acodec": "aac", + "width": 1280, + }, + ], + } + + selected = select_playback_source( + result=result, + usemanifest=True, + usedashbuilder=False, + maxwidth=1920, + isa_supports=lambda stream: stream == "hls", + preferred_format_url="https://example.com/missing.mp4", + ) + + assert selected is None + + def test_select_playback_source_uses_filtered_fallback_when_only_over_limit_formats_exist(): result = { "formats": [ From 453f956fb34fdca860b6801f56155689fbb5ff2f Mon Sep 17 00:00:00 2001 From: Tristan Teufel Date: Thu, 2 Apr 2026 14:14:13 +0200 Subject: [PATCH 2/4] Add support for native HLS without ISA and update related tests --- core/playback_selection.py | 41 +++++++++++++------ tests/test_playback_selection.py | 67 ++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 12 deletions(-) diff --git a/core/playback_selection.py b/core/playback_selection.py index 23e420bdf..f2f792a92 100644 --- a/core/playback_selection.py +++ b/core/playback_selection.py @@ -54,6 +54,20 @@ def should_filter_by_max_width(width, maxwidth): return width is not None and width > maxwidth +def should_allow_native_hls_without_isa(format_info, manifest_type): + if manifest_type != 'hls': + return False + + protocol = (format_info.get('protocol') or '').lower() + if not protocol.startswith('m3u'): + return False + + vcodec = (format_info.get('vcodec') or '').lower() + acodec = (format_info.get('acodec') or '').lower() + unknown_values = ('', 'none', 'unknown') + return vcodec not in unknown_values and acodec not in unknown_values + + def should_try_dash_builder(usedashbuilder, have_video, dash_video, have_audio, dash_audio, current_format, mpd_supported): return ( usedashbuilder @@ -113,7 +127,8 @@ def evaluate_raw_format_candidate(format_info, have_video, have_audio, maxwidth, ): return {'decision': 'skip'} - if manifest_type is not None and not manifest_supported: + native_hls_without_isa = should_allow_native_hls_without_isa(format_info, manifest_type) + if manifest_type is not None and not manifest_supported and not native_hls_without_isa: return {'decision': 'skip'} width = format_info.get('width', 0) @@ -123,7 +138,8 @@ def evaluate_raw_format_candidate(format_info, have_video, have_audio, maxwidth, return { 'decision': 'select', 'url': format_info['url'], - 'isa': manifest_supported, + # For muxed HLS variants this can be more reliable than ISA on some Kodi setups. + 'isa': False if native_hls_without_isa else manifest_supported, 'headers': format_info.get('http_headers'), } @@ -331,16 +347,17 @@ def select_playback_source( if preferred_format_url is not None and format_info.get('url') != preferred_format_url: continue - manifest_url = format_info.get('manifest_url') if usemanifest else None - format_manifest_candidate = resolve_manifest_candidate( - manifest_url, - isa_supports(guess_manifest_type(format_info, manifest_url)) if manifest_url is not None else False, - format_info.get('http_headers'), - ) - if format_manifest_candidate is not None: - format_manifest_candidate['source'] = 'format_manifest' - format_manifest_candidate['format_label'] = format_info.get('format', "") - return format_manifest_candidate + if preferred_format_url is None: + manifest_url = format_info.get('manifest_url') if usemanifest else None + format_manifest_candidate = resolve_manifest_candidate( + manifest_url, + isa_supports(guess_manifest_type(format_info, manifest_url)) if manifest_url is not None else False, + format_info.get('http_headers'), + ) + if format_manifest_candidate is not None: + format_manifest_candidate['source'] = 'format_manifest' + format_manifest_candidate['format_label'] = format_info.get('format', "") + return format_manifest_candidate if should_try_dash_builder( usedashbuilder, diff --git a/tests/test_playback_selection.py b/tests/test_playback_selection.py index e879a4633..e994e2015 100644 --- a/tests/test_playback_selection.py +++ b/tests/test_playback_selection.py @@ -6,6 +6,7 @@ evaluate_raw_format_candidate, guess_manifest_type, normalize_dash_audio_streams, + should_allow_native_hls_without_isa, add_dash_formats_to_builder, build_dash_manifest_candidate, resolve_filtered_fallback_candidate, @@ -126,6 +127,19 @@ def test_should_filter_by_max_width_only_when_exceeding_limit(): assert should_filter_by_max_width(None, 1280) is False +def test_should_allow_native_hls_without_isa_for_muxed_hls_variant(): + allowed = should_allow_native_hls_without_isa( + { + "protocol": "m3u8_native", + "vcodec": "avc1.64001f", + "acodec": "mp4a.40.2", + }, + manifest_type="hls", + ) + + assert allowed is True + + def test_should_try_dash_builder_true_for_last_video_format_when_supported(): current = {"id": "v2"} dash_video = [{"id": "v1"}, current] @@ -223,6 +237,30 @@ def test_evaluate_raw_format_candidate_selects_playable_stream(): } +def test_evaluate_raw_format_candidate_uses_native_for_muxed_hls_when_isa_unavailable(): + result = evaluate_raw_format_candidate( + format_info={ + "url": "https://example.com/stream.m3u8", + "protocol": "m3u8_native", + "vcodec": "avc1.64001f", + "acodec": "mp4a.40.2", + "http_headers": {"User-Agent": "UA"}, + }, + have_video=True, + have_audio=True, + maxwidth=1920, + manifest_type="hls", + manifest_supported=False, + ) + + assert result == { + "decision": "select", + "url": "https://example.com/stream.m3u8", + "isa": False, + "headers": {"User-Agent": "UA"}, + } + + def test_resolve_filtered_fallback_candidate_returns_none_without_filtered_format(): assert resolve_filtered_fallback_candidate(None, manifest_supported=False) is None @@ -575,6 +613,35 @@ def test_select_playback_source_returns_none_for_unplayable_user_selected_stream assert selected is None +def test_select_playback_source_user_selected_stream_skips_format_manifest_path(): + result = { + "formats": [ + { + "format": "hls-choice", + "url": "https://example.com/stream-360.m3u8", + "manifest_url": "https://example.com/master.m3u8", + "protocol": "m3u8_native", + "vcodec": "avc1.64001f", + "acodec": "mp4a.40.2", + "width": 640, + } + ] + } + + selected = select_playback_source( + result=result, + usemanifest=True, + usedashbuilder=False, + maxwidth=1920, + isa_supports=lambda _stream: False, + preferred_format_url="https://example.com/stream-360.m3u8", + ) + + assert selected["source"] == "raw_format" + assert selected["url"] == "https://example.com/stream-360.m3u8" + assert selected["isa"] is False + + def test_select_playback_source_uses_filtered_fallback_when_only_over_limit_formats_exist(): result = { "formats": [ From 252278991fc0c1d797720da917d4742b0cf6a7e3 Mon Sep 17 00:00:00 2001 From: Tristan Teufel Date: Thu, 2 Apr 2026 14:25:49 +0200 Subject: [PATCH 3/4] Enhance HLS stream handling by adding support for audio-only variants and updating related tests --- core/playback_selection.py | 4 +++- core/runtime/playback.py | 32 ++++++++++++++++++++++++--- tests/test_playback_selection.py | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/core/playback_selection.py b/core/playback_selection.py index f2f792a92..4e5e31cbd 100644 --- a/core/playback_selection.py +++ b/core/playback_selection.py @@ -65,7 +65,9 @@ def should_allow_native_hls_without_isa(format_info, manifest_type): vcodec = (format_info.get('vcodec') or '').lower() acodec = (format_info.get('acodec') or '').lower() unknown_values = ('', 'none', 'unknown') - return vcodec not in unknown_values and acodec not in unknown_values + is_audio_only = vcodec in unknown_values and acodec not in unknown_values + is_muxed_av = vcodec not in unknown_values and acodec not in unknown_values + return is_audio_only or is_muxed_av def should_try_dash_builder(usedashbuilder, have_video, dash_video, have_audio, dash_audio, current_format, mpd_supported): diff --git a/core/runtime/playback.py b/core/runtime/playback.py index 4301a7db8..9c14b8318 100644 --- a/core/runtime/playback.py +++ b/core/runtime/playback.py @@ -42,6 +42,27 @@ def _format_stream_option(format_info): return "{} | {} | {} / {} | {}".format(format_label, protocol, vcodec, acodec, resolution) +def _normalize_codec(codec): + value = (codec or "").strip().lower() + if value in ("", "none", "unknown", "null"): + return "none" + return value + + +def _infer_selected_stream_kind(result, selected_url): + for format_info in reversed(result.get("formats", [])): + if format_info.get("url") != selected_url: + continue + + vcodec = _normalize_codec(format_info.get("vcodec")) + acodec = _normalize_codec(format_info.get("acodec")) + if vcodec == "none" and acodec != "none": + return "audio" + return "video" + + return "video" + + def _prompt_preferred_stream_url(result): formats = result.get("formats", []) entries = [] @@ -143,9 +164,14 @@ def resolve_fresh_result(): log("creating list item for url {}".format(url)) list_item = xbmcgui.ListItem(result["title"], path=url) - video_info = list_item.getVideoInfoTag() - video_info.setTitle(result["title"]) - video_info.setPlot(result.get("description", None)) + stream_kind = _infer_selected_stream_kind(result, url) + if stream_kind == "audio": + music_info = list_item.getMusicInfoTag() + music_info.setTitle(result["title"]) + else: + video_info = list_item.getVideoInfoTag() + video_info.setTitle(result["title"]) + video_info.setPlot(result.get("description", None)) if result.get("thumbnail", None) is not None: list_item.setArt({"thumb": result["thumbnail"]}) diff --git a/tests/test_playback_selection.py b/tests/test_playback_selection.py index e994e2015..e6c5f397c 100644 --- a/tests/test_playback_selection.py +++ b/tests/test_playback_selection.py @@ -140,6 +140,19 @@ def test_should_allow_native_hls_without_isa_for_muxed_hls_variant(): assert allowed is True +def test_should_allow_native_hls_without_isa_for_audio_only_hls_variant(): + allowed = should_allow_native_hls_without_isa( + { + "protocol": "m3u8_native", + "vcodec": "none", + "acodec": "opus", + }, + manifest_type="hls", + ) + + assert allowed is True + + def test_should_try_dash_builder_true_for_last_video_format_when_supported(): current = {"id": "v2"} dash_video = [{"id": "v1"}, current] @@ -261,6 +274,30 @@ def test_evaluate_raw_format_candidate_uses_native_for_muxed_hls_when_isa_unavai } +def test_evaluate_raw_format_candidate_uses_native_for_audio_only_hls_when_isa_available(): + result = evaluate_raw_format_candidate( + format_info={ + "url": "https://example.com/audio.m3u8", + "protocol": "m3u8_native", + "vcodec": "none", + "acodec": "opus", + "http_headers": {"User-Agent": "UA"}, + }, + have_video=False, + have_audio=True, + maxwidth=1920, + manifest_type="hls", + manifest_supported=True, + ) + + assert result == { + "decision": "select", + "url": "https://example.com/audio.m3u8", + "isa": False, + "headers": {"User-Agent": "UA"}, + } + + def test_resolve_filtered_fallback_candidate_returns_none_without_filtered_format(): assert resolve_filtered_fallback_candidate(None, manifest_supported=False) is None From 22ea319d95a1cc21968dc9b7a7eb85795ed87ee4 Mon Sep 17 00:00:00 2001 From: Tristan Teufel Date: Thu, 2 Apr 2026 14:36:26 +0200 Subject: [PATCH 4/4] Add option to disable Opus codec for audio-only native HLS streams and update related tests --- core/playback_selection.py | 36 ++++++++++++- core/runtime/playback.py | 5 ++ .../resource.language.en_gb/strings.po | 4 ++ resources/settings.xml | 5 ++ service.py | 5 ++ tests/test_playback_selection.py | 53 +++++++++++++++++++ 6 files changed, 107 insertions(+), 1 deletion(-) diff --git a/core/playback_selection.py b/core/playback_selection.py index 4e5e31cbd..ce60b9eea 100644 --- a/core/playback_selection.py +++ b/core/playback_selection.py @@ -70,6 +70,23 @@ def should_allow_native_hls_without_isa(format_info, manifest_type): return is_audio_only or is_muxed_av +def should_skip_audio_only_hls_native_opus(format_info, manifest_type, disable_opus_for_audio_only_hls_native): + if not disable_opus_for_audio_only_hls_native: + return False + if manifest_type != 'hls': + return False + + protocol = (format_info.get('protocol') or '').lower() + if not protocol.startswith('m3u'): + return False + + vcodec = (format_info.get('vcodec') or '').lower() + acodec = (format_info.get('acodec') or '').lower() + unknown_values = ('', 'none', 'unknown') + is_audio_only = vcodec in unknown_values and acodec not in unknown_values + return is_audio_only and acodec == 'opus' + + def should_try_dash_builder(usedashbuilder, have_video, dash_video, have_audio, dash_audio, current_format, mpd_supported): return ( usedashbuilder @@ -117,7 +134,15 @@ def resolve_result_fallback_candidate(result_url, manifest_supported, headers): } -def evaluate_raw_format_candidate(format_info, have_video, have_audio, maxwidth, manifest_type, manifest_supported): +def evaluate_raw_format_candidate( + format_info, + have_video, + have_audio, + maxwidth, + manifest_type, + manifest_supported, + disable_opus_for_audio_only_hls_native=False, +): if 'url' not in format_info: return {'decision': 'skip'} @@ -129,6 +154,13 @@ def evaluate_raw_format_candidate(format_info, have_video, have_audio, maxwidth, ): return {'decision': 'skip'} + if should_skip_audio_only_hls_native_opus( + format_info, + manifest_type, + disable_opus_for_audio_only_hls_native, + ): + return {'decision': 'skip'} + native_hls_without_isa = should_allow_native_hls_without_isa(format_info, manifest_type) if manifest_type is not None and not manifest_supported and not native_hls_without_isa: return {'decision': 'skip'} @@ -319,6 +351,7 @@ def select_playback_source( isa_supports, dashbuilder=None, preferred_format_url=None, + disable_opus_for_audio_only_hls_native=False, ): dash_manifest_factory = None dash_start_httpd = None @@ -400,6 +433,7 @@ def select_playback_source( maxwidth, manifest_type, isa_supports(manifest_type), + disable_opus_for_audio_only_hls_native, ) if raw_candidate['decision'] == 'skip': continue diff --git a/core/runtime/playback.py b/core/runtime/playback.py index 9c14b8318..aff1eeb0c 100644 --- a/core/runtime/playback.py +++ b/core/runtime/playback.py @@ -91,6 +91,7 @@ def create_list_item_from_video( usedashbuilder, maxwidth, askstream, + disable_opus_for_audio_only_hls_native, isa_supports, youtube_dl_cls, log, @@ -121,6 +122,7 @@ def resolve_fresh_result(): isa_supports, dash_builder, preferred_stream_url, + disable_opus_for_audio_only_hls_native, ) if selected_source is None and preferred_stream_url is not None: @@ -132,6 +134,7 @@ def resolve_fresh_result(): maxwidth, isa_supports, dash_builder, + disable_opus_for_audio_only_hls_native=disable_opus_for_audio_only_hls_native, ) if selected_source is not None: @@ -234,6 +237,7 @@ def play_playlist_result( usedashbuilder, maxwidth, askstream, + disable_opus_for_audio_only_hls_native, isa_supports, youtube_dl_cls, log, @@ -259,6 +263,7 @@ def extract_starting_entry(url, _download): usedashbuilder, maxwidth, askstream, + disable_opus_for_audio_only_hls_native, isa_supports, youtube_dl_cls, log, diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index f3ba73bd8..9220c0386 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -108,6 +108,10 @@ msgctxt "#33045" msgid "Ask which stream to play" msgstr "" +msgctxt "#33046" +msgid "Audio-only HLS: disable Opus for native m3u streams" +msgstr "" + msgctxt "#33030" msgid "Maximum resolution" msgstr "" diff --git a/resources/settings.xml b/resources/settings.xml index ccf5eaa95..41e1df5f4 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -108,6 +108,11 @@ false + + 0 + true + + 0 120 diff --git a/service.py b/service.py index 8982bf9e8..e0ea9e46c 100644 --- a/service.py +++ b/service.py @@ -195,6 +195,9 @@ def handle_queue_action(paramstring): usemanifest = xbmcplugin.getSetting(__handle__,"usemanifest") == 'true' usedashbuilder = xbmcplugin.getSetting(__handle__,"usedashbuilder") == 'true' askstream = xbmcplugin.getSetting(__handle__,"askstream") == 'true' +disable_opus_for_audio_only_hls_native = ( + xbmcplugin.getSetting(__handle__, "audio_only_hls_disable_opus_native") == 'true' +) dash_httpd_idle_timeout_seconds = resolve_dash_httpd_idle_timeout(__handle__, xbmcplugin.getSetting) dash_builder.DASH_HTTPD_IDLE_TIMEOUT_SECONDS = dash_httpd_idle_timeout_seconds log("DASH MPD server idle timeout: {}s".format(dash_httpd_idle_timeout_seconds)) @@ -225,6 +228,7 @@ def handle_queue_action(paramstring): usedashbuilder, maxwidth, askstream, + disable_opus_for_audio_only_hls_native, isa_supports, YoutubeDL, log, @@ -241,6 +245,7 @@ def handle_queue_action(paramstring): usedashbuilder, maxwidth, askstream, + disable_opus_for_audio_only_hls_native, isa_supports, YoutubeDL, log, diff --git a/tests/test_playback_selection.py b/tests/test_playback_selection.py index e6c5f397c..eb92de485 100644 --- a/tests/test_playback_selection.py +++ b/tests/test_playback_selection.py @@ -298,6 +298,26 @@ def test_evaluate_raw_format_candidate_uses_native_for_audio_only_hls_when_isa_a } +def test_evaluate_raw_format_candidate_skips_audio_only_native_hls_opus_when_disabled_by_setting(): + result = evaluate_raw_format_candidate( + format_info={ + "url": "https://example.com/audio.m3u8", + "protocol": "m3u8_native", + "vcodec": "none", + "acodec": "opus", + "http_headers": {"User-Agent": "UA"}, + }, + have_video=False, + have_audio=True, + maxwidth=1920, + manifest_type="hls", + manifest_supported=True, + disable_opus_for_audio_only_hls_native=True, + ) + + assert result == {"decision": "skip"} + + def test_resolve_filtered_fallback_candidate_returns_none_without_filtered_format(): assert resolve_filtered_fallback_candidate(None, manifest_supported=False) is None @@ -704,6 +724,39 @@ def test_select_playback_source_uses_filtered_fallback_when_only_over_limit_form assert selected["url"] == "https://example.com/video4k.mp4" +def test_select_playback_source_skips_audio_only_native_hls_opus_when_setting_enabled(): + result = { + "formats": [ + { + "format": "aac-audio", + "url": "https://example.com/audio-aac.m3u8", + "protocol": "m3u8_native", + "vcodec": "none", + "acodec": "aac", + }, + { + "format": "opus-audio", + "url": "https://example.com/audio-opus.m3u8", + "protocol": "m3u8_native", + "vcodec": "none", + "acodec": "opus", + }, + ] + } + + selected = select_playback_source( + result=result, + usemanifest=False, + usedashbuilder=False, + maxwidth=1920, + isa_supports=lambda stream: stream == "hls", + disable_opus_for_audio_only_hls_native=True, + ) + + assert selected["source"] == "raw_format" + assert selected["url"] == "https://example.com/audio-aac.m3u8" + + def test_select_playback_source_uses_result_fallback_when_no_formats_selected(): result = { "formats": [{"format": "broken", "vcodec": "avc1", "acodec": "aac"}],