a8ebe89cc1
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>
534 lines
20 KiB
Python
534 lines
20 KiB
Python
"""
|
|
Embedded mpv player
|
|
===================
|
|
|
|
MpvRenderWidget renders libmpv into a QOpenGLWidget via the mpv render API
|
|
(the wid=winId() embedding path is broken on native Wayland, the render API
|
|
works on X11/Wayland/Windows/macOS alike).
|
|
|
|
PlayerPanel wraps the render widget with transport controls and resolves
|
|
YouTube URLs through mpv's ytdl_hook, pointed at the app-managed yt-dlp
|
|
binary, so stream URL freshness, DASH muxing, subtitles and PO-token/nsig
|
|
handling are all handled by the same yt-dlp the downloader uses.
|
|
|
|
Threading rule: libmpv fires property observers and the render-update
|
|
callback on its own threads. Nothing in those callbacks may touch Qt
|
|
widgets - they only emit queued Qt signals.
|
|
"""
|
|
|
|
from typing import Any, Dict, Optional
|
|
|
|
from PySide6.QtCore import Qt, QTimer, Signal, Slot
|
|
from PySide6.QtOpenGLWidgets import QOpenGLWidget
|
|
from PySide6.QtGui import QOpenGLContext
|
|
from PySide6.QtWidgets import (
|
|
QComboBox,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QPushButton,
|
|
QSizePolicy,
|
|
QSlider,
|
|
QStyle,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from ..core.ytsage_mpv import probe_player
|
|
from ..core.ytsage_yt_dlp import get_yt_dlp_path
|
|
from ..utils.ytsage_config_manager import ConfigManager
|
|
from ..utils.ytsage_localization import _
|
|
from ..utils.ytsage_logger import logger
|
|
|
|
QUALITY_CHOICES = [
|
|
("player.quality_auto", None),
|
|
("2160p", 2160),
|
|
("1440p", 1440),
|
|
("1080p", 1080),
|
|
("720p", 720),
|
|
("480p", 480),
|
|
("360p", 360),
|
|
]
|
|
|
|
SPEED_CHOICES = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
|
|
|
|
|
def _ytdl_format_for(height: Optional[int]) -> str:
|
|
if height is None:
|
|
return "bestvideo+bestaudio/best"
|
|
return f"bestvideo[height<=?{height}]+bestaudio/best[height<=?{height}]"
|
|
|
|
|
|
def _build_ytdl_raw_options() -> str:
|
|
"""Mirror the app's cookie/proxy config into ytdl_hook raw options."""
|
|
opts = []
|
|
if ConfigManager.get("cookie_active"):
|
|
if ConfigManager.get("cookie_source") == "file":
|
|
path = ConfigManager.get("cookie_file_path")
|
|
if path:
|
|
opts.append(f"cookies={path}")
|
|
else:
|
|
browser = ConfigManager.get("cookie_browser")
|
|
profile = ConfigManager.get("cookie_browser_profile")
|
|
if browser:
|
|
value = f"{browser}:{profile}" if profile else browser
|
|
opts.append(f"cookies-from-browser={value}")
|
|
proxy = ConfigManager.get("proxy_url")
|
|
if proxy:
|
|
opts.append(f"proxy={proxy}")
|
|
return ",".join(opts)
|
|
|
|
|
|
class MpvRenderWidget(QOpenGLWidget):
|
|
"""QOpenGLWidget hosting a libmpv render context."""
|
|
|
|
# Emitted from mpv threads; connected queued to GUI-thread slots
|
|
mpvPositionChanged = Signal(float)
|
|
mpvDurationChanged = Signal(float)
|
|
mpvPausedChanged = Signal(bool)
|
|
mpvEndReached = Signal(str) # end-file reason
|
|
mpvError = Signal(str)
|
|
_renderUpdateRequested = Signal()
|
|
|
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
|
super().__init__(parent)
|
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
self.setMinimumHeight(240)
|
|
self._mpv = None
|
|
self._render_ctx = None
|
|
self._renderUpdateRequested.connect(self.update, Qt.ConnectionType.QueuedConnection)
|
|
self._create_mpv()
|
|
|
|
# ------------------------------------------------------------------ mpv
|
|
|
|
def _create_mpv(self) -> None:
|
|
import mpv
|
|
|
|
kwargs: Dict[str, Any] = {
|
|
"vo": "libmpv",
|
|
"ytdl": True,
|
|
"keep_open": "yes",
|
|
"idle": "yes",
|
|
"osc": False,
|
|
"input_default_bindings": False,
|
|
}
|
|
ytdlp_path = get_yt_dlp_path()
|
|
if str(ytdlp_path) != "yt-dlp":
|
|
kwargs["script_opts"] = f"ytdl_hook-ytdl_path={ytdlp_path}"
|
|
|
|
self._mpv = mpv.MPV(log_handler=self._on_mpv_log, **kwargs)
|
|
self._mpv["ytdl-format"] = _ytdl_format_for(ConfigManager.get("player.default_quality"))
|
|
raw_opts = _build_ytdl_raw_options()
|
|
if raw_opts:
|
|
self._mpv["ytdl-raw-options"] = raw_opts
|
|
|
|
self._mpv.observe_property("time-pos", self._on_time_pos)
|
|
self._mpv.observe_property("duration", self._on_duration)
|
|
self._mpv.observe_property("pause", self._on_pause)
|
|
|
|
@self._mpv.event_callback("end-file")
|
|
def _on_end_file(event): # mpv thread
|
|
try:
|
|
reason = str(getattr(event.data, "reason", ""))
|
|
except Exception:
|
|
reason = ""
|
|
self.mpvEndReached.emit(reason)
|
|
|
|
# mpv-thread callbacks: signals only, no widget access
|
|
def _on_time_pos(self, _name, value) -> None:
|
|
if value is not None:
|
|
self.mpvPositionChanged.emit(float(value))
|
|
|
|
def _on_duration(self, _name, value) -> None:
|
|
if value is not None:
|
|
self.mpvDurationChanged.emit(float(value))
|
|
|
|
def _on_pause(self, _name, value) -> None:
|
|
if value is not None:
|
|
self.mpvPausedChanged.emit(bool(value))
|
|
|
|
def _on_mpv_log(self, level: str, prefix: str, text: str) -> None:
|
|
if level in ("error", "fatal"):
|
|
logger.error(f"mpv [{prefix}] {text.strip()}")
|
|
if "ytdl" in prefix or level == "fatal":
|
|
self.mpvError.emit(text.strip())
|
|
else:
|
|
logger.debug(f"mpv [{prefix}] {text.strip()}")
|
|
|
|
# --------------------------------------------------------------- OpenGL
|
|
|
|
def initializeGL(self) -> None:
|
|
from mpv import MpvGlGetProcAddressFn, MpvRenderContext
|
|
|
|
def get_proc_address(_ctx, name):
|
|
glctx = QOpenGLContext.currentContext()
|
|
if glctx is None:
|
|
return 0
|
|
address = glctx.getProcAddress(name if isinstance(name, bytes) else name.encode("utf-8"))
|
|
return int(address) if address else 0
|
|
|
|
self._get_proc_address = MpvGlGetProcAddressFn(get_proc_address)
|
|
try:
|
|
self._render_ctx = MpvRenderContext(
|
|
self._mpv,
|
|
"opengl",
|
|
opengl_init_params={"get_proc_address": self._get_proc_address},
|
|
)
|
|
self._render_ctx.update_cb = self._renderUpdateRequested.emit # mpv thread
|
|
except Exception as e:
|
|
# Leave the widget black but keep the app alive (e.g. software GL
|
|
# contexts that libmpv rejects)
|
|
logger.error(f"Failed to create mpv render context: {e}")
|
|
self._render_ctx = None
|
|
self.mpvError.emit(f"Video output initialization failed: {e}")
|
|
|
|
def paintGL(self) -> None:
|
|
if self._render_ctx is None:
|
|
return
|
|
ratio = self.devicePixelRatioF()
|
|
w = int(self.width() * ratio)
|
|
h = int(self.height() * ratio)
|
|
self._render_ctx.render(
|
|
flip_y=True,
|
|
opengl_fbo={"fbo": self.defaultFramebufferObject(), "w": w, "h": h},
|
|
)
|
|
|
|
def shutdown(self) -> None:
|
|
try:
|
|
if self._render_ctx is not None:
|
|
self._render_ctx.free()
|
|
self._render_ctx = None
|
|
except Exception as e:
|
|
logger.debug(f"Error freeing mpv render context: {e}")
|
|
try:
|
|
if self._mpv is not None:
|
|
self._mpv.terminate()
|
|
self._mpv = None
|
|
except Exception as e:
|
|
logger.debug(f"Error terminating mpv: {e}")
|
|
|
|
# ------------------------------------------------------------- controls
|
|
|
|
@property
|
|
def mpv(self):
|
|
return self._mpv
|
|
|
|
|
|
class PlayerPanel(QWidget):
|
|
"""Video area + transport controls. Public API: play/enqueue-agnostic."""
|
|
|
|
positionChanged = Signal(float)
|
|
durationChanged = Signal(float)
|
|
playbackEnded = Signal(str) # end-file reason ("eof", "error", ...)
|
|
playerError = Signal(str)
|
|
nowPlayingChanged = Signal(dict) # entry dict of the current item
|
|
|
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
|
super().__init__(parent)
|
|
self._current_entry: Dict[str, Any] = {}
|
|
self._duration: float = 0.0
|
|
self._slider_down = False
|
|
self._fullscreen_holder: Optional[QWidget] = None
|
|
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(4)
|
|
|
|
self.video = MpvRenderWidget(self)
|
|
layout.addWidget(self.video, stretch=1)
|
|
|
|
self.title_label = QLabel("")
|
|
self.title_label.setStyleSheet("font-weight: bold; padding: 2px 6px;")
|
|
self.title_label.setWordWrap(True)
|
|
layout.addWidget(self.title_label)
|
|
|
|
controls = QHBoxLayout()
|
|
controls.setSpacing(8)
|
|
controls.setContentsMargins(6, 0, 6, 4)
|
|
|
|
self.play_btn = QPushButton()
|
|
self.play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
|
|
self.play_btn.setFixedWidth(36)
|
|
self.play_btn.clicked.connect(self.toggle_pause)
|
|
controls.addWidget(self.play_btn)
|
|
|
|
self.time_label = QLabel("0:00 / 0:00")
|
|
controls.addWidget(self.time_label)
|
|
|
|
self.seek_slider = QSlider(Qt.Orientation.Horizontal)
|
|
self.seek_slider.setRange(0, 1000)
|
|
self.seek_slider.sliderPressed.connect(self._on_slider_pressed)
|
|
self.seek_slider.sliderReleased.connect(self._on_slider_released)
|
|
controls.addWidget(self.seek_slider, stretch=1)
|
|
|
|
self.quality_combo = QComboBox()
|
|
for label, height in QUALITY_CHOICES:
|
|
self.quality_combo.addItem(_(label) if label.startswith("player.") else label, height)
|
|
default_q = ConfigManager.get("player.default_quality")
|
|
idx = next((i for i, (_l, h) in enumerate(QUALITY_CHOICES) if h == default_q), 0)
|
|
self.quality_combo.setCurrentIndex(idx)
|
|
self.quality_combo.currentIndexChanged.connect(self._on_quality_changed)
|
|
controls.addWidget(self.quality_combo)
|
|
|
|
self.speed_combo = QComboBox()
|
|
for s in SPEED_CHOICES:
|
|
self.speed_combo.addItem(f"{s:g}x", s)
|
|
self.speed_combo.setCurrentIndex(SPEED_CHOICES.index(1.0))
|
|
self.speed_combo.currentIndexChanged.connect(self._on_speed_changed)
|
|
controls.addWidget(self.speed_combo)
|
|
|
|
self.volume_slider = QSlider(Qt.Orientation.Horizontal)
|
|
self.volume_slider.setRange(0, 100)
|
|
self.volume_slider.setFixedWidth(90)
|
|
self.volume_slider.setValue(int(ConfigManager.get("player.volume") or 100))
|
|
self.volume_slider.valueChanged.connect(self._on_volume_changed)
|
|
controls.addWidget(self.volume_slider)
|
|
|
|
self.subs_btn = QPushButton(_("player.subtitles"))
|
|
self.subs_btn.setCheckable(True)
|
|
self.subs_btn.toggled.connect(self._on_subs_toggled)
|
|
controls.addWidget(self.subs_btn)
|
|
|
|
self.fullscreen_btn = QPushButton()
|
|
self.fullscreen_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TitleBarMaxButton))
|
|
self.fullscreen_btn.setFixedWidth(36)
|
|
self.fullscreen_btn.clicked.connect(self.toggle_fullscreen)
|
|
controls.addWidget(self.fullscreen_btn)
|
|
|
|
layout.addLayout(controls)
|
|
|
|
# mpv-thread signals arrive queued on the GUI thread
|
|
self.video.mpvPositionChanged.connect(self._on_position, Qt.ConnectionType.QueuedConnection)
|
|
self.video.mpvDurationChanged.connect(self._on_duration, Qt.ConnectionType.QueuedConnection)
|
|
self.video.mpvPausedChanged.connect(self._on_paused_changed, Qt.ConnectionType.QueuedConnection)
|
|
self.video.mpvEndReached.connect(self._on_end_reached, Qt.ConnectionType.QueuedConnection)
|
|
self.video.mpvError.connect(self.playerError, Qt.ConnectionType.QueuedConnection)
|
|
|
|
self._volume_apply_timer = QTimer(self)
|
|
self._volume_apply_timer.setSingleShot(True)
|
|
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:
|
|
"""Play a video. entry needs at least {"url": ...}; extra keys
|
|
(id/title/channel/duration/thumbnail) travel to nowPlayingChanged."""
|
|
url = entry.get("url") or entry.get("webpage_url")
|
|
if not url:
|
|
self.playerError.emit("No playable URL in entry")
|
|
return
|
|
self._current_entry = dict(entry)
|
|
title = entry.get("title") or url
|
|
self.title_label.setText(title)
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is None:
|
|
return
|
|
options = {}
|
|
if resume_pos and resume_pos > 0:
|
|
options["start"] = f"+{max(0.0, resume_pos - 5.0):.1f}"
|
|
try:
|
|
mpv_inst.loadfile(url, **options)
|
|
mpv_inst["pause"] = False
|
|
except Exception as e:
|
|
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:
|
|
try:
|
|
mpv_inst.command("stop")
|
|
except Exception:
|
|
pass
|
|
|
|
def toggle_pause(self) -> None:
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is not None:
|
|
try:
|
|
mpv_inst["pause"] = not mpv_inst["pause"]
|
|
except Exception:
|
|
pass
|
|
|
|
def current_entry(self) -> Dict[str, Any]:
|
|
return dict(self._current_entry)
|
|
|
|
def current_position(self) -> float:
|
|
mpv_inst = self.video.mpv
|
|
try:
|
|
return float(mpv_inst["time-pos"] or 0.0) if mpv_inst else 0.0
|
|
except Exception:
|
|
return 0.0
|
|
|
|
def shutdown(self) -> None:
|
|
self.video.shutdown()
|
|
|
|
# ----------------------------------------------------------- slots (GUI)
|
|
|
|
@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))
|
|
self.seek_slider.blockSignals(False)
|
|
self.time_label.setText(f"{_format_time(pos)} / {_format_time(self._duration)}")
|
|
self.positionChanged.emit(pos)
|
|
|
|
@Slot(float)
|
|
def _on_duration(self, duration: float) -> None:
|
|
self._duration = duration
|
|
self.durationChanged.emit(duration)
|
|
|
|
@Slot(bool)
|
|
def _on_paused_changed(self, paused: bool) -> None:
|
|
icon = QStyle.StandardPixmap.SP_MediaPlay if paused else QStyle.StandardPixmap.SP_MediaPause
|
|
self.play_btn.setIcon(self.style().standardIcon(icon))
|
|
|
|
@Slot(str)
|
|
def _on_end_reached(self, reason: str) -> None:
|
|
self.playbackEnded.emit(reason)
|
|
|
|
def _on_slider_pressed(self) -> None:
|
|
self._slider_down = True
|
|
|
|
def _on_slider_released(self) -> None:
|
|
self._slider_down = False
|
|
if self._duration > 0:
|
|
target = self.seek_slider.value() / 1000 * self._duration
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is not None:
|
|
try:
|
|
mpv_inst.seek(target, reference="absolute")
|
|
except Exception as e:
|
|
logger.debug(f"Seek failed: {e}")
|
|
|
|
def _on_quality_changed(self, index: int) -> None:
|
|
height = self.quality_combo.itemData(index)
|
|
ConfigManager.set("player.default_quality", height)
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is None:
|
|
return
|
|
mpv_inst["ytdl-format"] = _ytdl_format_for(height)
|
|
# Reload the current item at the new quality, keeping position
|
|
if self._current_entry:
|
|
pos = self.current_position()
|
|
self.play(self._current_entry, resume_pos=pos + 5.0 if pos else 0.0)
|
|
|
|
def _on_speed_changed(self, index: int) -> None:
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is not None:
|
|
try:
|
|
mpv_inst["speed"] = self.speed_combo.itemData(index)
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_volume_changed(self, value: int) -> None:
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is not None:
|
|
try:
|
|
mpv_inst["volume"] = value
|
|
except Exception:
|
|
pass
|
|
self._volume_apply_timer.start()
|
|
|
|
def _persist_volume(self) -> None:
|
|
ConfigManager.set("player.volume", self.volume_slider.value())
|
|
|
|
def _on_subs_toggled(self, checked: bool) -> None:
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is not None:
|
|
try:
|
|
mpv_inst["sid"] = "auto" if checked else "no"
|
|
except Exception:
|
|
pass
|
|
|
|
def toggle_fullscreen(self) -> None:
|
|
if self._fullscreen_holder is None:
|
|
self._fullscreen_parent_layout = self.parentWidget().layout() if self.parentWidget() else None
|
|
self._fullscreen_holder = self.parentWidget()
|
|
self.setParent(None)
|
|
self.setWindowFlags(Qt.WindowType.Window)
|
|
self.showFullScreen()
|
|
else:
|
|
self.setWindowFlags(Qt.WindowType.Widget)
|
|
if self._fullscreen_parent_layout is not None:
|
|
self._fullscreen_parent_layout.addWidget(self)
|
|
else:
|
|
self.setParent(self._fullscreen_holder)
|
|
self.showNormal()
|
|
self.show()
|
|
self._fullscreen_holder = None
|
|
|
|
def keyPressEvent(self, event) -> None:
|
|
if event.key() == Qt.Key.Key_Escape and self._fullscreen_holder is not None:
|
|
self.toggle_fullscreen()
|
|
elif event.key() == Qt.Key.Key_Space:
|
|
self.toggle_pause()
|
|
elif event.key() == Qt.Key.Key_F:
|
|
self.toggle_fullscreen()
|
|
else:
|
|
super().keyPressEvent(event)
|
|
|
|
|
|
class PlayerUnavailablePanel(QWidget):
|
|
"""Placeholder shown when libmpv is missing; the rest of the app works."""
|
|
|
|
def __init__(self, hint: str, parent: Optional[QWidget] = None) -> None:
|
|
super().__init__(parent)
|
|
layout = QVBoxLayout(self)
|
|
layout.addStretch()
|
|
msg = QLabel(_("player.unavailable"))
|
|
msg.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
msg.setStyleSheet("font-size: 16px; font-weight: bold;")
|
|
layout.addWidget(msg)
|
|
hint_label = QLabel(hint)
|
|
hint_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
hint_label.setWordWrap(True)
|
|
layout.addWidget(hint_label)
|
|
layout.addStretch()
|
|
|
|
|
|
def create_player_panel(parent: Optional[QWidget] = None) -> QWidget:
|
|
"""PlayerPanel when libmpv is available, otherwise the hint placeholder."""
|
|
available, hint = probe_player()
|
|
if available:
|
|
return PlayerPanel(parent)
|
|
return PlayerUnavailablePanel(hint, parent)
|
|
|
|
|
|
def _format_time(seconds: float) -> str:
|
|
seconds = int(max(0, seconds))
|
|
h, rem = divmod(seconds, 3600)
|
|
m, s = divmod(rem, 60)
|
|
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
|