Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions core/playback_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down
65 changes: 61 additions & 4 deletions core/runtime/playback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand All @@ -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"]})

Expand Down
84 changes: 84 additions & 0 deletions manual-tests/manual_soundcloud_probe.py
Original file line number Diff line number Diff line change
@@ -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:]))
Loading