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.
This commit is contained in:
Kayos 2026-05-23 10:18:26 -07:00
parent 283693525c
commit 284fe5fde7
5 changed files with 72 additions and 40 deletions

View file

@ -48,19 +48,27 @@
- [ ] toast on skip + skip-counter in settings - [ ] toast on skip + skip-counter in settings
- [ ] category toggles in `settings.xml` - [ ] category toggles in `settings.xml`
## M6 — install + cross-compile [PARTIAL] ## M6 — install + cross-compile [DONE]
- [x] cross-compile sidecar for aarch64-musl static via throwaway - [x] cross-compile sidecar for aarch64-musl static via throwaway
`messense/rust-musl-cross:aarch64-musl` container. 6.2MB stripped `messense/rust-musl-cross:aarch64-musl` container. 6.2MB stripped
static binary. Builds clean from `scripts/build-addon-zip.sh`. static binary. Builds clean from `scripts/build-addon-zip.sh`.
- [x] bundle yt-dlp's `yt-dlp_linux_aarch64` release binary for Tier 2/3 - [x] bundle yt-dlp's universal Python zipapp (~3MB, not the PyInstaller
binary — that breaks on LibreELEC's armhf userspace because the
aarch64 dynamic loader doesn't exist; Python zipapp runs on
`/usr/bin/python3` which is always there).
- [x] zip layout matches Kodi "install from zip" expectations - [x] zip layout matches Kodi "install from zip" expectations
- [x] addon.zip dropped at `smb://lucy/downloads/torttube/` for Pi-side install - [x] addon.zip dropped at `smb://lucy/downloads/torttube/`
- [x] install + smoke recipe documented at `docs/install.md` - [x] install + smoke recipe documented at `docs/install.md`
- [ ] **install on the actual Pi** + verify the JSON-RPC `Player.Open` - [x] **installed on Livingroom Pi** (`192.168.0.158`) via SSH +
smoke against `192.168.0.158` — needs Cobb to either install via `systemctl restart kodi` + `Addons.SetAddonEnabled`
Kodi UI or grant SSH to drop the zip directly - [x] **JSON-RPC `Player.Open` smoke verified** — Rick Astley played
- [ ] armv7 build for older Pis (deferred — Cobb's TVs are all aarch64-capable) end-to-end with audio + video synced (yt-dlp `-f best[ext=mp4]/best`
picked format 18, 360p H.264+AAC progressive)
- [ ] armv7 build for older Pis (deferred — current TV is aarch64-capable
and my static sidecar runs on it even though userspace is armhf)
- [ ] 720p+ playback (deferred to M5+) — itag 22 (720p progressive) is
deprecated by YouTube; higher quality needs DASH manifest generation
## Upstream PR work (parallel lane — every bug evaluated for "fix it upstream?") ## Upstream PR work (parallel lane — every bug evaluated for "fix it upstream?")

View file

@ -65,17 +65,26 @@ def _extract_id(url_or_id: str) -> str:
def _call_sidecar(request: dict, timeout_s: int = 30) -> dict: def _call_sidecar(request: dict, timeout_s: int = 30) -> dict:
"""Invoke the sidecar with one JSON request, parse one JSON response.""" """Invoke the sidecar with one JSON request, parse one JSON response.
Puts the addon's bin/ on PATH so the sidecar's `yt-dlp` shell-outs find
the bundled zipapp (LibreELEC has no system yt-dlp).
"""
if not os.path.isfile(SIDECAR_BIN): if not os.path.isfile(SIDECAR_BIN):
raise RuntimeError(f"sidecar binary missing at {SIDECAR_BIN}") raise RuntimeError(f"sidecar binary missing at {SIDECAR_BIN}")
if not os.access(SIDECAR_BIN, os.X_OK): if not os.access(SIDECAR_BIN, os.X_OK):
raise RuntimeError(f"sidecar binary not executable at {SIDECAR_BIN}") raise RuntimeError(f"sidecar binary not executable at {SIDECAR_BIN}")
env = os.environ.copy()
addon_bin = os.path.join(ADDON_PATH, "bin")
env["PATH"] = addon_bin + os.pathsep + env.get("PATH", "")
proc = subprocess.run( proc = subprocess.run(
[SIDECAR_BIN], [SIDECAR_BIN],
input=(json.dumps(request) + "\n").encode("utf-8"), input=(json.dumps(request) + "\n").encode("utf-8"),
capture_output=True, capture_output=True,
timeout=timeout_s, timeout=timeout_s,
env=env,
) )
if proc.returncode != 0: if proc.returncode != 0:
raise RuntimeError( raise RuntimeError(

View file

@ -27,6 +27,25 @@ drops the result at `/mnt/user/downloads/torttube/` (Lucy SMB).
Unsigned addons need `Settings → System → Add-ons → Unknown sources` ON. Unsigned addons need `Settings → System → Add-ons → Unknown sources` ON.
## Install via SSH + JSON-RPC (no UI needed)
If SSH is enabled (`Settings → Services → SSH`):
```bash
# from any LAN host with ssh access to the Pi
scp plugin.video.torttube-0.0.1.zip kodi-host:/tmp/
ssh kodi-host 'cd /storage/.kodi/addons && unzip -o /tmp/plugin.video.torttube-0.0.1.zip && chmod +x plugin.video.torttube/bin/*'
ssh kodi-host 'systemctl restart kodi'
# wait ~5s for Kodi to come back, then enable the addon
curl -u kodi:<pw> -H "Content-Type: application/json" -X POST http://<kodi-host>:8080/jsonrpc \
-d '{"jsonrpc":"2.0","id":1,"method":"Addons.SetAddonEnabled","params":{"addonid":"plugin.video.torttube","enabled":true}}'
```
Freshly-installed addons land in Kodi's database as `enabled:false`
the SetAddonEnabled call above flips that. Without it, Player.Open with
the plugin URL fails silently with "Unable to find plugin" in kodi.log.
## Verify ## Verify
After install, fire the smoke from any LAN client: After install, fire the smoke from any LAN client:

View file

@ -39,9 +39,12 @@ ssh lucy "rm -rf $STAGE && mkdir -p $STAGE/plugin.video.torttube/bin"
ssh lucy "rsync -a $SRC/addon/plugin.video.torttube/ $STAGE/plugin.video.torttube/ --exclude bin" ssh lucy "rsync -a $SRC/addon/plugin.video.torttube/ $STAGE/plugin.video.torttube/ --exclude bin"
ssh lucy "cp $TARGET_DIR/aarch64-unknown-linux-musl/release/torttube-sidecar $STAGE/plugin.video.torttube/bin/" ssh lucy "cp $TARGET_DIR/aarch64-unknown-linux-musl/release/torttube-sidecar $STAGE/plugin.video.torttube/bin/"
echo ">>> Fetch yt-dlp aarch64 release binary" echo ">>> Fetch yt-dlp universal Python zipapp"
# Pure-Python zipapp runs on any Python 3.9+ — works around LibreELEC's
# armhf userspace (where yt-dlp's dynamic aarch64 binary can't find
# ld-linux-aarch64.so.1). Kodi 20 ships Python 3.11.
ssh lucy "curl -sSL -o $STAGE/plugin.video.torttube/bin/yt-dlp \ ssh lucy "curl -sSL -o $STAGE/plugin.video.torttube/bin/yt-dlp \
https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux_aarch64" https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
ssh lucy "chmod +x $STAGE/plugin.video.torttube/bin/*" ssh lucy "chmod +x $STAGE/plugin.video.torttube/bin/*"
echo ">>> Build addon.zip" echo ">>> Build addon.zip"

View file

@ -11,28 +11,15 @@ use crate::{run_yt_dlp, HandlerError};
pub(crate) async fn resolve_play(id: &str) -> Result<Value, HandlerError> { pub(crate) async fn resolve_play(id: &str) -> Result<Value, HandlerError> {
let url = format!("https://www.youtube.com/watch?v={id}"); let url = format!("https://www.youtube.com/watch?v={id}");
// -f best[ext=mp4]/best — prefer mp4 progressive, else any best combined. // -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 // We use -j to get the full info dump; the selected format's URL appears
// ListItem; the URL is then "url" at the top level. // as the top-level "url" field.
let stdout = run_yt_dlp(&[ let stdout = run_yt_dlp(&[
"-j", "--no-warnings", "--no-playlist", "-j", "--no-warnings", "--no-playlist",
"-f", "best[ext=mp4]/best", "-f", "best[ext=mp4]/best",
&url, &url,
]) ])
.await .await
.map_err(|e| { .map_err(|e| classify_yt_dlp_error(&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) let dump: Value = serde_json::from_slice(&stdout)
.map_err(|e| HandlerError::Extractor(format!("yt-dlp json parse: {e}")))?; .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. /// 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. /// 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 { 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 url = format!("https://www.youtube.com/watch?v={id}");
let stdout = run_yt_dlp(&["-j", "--no-warnings", "--no-playlist", &url]) let stdout = run_yt_dlp(&["-j", "--no-warnings", "--no-playlist", &url])
.await .await
.map_err(|e| { .map_err(|e| classify_yt_dlp_error(&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) let dump: Value = serde_json::from_slice(&stdout)
.map_err(|e| HandlerError::Extractor(format!("yt-dlp json parse: {e}")))?; .map_err(|e| HandlerError::Extractor(format!("yt-dlp json parse: {e}")))?;