M4 partial — Search shipped, browse UI live on the Pi

Sidecar gains the 'search' op via rustypipe's
query().search::<VideoItem,_>() — returns id, title, channel, duration,
thumbnails, view_count. Default limit 25.

Addon root directory is no longer a placeholder notification:
- 'Search' entry → ?action=search → keyboard input → result list →
  tap a result to play (each result is a play-action plugin URL).
- 'Play by URL' entry → ?action=play_by_url → keyboard input → PlayMedia.
- ?action=search also accepts inline 'q=…' so JSON-RPC clients can
  drive search without going through the on-TV keyboard (useful for
  share-to-TV from phone + tests).
- Result labels formatted as 'Title  ·  Channel  ·  Duration  ·  Views',
  with thumbnail + Kodi InfoLabels for richer skin views.

Verified via Files.GetDirectory JSON-RPC: 19 well-formatted LTT results
returned for query 'linus tech tips'.

Pending M4: channel browse, playlist browse, pagination, search history.
Addon version 0.0.6.
This commit is contained in:
Kayos 2026-05-23 11:20:41 -07:00
parent 45e1306bf3
commit 1b18c67fff
5 changed files with 211 additions and 13 deletions

View file

@ -34,12 +34,19 @@
- [ ] 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
## M4 — search + channel browse [PARTIAL]
- [ ] search box → sidecar `{"op":"search","q":"…"}` → results
- [ ] channel browse → `{"op":"channel","id":"…"}`
- [x] sidecar `search` op via rustypipe `query().search::<VideoItem,_>()`
- [x] root directory listing in Kodi addon (Search + Play by URL entries)
- [x] `?action=search` accepts inline `q=` (for JSON-RPC) or prompts keyboard
- [x] result labels show title · channel · duration · view-count, with
`IsPlayable=true`, thumbnails, video InfoLabels
- [x] verified via JSON-RPC: `Files.GetDirectory` returns 19+ formatted
results for "linus tech tips"
- [ ] channel browse → `{"op":"channel_videos","id":"…"}`
- [ ] playlist browse → `{"op":"playlist","id":"…"}`
- [ ] result thumbnails + duration + uploader
- [ ] paginated results (currently capped at limit=30)
- [ ] search history
## M5 — SponsorBlock skipping [DONE]

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.torttube"
name="torttube"
version="0.0.5"
version="0.0.6"
provider-name="Sulkta-Coop">
<requires>
<import addon="xbmc.python" version="3.0.0"/>

View file

@ -27,7 +27,7 @@ import subprocess
import sys
import threading
from typing import Any
from urllib.parse import parse_qsl, urlparse
from urllib.parse import parse_qsl, urlencode, urlparse
from xml.sax.saxutils import escape as xml_escape
import xbmc
@ -485,17 +485,155 @@ class SponsorBlockMonitor(xbmc.Monitor):
return
def _plugin_url(**kwargs: Any) -> str:
"""Build a `plugin://plugin.video.torttube/?...` URL for nav/play targets."""
return f"plugin://plugin.video.torttube/?{urlencode(kwargs)}"
def _format_duration(seconds: int | float | None) -> str:
"""Format seconds as `H:MM:SS` or `M:SS`."""
if not seconds:
return ""
s = int(seconds)
h, rem = divmod(s, 3600)
m, s = divmod(rem, 60)
if h:
return f"{h}:{m:02d}:{s:02d}"
return f"{m}:{s:02d}"
def _format_views(views: int | None) -> str:
"""Format view count compactly: 1234 → '1.2K', 1234567 → '1.2M'."""
if not views:
return ""
if views >= 1_000_000_000:
return f"{views / 1_000_000_000:.1f}B"
if views >= 1_000_000:
return f"{views / 1_000_000:.1f}M"
if views >= 1_000:
return f"{views / 1_000:.1f}K"
return str(views)
def _pick_thumbnail(thumbs: list[dict[str, Any]] | None) -> str:
"""Pick the largest thumbnail from rustypipe's thumbnail list."""
if not thumbs:
return ""
return max(thumbs, key=lambda t: (t.get("width") or 0) * (t.get("height") or 0)).get(
"url", ""
)
def _root_directory() -> None:
"""M0/M3 placeholder root menu — gets replaced by real browse UI in M4."""
"""Top-level addon menu. Search lands first because that's how most TV-side
YouTube use actually works. Remote-control via JSON-RPC still works."""
items = [
("Search", "DefaultAddonsSearch.png", _plugin_url(action="search")),
("Play by URL", "DefaultAddonNone.png", _plugin_url(action="play_by_url")),
]
for label, icon, url in items:
li = xbmcgui.ListItem(label=label)
li.setArt({"icon": icon})
xbmcplugin.addDirectoryItem(_HANDLE, url, li, isFolder=True)
xbmcplugin.endOfDirectory(_HANDLE)
def _search_directory(query: str | None = None) -> None:
"""Hit sidecar `search`, list results as playable items.
`query` arg: if provided (via `?action=search&q=...`), skip the keyboard
prompt used by JSON-RPC clients (phones, share-to-TV) and tests.
"""
if not query:
query = xbmcgui.Dialog().input("Search YouTube")
if not query:
xbmcplugin.endOfDirectory(_HANDLE, succeeded=False)
return
try:
resp = _call_sidecar({"op": "search", "query": query, "limit": 30}, timeout_s=15)
except Exception as e:
_log(f"search failed: {e}", xbmc.LOGERROR)
xbmcgui.Dialog().notification(
"torttube", f"search failed: {e}", xbmcgui.NOTIFICATION_ERROR, 4000
)
xbmcplugin.endOfDirectory(_HANDLE, succeeded=False)
return
if not resp.get("ok"):
_log(f"search not-ok: {resp.get('error')}", xbmc.LOGWARNING)
xbmcgui.Dialog().notification(
"torttube",
"M3 — send a play URL via JSON-RPC. Browse UI lands in M4.",
xbmcgui.NOTIFICATION_INFO,
3000,
f"search: {resp.get('error', 'unknown')}",
xbmcgui.NOTIFICATION_WARNING,
4000,
)
xbmcplugin.endOfDirectory(_HANDLE, succeeded=False)
return
items = resp.get("items") or []
_log(f"search '{query}'{len(items)} items")
xbmcplugin.setContent(_HANDLE, "videos")
for item in items:
yt_id = item.get("id") or ""
if not yt_id:
continue
name = item.get("name") or "(no title)"
channel = item.get("channel") or {}
channel_name = channel.get("name") if isinstance(channel, dict) else ""
duration = item.get("duration")
views = item.get("view_count")
# Label: "Title · Channel · Duration · ViewCount"
meta_bits = [b for b in (channel_name, _format_duration(duration), _format_views(views)) if b]
label = f"{name} · {' · '.join(meta_bits)}" if meta_bits else name
li = xbmcgui.ListItem(label=label)
li.setProperty("IsPlayable", "true")
thumb_url = _pick_thumbnail(item.get("thumbnail"))
if thumb_url:
li.setArt({"thumb": thumb_url, "poster": thumb_url, "fanart": thumb_url})
# Kodi InfoLabels (videoinfo dict) — surfaces in skins/views.
info: dict[str, Any] = {"title": name, "mediatype": "video"}
if duration:
info["duration"] = int(duration)
if channel_name:
info["studio"] = channel_name
if item.get("description"):
info["plot"] = item["description"]
try:
li.setInfo("video", info)
except Exception:
pass
xbmcplugin.addDirectoryItem(
_HANDLE, _plugin_url(action="play", id=yt_id), li, isFolder=False
)
xbmcplugin.endOfDirectory(_HANDLE)
def _play_by_url_prompt() -> None:
"""Manual URL/ID entry for tap-to-play from the Kodi UI."""
s = xbmcgui.Dialog().input("Paste YouTube URL or ID")
if not s:
xbmcplugin.endOfDirectory(_HANDLE, succeeded=False)
return
try:
yt_id = _extract_id(s)
except ValueError as e:
xbmcgui.Dialog().notification(
"torttube", str(e), xbmcgui.NOTIFICATION_ERROR, 4000
)
xbmcplugin.endOfDirectory(_HANDLE, succeeded=False)
return
# Open via Player.Open-equivalent: run the plugin URL through Kodi
# rather than calling _play() in directory context.
xbmc.executebuiltin(f"PlayMedia({_plugin_url(action='play', id=yt_id)})")
xbmcplugin.endOfDirectory(_HANDLE, succeeded=True)
def main() -> None:
params = dict(parse_qsl(_QS.lstrip("?")))
action = params.get("action")
@ -511,6 +649,10 @@ def main() -> None:
xbmcplugin.setResolvedUrl(_HANDLE, False, xbmcgui.ListItem())
return
_play(yt_id)
elif action == "search":
_search_directory(query=params.get("q"))
elif action == "play_by_url":
_play_by_url_prompt()
else:
_root_directory()

View file

@ -43,6 +43,17 @@ enum Request {
#[serde(default = "sponsor::default_categories")]
categories: Vec<String>,
},
/// Search YouTube for videos. Returns a list of VideoItem entries
/// (id, name, channel_name, channel_id, duration, thumbnail, views).
Search {
query: String,
#[serde(default = "default_search_limit")]
limit: u32,
},
}
fn default_search_limit() -> u32 {
25
}
#[derive(Debug, Serialize)]
@ -148,6 +159,10 @@ async fn handle_line(line: &str) -> Response {
Ok(v) => Response::ok(v),
Err(e) => Response::err(ErrorKind::Network, format!("sponsorblock: {e}")),
},
Request::Search { query, limit } => match resolve::search(&query, limit).await {
Ok(v) => Response::ok(v),
Err(e) => e.into(),
},
}
}

View file

@ -5,6 +5,40 @@ use serde_json::Value;
use crate::{run_yt_dlp, HandlerError};
/// Search YouTube via rustypipe. Returns `SearchResult<VideoItem>` as JSON —
/// the Python addon picks the fields it needs (id, name, channel, duration,
/// thumbnail, view_count, …) to build a Kodi directory listing.
pub(crate) async fn search(query: &str, limit: u32) -> Result<Value, HandlerError> {
use rustypipe::client::RustyPipe;
use rustypipe::model::VideoItem;
let rp = RustyPipe::new();
let result = rp
.query()
.search::<VideoItem, _>(query)
.await
.map_err(|e| classify_rustypipe_error(&e))?;
// SearchResult<VideoItem>.items is a Paginator<VideoItem> — take the first
// page truncated to `limit` items.
let items_json: Vec<Value> = result
.items
.items
.iter()
.take(limit as usize)
.filter_map(|v| serde_json::to_value(v).ok())
.collect();
tracing::info!(query, count = items_json.len(), "search ok via rustypipe");
Ok(serde_json::json!({
"source": "rustypipe",
"query": query,
"items": items_json,
"corrected_query": result.corrected_query,
}))
}
/// DASH-ready resolve: returns rustypipe's full `video_only_streams` +
/// `audio_streams` arrays + `details`. The Python addon builds an MPD
/// from these and hands it to inputstream.adaptive — unlocks 1080p+ via