From a8ebe89cc14752ab3ff8e00cb9879bd5af8e06ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Sat, 25 Jul 2026 02:42:38 +0200 Subject: [PATCH] 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 --- ytsage/gui/ytsage_gui_main.py | 7 ++++ ytsage/gui/ytsage_gui_player.py | 29 ++++++++++++++ ytsage/gui/ytsage_gui_watch.py | 65 +++++++++++++++++++++++++++++++- ytsage/languages/en.json | 3 +- ytsage/utils/ytsage_constants.py | 10 +++++ 5 files changed, 112 insertions(+), 2 deletions(-) diff --git a/ytsage/gui/ytsage_gui_main.py b/ytsage/gui/ytsage_gui_main.py index e0e4104..98eae1b 100644 --- a/ytsage/gui/ytsage_gui_main.py +++ b/ytsage/gui/ytsage_gui_main.py @@ -1250,6 +1250,13 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): def closeEvent(self, event) -> None: """Handle application close event to ensure proper cleanup of background threads.""" try: + # Persist watch position/queue and release libmpv + if hasattr(self, "watch_page"): + try: + self.watch_page.shutdown() + except Exception as e: + logger.debug(f"Watch page shutdown error: {e}") + # Stop the analysis thread if it's running if hasattr(self, "_analysis_thread") and self._analysis_thread is not None and self._analysis_thread.isRunning(): logger.info("Stopping analysis thread...") diff --git a/ytsage/gui/ytsage_gui_player.py b/ytsage/gui/ytsage_gui_player.py index c3df031..bc0609c 100644 --- a/ytsage/gui/ytsage_gui_player.py +++ b/ytsage/gui/ytsage_gui_player.py @@ -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)) diff --git a/ytsage/gui/ytsage_gui_watch.py b/ytsage/gui/ytsage_gui_watch.py index e57ad55..284a5aa 100644 --- a/ytsage/gui/ytsage_gui_watch.py +++ b/ytsage/gui/ytsage_gui_watch.py @@ -8,7 +8,7 @@ play queue. Entries arrive via AppRouter.playVideo / queueVideo. from typing import Any, Dict, List, Optional -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QTimer from PySide6.QtWidgets import ( QHBoxLayout, QLabel, @@ -21,9 +21,13 @@ from PySide6.QtWidgets import ( ) from .ytsage_gui_player import PlayerPanel, create_player_panel +from ..utils.ytsage_config_manager import ConfigManager +from ..utils.ytsage_library_manager import LibraryManager from ..utils.ytsage_localization import _ from ..utils.ytsage_logger import logger +POSITION_SAVE_INTERVAL_MS = 5000 + class WatchPage(QWidget): def __init__(self, router, parent: Optional[QWidget] = None) -> None: @@ -69,11 +73,23 @@ class WatchPage(QWidget): if isinstance(self.player, PlayerPanel): self.player.playbackEnded.connect(self._on_playback_ended) + self._duration = 0.0 + self.player.durationChanged.connect(self._on_duration_changed) + self._position_timer = QTimer(self) + self._position_timer.setInterval(POSITION_SAVE_INTERVAL_MS) + self._position_timer.timeout.connect(self._save_position) + self.player.nowPlayingChanged.connect(self._on_now_playing) + + self._restore_queue() # ------------------------------------------------------------ public API def play_entry(self, entry: Dict[str, Any], resume_pos: float = 0.0) -> None: if isinstance(self.player, PlayerPanel): + if resume_pos <= 0 and (ConfigManager.get("player.resume") or "auto") == "auto": + video_id = entry.get("id") or entry.get("url") + if video_id: + resume_pos = LibraryManager.get_resume_position(str(video_id)) self.player.play(entry, resume_pos=resume_pos) else: logger.warning("Play requested but libmpv is unavailable") @@ -83,6 +99,7 @@ class WatchPage(QWidget): item = QListWidgetItem(entry.get("title") or entry.get("url") or "?") item.setData(Qt.ItemDataRole.UserRole, dict(entry)) self.queue_list.addItem(item) + self._persist_queue() # Start playing right away when nothing is on and this is the first item if isinstance(self.player, PlayerPanel) and not self.player.current_entry() and len(self._queue) == 1: self._play_next_from_queue() @@ -90,6 +107,7 @@ class WatchPage(QWidget): def clear_queue(self) -> None: self._queue.clear() self.queue_list.clear() + self._persist_queue() def queue_entries(self) -> List[Dict[str, Any]]: return [self.queue_list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.queue_list.count())] @@ -103,9 +121,11 @@ class WatchPage(QWidget): entry = item.data(Qt.ItemDataRole.UserRole) if entry in self._queue: self._queue.remove(entry) + self._persist_queue() self.play_entry(entry) def _on_playback_ended(self, reason: str) -> None: + self._save_position(final=True) if reason in ("eof", "") and self.queue_list.count() > 0: self._play_next_from_queue() @@ -115,11 +135,54 @@ class WatchPage(QWidget): self.queue_list.takeItem(row) if entry in self._queue: self._queue.remove(entry) + self._persist_queue() self.play_entry(entry) def _on_rows_moved(self, *args) -> None: self._queue = self.queue_entries() + self._persist_queue() + + # ------------------------------------------------- history & persistence + + def _on_now_playing(self, entry: Dict[str, Any]) -> None: + LibraryManager.upsert_watch(entry) + self._duration = 0.0 + self._position_timer.start() + + def _on_duration_changed(self, duration: float) -> None: + self._duration = duration + + def _save_position(self, final: bool = False) -> None: + if not isinstance(self.player, PlayerPanel): + return + entry = self.player.current_entry() + video_id = entry.get("id") or entry.get("url") + if not video_id: + return + pos = self.player.current_position() + if pos > 0: + LibraryManager.update_position(str(video_id), pos, self._duration or entry.get("duration")) + if final: + self._position_timer.stop() + + def _persist_queue(self) -> None: + try: + LibraryManager.save_queue(self.queue_entries()) + except Exception as e: + logger.debug(f"Could not persist queue: {e}") + + def _restore_queue(self) -> None: + try: + for entry in LibraryManager.load_queue(): + self._queue.append(entry) + item = QListWidgetItem(entry.get("title") or entry.get("url") or "?") + item.setData(Qt.ItemDataRole.UserRole, entry) + self.queue_list.addItem(item) + except Exception as e: + logger.debug(f"Could not restore queue: {e}") def shutdown(self) -> None: if isinstance(self.player, PlayerPanel): + self._save_position(final=True) + self._persist_queue() self.player.shutdown() diff --git a/ytsage/languages/en.json b/ytsage/languages/en.json index 5d06d1a..85ccc7d 100644 --- a/ytsage/languages/en.json +++ b/ytsage/languages/en.json @@ -647,7 +647,8 @@ "now_playing": "Now Playing", "queue": "Queue", "play": "Play", - "pause": "Pause" + "pause": "Pause", + "stream_stalled": "Stream failed to start after several attempts - YouTube may be throttling. Try again or pick a lower quality." }, "cards": { "play": "▶ Play", diff --git a/ytsage/utils/ytsage_constants.py b/ytsage/utils/ytsage_constants.py index af80163..a36d44b 100644 --- a/ytsage/utils/ytsage_constants.py +++ b/ytsage/utils/ytsage_constants.py @@ -238,3 +238,13 @@ else: YTDLP_APP_BIN_PATH.parent.mkdir(parents=True, exist_ok=True) if "DENO_APP_BIN_PATH" in globals(): DENO_APP_BIN_PATH.parent.mkdir(parents=True, exist_ok=True) + + # Put the managed-binaries dir on PATH for this process and all children: + # yt-dlp locates the Deno JS runtime (nsig/PO-token challenges) via PATH, + # and nothing else ever exports APP_BIN_DIR. + _bin_dirs = {str(APP_BIN_DIR)} + if "DENO_APP_BIN_PATH" in globals(): + _bin_dirs.add(str(DENO_APP_BIN_PATH.parent)) + for _bin_dir in _bin_dirs: + if _bin_dir not in os.environ.get("PATH", "").split(os.pathsep): + os.environ["PATH"] = _bin_dir + os.pathsep + os.environ.get("PATH", "")