From 9b2a47c9095e1ea3841d7274a740c84d8cd357b3 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 23 May 2026 08:37:57 -0700 Subject: [PATCH] addon: wire play action + remote-control via Kodi JSON-RPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.py now handles the standard Kodi plugin-URL routing: plugin://plugin.video.torttube/?action=play&id= plugin://plugin.video.torttube/?action=play&url= Either form calls the sidecar resolve op, picks a stream URL from the response (rustypipe video_stream preferred, yt-dlp combined fallback), and hands it to Kodi via xbmcplugin.setResolvedUrl. URL parser accepts watch?v=, youtu.be/, /shorts/, /embed/, /live/, and bare 11-char IDs. setResolvedUrl flags inputstream.adaptive for .mpd and .m3u8 manifests so DASH/HLS streams play with the right demuxer. This makes 'share to TV' work over Kodi's existing JSON-RPC API on :8080 — Player.Open with a plugin URL is all the remote client needs. No new server, no app — Kore / Yatse / curl / HA all already work. docs/remote-control.md captures the curl recipe + Android share-target plan for the eventual companion app. --- MILESTONES.md | 9 +- addon/plugin.video.torttube/main.py | 185 +++++++++++++++++++++++++++- docs/remote-control.md | 74 +++++++++++ 3 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 docs/remote-control.md diff --git a/MILESTONES.md b/MILESTONES.md index 7baa524..342ffe1 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -27,9 +27,12 @@ ## M3 — Kodi addon plays one video - [ ] `addon.xml` + `main.py` register as video plugin -- [ ] hardcoded list of 3 test videos -- [ ] select → sidecar `resolve` → stream URL → Kodi player -- [ ] verified end-to-end on LibreELEC RPi at `192.168.0.158` +- [x] `main.py` handles `plugin://plugin.video.torttube/?action=play&id=` and + `?url=` — wired so JSON-RPC `Player.Open` from any LAN client + (phone, HA, curl) triggers resolve + play. See docs/remote-control.md. +- [ ] cross-compile sidecar for aarch64, drop into `bin/` of addon dir +- [ ] install + smoke on LibreELEC RPi at `192.168.0.158` +- [ ] (later) hardcoded list of 3 test videos for in-Kodi navigation ## M4 — search + channel browse diff --git a/addon/plugin.video.torttube/main.py b/addon/plugin.video.torttube/main.py index e84d48f..dba81cd 100644 --- a/addon/plugin.video.torttube/main.py +++ b/addon/plugin.video.torttube/main.py @@ -1,24 +1,201 @@ # torttube — Kodi YouTube addon # SPDX-License-Identifier: GPL-3.0-or-later -"""M0 scaffold entry point. Sidecar handoff lands in M1+.""" +""" +Entry point. Plugin URL shapes handled: + plugin://plugin.video.torttube/ (root: M3 will add browse UI) + plugin://plugin.video.torttube/?action=play&id=ID (resolve + play a YouTube ID) + plugin://plugin.video.torttube/?action=play&url=URL (extract ID from URL, then play) + +Remote-control / share-to-TV pattern (Kodi JSON-RPC): + + POST http://:8080/jsonrpc + Basic auth (kodi user) + {"jsonrpc":"2.0","method":"Player.Open","params":{ + "item":{"file":"plugin://plugin.video.torttube/?action=play&id="}}} + +That's how Android / phone / "send to TV" flows hand off — Kodi already +exposes the endpoint, we just need to register the plugin URL. +""" + +import json +import os +import re +import subprocess import sys +from urllib.parse import parse_qsl, urlparse +import xbmc +import xbmcaddon import xbmcgui import xbmcplugin +ADDON = xbmcaddon.Addon() +ADDON_PATH = ADDON.getAddonInfo("path") +SIDECAR_BIN = os.path.join(ADDON_PATH, "bin", "torttube-sidecar") + _HANDLE = int(sys.argv[1]) if len(sys.argv) > 1 else -1 +_QS = sys.argv[2] if len(sys.argv) > 2 else "" -def main() -> None: +def _log(msg, level=xbmc.LOGINFO): + xbmc.log(f"[torttube] {msg}", level) + + +def _extract_id(url_or_id: str) -> str: + """Accept either a bare ID or any common YouTube URL form, return the ID.""" + s = url_or_id.strip() + # Bare 11-char ID (YouTube's canonical length). + if re.fullmatch(r"[A-Za-z0-9_-]{11}", s): + return s + parsed = urlparse(s) + # https://youtu.be/ + if parsed.netloc.endswith("youtu.be"): + return parsed.path.lstrip("/").split("/")[0] + # https://www.youtube.com/watch?v= + if "youtube.com" in parsed.netloc: + for k, v in parse_qsl(parsed.query): + if k == "v": + return v + # /shorts/, /embed/, /live/ + m = re.match(r"^/(shorts|embed|live)/([A-Za-z0-9_-]{11})", parsed.path) + if m: + return m.group(2) + raise ValueError(f"could not extract YouTube id from {url_or_id!r}") + + +def _call_sidecar(request: dict, timeout_s: int = 30) -> dict: + """Invoke the sidecar with one JSON request, parse one JSON response.""" + 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}") + + proc = subprocess.run( + [SIDECAR_BIN], + input=(json.dumps(request) + "\n").encode("utf-8"), + capture_output=True, + timeout=timeout_s, + ) + if proc.returncode != 0: + raise RuntimeError( + f"sidecar exited {proc.returncode}: {proc.stderr.decode('utf-8', 'replace')[:500]}" + ) + # Stdout may have multiple lines if the sidecar logged something; take the first + # non-empty line as the response. + for line in proc.stdout.splitlines(): + line = line.strip() + if line: + return json.loads(line.decode("utf-8") if isinstance(line, bytes) else line) + raise RuntimeError("sidecar produced no response") + + +def _resolved_listitem(stream_url: str, title: str | None) -> xbmcgui.ListItem: + li = xbmcgui.ListItem(label=title or "torttube") + li.setPath(stream_url) + li.setProperty("IsPlayable", "true") + # Tell inputstream.adaptive to handle DASH/HLS if the URL looks like a manifest. + if stream_url.endswith(".mpd"): + li.setProperty("inputstream", "inputstream.adaptive") + li.setProperty("inputstream.adaptive.manifest_type", "mpd") + elif stream_url.endswith(".m3u8"): + li.setProperty("inputstream", "inputstream.adaptive") + li.setProperty("inputstream.adaptive.manifest_type", "hls") + return li + + +def _play(yt_id: str) -> None: + """Resolve via sidecar, hand the URL to Kodi's player.""" + _log(f"play id={yt_id}") + try: + resp = _call_sidecar({"op": "resolve", "id": yt_id}) + except Exception as e: + _log(f"sidecar resolve failed: {e}", xbmc.LOGERROR) + xbmcgui.Dialog().notification( + "torttube", f"resolve failed: {e}", xbmcgui.NOTIFICATION_ERROR, 5000 + ) + xbmcplugin.setResolvedUrl(_HANDLE, False, xbmcgui.ListItem()) + return + + if not resp.get("ok"): + kind = resp.get("kind", "unknown") + err = resp.get("error", "unknown") + _log(f"sidecar returned not-ok: {kind}: {err}", xbmc.LOGWARNING) + xbmcgui.Dialog().notification( + "torttube", f"{kind}: {err}", xbmcgui.NOTIFICATION_WARNING, 5000 + ) + xbmcplugin.setResolvedUrl(_HANDLE, False, xbmcgui.ListItem()) + return + + # Pick a URL. Preference order for M3: + # 1. video_stream.url if it has embedded audio (yt-dlp combined formats) + # 2. video_stream.url (will need audio mux in a later milestone) + # 3. audio_stream.url (audio-only playback) + stream_url = None + title = None + source = resp.get("source", "?") + + if source == "rustypipe": + details = resp.get("details") or {} + title = details.get("name") or details.get("title") + vs = resp.get("video_stream") + as_ = resp.get("audio_stream") + # rustypipe separates audio + video. For M3 we play video_stream; + # M3+ will wire a DASH manifest or merged-format selection for sync. + if vs and vs.get("url"): + stream_url = vs["url"] + elif as_ and as_.get("url"): + stream_url = as_["url"] + else: # yt-dlp tier 2 + title = resp.get("title") + streams = resp.get("streams") or [] + # yt-dlp's combined formats come back as entries with both audio + video. + combined = [s for s in streams if not s.get("is_audio_only") and not s.get("is_video_only")] + candidates = combined or streams + if candidates: + stream_url = candidates[0].get("url") + + if not stream_url: + _log("no usable stream URL in sidecar response", xbmc.LOGERROR) + xbmcgui.Dialog().notification( + "torttube", "no stream URL", xbmcgui.NOTIFICATION_ERROR, 5000 + ) + xbmcplugin.setResolvedUrl(_HANDLE, False, xbmcgui.ListItem()) + return + + _log(f"resolved via {source}, playing") + xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(stream_url, title)) + + +def _root_directory() -> None: + """M0/M3 placeholder root menu — gets replaced by real browse UI in M4.""" xbmcgui.Dialog().notification( "torttube", - "M0 scaffold — playback wires up in M3", + "M3 — send a play URL via JSON-RPC. Browse UI lands in M4.", xbmcgui.NOTIFICATION_INFO, - 2500, + 3000, ) xbmcplugin.endOfDirectory(_HANDLE) +def main() -> None: + params = dict(parse_qsl(_QS.lstrip("?"))) + action = params.get("action") + + if action == "play": + try: + yt_id = _extract_id(params.get("url") or params.get("id") or "") + except ValueError as e: + _log(str(e), xbmc.LOGERROR) + xbmcgui.Dialog().notification( + "torttube", str(e), xbmcgui.NOTIFICATION_ERROR, 4000 + ) + xbmcplugin.setResolvedUrl(_HANDLE, False, xbmcgui.ListItem()) + return + _play(yt_id) + else: + _root_directory() + + if __name__ == "__main__": main() diff --git a/docs/remote-control.md b/docs/remote-control.md new file mode 100644 index 0000000..42911ce --- /dev/null +++ b/docs/remote-control.md @@ -0,0 +1,74 @@ +# Remote control — sending a YouTube URL to Kodi + +Kodi exposes a JSON-RPC HTTP endpoint that any client on the LAN can hit +to start playback. torttube wires up the `play` action so this works +out of the box once the addon is installed. + +## Endpoint + +``` +POST http://:8080/jsonrpc +Authorization: Basic base64(kodi:) +Content-Type: application/json +``` + +Sulkta defaults (per REFERENCE.md): `http://192.168.0.158:8080`, user +`kodi`, password `pineapple`. + +## Play by YouTube ID + +```bash +curl -u kodi:pineapple -H "Content-Type: application/json" \ + -X POST http://192.168.0.158:8080/jsonrpc -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "Player.Open", + "params": { + "item": { + "file": "plugin://plugin.video.torttube/?action=play&id=dQw4w9WgXcQ" + } + } + }' +``` + +## Play by full URL + +```bash +curl -u kodi:pineapple -H "Content-Type: application/json" \ + -X POST http://192.168.0.158:8080/jsonrpc -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "Player.Open", + "params": { + "item": { + "file": "plugin://plugin.video.torttube/?action=play&url=https%3A//www.youtube.com/watch%3Fv%3DdQw4w9WgXcQ" + } + } + }' +``` + +The addon's `_extract_id()` accepts any of: +- bare 11-char ID +- `https://youtu.be/` +- `https://www.youtube.com/watch?v=` +- `https://www.youtube.com/shorts/` +- `https://www.youtube.com/embed/` +- `https://www.youtube.com/live/` + +## Android share-target wiring (later) + +Future companion app (or HA automation) takes a YouTube URL from the +Android share sheet and posts it to whichever TV is selected. Until +that lands, any HTTP-capable client works (Kore, Yatse, curl, an HA +script, a phone shortcut, etc.). + +## Common Kodi JSON-RPC ops you'll want + +- `Player.Open` — start playback +- `Player.Stop` — stop +- `Player.PlayPause` — toggle +- `Player.Seek` — jump to position +- `Input.ShowOSD` — bring up the OSD +- `GUI.ShowNotification` — toast a message + +Full docs: https://kodi.wiki/view/JSON-RPC_API