diff --git a/MILESTONES.md b/MILESTONES.md index b914c26..8b9fd5f 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -48,19 +48,27 @@ - [ ] toast on skip + skip-counter in settings - [ ] 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 `messense/rust-musl-cross:aarch64-musl` container. 6.2MB stripped 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] 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` -- [ ] **install on the actual Pi** + verify the JSON-RPC `Player.Open` - smoke against `192.168.0.158` — needs Cobb to either install via - Kodi UI or grant SSH to drop the zip directly -- [ ] armv7 build for older Pis (deferred — Cobb's TVs are all aarch64-capable) +- [x] **installed on Livingroom Pi** (`192.168.0.158`) via SSH + + `systemctl restart kodi` + `Addons.SetAddonEnabled` +- [x] **JSON-RPC `Player.Open` smoke verified** — Rick Astley played + 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?") diff --git a/addon/plugin.video.torttube/main.py b/addon/plugin.video.torttube/main.py index 591081b..0a5129b 100644 --- a/addon/plugin.video.torttube/main.py +++ b/addon/plugin.video.torttube/main.py @@ -65,17 +65,26 @@ def _extract_id(url_or_id: str) -> str: 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): raise RuntimeError(f"sidecar binary missing at {SIDECAR_BIN}") if not os.access(SIDECAR_BIN, os.X_OK): 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( [SIDECAR_BIN], input=(json.dumps(request) + "\n").encode("utf-8"), capture_output=True, timeout=timeout_s, + env=env, ) if proc.returncode != 0: raise RuntimeError( diff --git a/docs/install.md b/docs/install.md index 0ff36a1..6116636 100644 --- a/docs/install.md +++ b/docs/install.md @@ -27,6 +27,25 @@ drops the result at `/mnt/user/downloads/torttube/` (Lucy SMB). 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: -H "Content-Type: application/json" -X POST http://: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 After install, fire the smoke from any LAN client: diff --git a/scripts/build-addon-zip.sh b/scripts/build-addon-zip.sh index 3b4ef60..fe52ef8 100755 --- a/scripts/build-addon-zip.sh +++ b/scripts/build-addon-zip.sh @@ -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 "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 \ - 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/*" echo ">>> Build addon.zip" diff --git a/sidecar/crates/torttube-sidecar/src/resolve.rs b/sidecar/crates/torttube-sidecar/src/resolve.rs index a6ed47a..e05150b 100644 --- a/sidecar/crates/torttube-sidecar/src/resolve.rs +++ b/sidecar/crates/torttube-sidecar/src/resolve.rs @@ -11,28 +11,15 @@ use crate::{run_yt_dlp, HandlerError}; pub(crate) async fn resolve_play(id: &str) -> Result { 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 { })) } +/// 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 { 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}")))?;