From 1b18c67fffc02f5ecf6f91d79c6cd96ad5f0260f Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 23 May 2026 11:20:41 -0700 Subject: [PATCH] =?UTF-8?q?M4=20partial=20=E2=80=94=20Search=20shipped,=20?= =?UTF-8?q?browse=20UI=20live=20on=20the=20Pi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidecar gains the 'search' op via rustypipe's query().search::() — 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. --- MILESTONES.md | 15 +- addon/plugin.video.torttube/addon.xml | 2 +- addon/plugin.video.torttube/main.py | 158 +++++++++++++++++- sidecar/crates/torttube-sidecar/src/main.rs | 15 ++ .../crates/torttube-sidecar/src/resolve.rs | 34 ++++ 5 files changed, 211 insertions(+), 13 deletions(-) diff --git a/MILESTONES.md b/MILESTONES.md index 8e8754f..0d0267e 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -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::()` +- [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] diff --git a/addon/plugin.video.torttube/addon.xml b/addon/plugin.video.torttube/addon.xml index 6692031..8fc38b1 100644 --- a/addon/plugin.video.torttube/addon.xml +++ b/addon/plugin.video.torttube/addon.xml @@ -1,7 +1,7 @@ diff --git a/addon/plugin.video.torttube/main.py b/addon/plugin.video.torttube/main.py index 54c2c69..ee910a2 100644 --- a/addon/plugin.video.torttube/main.py +++ b/addon/plugin.video.torttube/main.py @@ -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 _root_directory() -> None: - """M0/M3 placeholder root menu — gets replaced by real browse UI in M4.""" - xbmcgui.Dialog().notification( - "torttube", - "M3 — send a play URL via JSON-RPC. Browse UI lands in M4.", - xbmcgui.NOTIFICATION_INFO, - 3000, +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: + """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", + 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() diff --git a/sidecar/crates/torttube-sidecar/src/main.rs b/sidecar/crates/torttube-sidecar/src/main.rs index 7593917..ad2efb5 100644 --- a/sidecar/crates/torttube-sidecar/src/main.rs +++ b/sidecar/crates/torttube-sidecar/src/main.rs @@ -43,6 +43,17 @@ enum Request { #[serde(default = "sponsor::default_categories")] categories: Vec, }, + /// 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(), + }, } } diff --git a/sidecar/crates/torttube-sidecar/src/resolve.rs b/sidecar/crates/torttube-sidecar/src/resolve.rs index 98da975..0e1a68a 100644 --- a/sidecar/crates/torttube-sidecar/src/resolve.rs +++ b/sidecar/crates/torttube-sidecar/src/resolve.rs @@ -5,6 +5,40 @@ use serde_json::Value; use crate::{run_yt_dlp, HandlerError}; +/// Search YouTube via rustypipe. Returns `SearchResult` 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 { + use rustypipe::client::RustyPipe; + use rustypipe::model::VideoItem; + + let rp = RustyPipe::new(); + let result = rp + .query() + .search::(query) + .await + .map_err(|e| classify_rustypipe_error(&e))?; + + // SearchResult.items is a Paginator — take the first + // page truncated to `limit` items. + let items_json: Vec = 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