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:
parent
55f8fc7c06
commit
2020035373
5 changed files with 72 additions and 40 deletions
|
|
@ -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}")))?;
|
||||
|
|
|
|||
Reference in a new issue