From d463781aae4f8eddca1cfea68270b0f9a7773469 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 23 May 2026 11:24:59 -0700 Subject: [PATCH] =?UTF-8?q?M4=20=E2=80=94=20channel=20browse=20+=20context?= =?UTF-8?q?-menu=20drill-down?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidecar ChannelVideos op via rustypipe channel_videos(). Returns the channel metadata block (id, name, subscribers, banner) alongside the items array — same VideoItem shape as search. Addon refactor: _add_video_items is now the shared listing builder. Both _search_directory and _channel_directory call it. Each video result gets a 'Go to ' context-menu entry that Container.Update's to ?action=channel&id= — so from any search result, the user can drill into that channel's recent uploads without going back through search. Smoke verified on the Pi via Files.GetDirectory: LTT channel (UCXuqSBlHAE6Xw-yeJA0Tunw) returned 30 recent videos. Addon version 0.0.7. --- MILESTONES.md | 6 +- addon/plugin.video.torttube/addon.xml | 2 +- addon/plugin.video.torttube/main.py | 126 +++++++++++++----- sidecar/crates/torttube-sidecar/src/main.rs | 10 ++ .../crates/torttube-sidecar/src/resolve.rs | 37 +++++ 5 files changed, 143 insertions(+), 38 deletions(-) diff --git a/MILESTONES.md b/MILESTONES.md index 0d0267e..a9527df 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -43,9 +43,11 @@ `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":"…"}` +- [x] channel browse → `{"op":"channel_videos","id":"…"}` — verified 30 + videos returned for LTT's UCXuqSBlHAE6Xw-yeJA0Tunw via JSON-RPC +- [x] context-menu entry on every result: "Go to " → channel listing - [ ] playlist browse → `{"op":"playlist","id":"…"}` -- [ ] paginated results (currently capped at limit=30) +- [ ] paginated results (currently capped at limit=30/50) - [ ] search history ## M5 — SponsorBlock skipping [DONE] diff --git a/addon/plugin.video.torttube/addon.xml b/addon/plugin.video.torttube/addon.xml index 8fc38b1..2dce120 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 ee910a2..ee648d2 100644 --- a/addon/plugin.video.torttube/main.py +++ b/addon/plugin.video.torttube/main.py @@ -538,6 +538,67 @@ def _root_directory() -> None: xbmcplugin.endOfDirectory(_HANDLE) +def _add_video_items(items: list[dict[str, Any]]) -> None: + """Add VideoItem dicts to the current plugin directory, formatted for Kodi. + + Each item gets a play-action plugin URL, channel + duration + view-count + metadata in the label, thumbnail art, video InfoLabels for skin support, + and a 'Go to channel' context menu entry when the channel id is known. + """ + 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 {} + if isinstance(channel, dict): + channel_name = channel.get("name") or "" + channel_id = channel.get("id") or "" + else: + channel_name = "" + channel_id = "" + 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}) + + 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 + + # Context menu: jump to channel listing if we have the id. + if channel_id: + li.addContextMenuItems( + [ + ( + f"Go to {channel_name or 'channel'}", + f"Container.Update({_plugin_url(action='channel', id=channel_id)})", + ) + ] + ) + + xbmcplugin.addDirectoryItem( + _HANDLE, _plugin_url(action="play", id=yt_id), li, isFolder=False + ) + + def _search_directory(query: str | None = None) -> None: """Hit sidecar `search`, list results as playable items. @@ -573,44 +634,37 @@ def _search_directory(query: str | None = None) -> None: items = resp.get("items") or [] _log(f"search '{query}' → {len(items)} items") + _add_video_items(items) + xbmcplugin.endOfDirectory(_HANDLE) - 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 +def _channel_directory(channel_id: str) -> None: + """List a channel's recent videos.""" + try: + resp = _call_sidecar( + {"op": "channel_videos", "id": channel_id, "limit": 50}, timeout_s=15 ) + except Exception as e: + _log(f"channel_videos failed: {e}", xbmc.LOGERROR) + xbmcgui.Dialog().notification( + "torttube", f"channel failed: {e}", xbmcgui.NOTIFICATION_ERROR, 4000 + ) + xbmcplugin.endOfDirectory(_HANDLE, succeeded=False) + return + if not resp.get("ok"): + xbmcgui.Dialog().notification( + "torttube", + f"channel: {resp.get('error', 'unknown')}", + xbmcgui.NOTIFICATION_WARNING, + 4000, + ) + xbmcplugin.endOfDirectory(_HANDLE, succeeded=False) + return + + items = resp.get("items") or [] + ch = resp.get("channel") or {} + _log(f"channel {ch.get('name') or channel_id}: {len(items)} items") + _add_video_items(items) xbmcplugin.endOfDirectory(_HANDLE) @@ -651,6 +705,8 @@ def main() -> None: _play(yt_id) elif action == "search": _search_directory(query=params.get("q")) + elif action == "channel": + _channel_directory(params.get("id") or "") elif action == "play_by_url": _play_by_url_prompt() else: diff --git a/sidecar/crates/torttube-sidecar/src/main.rs b/sidecar/crates/torttube-sidecar/src/main.rs index ad2efb5..6cefcc5 100644 --- a/sidecar/crates/torttube-sidecar/src/main.rs +++ b/sidecar/crates/torttube-sidecar/src/main.rs @@ -50,6 +50,12 @@ enum Request { #[serde(default = "default_search_limit")] limit: u32, }, + /// List a channel's recent videos. `id` is a YouTube channel ID (UC…). + ChannelVideos { + id: String, + #[serde(default = "default_search_limit")] + limit: u32, + }, } fn default_search_limit() -> u32 { @@ -163,6 +169,10 @@ async fn handle_line(line: &str) -> Response { Ok(v) => Response::ok(v), Err(e) => e.into(), }, + Request::ChannelVideos { id, limit } => match resolve::channel_videos(&id, 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 0e1a68a..dbbf8a3 100644 --- a/sidecar/crates/torttube-sidecar/src/resolve.rs +++ b/sidecar/crates/torttube-sidecar/src/resolve.rs @@ -39,6 +39,43 @@ pub(crate) async fn search(query: &str, limit: u32) -> Result Result { + use rustypipe::client::RustyPipe; + + let rp = RustyPipe::new(); + let ch = rp + .query() + .channel_videos(channel_id) + .await + .map_err(|e| classify_rustypipe_error(&e))?; + + let items_json: Vec = ch + .content + .items + .iter() + .take(limit as usize) + .filter_map(|v| serde_json::to_value(v).ok()) + .collect(); + + tracing::info!(channel_id, count = items_json.len(), "channel_videos ok"); + + Ok(serde_json::json!({ + "source": "rustypipe", + "channel": { + "id": ch.id, + "name": ch.name, + "description": ch.description, + "subscribers": ch.subscriber_count, + "video_count": ch.video_count, + "avatar": ch.avatar, + "banner": ch.banner, + }, + "items": items_json, + })) +} + /// 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