diff --git a/core/playback_selection.py b/core/playback_selection.py index 6f20bc47a..50706ce77 100644 --- a/core/playback_selection.py +++ b/core/playback_selection.py @@ -54,6 +54,34 @@ def should_filter_by_max_width(width, maxwidth): return width is not None and width > maxwidth +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 @@ -318,6 +346,8 @@ def select_playback_source( return original_manifest_candidate filtered_format = None + deferred_audio_manifest_candidate = None + deferred_audio_manifest_format = None all_formats = result.get('formats', []) have_video, have_audio, dash_video, dash_audio = analyze_formats(all_formats) @@ -385,10 +415,30 @@ 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 + 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 + 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:]))