M4 — channel browse + context-menu drill-down
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 <channel>' context-menu entry that Container.Update's to ?action=channel&id=<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.
This commit is contained in:
parent
6a10c0045c
commit
268c804b91
5 changed files with 143 additions and 38 deletions
|
|
@ -43,9 +43,11 @@
|
||||||
`IsPlayable=true`, thumbnails, video InfoLabels
|
`IsPlayable=true`, thumbnails, video InfoLabels
|
||||||
- [x] verified via JSON-RPC: `Files.GetDirectory` returns 19+ formatted
|
- [x] verified via JSON-RPC: `Files.GetDirectory` returns 19+ formatted
|
||||||
results for "linus tech tips"
|
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>" → channel listing
|
||||||
- [ ] playlist browse → `{"op":"playlist","id":"…"}`
|
- [ ] playlist browse → `{"op":"playlist","id":"…"}`
|
||||||
- [ ] paginated results (currently capped at limit=30)
|
- [ ] paginated results (currently capped at limit=30/50)
|
||||||
- [ ] search history
|
- [ ] 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.6"
|
version="0.0.7"
|
||||||
provider-name="Sulkta">
|
provider-name="Sulkta">
|
||||||
<requires>
|
<requires>
|
||||||
<import addon="xbmc.python" version="3.0.0"/>
|
<import addon="xbmc.python" version="3.0.0"/>
|
||||||
|
|
|
||||||
|
|
@ -538,6 +538,67 @@ def _root_directory() -> None:
|
||||||
xbmcplugin.endOfDirectory(_HANDLE)
|
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:
|
def _search_directory(query: str | None = None) -> None:
|
||||||
"""Hit sidecar `search`, list results as playable items.
|
"""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 []
|
items = resp.get("items") or []
|
||||||
_log(f"search '{query}' → {len(items)} items")
|
_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"
|
def _channel_directory(channel_id: str) -> None:
|
||||||
meta_bits = [b for b in (channel_name, _format_duration(duration), _format_views(views)) if b]
|
"""List a channel's recent videos."""
|
||||||
label = f"{name} · {' · '.join(meta_bits)}" if meta_bits else name
|
try:
|
||||||
|
resp = _call_sidecar(
|
||||||
li = xbmcgui.ListItem(label=label)
|
{"op": "channel_videos", "id": channel_id, "limit": 50}, timeout_s=15
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
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)
|
xbmcplugin.endOfDirectory(_HANDLE)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -651,6 +705,8 @@ def main() -> None:
|
||||||
_play(yt_id)
|
_play(yt_id)
|
||||||
elif action == "search":
|
elif action == "search":
|
||||||
_search_directory(query=params.get("q"))
|
_search_directory(query=params.get("q"))
|
||||||
|
elif action == "channel":
|
||||||
|
_channel_directory(params.get("id") or "")
|
||||||
elif action == "play_by_url":
|
elif action == "play_by_url":
|
||||||
_play_by_url_prompt()
|
_play_by_url_prompt()
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,12 @@ enum Request {
|
||||||
#[serde(default = "default_search_limit")]
|
#[serde(default = "default_search_limit")]
|
||||||
limit: u32,
|
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 {
|
fn default_search_limit() -> u32 {
|
||||||
|
|
@ -163,6 +169,10 @@ async fn handle_line(line: &str) -> Response {
|
||||||
Ok(v) => Response::ok(v),
|
Ok(v) => Response::ok(v),
|
||||||
Err(e) => e.into(),
|
Err(e) => e.into(),
|
||||||
},
|
},
|
||||||
|
Request::ChannelVideos { id, limit } => match resolve::channel_videos(&id, limit).await {
|
||||||
|
Ok(v) => Response::ok(v),
|
||||||
|
Err(e) => e.into(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,43 @@ pub(crate) async fn search(query: &str, limit: u32) -> Result<Value, HandlerErro
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List a channel's recent videos. Returns the same VideoItem shape as
|
||||||
|
/// `search`, plus channel metadata (name, subscribers, description, banner).
|
||||||
|
pub(crate) async fn channel_videos(channel_id: &str, limit: u32) -> Result<Value, HandlerError> {
|
||||||
|
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<Value> = 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` +
|
/// 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
|
||||||
|
|
|
||||||
Reference in a new issue