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.
194 lines
7.5 KiB
Rust
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,
|
|
}))
|
|
}
|