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`
|
- [ ] install + smoke on LibreELEC RPi at `192.168.0.158`
|
||||||
- [ ] (later) hardcoded list of 3 test videos for in-Kodi navigation
|
- [ ] (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
|
- [x] sidecar `search` op via rustypipe `query().search::<VideoItem,_>()`
|
||||||
- [ ] channel browse → `{"op":"channel","id":"…"}`
|
- [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":"…"}`
|
- [ ] playlist browse → `{"op":"playlist","id":"…"}`
|
||||||
- [ ] result thumbnails + duration + uploader
|
- [ ] paginated results (currently capped at limit=30)
|
||||||
|
- [ ] search history
|
||||||
|
|
||||||
## M5 — SponsorBlock skipping [DONE]
|
## M5 — SponsorBlock skipping [DONE]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<addon id="plugin.video.torttube"
|
<addon id="plugin.video.torttube"
|
||||||
name="torttube"
|
name="torttube"
|
||||||
version="0.0.5"
|
version="0.0.6"
|
||||||
provider-name="Sulkta-Coop">
|
provider-name="Sulkta-Coop">
|
||||||
<requires>
|
<requires>
|
||||||
<import addon="xbmc.python" version="3.0.0"/>
|
<import addon="xbmc.python" version="3.0.0"/>
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from typing import Any
|
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
|
from xml.sax.saxutils import escape as xml_escape
|
||||||
|
|
||||||
import xbmc
|
import xbmc
|
||||||
|
|
@ -485,17 +485,155 @@ class SponsorBlockMonitor(xbmc.Monitor):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
def _root_directory() -> None:
|
def _plugin_url(**kwargs: Any) -> str:
|
||||||
"""M0/M3 placeholder root menu — gets replaced by real browse UI in M4."""
|
"""Build a `plugin://plugin.video.torttube/?...` URL for nav/play targets."""
|
||||||
xbmcgui.Dialog().notification(
|
return f"plugin://plugin.video.torttube/?{urlencode(kwargs)}"
|
||||||
"torttube",
|
|
||||||
"M3 — send a play URL via JSON-RPC. Browse UI lands in M4.",
|
|
||||||
xbmcgui.NOTIFICATION_INFO,
|
def _format_duration(seconds: int | float | None) -> str:
|
||||||
3000,
|
"""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)
|
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:
|
def main() -> None:
|
||||||
params = dict(parse_qsl(_QS.lstrip("?")))
|
params = dict(parse_qsl(_QS.lstrip("?")))
|
||||||
action = params.get("action")
|
action = params.get("action")
|
||||||
|
|
@ -511,6 +649,10 @@ def main() -> None:
|
||||||
xbmcplugin.setResolvedUrl(_HANDLE, False, xbmcgui.ListItem())
|
xbmcplugin.setResolvedUrl(_HANDLE, False, xbmcgui.ListItem())
|
||||||
return
|
return
|
||||||
_play(yt_id)
|
_play(yt_id)
|
||||||
|
elif action == "search":
|
||||||
|
_search_directory(query=params.get("q"))
|
||||||
|
elif action == "play_by_url":
|
||||||
|
_play_by_url_prompt()
|
||||||
else:
|
else:
|
||||||
_root_directory()
|
_root_directory()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,17 @@ enum Request {
|
||||||
#[serde(default = "sponsor::default_categories")]
|
#[serde(default = "sponsor::default_categories")]
|
||||||
categories: Vec<String>,
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
|
|
@ -148,6 +159,10 @@ async fn handle_line(line: &str) -> Response {
|
||||||
Ok(v) => Response::ok(v),
|
Ok(v) => Response::ok(v),
|
||||||
Err(e) => Response::err(ErrorKind::Network, format!("sponsorblock: {e}")),
|
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};
|
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` +
|
/// DASH-ready resolve: returns rustypipe's full `video_only_streams` +
|
||||||
/// `audio_streams` arrays + `details`. The Python addon builds an MPD
|
/// `audio_streams` arrays + `details`. The Python addon builds an MPD
|
||||||
/// from these and hands it to inputstream.adaptive — unlocks 1080p+ via
|
/// from these and hands it to inputstream.adaptive — unlocks 1080p+ via
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue