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:
parent
45e1306bf3
commit
1b18c67fff
5 changed files with 211 additions and 13 deletions
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue