Wire watch history, resume positions and persistent queue

WatchPage now records every played video in watch_history, saves the
playback position every 5 seconds and on stop/end (completed at >=95%),
and seeks back on replay when player.resume is "auto" - resume kicks in
between 30 seconds and 95% of the duration. The play queue persists
across restarts and reorders/removals write through immediately; the
main window's closeEvent flushes position and queue and releases libmpv.

PlayerPanel gains an automatic stall-retry: YouTube's CDN intermittently
serves stalled streams to non-browser clients (reproduced ~1/3 of
attempts headless with identical code), and a reload re-resolves onto a
healthy node - two retries after 25s of no playback, then a user-facing
error.

ytsage_constants now prepends the managed-binaries dir to PATH so
yt-dlp subprocesses (including mpv's ytdl_hook) can find the managed
Deno runtime - previously nothing exported APP_BIN_DIR, so the deno
integration silently never worked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-25 02:42:38 +02:00
parent 3afe96a8cd
commit a8ebe89cc1
5 changed files with 112 additions and 2 deletions
+29
View File
@@ -308,6 +308,16 @@ class PlayerPanel(QWidget):
self._volume_apply_timer.setInterval(400)
self._volume_apply_timer.timeout.connect(self._persist_volume)
# YouTube's CDN intermittently serves stalled/poisoned streams to
# non-browser clients; a reload re-resolves the URLs and usually
# lands on a healthy node. Retry automatically on startup stall.
self._stall_timer = QTimer(self)
self._stall_timer.setSingleShot(True)
self._stall_timer.setInterval(25000)
self._stall_timer.timeout.connect(self._on_startup_stall)
self._stall_retries = 0
self._playback_started = False
# ------------------------------------------------------------ public API
def play(self, entry: Dict[str, Any], resume_pos: float = 0.0) -> None:
@@ -333,8 +343,23 @@ class PlayerPanel(QWidget):
logger.exception(f"mpv loadfile failed: {e}")
self.playerError.emit(str(e))
return
self._playback_started = False
self._stall_timer.start()
self.nowPlayingChanged.emit(self._current_entry)
def _on_startup_stall(self) -> None:
if self._playback_started or not self._current_entry:
return
if self._stall_retries < 2:
self._stall_retries += 1
logger.warning(f"Stream stalled before starting; retrying ({self._stall_retries}/2)")
entry = self._current_entry
self._current_entry = {}
self.play(entry)
else:
self._stall_retries = 0
self.playerError.emit(_("player.stream_stalled"))
def stop(self) -> None:
mpv_inst = self.video.mpv
if mpv_inst is not None:
@@ -368,6 +393,10 @@ class PlayerPanel(QWidget):
@Slot(float)
def _on_position(self, pos: float) -> None:
if pos > 0 and not self._playback_started:
self._playback_started = True
self._stall_retries = 0
self._stall_timer.stop()
if not self._slider_down and self._duration > 0:
self.seek_slider.blockSignals(True)
self.seek_slider.setValue(int(pos / self._duration * 1000))