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:
parent
d463781aae
commit
a784321759
7 changed files with 94 additions and 7 deletions
|
|
@ -46,8 +46,10 @@
|
||||||
- [x] channel browse → `{"op":"channel_videos","id":"…"}` — verified 30
|
- [x] channel browse → `{"op":"channel_videos","id":"…"}` — verified 30
|
||||||
videos returned for LTT's UCXuqSBlHAE6Xw-yeJA0Tunw via JSON-RPC
|
videos returned for LTT's UCXuqSBlHAE6Xw-yeJA0Tunw via JSON-RPC
|
||||||
- [x] context-menu entry on every result: "Go to <channel>" → channel listing
|
- [x] context-menu entry on every result: "Go to <channel>" → channel listing
|
||||||
- [ ] playlist browse → `{"op":"playlist","id":"…"}`
|
- [x] playlist browse → `{"op":"playlist","id":"…"}` — verified with
|
||||||
- [ ] paginated results (currently capped at limit=30/50)
|
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
|
- [ ] 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.7"
|
version="0.0.9"
|
||||||
provider-name="Sulkta-Coop">
|
provider-name="Sulkta-Coop">
|
||||||
<requires>
|
<requires>
|
||||||
<import addon="xbmc.python" version="3.0.0"/>
|
<import addon="xbmc.python" version="3.0.0"/>
|
||||||
|
|
|
||||||
|
|
@ -314,7 +314,13 @@ def _play(yt_id: str) -> None:
|
||||||
|
|
||||||
mpd_bytes: bytes | None = None
|
mpd_bytes: bytes | None = None
|
||||||
dash_resp: dict[str, Any] = {}
|
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)
|
mpd_bytes, dash_resp = _try_dash(yt_id)
|
||||||
if mpd_bytes:
|
if mpd_bytes:
|
||||||
details = dash_resp.get("details") or {}
|
details = dash_resp.get("details") or {}
|
||||||
|
|
@ -638,6 +644,36 @@ def _search_directory(query: str | None = None) -> None:
|
||||||
xbmcplugin.endOfDirectory(_HANDLE)
|
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:
|
def _channel_directory(channel_id: str) -> None:
|
||||||
"""List a channel's recent videos."""
|
"""List a channel's recent videos."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -707,6 +743,8 @@ def main() -> None:
|
||||||
_search_directory(query=params.get("q"))
|
_search_directory(query=params.get("q"))
|
||||||
elif action == "channel":
|
elif action == "channel":
|
||||||
_channel_directory(params.get("id") or "")
|
_channel_directory(params.get("id") or "")
|
||||||
|
elif action == "playlist":
|
||||||
|
_playlist_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:
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
<settings version="1">
|
<settings version="1">
|
||||||
<section id="torttube">
|
<section id="torttube">
|
||||||
<category id="general" label="General">
|
<category id="general" label="General">
|
||||||
<group id="placeholder">
|
<group id="quality" label="Quality">
|
||||||
<!-- M2+ — SponsorBlock category toggles, sidecar path override, etc. -->
|
<setting id="dash_enabled" type="boolean" label="Enable DASH (up to 1080p H.264) — WIP, may not work">
|
||||||
|
<default>false</default>
|
||||||
|
</setting>
|
||||||
</group>
|
</group>
|
||||||
</category>
|
</category>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ _(none yet — opens with M1 development)_
|
||||||
|
|
||||||
| Project | PR/Issue | Title | Why we care |
|
| 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 | [#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 | [#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. |
|
| NPE | [#1360](https://github.com/TeamNewPipe/NewPipeExtractor/issues/1360) | Refactor link handlers | "help wanted" label. |
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,12 @@ enum Request {
|
||||||
#[serde(default = "default_search_limit")]
|
#[serde(default = "default_search_limit")]
|
||||||
limit: u32,
|
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 {
|
fn default_search_limit() -> u32 {
|
||||||
|
|
@ -173,6 +179,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::Playlist { id, limit } => match resolve::playlist(&id, limit).await {
|
||||||
|
Ok(v) => Response::ok(v),
|
||||||
|
Err(e) => e.into(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` +
|
/// 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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue