torttube/sidecar/crates/torttube-sidecar/src/resolve.rs
Kayos 284fe5fde7 M6 DONE — torttube ships, Rick Astley plays fullscreen on the Livingroom Pi
Live install verified end-to-end:
- SSH'd into 192.168.0.158 (LibreELEC, Kodi 20.3 Nexus, kernel aarch64
  / userspace armhf — that's why the static Rust sidecar runs but the
  PyInstaller yt-dlp binary couldn't)
- Dropped addon dir into /storage/.kodi/addons/
- systemctl restart kodi → Kodi rescans /storage/.kodi/addons/
- JSON-RPC Addons.SetAddonEnabled flipped enabled:false → true
- Player.Open with plugin URL → 7s yt-dlp resolve → VideoFullScreen.xml,
  fullscreen:true, currentwindow 12005, audio+video synced

Fixes that surfaced during the install:
- yt-dlp swap: PyInstaller aarch64 binary needs ld-linux-aarch64.so.1
  which LibreELEC doesn't ship. Switched to the universal Python zipapp
  (~3MB) which runs on /usr/bin/python3.11. build-addon-zip.sh updated.
- main.py now puts the addon's bin/ dir on PATH so the sidecar's
  Command::new('yt-dlp') call resolves to the bundled zipapp.
- Cosmetic fix: resolve.rs's classify_yt_dlp_error preserves the
  original error message (was downcasing it for keyword matching and
  then using the lowercased copy as the user-facing error).

Caveats logged for later:
- 360p ceiling (yt-dlp '-f best[ext=mp4]' picks itag 18; 720p
  progressive itag 22 is deprecated by YouTube; higher quality wants
  DASH manifest generation).
- ALSA sink: device 'sysdefault:CARD=vc4hdmi1' fails to open on this
  Pi but Kodi auto-falls-back to 'sysdefault' so audio works. Worth
  cleaning up in Kodi audio settings later.

MILESTONES + docs/install.md updated with the SSH + JSON-RPC alternate
install path.
2026-05-23 10:18:26 -07:00

194 lines
7.5 KiB
Rust

// resolve.rs — Tier 1 (rustypipe) → Tier 2 (yt-dlp -j fallback)
// SPDX-License-Identifier: GPL-3.0-or-later
use serde_json::Value;
use crate::{run_yt_dlp, HandlerError};
/// 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.
// We use -j to get the full info dump; the selected format's URL appears
// as the top-level "url" field.
let stdout = run_yt_dlp(&[
"-j", "--no-warnings", "--no-playlist",
"-f", "best[ext=mp4]/best",
&url,
])
.await
.map_err(|e| classify_yt_dlp_error(&e))?;
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) => {
tracing::info!(id, source = "rustypipe", "resolve ok");
Ok(v)
}
Err(e) => {
tracing::warn!(id, error = %e, "rustypipe failed; falling back to yt-dlp");
// Typed errors that mean "video can't be played by anyone" — don't retry yt-dlp,
// it'll just hit the same wall.
if matches!(
e,
HandlerError::AgeRestricted
| HandlerError::PrivateVideo
| HandlerError::NotFound
) {
return Err(e);
}
tier2_yt_dlp(id).await
}
}
}
/// Tier 1 — native rustypipe. Serializes the whole player.details + selected streams as
/// opaque pass-through JSON. The Python addon parses the fields it needs; this keeps us
/// resilient to rustypipe shape evolution and unblocks tier-2 normalization later.
async fn tier1_rustypipe(id: &str) -> Result<Value, HandlerError> {
use rustypipe::client::RustyPipe;
use rustypipe::param::StreamFilter;
let rp = RustyPipe::new();
let player = rp
.query()
.player(id)
.await
.map_err(|e| classify_rustypipe_error(&e))?;
let (video, audio) = player.select_video_audio_stream(&StreamFilter::default());
let details_json = serde_json::to_value(&player.details)
.map_err(|e| HandlerError::Internal(format!("serialize details: {e}")))?;
let video_json = video
.map(|v| serde_json::to_value(v))
.transpose()
.map_err(|e| HandlerError::Internal(format!("serialize video: {e}")))?
.unwrap_or(Value::Null);
let audio_json = audio
.map(|a| serde_json::to_value(a))
.transpose()
.map_err(|e| HandlerError::Internal(format!("serialize audio: {e}")))?
.unwrap_or(Value::Null);
Ok(serde_json::json!({
"source": "rustypipe",
"details": details_json,
"video_stream": video_json,
"audio_stream": audio_json,
}))
}
/// Classify a yt-dlp shell-out error into one of our typed handler errors.
/// yt-dlp's stderr is freeform English; we match on substrings, case-insensitive
/// via a lowercase copy, but preserve the original message in the returned error.
fn classify_yt_dlp_error(e: &anyhow::Error) -> HandlerError {
let original = e.to_string();
let lower = original.to_lowercase();
if lower.contains("age") {
HandlerError::AgeRestricted
} else if lower.contains("private") {
HandlerError::PrivateVideo
} else if lower.contains("not available") || lower.contains("does not exist") {
HandlerError::NotFound
} else if lower.contains("geo") || lower.contains("region") {
HandlerError::RegionBlocked
} else {
HandlerError::Extractor(original)
}
}
/// Classify a rustypipe error into one of our typed handler errors.
/// rustypipe's error enum varies by version; we match on the Display string for resilience.
fn classify_rustypipe_error(e: &dyn std::fmt::Display) -> HandlerError {
let msg = e.to_string().to_lowercase();
if msg.contains("age") && msg.contains("restrict") {
HandlerError::AgeRestricted
} else if msg.contains("region") || msg.contains("country") || msg.contains("geo") {
HandlerError::RegionBlocked
} else if msg.contains("private") {
HandlerError::PrivateVideo
} else if msg.contains("not found") || msg.contains("unavailable") {
HandlerError::NotFound
} else if msg.contains("network") || msg.contains("timeout") || msg.contains("connect") {
HandlerError::Network(msg)
} else {
HandlerError::Extractor(msg)
}
}
/// Tier 2 — shell out to yt-dlp -j.
async fn tier2_yt_dlp(id: &str) -> Result<Value, HandlerError> {
let url = format!("https://www.youtube.com/watch?v={id}");
let stdout = run_yt_dlp(&["-j", "--no-warnings", "--no-playlist", &url])
.await
.map_err(|e| classify_yt_dlp_error(&e))?;
let dump: Value = serde_json::from_slice(&stdout)
.map_err(|e| HandlerError::Extractor(format!("yt-dlp json parse: {e}")))?;
// yt-dlp's JSON has a `formats` array. We pass it through largely as-is — the addon
// can pick what inputstream.adaptive wants. Shape it to match our protocol.
let streams: Vec<Value> = dump
.get("formats")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
.filter_map(|f| {
let url = f.get("url")?.as_str()?.to_string();
let vcodec = f.get("vcodec").and_then(Value::as_str).unwrap_or("none");
let acodec = f.get("acodec").and_then(Value::as_str).unwrap_or("none");
let is_audio_only = vcodec == "none" && acodec != "none";
let is_video_only = vcodec != "none" && acodec == "none";
Some(serde_json::json!({
"url": url,
"itag": f.get("format_id").and_then(|v| v.as_str()).and_then(|s| s.parse::<u32>().ok()),
"mime": f.get("ext"),
"width": f.get("width"),
"height": f.get("height"),
"bitrate": f.get("tbr"),
"is_audio_only": is_audio_only,
"is_video_only": is_video_only,
}))
})
.collect();
Ok(serde_json::json!({
"source": "yt-dlp",
"title": dump.get("title"),
"duration_s": dump.get("duration"),
"channel_name": dump.get("channel"),
"channel_id": dump.get("channel_id"),
"streams": streams,
}))
}