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
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue