v1.0.3 — bundle pv.youtube + service-level SponsorBlock
Two big changes: - service.py is a new background-service component (xbmc.service extension). On first run it extracts the bundled pv.youtube 7.4.3 zip into Kodi's addons dir, forces a rescan, enables it. No more two-step install. The bundle stays as a separate Kodi addon at runtime; we just ship + auto-install it. - The service also subclasses xbmc.Player and runs SponsorBlock for ANY YouTube playback, not just plays initiated by torttube. The TorttubePlayerMonitor watches onAVStarted globally, pulls the YouTube ID from the playing-file URL (matches ?v=, ?file=XXX.mpd, ?video_id= — covers pv.youtube's resolved URLs in every shape), then runs a poll- skip loop on a background thread. Critical: this means SponsorBlock now works when a video is cast to Kodi from the phone YouTube app — the cast hits pv.youtube directly and our plugin code is never invoked, but the service is always alive and catches it. - Dropped the three _attach_sponsorblock call sites in main.py._play() and the SponsorBlockMonitor class. The DASH path still needs to block until playback ends so it can shut down its localhost MPD server; that's now a tiny _wait_for_playback_end() that doesn't do SB. - Removed the <import addon="plugin.video.youtube"> from addon.xml since we bundle it; kept inputstream.adaptive as an optional import.
This commit is contained in:
parent
7645688533
commit
11eecbccc2
4 changed files with 275 additions and 110 deletions
|
|
@ -515,12 +515,8 @@ def _play(yt_id: str) -> None:
|
|||
except Exception:
|
||||
pass
|
||||
if use_pv_youtube and _delegate_to_pv_youtube(yt_id):
|
||||
try:
|
||||
_attach_sponsorblock(yt_id)
|
||||
except Exception as e:
|
||||
# Don't let a SB monitor bug pop a 'Plugin error' dialog on the TV
|
||||
# after a successful delegate-to-pv.youtube hand-off.
|
||||
_log(f"sponsorblock attach error (non-fatal): {e}", xbmc.LOGWARNING)
|
||||
# SponsorBlock is owned by service.py and fires for every YouTube
|
||||
# play (including phone-cast plays that bypass our plugin entirely).
|
||||
return
|
||||
|
||||
mpd_bytes: bytes | None = None
|
||||
|
|
@ -552,10 +548,8 @@ def _play(yt_id: str) -> None:
|
|||
_log(f"resolved via rustypipe DASH, serving manifest at {mpd_url}")
|
||||
xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(mpd_url, title))
|
||||
try:
|
||||
_attach_sponsorblock(yt_id)
|
||||
_wait_for_playback_end()
|
||||
finally:
|
||||
# Shut down the MPD server cleanly once playback ends or aborts.
|
||||
# _attach_sponsorblock blocks while playback is active.
|
||||
try:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
|
@ -597,107 +591,30 @@ def _play(yt_id: str) -> None:
|
|||
|
||||
_log(f"resolved via yt-dlp progressive fallback, playing")
|
||||
xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(stream_url, title))
|
||||
try:
|
||||
_attach_sponsorblock(yt_id)
|
||||
except Exception as e:
|
||||
_log(f"sponsorblock attach error (non-fatal): {e}", xbmc.LOGWARNING)
|
||||
|
||||
|
||||
def _attach_sponsorblock(yt_id: str) -> None:
|
||||
"""Fetch SponsorBlock segments and block on the monitor loop. Always blocks
|
||||
until playback ends (or 30s if playback never starts) so the caller can
|
||||
use this as a 'wait for playback to finish' signal — needed to keep the
|
||||
MPD HTTP server alive throughout playback.
|
||||
|
||||
Non-fatal on segment fetch error.
|
||||
"""
|
||||
skip_segments: list[dict[str, Any]] = []
|
||||
try:
|
||||
sb_resp = _call_sidecar({"op": "sponsorblock", "id": yt_id}, timeout_s=8)
|
||||
if sb_resp.get("ok"):
|
||||
segs = sb_resp.get("segments") or []
|
||||
skip_segments = [s for s in segs if s.get("actionType") == "skip"]
|
||||
_log(f"sponsorblock: {len(skip_segments)} skip segments")
|
||||
except Exception as e:
|
||||
_log(f"sponsorblock fetch failed (non-fatal): {e}", xbmc.LOGWARNING)
|
||||
|
||||
# Always run the watcher even with zero segments — it doubles as the
|
||||
# 'block until playback ends' signal that gates MPD-server shutdown.
|
||||
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")
|
||||
def _wait_for_playback_end() -> None:
|
||||
# Blocks until playback ends. Used by the DASH path to keep its
|
||||
# localhost MPD HTTP server alive for the duration of playback. The
|
||||
# service addon (service.py) owns SponsorBlock for all playback paths;
|
||||
# this function only handles HTTP-server lifecycle.
|
||||
monitor = xbmc.Monitor()
|
||||
player = xbmc.Player()
|
||||
waited = 0.0
|
||||
while waited < 30.0:
|
||||
if monitor.abortRequested():
|
||||
return
|
||||
if player.isPlaying():
|
||||
break
|
||||
if monitor.waitForAbort(0.25):
|
||||
return
|
||||
waited += 0.25
|
||||
else:
|
||||
_log("dash: timed out waiting for playback to start")
|
||||
return
|
||||
while not monitor.abortRequested() and player.isPlaying():
|
||||
if monitor.waitForAbort(0.5):
|
||||
return
|
||||
|
||||
while not self.abortRequested() and player.isPlaying():
|
||||
try:
|
||||
pos = float(player.getTime())
|
||||
except Exception:
|
||||
# getTime raises various types when the player goes away
|
||||
# mid-poll (Kodi shutdown, plugin reload, etc). Wider catch so
|
||||
# an exception path doesn't escape into _play's finally and
|
||||
# leak the MPD HTTP server.
|
||||
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 _plugin_url(**kwargs: Any) -> str:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue