diff --git a/MILESTONES.md b/MILESTONES.md index 8b9fd5f..e4aea84 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -41,12 +41,16 @@ - [ ] playlist browse → `{"op":"playlist","id":"…"}` - [ ] result thumbnails + duration + uploader -## M5 — SponsorBlock skipping +## M5 — SponsorBlock skipping [DONE] -- [ ] background thread on `Player()` polls position -- [ ] when position enters a skip segment → `xbmc.Player().seekTime(end)` -- [ ] toast on skip + skip-counter in settings -- [ ] category toggles in `settings.xml` +- [x] `SponsorBlockMonitor` (xbmc.Monitor subclass) polls `Player().getTime()` + every 0.5s after playback starts; seeks past each segment exactly + once (UUID dedup); exits cleanly on abort or playback stop +- [x] toast on skip (`SponsorBlock — Skipped (s)`) +- [x] only segments with `actionType: skip` are honored (mute/etc. ignored + for now — adding those is a multi-pass M5+ pass) +- [ ] category toggles + skip-counter in settings.xml (deferred — defaults + currently: sponsor, selfpromo, interaction skipped automatically) ## M6 — install + cross-compile [DONE] diff --git a/addon/plugin.video.torttube/addon.xml b/addon/plugin.video.torttube/addon.xml index d1b51ab..9f0344a 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 0a5129b..8c66e40 100644 --- a/addon/plugin.video.torttube/main.py +++ b/addon/plugin.video.torttube/main.py @@ -23,6 +23,7 @@ import os import re import subprocess import sys +from typing import Any from urllib.parse import parse_qsl, urlparse import xbmc @@ -152,6 +153,97 @@ def _play(yt_id: str) -> None: _log(f"resolved via {resp.get('source')}, playing") xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(stream_url, title)) + # SponsorBlock: fetch segments, then block on a monitor that seeks past + # each skip segment as the playhead enters it. Best-effort — failure to + # fetch segments is non-fatal, playback proceeds without skipping. + try: + sb_resp = _call_sidecar({"op": "sponsorblock", "id": yt_id}, timeout_s=8) + except Exception as e: + _log(f"sponsorblock fetch failed (non-fatal): {e}", xbmc.LOGWARNING) + return + + if not sb_resp.get("ok"): + return + segments = sb_resp.get("segments") or [] + skip_segments = [s for s in segments if s.get("actionType") == "skip"] + if not skip_segments: + _log("sponsorblock: no skip segments for this video") + return + + _log(f"sponsorblock: monitoring {len(skip_segments)} skip segments") + SponsorBlockMonitor(skip_segments).run() + + +class SponsorBlockMonitor(xbmc.Monitor): + """Polls Kodi's player position and seeks past each SponsorBlock skip + segment exactly once. Exits on playback stop or Kodi shutdown. + + Each segment has shape {"segment": [start_s, end_s], "category": str, + "UUID": str, "actionType": "skip"}. We sort by start time so we can + short-circuit the scan; we also dedupe via the UUID set so the same + segment doesn't trigger twice if the user manually rewinds into it. + """ + + POLL_S = 0.5 + MAX_WAIT_FOR_PLAYBACK_S = 30.0 + + def __init__(self, segments: list[dict[str, Any]]): + super().__init__() + self.segments = sorted( + segments, key=lambda s: float(s.get("segment", [0, 0])[0]) + ) + self.skipped: set[str] = set() + + def run(self) -> None: + player = xbmc.Player() + # Wait for playback to actually begin — setResolvedUrl is async, + # Kodi takes a beat to demux + start the streams. + waited = 0.0 + while waited < self.MAX_WAIT_FOR_PLAYBACK_S: + if self.abortRequested(): + return + if player.isPlaying(): + break + if self.waitForAbort(0.25): + return + waited += 0.25 + else: + _log("sponsorblock: timed out waiting for playback to start") + return + + while not self.abortRequested() and player.isPlaying(): + try: + pos = float(player.getTime()) + except (RuntimeError, OSError): + # getTime raises if the player went away between checks. + return + for seg in self.segments: + uuid = seg.get("UUID", "") + if uuid in self.skipped: + continue + start, end = float(seg["segment"][0]), float(seg["segment"][1]) + if pos < start: + # Segments are sorted; nothing past here can match either. + break + if pos < end: + duration = end - start + category = seg.get("category", "?") + _log( + f"sponsorblock skip: {category} {start:.1f}-{end:.1f} " + f"({duration:.0f}s)" + ) + player.seekTime(end) + self.skipped.add(uuid) + xbmcgui.Dialog().notification( + "SponsorBlock", + f"Skipped {category} ({duration:.0f}s)", + xbmcgui.NOTIFICATION_INFO, + 2500, + ) + break + if self.waitForAbort(self.POLL_S): + return + def _root_directory() -> None: """M0/M3 placeholder root menu — gets replaced by real browse UI in M4."""