diff --git a/MILESTONES.md b/MILESTONES.md index a9527df..ac5f6ac 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -46,8 +46,10 @@ - [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/50) +- [x] playlist browse → `{"op":"playlist","id":"…"}` — verified with + LTT's "Consumer Advocacy" playlist, returns playlist metadata + (name, channel, video_count) + items array +- [ ] paginated results (currently capped at limit=30/50/100) - [ ] search history ## M5 — SponsorBlock skipping [DONE] diff --git a/addon/plugin.video.torttube/addon.xml b/addon/plugin.video.torttube/addon.xml index 2dce120..e4f8846 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 ee648d2..a963c49 100644 --- a/addon/plugin.video.torttube/main.py +++ b/addon/plugin.video.torttube/main.py @@ -314,7 +314,13 @@ def _play(yt_id: str) -> None: mpd_bytes: bytes | None = None dash_resp: dict[str, Any] = {} - if os.environ.get("TORTTUBE_DASH") == "1": + dash_enabled = False + try: + dash_enabled = ADDON.getSettingBool("dash_enabled") + except Exception: + # Setting might not exist on older configs; treat as off. + pass + if dash_enabled or os.environ.get("TORTTUBE_DASH") == "1": mpd_bytes, dash_resp = _try_dash(yt_id) if mpd_bytes: details = dash_resp.get("details") or {} @@ -638,6 +644,36 @@ def _search_directory(query: str | None = None) -> None: xbmcplugin.endOfDirectory(_HANDLE) +def _playlist_directory(playlist_id: str) -> None: + """List a playlist's videos.""" + try: + resp = _call_sidecar( + {"op": "playlist", "id": playlist_id, "limit": 100}, timeout_s=15 + ) + except Exception as e: + _log(f"playlist failed: {e}", xbmc.LOGERROR) + xbmcgui.Dialog().notification( + "torttube", f"playlist failed: {e}", xbmcgui.NOTIFICATION_ERROR, 4000 + ) + xbmcplugin.endOfDirectory(_HANDLE, succeeded=False) + return + if not resp.get("ok"): + xbmcgui.Dialog().notification( + "torttube", + f"playlist: {resp.get('error', 'unknown')}", + xbmcgui.NOTIFICATION_WARNING, + 4000, + ) + xbmcplugin.endOfDirectory(_HANDLE, succeeded=False) + return + + items = resp.get("items") or [] + pl = resp.get("playlist") or {} + _log(f"playlist {pl.get('name') or playlist_id}: {len(items)} items") + _add_video_items(items) + xbmcplugin.endOfDirectory(_HANDLE) + + def _channel_directory(channel_id: str) -> None: """List a channel's recent videos.""" try: @@ -707,6 +743,8 @@ def main() -> None: _search_directory(query=params.get("q")) elif action == "channel": _channel_directory(params.get("id") or "") + elif action == "playlist": + _playlist_directory(params.get("id") or "") elif action == "play_by_url": _play_by_url_prompt() else: diff --git a/addon/plugin.video.torttube/resources/settings.xml b/addon/plugin.video.torttube/resources/settings.xml index f4de3ff..01d5095 100644 --- a/addon/plugin.video.torttube/resources/settings.xml +++ b/addon/plugin.video.torttube/resources/settings.xml @@ -2,8 +2,10 @@
- - + + + false +
diff --git a/docs/upstream.md b/docs/upstream.md index bd80008..ac3d228 100644 --- a/docs/upstream.md +++ b/docs/upstream.md @@ -34,7 +34,7 @@ _(none yet — opens with M1 development)_ | Project | PR/Issue | Title | Why we care | |---------|----------|-------|-------------| -| rustypipe | [PR #77](https://codeberg.org/ThetaDev/rustypipe/pulls/77) | "Some fixes" | Open 2026-05-23, unmerged. If maintainer stays quiet we may need to help land it or fork. | +| rustypipe | [PR #77](https://codeberg.org/ThetaDev/rustypipe/pulls/77) | "Some fixes" (Schmiddiii) | Targeted fixes for YouTube changes ~2026-05-21: video metadata parsing (channel-tag removal) + duration parsing (thumbnail overlay field renames). +15/-11 across 2 files. Stalled in CI-needs-approval. We've **independently verified** rustypipe-0.11.4 (which predates this PR) still works for `player()`, `search()`, and `channel_videos()` against current YouTube as of 2026-05-23 — Rick Astley video, LTT search returning 19 results, LTT channel browse returning 30 videos. Suggests the breaks PR #77 targets are in code paths we don't exercise. **TODO**: comment on the PR with this test data + offer to mirror to GitHub if codeberg CI stays blocked. Gated on creating a Sulkta-Coop codeberg account. | | NPE | [#1339](https://github.com/TeamNewPipe/NewPipeExtractor/issues/1339) | n-parameter deobfuscation broken | Core to playback. Fix here = fix in our Tier-1. | | NPE | [#1444](https://github.com/TeamNewPipe/NewPipeExtractor/issues/1444) | Distinguish unavailable vs unextractable | Clean typed-error PR target. | | NPE | [#1360](https://github.com/TeamNewPipe/NewPipeExtractor/issues/1360) | Refactor link handlers | "help wanted" label. | diff --git a/sidecar/crates/torttube-sidecar/src/main.rs b/sidecar/crates/torttube-sidecar/src/main.rs index 6cefcc5..8df36cc 100644 --- a/sidecar/crates/torttube-sidecar/src/main.rs +++ b/sidecar/crates/torttube-sidecar/src/main.rs @@ -56,6 +56,12 @@ enum Request { #[serde(default = "default_search_limit")] limit: u32, }, + /// List a playlist's videos. `id` is a YouTube playlist ID (PL…). + Playlist { + id: String, + #[serde(default = "default_search_limit")] + limit: u32, + }, } fn default_search_limit() -> u32 { @@ -173,6 +179,10 @@ async fn handle_line(line: &str) -> Response { Ok(v) => Response::ok(v), Err(e) => e.into(), }, + Request::Playlist { id, limit } => match resolve::playlist(&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 dbbf8a3..4a2b9af 100644 --- a/sidecar/crates/torttube-sidecar/src/resolve.rs +++ b/sidecar/crates/torttube-sidecar/src/resolve.rs @@ -76,6 +76,41 @@ pub(crate) async fn channel_videos(channel_id: &str, limit: u32) -> Result Result { + use rustypipe::client::RustyPipe; + + let rp = RustyPipe::new(); + let pl = rp + .query() + .playlist(playlist_id) + .await + .map_err(|e| classify_rustypipe_error(&e))?; + + let items_json: Vec = pl + .videos + .items + .iter() + .take(limit as usize) + .filter_map(|v| serde_json::to_value(v).ok()) + .collect(); + + tracing::info!(playlist_id, count = items_json.len(), "playlist ok"); + + Ok(serde_json::json!({ + "source": "rustypipe", + "playlist": { + "id": pl.id, + "name": pl.name, + "description": pl.description, + "video_count": pl.video_count, + "channel": pl.channel, + "thumbnail": pl.thumbnail, + }, + "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