addon: wire play action + remote-control via Kodi JSON-RPC
main.py now handles the standard Kodi plugin-URL routing: plugin://plugin.video.torttube/?action=play&id=<yt-id> plugin://plugin.video.torttube/?action=play&url=<full-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.
This commit is contained in:
parent
7add3cb469
commit
9b2a47c909
3 changed files with 261 additions and 7 deletions
|
|
@ -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=<id>` and
|
||||
`?url=<full-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
|
||||
|
||||
|
|
|
|||
|
|
@ -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://<rpi>:8080/jsonrpc
|
||||
Basic auth (kodi user)
|
||||
{"jsonrpc":"2.0","method":"Player.Open","params":{
|
||||
"item":{"file":"plugin://plugin.video.torttube/?action=play&id=<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/<id>
|
||||
if parsed.netloc.endswith("youtu.be"):
|
||||
return parsed.path.lstrip("/").split("/")[0]
|
||||
# https://www.youtube.com/watch?v=<id>
|
||||
if "youtube.com" in parsed.netloc:
|
||||
for k, v in parse_qsl(parsed.query):
|
||||
if k == "v":
|
||||
return v
|
||||
# /shorts/<id>, /embed/<id>, /live/<id>
|
||||
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()
|
||||
|
|
|
|||
74
docs/remote-control.md
Normal file
74
docs/remote-control.md
Normal file
|
|
@ -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://<rpi-host>:8080/jsonrpc
|
||||
Authorization: Basic base64(kodi:<password>)
|
||||
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/<id>`
|
||||
- `https://www.youtube.com/watch?v=<id>`
|
||||
- `https://www.youtube.com/shorts/<id>`
|
||||
- `https://www.youtube.com/embed/<id>`
|
||||
- `https://www.youtube.com/live/<id>`
|
||||
|
||||
## 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue