M4 wrap — playlist browse + addon settings + upstream PR-77 notes

Sidecar Playlist op via rustypipe playlist(). Returns playlist metadata
block (id, name, channel, video_count) + items array. Verified live
against LTT's 'Consumer Advocacy' (PL8mG-RkN2uTzwoF72GqeqAJMI-N7scqtI):
returns the single video with full metadata.

Addon ?action=playlist&id=PL... lists items via _add_video_items reuse.
Verified via Files.GetDirectory JSON-RPC.

resources/settings.xml gains a 'dash_enabled' toggle (boolean, default
off). main.py checks ADDON.getSettingBool('dash_enabled') OR the
TORTTUBE_DASH env fallback before attempting the DASH path. Toggle via
Kodi Settings → Add-on settings → torttube, OR via
Addons.SetSettings JSON-RPC.

docs/upstream.md: filed a 'watching' entry for rustypipe PR #77
(Schmiddiii's late-May YouTube parsing fixes) with our independent
test data — player(), search(), and channel_videos() all still work
against current YouTube on 0.11.4, suggesting the PR fixes code paths
torttube doesn't yet exercise. Endorsement comment pending: gated on
creating a Sulkta-Coop codeberg account.

Observation from kodi.log: plugin.video.youtube successfully parsed a
DASH MPD with 26 streams via inputstream.adaptive on this same Pi —
proves DASH is solvable on our setup, just need to match the URL
pattern they use. M7 stabilization carrying forward.

Addon version 0.0.9.
This commit is contained in:
Kayos 2026-05-23 11:33:20 -07:00
parent d463781aae
commit a784321759
7 changed files with 94 additions and 7 deletions

View file

@ -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>" → 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]

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.torttube"
name="torttube"
version="0.0.7"
version="0.0.9"
provider-name="Sulkta-Coop">
<requires>
<import addon="xbmc.python" version="3.0.0"/>

View file

@ -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:

View file

@ -2,8 +2,10 @@
<settings version="1">
<section id="torttube">
<category id="general" label="General">
<group id="placeholder">
<!-- M2+ — SponsorBlock category toggles, sidecar path override, etc. -->
<group id="quality" label="Quality">
<setting id="dash_enabled" type="boolean" label="Enable DASH (up to 1080p H.264) — WIP, may not work">
<default>false</default>
</setting>
</group>
</category>
</section>

View file

@ -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. |

View file

@ -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(),
},
}
}

View file

@ -76,6 +76,41 @@ pub(crate) async fn channel_videos(channel_id: &str, limit: u32) -> Result<Value
}))
}
/// List a playlist's videos. Returns the same VideoItem shape as search/channel.
pub(crate) async fn playlist(playlist_id: &str, limit: u32) -> Result<Value, HandlerError> {
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<Value> = 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