M5 DONE — SponsorBlock auto-skip wired + verified on the TV
SponsorBlockMonitor (xbmc.Monitor subclass) attaches after setResolvedUrl:
- fetches segments from sidecar via the existing sponsorblock op
(SHA-256 prefix lookup, defaults to sponsor + selfpromo + interaction
categories)
- waits up to 30s for playback to actually start, then polls
Player.getTime() every 0.5s
- when position enters a skip segment, calls seekTime(end) and shows
a 'SponsorBlock — Skipped <category> (<duration>s)' toast
- UUIDs are remembered so a manual rewind into a previously-skipped
segment doesn't trigger again
- exits cleanly on playback stop or Kodi shutdown
Live-verified on the LibreELEC Pi with LTT 2T8x5antlnc ('Trump Phone'),
which has two locked sponsor segments. Sought to 1:45, the monitor
fired at 108.3s and seeked to 128.4s — log line:
[torttube] sponsorblock skip: sponsor 108.3-128.4 (20s)
addon.xml v0.0.1 → v0.0.2.
Deferred for v0.0.3+: settings.xml category toggles + a skip-counter,
support for non-skip action types (mute, full, poi).
This commit is contained in:
parent
2020035373
commit
6d07ed16fa
3 changed files with 102 additions and 6 deletions
|
|
@ -41,12 +41,16 @@
|
||||||
- [ ] playlist browse → `{"op":"playlist","id":"…"}`
|
- [ ] playlist browse → `{"op":"playlist","id":"…"}`
|
||||||
- [ ] result thumbnails + duration + uploader
|
- [ ] result thumbnails + duration + uploader
|
||||||
|
|
||||||
## M5 — SponsorBlock skipping
|
## M5 — SponsorBlock skipping [DONE]
|
||||||
|
|
||||||
- [ ] background thread on `Player()` polls position
|
- [x] `SponsorBlockMonitor` (xbmc.Monitor subclass) polls `Player().getTime()`
|
||||||
- [ ] when position enters a skip segment → `xbmc.Player().seekTime(end)`
|
every 0.5s after playback starts; seeks past each segment exactly
|
||||||
- [ ] toast on skip + skip-counter in settings
|
once (UUID dedup); exits cleanly on abort or playback stop
|
||||||
- [ ] category toggles in `settings.xml`
|
- [x] toast on skip (`SponsorBlock — Skipped <category> (<duration>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]
|
## M6 — install + cross-compile [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.1"
|
version="0.0.2"
|
||||||
provider-name="Sulkta">
|
provider-name="Sulkta">
|
||||||
<requires>
|
<requires>
|
||||||
<import addon="xbmc.python" version="3.0.0"/>
|
<import addon="xbmc.python" version="3.0.0"/>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from typing import Any
|
||||||
from urllib.parse import parse_qsl, urlparse
|
from urllib.parse import parse_qsl, urlparse
|
||||||
|
|
||||||
import xbmc
|
import xbmc
|
||||||
|
|
@ -152,6 +153,97 @@ def _play(yt_id: str) -> None:
|
||||||
_log(f"resolved via {resp.get('source')}, playing")
|
_log(f"resolved via {resp.get('source')}, playing")
|
||||||
xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(stream_url, title))
|
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:
|
def _root_directory() -> None:
|
||||||
"""M0/M3 placeholder root menu — gets replaced by real browse UI in M4."""
|
"""M0/M3 placeholder root menu — gets replaced by real browse UI in M4."""
|
||||||
|
|
|
||||||
Reference in a new issue