diff --git a/core/playback_selection.py b/core/playback_selection.py index 6f20bc47a..13eb44a5f 100644 --- a/core/playback_selection.py +++ b/core/playback_selection.py @@ -54,6 +54,77 @@ 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 + + unknown_values = (None, '', 'none', 'unknown') + vcodec = (format_info.get('vcodec') or '').lower() + acodec = (format_info.get('acodec') or '').lower() + return vcodec not in unknown_values and acodec not in unknown_values + + +def should_skip_original_manifest(result): + extractor_key = (result.get('extractor_key') or '').lower() + return extractor_key == 'peertube' + + +def should_skip_format_manifest(result, format_info): + if not should_skip_original_manifest(result): + return False + manifest_url = format_info.get('manifest_url') + manifest_type = guess_manifest_type(format_info, manifest_url) if manifest_url else None + return manifest_type == 'hls' + + +def should_defer_fragmented_raw_candidate(format_info, manifest_type): + # Some extractors (notably PeerTube) expose "...-fragmented.mp4" direct URLs + # that can lose audio on certain Kodi/player combinations. + if manifest_type is not None: + return False + + stream_url = (format_info.get('url') or '').lower() + if 'fragmented' not in stream_url: + return False + + vcodec = format_info.get('vcodec') + acodec = format_info.get('acodec') + unknown_values = (None, '', 'none', 'unknown') + return vcodec in unknown_values or acodec in unknown_values + + +def audio_manifest_candidate_score(format_info): + """Higher score means better fallback candidate for audio-only manifest playback.""" + score = 0 + acodec = (format_info.get('acodec') or '').lower() + format_label = (format_info.get('format') or format_info.get('format_id') or '').lower() + stream_url = (format_info.get('url') or '').lower() + + # Prefer broadly compatible mp3 streams over opus for SoundCloud HLS manifests. + if acodec == 'mp3' or 'hls_mp3' in format_label or '.mp3/' in stream_url: + score += 100 + elif acodec == 'opus' or 'hls_opus' in format_label or '.opus/' in stream_url: + score += 10 + + abr = format_info.get('abr') + if isinstance(abr, (int, float)): + score += int(abr) + + return score + + +def pick_better_audio_manifest_candidate(current, candidate): + if current is None: + return candidate + if audio_manifest_candidate_score(candidate) > audio_manifest_candidate_score(current): + return candidate + return current + + def should_try_dash_builder(usedashbuilder, have_video, dash_video, have_audio, dash_audio, current_format, mpd_supported): return ( usedashbuilder @@ -113,7 +184,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 +195,9 @@ def evaluate_raw_format_candidate(format_info, have_video, have_audio, maxwidth, return { 'decision': 'select', 'url': format_info['url'], - 'isa': manifest_supported, + # Prefer Kodi-native playback for muxed m3u8 variants with known codecs. + # This avoids audio loss observed on some platforms with ISA/HLS on PeerTube. + 'isa': False if native_hls_without_isa else manifest_supported, 'headers': format_info.get('http_headers'), } @@ -308,16 +382,20 @@ def select_playback_source( dash_start_httpd = dashbuilder.start_httpd manifest_url = result.get('manifest_url') if usemanifest else None - original_manifest_candidate = resolve_manifest_candidate( - manifest_url, - 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: - original_manifest_candidate['source'] = 'original_manifest' - return original_manifest_candidate + if not should_skip_original_manifest(result): + original_manifest_candidate = resolve_manifest_candidate( + manifest_url, + 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: + original_manifest_candidate['source'] = 'original_manifest' + return original_manifest_candidate filtered_format = None + deferred_audio_manifest_candidate = None + deferred_audio_manifest_format = None + deferred_fragmented_raw_candidate = None all_formats = result.get('formats', []) have_video, have_audio, dash_video, dash_audio = analyze_formats(all_formats) @@ -328,15 +406,16 @@ def select_playback_source( 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 not should_skip_format_manifest(result, format_info): + 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, @@ -385,10 +464,40 @@ def select_playback_source( filtered_format = format_info continue + # For audio-only results, prefer direct media URLs over manifest URLs. + # Some HLS audio manifests (for example SoundCloud hls_opus) can fail demux/decoder init. + if not have_video and have_audio and manifest_type is not None: + raw_candidate['source'] = 'raw_format' + raw_candidate['format_label'] = format_info.get('format', "") + # Audio-only HLS manifests are more reliable through Kodi's native path + # than through inputstream.adaptive on some platforms. + raw_candidate['isa'] = False + better_format = pick_better_audio_manifest_candidate( + deferred_audio_manifest_format, + format_info, + ) + if better_format is format_info: + deferred_audio_manifest_candidate = raw_candidate + deferred_audio_manifest_format = format_info + continue + + if should_defer_fragmented_raw_candidate(format_info, manifest_type): + raw_candidate['source'] = 'raw_format' + raw_candidate['format_label'] = format_info.get('format', "") + if deferred_fragmented_raw_candidate is None: + deferred_fragmented_raw_candidate = raw_candidate + continue + raw_candidate['source'] = 'raw_format' raw_candidate['format_label'] = format_info.get('format', "") return raw_candidate + if deferred_audio_manifest_candidate is not None: + return deferred_audio_manifest_candidate + + if deferred_fragmented_raw_candidate is not None: + return deferred_fragmented_raw_candidate + filtered_fallback = resolve_filtered_fallback_candidate( filtered_format, isa_supports(guess_manifest_type(filtered_format, filtered_format['url'])) if filtered_format is not None else False, diff --git a/core/runtime/playback.py b/core/runtime/playback.py index d8eede92f..ae64cdac1 100644 --- a/core/runtime/playback.py +++ b/core/runtime/playback.py @@ -19,6 +19,20 @@ ) +def _append_headers_to_url(url, headers): + if not url or not headers: + return url + if not str(url).startswith(("http://", "https://")): + return url + + encoded_headers = encode_inputstream_headers(headers) + if not encoded_headers: + return url + if "|" in url: + return url + return "{}|{}".format(url, encoded_headers) + + def _resolve_downloaded_file_path(result): requested_downloads = result.get("requested_downloads", []) for downloaded_item in requested_downloads: @@ -73,10 +87,12 @@ def resolve_fresh_result(): url = selected_source["url"] isa = selected_source["isa"] headers = selected_source["headers"] + selected_format_label = selected_source.get("format_label", "") else: url = None isa = None headers = None + selected_format_label = "" if url is None: msg = "No supported streams found" @@ -95,11 +111,52 @@ def resolve_fresh_result(): xbmc.LOGWARNING, ) + if not isa: + url = _append_headers_to_url(url, resolve_effective_headers(headers, result.get("http_headers"))) + 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)) + title = result.get("title") or result.get("fulltitle") or "SendToKodi" + list_item = xbmcgui.ListItem(title, path=url) + is_audio_only = "audio only" in str(selected_format_label).lower() + + if is_audio_only: + try: + music_info = list_item.getMusicInfoTag() + if hasattr(music_info, "setMediaType"): + music_info.setMediaType("song") + music_info.setTitle(title) + except Exception: + pass + try: + list_item.setInfo("music", {"title": title}) + except Exception: + pass + else: + try: + video_info = list_item.getVideoInfoTag() + if hasattr(video_info, "setMediaType"): + video_info.setMediaType("video") + video_info.setTitle(title) + except Exception: + pass + try: + list_item.setInfo("video", {"title": title}) + except Exception: + pass + + description = result.get("description") + if description: + try: + if is_audio_only: + music_info = list_item.getMusicInfoTag() + if hasattr(music_info, "setComment"): + music_info.setComment(description) + else: + video_info = list_item.getVideoInfoTag() + if hasattr(video_info, "setPlot"): + video_info.setPlot(description) + except Exception: + pass if result.get("thumbnail", None) is not None: list_item.setArt({"thumb": result["thumbnail"]}) diff --git a/manual-tests/manual_soundcloud_probe.py b/manual-tests/manual_soundcloud_probe.py new file mode 100644 index 000000000..9a8fe999f --- /dev/null +++ b/manual-tests/manual_soundcloud_probe.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Manual probe for yt-dlp SoundCloud extraction formats.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any + +try: + from yt_dlp import YoutubeDL +except Exception: + ROOT_DIR = os.path.dirname(os.path.dirname(__file__)) + VENDORED_LIB = os.path.join(ROOT_DIR, "lib") + if VENDORED_LIB not in sys.path: + sys.path.insert(0, VENDORED_LIB) + from yt_dlp import YoutubeDL + + +def _short_url(value: Any, max_len: int = 140) -> str: + text = str(value or "") + if len(text) <= max_len: + return text + return text[: max_len - 3] + "..." + + +def _format_row(fmt: dict[str, Any]) -> dict[str, Any]: + return { + "id": fmt.get("format_id"), + "protocol": fmt.get("protocol"), + "ext": fmt.get("ext"), + "acodec": fmt.get("acodec"), + "vcodec": fmt.get("vcodec"), + "abr": fmt.get("abr"), + "audio_ext": fmt.get("audio_ext"), + "video_ext": fmt.get("video_ext"), + "manifest_url": bool(fmt.get("manifest_url")), + "url": _short_url(fmt.get("url")), + } + + +def run_probe(url: str) -> int: + opts = { + "quiet": True, + "no_warnings": True, + "skip_download": True, + } + with YoutubeDL(opts) as ydl: + info = ydl.extract_info(url, download=False) + + formats = info.get("formats") or [] + + print("title:", info.get("title")) + print("webpage_url:", info.get("webpage_url")) + print("extractor:", info.get("extractor")) + print("num_formats:", len(formats)) + + print("\nformats:") + for fmt in formats: + print(json.dumps(_format_row(fmt), ensure_ascii=True)) + + return 0 + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Probe SoundCloud extraction output") + parser.add_argument( + "url", + nargs="?", + default="https://soundcloud.com/chiefkeef/video-shoot-feat-ian", + help="SoundCloud track URL", + ) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + return run_probe(args.url) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/test_playback_selection.py b/tests/test_playback_selection.py index 73a280f5e..ef7cd1485 100644 --- a/tests/test_playback_selection.py +++ b/tests/test_playback_selection.py @@ -515,6 +515,158 @@ def test_select_playback_source_uses_raw_format_when_playable(): assert selected["url"] == "https://example.com/video.mp4" +def test_select_playback_source_prefers_non_fragmented_direct_url_over_fragmented_fallback(): + result = { + "formats": [ + { + "format": "360p-0 - 360p", + "url": "https://tube.example/static/webseed/video-360.mp4", + "vcodec": None, + "acodec": None, + "width": 640, + }, + { + "format": "360p-1 - 360p", + "url": "https://tube.example/static/streaming/video-360-fragmented.mp4", + "vcodec": None, + "acodec": None, + "width": 640, + }, + ] + } + + selected = select_playback_source( + result=result, + usemanifest=False, + usedashbuilder=False, + maxwidth=1920, + isa_supports=lambda _stream: False, + ) + + assert selected["source"] == "raw_format" + assert selected["format_label"] == "360p-0 - 360p" + assert selected["url"] == "https://tube.example/static/webseed/video-360.mp4" + + +def test_select_playback_source_keeps_fragmented_direct_url_as_last_resort(): + result = { + "formats": [ + { + "format": "only-fragmented", + "url": "https://tube.example/static/streaming/video-fragmented.mp4", + "vcodec": None, + "acodec": None, + "width": 640, + } + ] + } + + selected = select_playback_source( + result=result, + usemanifest=False, + usedashbuilder=False, + maxwidth=1920, + isa_supports=lambda _stream: False, + ) + + assert selected["source"] == "raw_format" + assert selected["format_label"] == "only-fragmented" + assert selected["url"] == "https://tube.example/static/streaming/video-fragmented.mp4" + + +def test_select_playback_source_uses_native_hls_when_isa_is_unavailable(): + result = { + "formats": [ + { + "format": "360p-0 - 360p", + "url": "https://tube.example/static/webseed/video-360.mp4", + "protocol": "https", + "vcodec": None, + "acodec": None, + "width": 640, + }, + { + "format": "8 - 638x360", + "url": "https://tube.example/static/streaming/video-360.m3u8", + "protocol": "m3u8_native", + "vcodec": "avc1.64001f", + "acodec": "mp4a.40.2", + "width": 638, + }, + ] + } + + selected = select_playback_source( + result=result, + usemanifest=False, + usedashbuilder=False, + maxwidth=1920, + isa_supports=lambda _stream: False, + ) + + assert selected["source"] == "raw_format" + assert selected["format_label"] == "8 - 638x360" + assert selected["url"] == "https://tube.example/static/streaming/video-360.m3u8" + assert selected["isa"] is False + + +def test_select_playback_source_uses_native_hls_when_isa_is_available_too(): + result = { + "formats": [ + { + "format": "8 - 638x360", + "url": "https://tube.example/static/streaming/video-360.m3u8", + "protocol": "m3u8_native", + "vcodec": "avc1.64001f", + "acodec": "mp4a.40.2", + "width": 638, + }, + ] + } + + selected = select_playback_source( + result=result, + usemanifest=False, + usedashbuilder=False, + maxwidth=1920, + isa_supports=lambda stream: stream == "hls", + ) + + assert selected["source"] == "raw_format" + assert selected["url"] == "https://tube.example/static/streaming/video-360.m3u8" + assert selected["isa"] is False + + +def test_select_playback_source_skips_original_manifest_for_peertube(): + result = { + "extractor_key": "PeerTube", + "manifest_url": "https://tube.example/master.m3u8", + "formats": [ + { + "format": "8 - 638x360", + "url": "https://tube.example/stream-360.m3u8", + "manifest_url": "https://tube.example/format-master.m3u8", + "protocol": "m3u8_native", + "vcodec": "avc1.64001f", + "acodec": "mp4a.40.2", + "width": 638, + } + ], + } + + selected = select_playback_source( + result=result, + usemanifest=True, + usedashbuilder=False, + maxwidth=1920, + isa_supports=lambda stream: stream == "hls", + ) + + assert selected["source"] == "raw_format" + assert selected["url"] == "https://tube.example/stream-360.m3u8" + assert selected["isa"] is False + + def test_select_playback_source_uses_filtered_fallback_when_only_over_limit_formats_exist(): result = { "formats": [