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:
parent
283693525c
commit
284fe5fde7
5 changed files with 72 additions and 40 deletions
|
|
@ -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?")
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:<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
|
||||
|
||||
After install, fire the smoke from any LAN client:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}")))?;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue