v1.0.4 — fix mid-play swap: don't block Player callback thread

onAVStarted was calling _fetch_sb_segments synchronously, which
subprocess.run()'s our sidecar — up to 8s of blocking on Kodi's
serialized player event thread. When the user started a new video while
one was playing, pv.youtube's stream resolve for the new video raced
with our blocked callback and the new play got dropped as "unplayable
item" before pv.youtube could finish.

Moved the segment fetch + skip loop into a background thread that
starts from onAVStarted and returns instantly. Player callbacks now
clear in microseconds.
This commit is contained in:
Kayos 2026-05-23 16:31:59 -07:00
parent 11eecbccc2
commit 63962b29b5
3 changed files with 22 additions and 15 deletions

View file

@ -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="1.0.3" version="1.0.4"
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"/>

View file

@ -89,6 +89,10 @@ class TorttubePlayerMonitor(xbmc.Player):
self._stop_event = threading.Event() self._stop_event = threading.Event()
def onAVStarted(self) -> None: def onAVStarted(self) -> None:
# Must return fast — Kodi's player event dispatch is serialized and
# blocking here while pv.youtube is mid-resolve can drop the next
# play. The actual segment fetch + skip loop runs in a background
# thread.
try: try:
playing_file = self.getPlayingFile() playing_file = self.getPlayingFile()
except Exception: except Exception:
@ -98,26 +102,29 @@ class TorttubePlayerMonitor(xbmc.Player):
return return
with self._lock: with self._lock:
if yt_id == self._current_id: if yt_id == self._current_id:
# Same video — segments already armed, skip thread alive.
return return
self._stop_event.set() self._stop_event.set()
self._current_id = yt_id 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() stop_event = threading.Event()
thread = threading.Thread( self._stop_event = stop_event
target=self._skip_loop, self._skip_thread = threading.Thread(
args=(yt_id, segments, stop_event), target=self._arm_and_skip,
args=(yt_id, playing_file, stop_event),
daemon=True, daemon=True,
) )
with self._lock: self._skip_thread.start()
self._stop_event = stop_event
self._skip_thread = thread def _arm_and_skip(
thread.start() self,
yt_id: str,
playing_file: str,
stop_event: threading.Event,
) -> None:
segments = _fetch_sb_segments(yt_id)
_log(f"armed {len(segments)} segments for {yt_id} (file={playing_file[:80]})")
if not segments or stop_event.is_set():
return
self._skip_loop(yt_id, segments, stop_event)
def onPlayBackStopped(self) -> None: def onPlayBackStopped(self) -> None:
self._stop_event.set() self._stop_event.set()

View file

@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/torttube-sidecar"] members = ["crates/torttube-sidecar"]
[workspace.package] [workspace.package]
version = "1.0.3" version = "1.0.4"
edition = "2021" edition = "2021"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
authors = ["Cobb <cobb@sulkta.com>"] authors = ["Cobb <cobb@sulkta.com>"]