M6 DONE — torttube ships, Rick Astley plays fullscreen on the LibreELEC Pi

Live install verified end-to-end:
- SSH'd into kodi-host (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.
This commit is contained in:
Sulkta 2026-05-23 10:18:26 -07:00
parent 55f8fc7c06
commit 2020035373
5 changed files with 72 additions and 40 deletions

View file

@ -11,28 +11,15 @@ use crate::{run_yt_dlp, HandlerError};
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.
// 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| {
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)
}
})?;
.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}")))?;
@ -121,6 +108,25 @@ async fn tier1_rustypipe(id: &str) -> Result<Value, HandlerError> {
}))
}
/// 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 {
@ -145,20 +151,7 @@ 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| {
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)
}
})?;
.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}")))?;