addon: switch play action to resolve_play (yt-dlp combined format)
Realized during M6 packaging that the rustypipe path returns separate audio + video DASH streams (Opus 251 + AV1 401 on the smoke video). Kodi can't sync those without an inputstream.adaptive DASH manifest, which would need server-side manifest generation — M3+ territory. Stopgap for shippable M3: new sidecar op resolve_play that asks yt-dlp for -f best[ext=mp4]/best — one combined audio+video URL Kodi plays as plain HTTP. ~3-5s overhead vs rustypipe but reliable sync. main.py _play() now calls resolve_play. resolve still exists for metadata + browse paths (M4 will use it). Rebuilt aarch64-musl binary, repackaged plugin.video.torttube-0.0.1.zip (38.7MB, md5 f2c08aed130b1c1bd231a9b6cbfac93c). Live at: smb://lucy/downloads/torttube/plugin.video.torttube-0.0.1.zip
This commit is contained in:
parent
f4ceae3b70
commit
283693525c
3 changed files with 76 additions and 33 deletions
|
|
@ -105,12 +105,15 @@ def _resolved_listitem(stream_url: str, title: str | None) -> xbmcgui.ListItem:
|
|||
|
||||
|
||||
def _play(yt_id: str) -> None:
|
||||
"""Resolve via sidecar, hand the URL to Kodi's player."""
|
||||
"""Resolve via sidecar (yt-dlp combined-format path), hand URL to Kodi."""
|
||||
_log(f"play id={yt_id}")
|
||||
try:
|
||||
resp = _call_sidecar({"op": "resolve", "id": yt_id})
|
||||
# resolve_play returns ONE combined audio+video URL — guaranteed
|
||||
# to play in Kodi without needing inputstream.adaptive/DASH.
|
||||
# ~3-5s overhead vs rustypipe but reliable audio sync.
|
||||
resp = _call_sidecar({"op": "resolve_play", "id": yt_id}, timeout_s=45)
|
||||
except Exception as e:
|
||||
_log(f"sidecar resolve failed: {e}", xbmc.LOGERROR)
|
||||
_log(f"sidecar resolve_play failed: {e}", xbmc.LOGERROR)
|
||||
xbmcgui.Dialog().notification(
|
||||
"torttube", f"resolve failed: {e}", xbmcgui.NOTIFICATION_ERROR, 5000
|
||||
)
|
||||
|
|
@ -127,34 +130,8 @@ def _play(yt_id: str) -> None:
|
|||
xbmcplugin.setResolvedUrl(_HANDLE, False, xbmcgui.ListItem())
|
||||
return
|
||||
|
||||
# Pick a URL. Preference order for M3:
|
||||
# 1. video_stream.url if it has embedded audio (yt-dlp combined formats)
|
||||
# 2. video_stream.url (will need audio mux in a later milestone)
|
||||
# 3. audio_stream.url (audio-only playback)
|
||||
stream_url = None
|
||||
title = None
|
||||
source = resp.get("source", "?")
|
||||
|
||||
if source == "rustypipe":
|
||||
details = resp.get("details") or {}
|
||||
title = details.get("name") or details.get("title")
|
||||
vs = resp.get("video_stream")
|
||||
as_ = resp.get("audio_stream")
|
||||
# rustypipe separates audio + video. For M3 we play video_stream;
|
||||
# M3+ will wire a DASH manifest or merged-format selection for sync.
|
||||
if vs and vs.get("url"):
|
||||
stream_url = vs["url"]
|
||||
elif as_ and as_.get("url"):
|
||||
stream_url = as_["url"]
|
||||
else: # yt-dlp tier 2
|
||||
title = resp.get("title")
|
||||
streams = resp.get("streams") or []
|
||||
# yt-dlp's combined formats come back as entries with both audio + video.
|
||||
combined = [s for s in streams if not s.get("is_audio_only") and not s.get("is_video_only")]
|
||||
candidates = combined or streams
|
||||
if candidates:
|
||||
stream_url = candidates[0].get("url")
|
||||
|
||||
stream_url = resp.get("stream_url")
|
||||
title = resp.get("title")
|
||||
if not stream_url:
|
||||
_log("no usable stream URL in sidecar response", xbmc.LOGERROR)
|
||||
xbmcgui.Dialog().notification(
|
||||
|
|
@ -163,7 +140,7 @@ def _play(yt_id: str) -> None:
|
|||
xbmcplugin.setResolvedUrl(_HANDLE, False, xbmcgui.ListItem())
|
||||
return
|
||||
|
||||
_log(f"resolved via {source}, playing")
|
||||
_log(f"resolved via {resp.get('source')}, playing")
|
||||
xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(stream_url, title))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,14 @@ mod sponsor;
|
|||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
enum Request {
|
||||
Ping,
|
||||
/// Metadata-rich resolve (rustypipe → yt-dlp). May return separate
|
||||
/// audio + video streams; the addon must mux or use a DASH wrapper.
|
||||
Resolve { id: String },
|
||||
/// Playback-ready resolve: returns ONE combined audio+video URL via
|
||||
/// yt-dlp `-f best[ext=mp4]/best`. Kodi plays it as a plain HTTP URL,
|
||||
/// no inputstream.adaptive needed. Slower than `resolve` but always
|
||||
/// gives a working stream.
|
||||
ResolvePlay { id: String },
|
||||
Rip { id: String, dest_dir: String },
|
||||
Sponsorblock {
|
||||
id: String,
|
||||
|
|
@ -120,6 +127,10 @@ async fn handle_line(line: &str) -> Response {
|
|||
Ok(v) => Response::ok(v),
|
||||
Err(e) => e.into(),
|
||||
},
|
||||
Request::ResolvePlay { id } => match resolve::resolve_play(&id).await {
|
||||
Ok(v) => Response::ok(v),
|
||||
Err(e) => e.into(),
|
||||
},
|
||||
Request::Rip { id, dest_dir } => match rip::rip(&id, &dest_dir).await {
|
||||
Ok(v) => Response::ok(v),
|
||||
Err(e) => e.into(),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,62 @@ use serde_json::Value;
|
|||
|
||||
use crate::{run_yt_dlp, HandlerError};
|
||||
|
||||
/// Top-level resolve. Tries Tier 1 (rustypipe), falls back to Tier 2 (yt-dlp -j).
|
||||
/// Playback-ready single-URL resolve. Asks yt-dlp for `best[ext=mp4]/best` —
|
||||
/// a combined audio+video format that Kodi can play as a plain HTTP URL.
|
||||
/// Slower than `resolve()` (~3-5s) but guarantees a working stream.
|
||||
pub(crate) async fn resolve_play(id: &str) -> Result<Value, HandlerError> {
|
||||
let url = format!("https://www.youtube.com/watch?v={id}");
|
||||
// -f best[ext=mp4]/best — prefer mp4 progressive, else any best combined.
|
||||
// -g prints just the URL. We use -j to also get title/duration for the
|
||||
// ListItem; the URL is then "url" at the top level.
|
||||
let stdout = run_yt_dlp(&[
|
||||
"-j", "--no-warnings", "--no-playlist",
|
||||
"-f", "best[ext=mp4]/best",
|
||||
&url,
|
||||
])
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string().to_lowercase();
|
||||
if msg.contains("age") {
|
||||
HandlerError::AgeRestricted
|
||||
} else if msg.contains("private") {
|
||||
HandlerError::PrivateVideo
|
||||
} else if msg.contains("not available") || msg.contains("does not exist") {
|
||||
HandlerError::NotFound
|
||||
} else if msg.contains("geo") || msg.contains("region") {
|
||||
HandlerError::RegionBlocked
|
||||
} else {
|
||||
HandlerError::Extractor(msg)
|
||||
}
|
||||
})?;
|
||||
|
||||
let dump: Value = serde_json::from_slice(&stdout)
|
||||
.map_err(|e| HandlerError::Extractor(format!("yt-dlp json parse: {e}")))?;
|
||||
|
||||
let stream_url = dump
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| HandlerError::Extractor("yt-dlp: no top-level url".into()))?
|
||||
.to_string();
|
||||
|
||||
tracing::info!(id, "resolve_play ok via yt-dlp combined");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"source": "yt-dlp",
|
||||
"stream_url": stream_url,
|
||||
"title": dump.get("title"),
|
||||
"duration_s": dump.get("duration"),
|
||||
"channel_name": dump.get("channel"),
|
||||
"channel_id": dump.get("channel_id"),
|
||||
"thumbnail": dump.get("thumbnail"),
|
||||
"format_id": dump.get("format_id"),
|
||||
"ext": dump.get("ext"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Metadata-rich resolve. Tries Tier 1 (rustypipe), falls back to Tier 2 (yt-dlp -j).
|
||||
/// Returns the full extractor response including separate audio + video streams.
|
||||
/// Use `resolve_play` instead for direct playback (returns a single combined URL).
|
||||
pub(crate) async fn resolve(id: &str) -> Result<Value, HandlerError> {
|
||||
match tier1_rustypipe(id).await {
|
||||
Ok(v) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue