diff --git a/addon/plugin.video.torttube/addon.xml b/addon/plugin.video.torttube/addon.xml index 72eca3a..9a2a599 100644 --- a/addon/plugin.video.torttube/addon.xml +++ b/addon/plugin.video.torttube/addon.xml @@ -1,16 +1,16 @@ - video + YouTube via RustyPipe + SponsorBlock Browse, search, and play YouTube videos without an account. Backed by a native RustyPipe sidecar binary. SponsorBlock segments are skipped automatically. diff --git a/addon/plugin.video.torttube/main.py b/addon/plugin.video.torttube/main.py index ee54755..38ef1fd 100644 --- a/addon/plugin.video.torttube/main.py +++ b/addon/plugin.video.torttube/main.py @@ -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: diff --git a/addon/plugin.video.torttube/service.py b/addon/plugin.video.torttube/service.py new file mode 100644 index 0000000..eda9e30 --- /dev/null +++ b/addon/plugin.video.torttube/service.py @@ -0,0 +1,248 @@ +# service.py - Kodi background service for torttube. +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Two jobs: +# 1. First-run install of the bundled plugin.video.youtube zip, so users +# get one-addon UX. The bundled zip is the upstream release; we just +# extract it into Kodi's addons dir and force a rescan. +# 2. SponsorBlock for ANY YouTube playback, not just plays initiated by +# torttube. That includes phone-cast plays where Kodi receives a +# plugin://plugin.video.youtube/play/... URL directly and our plugin +# code never runs. The xbmc.Player subclass below observes every play, +# extracts the YouTube ID from the resolved URL, and runs the skip +# loop in a thread. + +import json +import os +import re +import subprocess +import threading +import time +import zipfile +from typing import Any + +import xbmc +import xbmcaddon +import xbmcgui +import xbmcvfs + +ADDON = xbmcaddon.Addon() +ADDON_ID = ADDON.getAddonInfo("id") +ADDON_PATH = ADDON.getAddonInfo("path") + + +def _log(msg: str, level: int = xbmc.LOGINFO) -> None: + xbmc.log(f"[{ADDON_ID}.service] {msg}", level=level) + + +# YouTube IDs appear in several URL shapes once pv.youtube + IA finish +# resolving: ?v=XXX (uri2addon), ?video_id=XXX (play handler), +# ?file=XXX.mpd (the localhost MPD server pv.youtube spins up). Match all. +_YT_ID_RE = re.compile(r"(?:[?&](?:v|file|video_id)=)([A-Za-z0-9_-]{11})") + + +def _extract_yt_id(url: str) -> str | None: + if not url: + return None + m = _YT_ID_RE.search(url) + return m.group(1) if m else None + + +def _call_sidecar(payload: dict, timeout_s: float = 10.0) -> dict: + binary = os.path.join(ADDON_PATH, "bin", "torttube-sidecar") + proc = subprocess.run( + [binary], + input=json.dumps(payload), + capture_output=True, + text=True, + timeout=timeout_s, + ) + if proc.returncode != 0: + raise RuntimeError(f"sidecar rc={proc.returncode}: {proc.stderr[:200]}") + return json.loads(proc.stdout) + + +def _fetch_sb_segments(yt_id: str) -> list[dict[str, Any]]: + try: + resp = _call_sidecar({"op": "sponsorblock", "id": yt_id}, timeout_s=8) + if resp.get("ok"): + segs = resp.get("segments") or [] + return [s for s in segs if s.get("actionType") == "skip"] + except Exception as e: + _log(f"sponsorblock fetch failed for {yt_id}: {e}", xbmc.LOGWARNING) + return [] + + +class TorttubePlayerMonitor(xbmc.Player): + """Watches every playback. On AV-started, pulls the YouTube ID from + the playing-file URL (works for torttube plays AND phone-cast plays + that bypass our plugin entirely), fetches SponsorBlock segments, and + runs a poll-skip loop on a background thread until playback ends.""" + + POLL_S = 0.5 + + def __init__(self) -> None: + super().__init__() + self._lock = threading.Lock() + self._current_id: str | None = None + self._skip_thread: threading.Thread | None = None + self._stop_event = threading.Event() + + def onAVStarted(self) -> None: + try: + playing_file = self.getPlayingFile() + except Exception: + return + yt_id = _extract_yt_id(playing_file) + if not yt_id: + return + with self._lock: + if yt_id == self._current_id: + # Same video — segments already armed, skip thread alive. + return + self._stop_event.set() + self._current_id = yt_id + + segments = _fetch_sb_segments(yt_id) + _log(f"armed {len(segments)} segments for {yt_id} (file={playing_file[:80]})") + if not segments: + return + + stop_event = threading.Event() + thread = threading.Thread( + target=self._skip_loop, + args=(yt_id, segments, stop_event), + daemon=True, + ) + with self._lock: + self._stop_event = stop_event + self._skip_thread = thread + thread.start() + + def onPlayBackStopped(self) -> None: + self._stop_event.set() + with self._lock: + self._current_id = None + + def onPlayBackEnded(self) -> None: + self.onPlayBackStopped() + + def onPlayBackError(self) -> None: + self.onPlayBackStopped() + + def _skip_loop( + self, + yt_id: str, + segments: list[dict[str, Any]], + stop_event: threading.Event, + ) -> None: + segs = sorted(segments, key=lambda s: float(s.get("segment", [0, 0])[0])) + skipped: set[str] = set() + while not stop_event.is_set(): + try: + if not self.isPlaying(): + return + pos = float(self.getTime()) + # Once we switch to a different video the new monitor will + # also start; bail this loop if the underlying id changed. + current_file = self.getPlayingFile() + except Exception: + return + if _extract_yt_id(current_file) != yt_id: + return + for seg in segs: + uuid = seg.get("UUID", "") + if uuid in skipped: + continue + start = float(seg["segment"][0]) + end = float(seg["segment"][1]) + if pos < start: + break + if pos < end: + category = seg.get("category", "?") + duration = end - start + _log(f"skip {yt_id} {category} {start:.1f}-{end:.1f} ({duration:.0f}s)") + try: + self.seekTime(end) + except Exception as e: + _log(f"seekTime failed: {e}", xbmc.LOGWARNING) + skipped.add(uuid) + try: + xbmcgui.Dialog().notification( + "SponsorBlock", + f"Skipped {category} ({duration:.0f}s)", + xbmcgui.NOTIFICATION_INFO, + 2500, + ) + except Exception: + pass + break + if stop_event.wait(self.POLL_S): + return + + +def _ensure_pv_youtube() -> None: + addons_dir = xbmcvfs.translatePath("special://home/addons/") + target = os.path.join(addons_dir, "plugin.video.youtube") + if os.path.isdir(target): + return + + bundled = os.path.join(ADDON_PATH, "vendored", "plugin.video.youtube-7.4.3.zip") + if not os.path.isfile(bundled): + _log(f"bundled pv.youtube zip missing at {bundled}", xbmc.LOGERROR) + return + + _log("first-run: installing bundled pv.youtube 7.4.3") + try: + with zipfile.ZipFile(bundled) as zf: + zf.extractall(addons_dir) + except Exception as e: + _log(f"pv.youtube extract failed: {e}", xbmc.LOGERROR) + return + + xbmc.executebuiltin("UpdateLocalAddons") + time.sleep(1.5) + + enable_cmd = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": "Addons.SetAddonEnabled", + "params": {"addonid": "plugin.video.youtube", "enabled": True}, + }) + try: + xbmc.executeJSONRPC(enable_cmd) + except Exception as e: + _log(f"failed to enable pv.youtube: {e}", xbmc.LOGWARNING) + + try: + xbmcgui.Dialog().notification( + "torttube", + "Installed YouTube player addon", + xbmcgui.NOTIFICATION_INFO, + 5000, + ) + except Exception: + pass + _log("pv.youtube install complete") + + +def main() -> None: + _log("service starting") + try: + _ensure_pv_youtube() + except Exception as e: + _log(f"_ensure_pv_youtube failed (non-fatal): {e}", xbmc.LOGWARNING) + + player_monitor = TorttubePlayerMonitor() + abort = xbmc.Monitor() + # The Player instance only stays alive while there's a reference to it; + # keep the reference here for the lifetime of the service. + _ = player_monitor + while not abort.abortRequested(): + if abort.waitForAbort(2.0): + break + _log("service stopping") + + +if __name__ == "__main__": + main() diff --git a/sidecar/Cargo.toml b/sidecar/Cargo.toml index 3e638af..ba12993 100644 --- a/sidecar/Cargo.toml +++ b/sidecar/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/torttube-sidecar"] [workspace.package] -version = "1.0.2" +version = "1.0.3" edition = "2021" license = "GPL-3.0-or-later" authors = ["Cobb "]