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:
@@ -1250,6 +1250,13 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
|||||||
def closeEvent(self, event) -> None:
|
def closeEvent(self, event) -> None:
|
||||||
"""Handle application close event to ensure proper cleanup of background threads."""
|
"""Handle application close event to ensure proper cleanup of background threads."""
|
||||||
try:
|
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
|
# 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():
|
if hasattr(self, "_analysis_thread") and self._analysis_thread is not None and self._analysis_thread.isRunning():
|
||||||
logger.info("Stopping analysis thread...")
|
logger.info("Stopping analysis thread...")
|
||||||
|
|||||||
@@ -308,6 +308,16 @@ class PlayerPanel(QWidget):
|
|||||||
self._volume_apply_timer.setInterval(400)
|
self._volume_apply_timer.setInterval(400)
|
||||||
self._volume_apply_timer.timeout.connect(self._persist_volume)
|
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
|
# ------------------------------------------------------------ public API
|
||||||
|
|
||||||
def play(self, entry: Dict[str, Any], resume_pos: float = 0.0) -> None:
|
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}")
|
logger.exception(f"mpv loadfile failed: {e}")
|
||||||
self.playerError.emit(str(e))
|
self.playerError.emit(str(e))
|
||||||
return
|
return
|
||||||
|
self._playback_started = False
|
||||||
|
self._stall_timer.start()
|
||||||
self.nowPlayingChanged.emit(self._current_entry)
|
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:
|
def stop(self) -> None:
|
||||||
mpv_inst = self.video.mpv
|
mpv_inst = self.video.mpv
|
||||||
if mpv_inst is not None:
|
if mpv_inst is not None:
|
||||||
@@ -368,6 +393,10 @@ class PlayerPanel(QWidget):
|
|||||||
|
|
||||||
@Slot(float)
|
@Slot(float)
|
||||||
def _on_position(self, pos: float) -> None:
|
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:
|
if not self._slider_down and self._duration > 0:
|
||||||
self.seek_slider.blockSignals(True)
|
self.seek_slider.blockSignals(True)
|
||||||
self.seek_slider.setValue(int(pos / self._duration * 1000))
|
self.seek_slider.setValue(int(pos / self._duration * 1000))
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ play queue. Entries arrive via AppRouter.playVideo / queueVideo.
|
|||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt, QTimer
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -21,9 +21,13 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .ytsage_gui_player import PlayerPanel, create_player_panel
|
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_localization import _
|
||||||
from ..utils.ytsage_logger import logger
|
from ..utils.ytsage_logger import logger
|
||||||
|
|
||||||
|
POSITION_SAVE_INTERVAL_MS = 5000
|
||||||
|
|
||||||
|
|
||||||
class WatchPage(QWidget):
|
class WatchPage(QWidget):
|
||||||
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
|
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
|
||||||
@@ -69,11 +73,23 @@ class WatchPage(QWidget):
|
|||||||
|
|
||||||
if isinstance(self.player, PlayerPanel):
|
if isinstance(self.player, PlayerPanel):
|
||||||
self.player.playbackEnded.connect(self._on_playback_ended)
|
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
|
# ------------------------------------------------------------ public API
|
||||||
|
|
||||||
def play_entry(self, entry: Dict[str, Any], resume_pos: float = 0.0) -> None:
|
def play_entry(self, entry: Dict[str, Any], resume_pos: float = 0.0) -> None:
|
||||||
if isinstance(self.player, PlayerPanel):
|
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)
|
self.player.play(entry, resume_pos=resume_pos)
|
||||||
else:
|
else:
|
||||||
logger.warning("Play requested but libmpv is unavailable")
|
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 = QListWidgetItem(entry.get("title") or entry.get("url") or "?")
|
||||||
item.setData(Qt.ItemDataRole.UserRole, dict(entry))
|
item.setData(Qt.ItemDataRole.UserRole, dict(entry))
|
||||||
self.queue_list.addItem(item)
|
self.queue_list.addItem(item)
|
||||||
|
self._persist_queue()
|
||||||
# Start playing right away when nothing is on and this is the first item
|
# 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:
|
if isinstance(self.player, PlayerPanel) and not self.player.current_entry() and len(self._queue) == 1:
|
||||||
self._play_next_from_queue()
|
self._play_next_from_queue()
|
||||||
@@ -90,6 +107,7 @@ class WatchPage(QWidget):
|
|||||||
def clear_queue(self) -> None:
|
def clear_queue(self) -> None:
|
||||||
self._queue.clear()
|
self._queue.clear()
|
||||||
self.queue_list.clear()
|
self.queue_list.clear()
|
||||||
|
self._persist_queue()
|
||||||
|
|
||||||
def queue_entries(self) -> List[Dict[str, Any]]:
|
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())]
|
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)
|
entry = item.data(Qt.ItemDataRole.UserRole)
|
||||||
if entry in self._queue:
|
if entry in self._queue:
|
||||||
self._queue.remove(entry)
|
self._queue.remove(entry)
|
||||||
|
self._persist_queue()
|
||||||
self.play_entry(entry)
|
self.play_entry(entry)
|
||||||
|
|
||||||
def _on_playback_ended(self, reason: str) -> None:
|
def _on_playback_ended(self, reason: str) -> None:
|
||||||
|
self._save_position(final=True)
|
||||||
if reason in ("eof", "") and self.queue_list.count() > 0:
|
if reason in ("eof", "") and self.queue_list.count() > 0:
|
||||||
self._play_next_from_queue()
|
self._play_next_from_queue()
|
||||||
|
|
||||||
@@ -115,11 +135,54 @@ class WatchPage(QWidget):
|
|||||||
self.queue_list.takeItem(row)
|
self.queue_list.takeItem(row)
|
||||||
if entry in self._queue:
|
if entry in self._queue:
|
||||||
self._queue.remove(entry)
|
self._queue.remove(entry)
|
||||||
|
self._persist_queue()
|
||||||
self.play_entry(entry)
|
self.play_entry(entry)
|
||||||
|
|
||||||
def _on_rows_moved(self, *args) -> None:
|
def _on_rows_moved(self, *args) -> None:
|
||||||
self._queue = self.queue_entries()
|
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:
|
def shutdown(self) -> None:
|
||||||
if isinstance(self.player, PlayerPanel):
|
if isinstance(self.player, PlayerPanel):
|
||||||
|
self._save_position(final=True)
|
||||||
|
self._persist_queue()
|
||||||
self.player.shutdown()
|
self.player.shutdown()
|
||||||
|
|||||||
@@ -647,7 +647,8 @@
|
|||||||
"now_playing": "Now Playing",
|
"now_playing": "Now Playing",
|
||||||
"queue": "Queue",
|
"queue": "Queue",
|
||||||
"play": "Play",
|
"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": {
|
"cards": {
|
||||||
"play": "▶ Play",
|
"play": "▶ Play",
|
||||||
|
|||||||
@@ -238,3 +238,13 @@ else:
|
|||||||
YTDLP_APP_BIN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
YTDLP_APP_BIN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if "DENO_APP_BIN_PATH" in globals():
|
if "DENO_APP_BIN_PATH" in globals():
|
||||||
DENO_APP_BIN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
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", "")
|
||||||
|
|||||||
Reference in New Issue
Block a user