Make the YouTube account reachable, and applied without a restart
The cookie login was not missing, it was buried: Downloads tab -> Custom Options -> the "Login with Cookies" tab, where Downloads is one tab of five. The three features that depend on it -- the Feed's account mode, Search and Browse -- are other tabs, with no route to it. show_cookie_login_dialog(), which opens that dialog on the right tab, had no caller anywhere in the codebase; it does now. An account button sits in the corner of the tab bar, visible from every tab, saying whether cookies are in use and where they came from. The Feed offers the same thing next to its account mode rather than greying the option out and leaving it at that, and the disabled entry now says why it is disabled. Applying cookies used to require a restart before playback saw them: _build_ytdl_raw_options was read once, when the player widget was built. A cookiesChanged signal now reaches the account button, the Feed and the live mpv handle, and the player reloads at its current position -- ytdl_hook consults the option when it resolves a URL, so anything already playing keeps the streams it resolved with. YtdlpClient needed no change: it re-reads config per call. Two bugs in the same function: the options are joined with commas and were never escaped, so a cookie path or a proxy URL containing one silently ended the option and started a bogus one; and geo_proxy_url was honoured by the downloader but never passed to the player, so a geo-restricted video would download and then refuse to play. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,18 @@ records.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **The YouTube account is findable.** Signing in with browser cookies was
|
||||||
|
reachable only via Downloads → *Custom Options* → *Login with Cookies* — the
|
||||||
|
last tab — while the features that need it (the Feed's account mode, Search,
|
||||||
|
Browse) are other tabs with no route to it, and the method that opens that
|
||||||
|
dialog on the right tab had no caller at all. There is now an account button
|
||||||
|
in the corner of the tab bar, visible from everywhere, showing whether
|
||||||
|
cookies are in use and which browser they came from. The Feed offers the same
|
||||||
|
thing beside its account mode instead of just greying it out, and now says
|
||||||
|
why it is unavailable.
|
||||||
|
- **Cookie changes take effect immediately.** The player read them once, when
|
||||||
|
it was built, so signing in only reached playback after restarting the app.
|
||||||
|
It now reloads at the current position.
|
||||||
- **The player responds to the keyboard and the mouse.** It had a key handler
|
- **The player responds to the keyboard and the mouse.** It had a key handler
|
||||||
for Space, F and Escape that could never fire — nothing in the player set a
|
for Space, F and Escape that could never fire — nothing in the player set a
|
||||||
focus policy, so focus always landed on a child button or slider, which
|
focus policy, so focus always landed on a child button or slider, which
|
||||||
@@ -76,6 +88,10 @@ records.
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- The player's cookie and proxy options were joined with commas and never
|
||||||
|
escaped, so a cookie path or a proxy URL containing one silently truncated
|
||||||
|
the option and produced a bogus one. The geo-bypass proxy was honoured by the
|
||||||
|
downloader but never passed to the player at all.
|
||||||
- **The rest of the interface is themed.** `StyleSheet.MAIN` styled the window,
|
- **The rest of the interface is themed.** `StyleSheet.MAIN` styled the window,
|
||||||
inputs, buttons and tables and nothing else, so the main tab bar, combo
|
inputs, buttons and tables and nothing else, so the main tab bar, combo
|
||||||
boxes, sliders, lists, menus, splitters, tooltips and the horizontal
|
boxes, sliders, lists, menus, splitters, tooltips and the horizontal
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
The YouTube account indicator
|
||||||
|
=============================
|
||||||
|
|
||||||
|
SageTube can use browser cookies to reach a signed-in YouTube: the account
|
||||||
|
feed, age-restricted and members-only videos, and premium formats. All of that
|
||||||
|
worked -- but the only way to switch it on was Downloads tab -> Custom Options
|
||||||
|
-> the "Login with Cookies" tab, and Downloads is one tab among five. The three
|
||||||
|
features that need it (the Feed's account mode, Search, Browse) had no route to
|
||||||
|
it at all, and `show_cookie_login_dialog()` existed with no caller anywhere in
|
||||||
|
the codebase.
|
||||||
|
|
||||||
|
So this is a button in the tab bar's corner, visible from every tab, that says
|
||||||
|
whether cookies are active and opens the existing dialog. Nothing about the
|
||||||
|
cookie handling itself changes; it just became findable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtWidgets import QPushButton, QWidget
|
||||||
|
|
||||||
|
from . import ytsage_icons as icons
|
||||||
|
from . import ytsage_theme as theme
|
||||||
|
from ..utils.ytsage_config_manager import ConfigManager
|
||||||
|
from ..utils.ytsage_localization import _
|
||||||
|
|
||||||
|
|
||||||
|
class AccountStatusButton(QPushButton):
|
||||||
|
"""Shows the cookie state and opens the login dialog when pressed."""
|
||||||
|
|
||||||
|
loginRequested = Signal()
|
||||||
|
|
||||||
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("accountStatusButton")
|
||||||
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
# Not a call to action -- it sits in the chrome, so it should not
|
||||||
|
# compete with the accent-coloured buttons in the pages.
|
||||||
|
self.setStyleSheet(
|
||||||
|
f"""
|
||||||
|
QPushButton#accountStatusButton {{
|
||||||
|
background-color: transparent;
|
||||||
|
color: {theme.TEXT_MUTED};
|
||||||
|
border: 1px solid {theme.BORDER};
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
margin: 4px 8px 4px 4px;
|
||||||
|
font-weight: normal;
|
||||||
|
}}
|
||||||
|
QPushButton#accountStatusButton:hover {{
|
||||||
|
color: {theme.TEXT};
|
||||||
|
background-color: {theme.SURFACE_HOVER};
|
||||||
|
border-color: {theme.BORDER_STRONG};
|
||||||
|
}}
|
||||||
|
QPushButton#accountStatusButton:pressed {{
|
||||||
|
background-color: {theme.BORDER};
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self.clicked.connect(self.loginRequested)
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
"""Re-read the cookie config and restate what it says."""
|
||||||
|
active = bool(ConfigManager.get("cookie_active"))
|
||||||
|
if not active:
|
||||||
|
self.setIcon(icons.icon("user", theme.TEXT_MUTED))
|
||||||
|
self.setText(_("account.signed_out"))
|
||||||
|
self.setToolTip(_("account.signed_out_tooltip"))
|
||||||
|
return
|
||||||
|
|
||||||
|
if (ConfigManager.get("cookie_source") or "browser") == "file":
|
||||||
|
source = _("account.source_file")
|
||||||
|
else:
|
||||||
|
browser = ConfigManager.get("cookie_browser") or "?"
|
||||||
|
profile = ConfigManager.get("cookie_browser_profile")
|
||||||
|
source = f"{browser}:{profile}" if profile else str(browser)
|
||||||
|
|
||||||
|
self.setIcon(icons.icon("user-check", theme.TEXT))
|
||||||
|
self.setText(_("account.signed_in", source=source))
|
||||||
|
self.setToolTip(_("account.signed_in_tooltip", source=source))
|
||||||
@@ -128,9 +128,18 @@ class FeedPage(QWidget):
|
|||||||
|
|
||||||
self.refresh_btn = QPushButton(_("feed.refresh"))
|
self.refresh_btn = QPushButton(_("feed.refresh"))
|
||||||
self.refresh_btn.setToolTip(_("feed.refresh_tooltip"))
|
self.refresh_btn.setToolTip(_("feed.refresh_tooltip"))
|
||||||
|
self.refresh_btn.setProperty("sageIcon", "refresh")
|
||||||
self.refresh_btn.clicked.connect(self.refresh)
|
self.refresh_btn.clicked.connect(self.refresh)
|
||||||
bar.addWidget(self.refresh_btn)
|
bar.addWidget(self.refresh_btn)
|
||||||
|
|
||||||
|
# Account mode is disabled without cookies. Rather than leaving that
|
||||||
|
# as a dead end, offer the way out right beside it.
|
||||||
|
self.signin_btn = QPushButton(_("feed.sign_in"))
|
||||||
|
self.signin_btn.setToolTip(_("feed.sign_in_tooltip"))
|
||||||
|
self.signin_btn.setProperty("sageIcon", "user")
|
||||||
|
self.signin_btn.clicked.connect(self._router.cookieSetupRequested)
|
||||||
|
bar.addWidget(self.signin_btn)
|
||||||
|
|
||||||
self.status_label = QLabel("")
|
self.status_label = QLabel("")
|
||||||
self.status_label.setStyleSheet("color: #9aa0a6;")
|
self.status_label.setStyleSheet("color: #9aa0a6;")
|
||||||
bar.addWidget(self.status_label, stretch=1)
|
bar.addWidget(self.status_label, stretch=1)
|
||||||
@@ -393,8 +402,17 @@ class FeedPage(QWidget):
|
|||||||
model_item = self.mode_combo.model().item(account_index)
|
model_item = self.mode_combo.model().item(account_index)
|
||||||
if model_item is not None:
|
if model_item is not None:
|
||||||
model_item.setEnabled(cookies_on)
|
model_item.setEnabled(cookies_on)
|
||||||
|
# Say *why* it is unavailable. It used to be greyed out with no
|
||||||
|
# explanation and no way to do anything about it.
|
||||||
|
model_item.setToolTip("" if cookies_on else _("feed.account_requires_cookies"))
|
||||||
if not cookies_on and self.mode_combo.currentData() == "account":
|
if not cookies_on and self.mode_combo.currentData() == "account":
|
||||||
self.mode_combo.setCurrentIndex(self.mode_combo.findData("local"))
|
self.mode_combo.setCurrentIndex(self.mode_combo.findData("local"))
|
||||||
|
# The way out of that dead end: offered only while it is one.
|
||||||
|
self.signin_btn.setVisible(not cookies_on)
|
||||||
|
|
||||||
|
def on_cookies_changed(self) -> None:
|
||||||
|
"""Cookies were applied elsewhere; account mode may now be usable."""
|
||||||
|
self._update_mode_availability()
|
||||||
|
|
||||||
def showEvent(self, event) -> None:
|
def showEvent(self, event) -> None:
|
||||||
self._update_mode_availability()
|
self._update_mode_availability()
|
||||||
|
|||||||
@@ -567,6 +567,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
|||||||
from .ytsage_gui_router import AppRouter
|
from .ytsage_gui_router import AppRouter
|
||||||
from .ytsage_gui_search import SearchPage
|
from .ytsage_gui_search import SearchPage
|
||||||
from .ytsage_gui_watch import WatchPage
|
from .ytsage_gui_watch import WatchPage
|
||||||
|
from .ytsage_gui_account import AccountStatusButton
|
||||||
from .ytsage_player_fullscreen import FullscreenController
|
from .ytsage_player_fullscreen import FullscreenController
|
||||||
|
|
||||||
self.router = AppRouter(self)
|
self.router = AppRouter(self)
|
||||||
@@ -607,6 +608,19 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
|||||||
# A new subscription changes what the Feed's empty state should say.
|
# A new subscription changes what the Feed's empty state should say.
|
||||||
self.feed_page.subscriptionsChanged.connect(self.browse_page.refresh_subscribe_state)
|
self.feed_page.subscriptionsChanged.connect(self.browse_page.refresh_subscribe_state)
|
||||||
|
|
||||||
|
# The cookie login had exactly one entry point -- Downloads tab ->
|
||||||
|
# Custom Options -> Login with Cookies -- while the features needing
|
||||||
|
# it live on other tabs. A corner button makes it reachable from all
|
||||||
|
# of them, and the Feed offers it where account mode is disabled.
|
||||||
|
self.account_button = AccountStatusButton(self.main_tabs)
|
||||||
|
self.account_button.loginRequested.connect(self.show_cookie_login_dialog)
|
||||||
|
self.main_tabs.setCornerWidget(self.account_button)
|
||||||
|
self.router.cookieSetupRequested.connect(self.show_cookie_login_dialog)
|
||||||
|
self.router.cookiesChanged.connect(self.account_button.refresh)
|
||||||
|
self.router.cookiesChanged.connect(self.feed_page.on_cookies_changed)
|
||||||
|
if isinstance(self.watch_page.player, PlayerPanel):
|
||||||
|
self.router.cookiesChanged.connect(self.watch_page.player.reload_auth)
|
||||||
|
|
||||||
def _on_main_tab_changed(self, index: int) -> None:
|
def _on_main_tab_changed(self, index: int) -> None:
|
||||||
"""
|
"""
|
||||||
Tell a page it has become visible.
|
Tell a page it has become visible.
|
||||||
@@ -1333,6 +1347,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
|||||||
_("proxy.cleared_message"),
|
_("proxy.cleared_message"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# This dialog's Cookies tab persists on OK too, and proxy settings
|
||||||
|
# reach the player by the same route, so announce both from here.
|
||||||
|
if hasattr(self, "router"):
|
||||||
|
self.router.cookiesChanged.emit()
|
||||||
|
|
||||||
def show_about_dialog(self) -> None: # ADDED METHOD HERE
|
def show_about_dialog(self) -> None: # ADDED METHOD HERE
|
||||||
dialog = AboutDialog(self)
|
dialog = AboutDialog(self)
|
||||||
self.run_dialog_with_blur(dialog)
|
self.run_dialog_with_blur(dialog)
|
||||||
@@ -1711,6 +1730,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
|||||||
self.cookie_file_path = None # Clear path if dialog accepted but no file selected
|
self.cookie_file_path = None # Clear path if dialog accepted but no file selected
|
||||||
self.browser_cookies_option = None # Clear browser cookies too
|
self.browser_cookies_option = None # Clear browser cookies too
|
||||||
|
|
||||||
|
# The dialog has already written the config; tell the rest of the
|
||||||
|
# app so signing in takes effect now rather than after a restart.
|
||||||
|
if hasattr(self, "router"):
|
||||||
|
self.router.cookiesChanged.emit()
|
||||||
|
|
||||||
def cancel_download(self) -> None:
|
def cancel_download(self) -> None:
|
||||||
if self.current_download:
|
if self.current_download:
|
||||||
self.current_download.cancelled = True
|
self.current_download.cancelled = True
|
||||||
|
|||||||
@@ -65,22 +65,38 @@ def _ytdl_format_for(height: Optional[int]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _build_ytdl_raw_options() -> str:
|
def _build_ytdl_raw_options() -> str:
|
||||||
"""Mirror the app's cookie/proxy config into ytdl_hook raw options."""
|
"""
|
||||||
|
Mirror the app's cookie/proxy config into ytdl_hook raw options.
|
||||||
|
|
||||||
|
mpv splits this option on commas, so any comma inside a value would end
|
||||||
|
the option and start a bogus one -- silently, and a Windows cookie path or
|
||||||
|
a proxy URL with credentials can easily contain one. mpv's own escape for
|
||||||
|
that is a backslash, so values are escaped rather than trusted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def esc(value) -> str:
|
||||||
|
return str(value).replace("\\", "\\\\").replace(",", "\\,")
|
||||||
|
|
||||||
opts = []
|
opts = []
|
||||||
if ConfigManager.get("cookie_active"):
|
if ConfigManager.get("cookie_active"):
|
||||||
if ConfigManager.get("cookie_source") == "file":
|
if ConfigManager.get("cookie_source") == "file":
|
||||||
path = ConfigManager.get("cookie_file_path")
|
path = ConfigManager.get("cookie_file_path")
|
||||||
if path:
|
if path:
|
||||||
opts.append(f"cookies={path}")
|
opts.append(f"cookies={esc(path)}")
|
||||||
else:
|
else:
|
||||||
browser = ConfigManager.get("cookie_browser")
|
browser = ConfigManager.get("cookie_browser")
|
||||||
profile = ConfigManager.get("cookie_browser_profile")
|
profile = ConfigManager.get("cookie_browser_profile")
|
||||||
if browser:
|
if browser:
|
||||||
value = f"{browser}:{profile}" if profile else browser
|
value = f"{browser}:{profile}" if profile else browser
|
||||||
opts.append(f"cookies-from-browser={value}")
|
opts.append(f"cookies-from-browser={esc(value)}")
|
||||||
proxy = ConfigManager.get("proxy_url")
|
proxy = ConfigManager.get("proxy_url")
|
||||||
if proxy:
|
if proxy:
|
||||||
opts.append(f"proxy={proxy}")
|
opts.append(f"proxy={esc(proxy)}")
|
||||||
|
# The downloader honours geo_proxy_url; the player was ignoring it, so a
|
||||||
|
# geo-restricted video downloaded but would not play.
|
||||||
|
geo_proxy = ConfigManager.get("geo_proxy_url")
|
||||||
|
if geo_proxy:
|
||||||
|
opts.append(f"geo-verification-proxy={esc(geo_proxy)}")
|
||||||
return ",".join(opts)
|
return ",".join(opts)
|
||||||
|
|
||||||
|
|
||||||
@@ -834,6 +850,32 @@ class PlayerPanel(QWidget):
|
|||||||
if url:
|
if url:
|
||||||
webbrowser.open(str(url))
|
webbrowser.open(str(url))
|
||||||
|
|
||||||
|
def reload_auth(self) -> None:
|
||||||
|
"""
|
||||||
|
Push changed cookie/proxy settings onto the live mpv handle.
|
||||||
|
|
||||||
|
These were read once, when the widget was built, so signing in only
|
||||||
|
reached playback after restarting the app. ytdl_hook consults the
|
||||||
|
option when it resolves a URL, so anything already playing keeps the
|
||||||
|
streams it resolved with -- it is reloaded at its current position.
|
||||||
|
"""
|
||||||
|
mpv_inst = self.video.mpv
|
||||||
|
if mpv_inst is None:
|
||||||
|
return
|
||||||
|
raw_opts = _build_ytdl_raw_options()
|
||||||
|
try:
|
||||||
|
mpv_inst["ytdl-raw-options"] = raw_opts
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not update ytdl-raw-options: {e}")
|
||||||
|
return
|
||||||
|
logger.info("Player authentication settings reloaded.")
|
||||||
|
|
||||||
|
if self._current_entry and self._playback_started:
|
||||||
|
position = self.current_position()
|
||||||
|
entry = self._current_entry
|
||||||
|
self._current_entry = {}
|
||||||
|
self.play(entry, resume_pos=position)
|
||||||
|
|
||||||
def current_entry(self) -> Dict[str, Any]:
|
def current_entry(self) -> Dict[str, Any]:
|
||||||
return dict(self._current_entry)
|
return dict(self._current_entry)
|
||||||
|
|
||||||
|
|||||||
@@ -17,3 +17,10 @@ class AppRouter(QObject):
|
|||||||
downloadVideo = Signal(str) # open Downloads tab with URL prefilled + analyzed
|
downloadVideo = Signal(str) # open Downloads tab with URL prefilled + analyzed
|
||||||
openChannel = Signal(str) # open a channel URL in the Browse tab
|
openChannel = Signal(str) # open a channel URL in the Browse tab
|
||||||
openPlaylist = Signal(str) # open a playlist URL in the Browse tab
|
openPlaylist = Signal(str) # open a playlist URL in the Browse tab
|
||||||
|
# Cookie settings were applied. YtdlpClient re-reads config per call so it
|
||||||
|
# needs no telling, but the account button, the Feed's mode availability
|
||||||
|
# and the live mpv handle all do -- otherwise signing in only takes effect
|
||||||
|
# after a restart.
|
||||||
|
cookiesChanged = Signal()
|
||||||
|
# Somewhere in the UI asked for the cookie login dialog.
|
||||||
|
cookieSetupRequested = Signal()
|
||||||
|
|||||||
@@ -757,6 +757,16 @@
|
|||||||
"refreshed_with_failures": "{count} videos — {failed} channel(s) could not be reached",
|
"refreshed_with_failures": "{count} videos — {failed} channel(s) could not be reached",
|
||||||
"account_needs_refresh": "Press Refresh to load your account feed",
|
"account_needs_refresh": "Press Refresh to load your account feed",
|
||||||
"empty_no_subscriptions": "No subscriptions yet.\n\nFind channels in Search or Browse, then use Subscribe to follow them — their new uploads will appear here.",
|
"empty_no_subscriptions": "No subscriptions yet.\n\nFind channels in Search or Browse, then use Subscribe to follow them — their new uploads will appear here.",
|
||||||
"empty_not_refreshed": "Nothing here yet.\n\nPress Refresh to fetch the latest uploads from your subscriptions."
|
"empty_not_refreshed": "Nothing here yet.\n\nPress Refresh to fetch the latest uploads from your subscriptions.",
|
||||||
|
"sign_in": "Sign in with cookies…",
|
||||||
|
"sign_in_tooltip": "Use your YouTube account via browser cookies, which enables the account feed",
|
||||||
|
"account_requires_cookies": "Requires signing in with cookies"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"signed_out": "Not signed in",
|
||||||
|
"signed_out_tooltip": "SageTube is not using a YouTube account. Sign in with browser cookies to use your subscription feed and reach age-restricted or members-only videos.",
|
||||||
|
"signed_in": "Account: {source}",
|
||||||
|
"signed_in_tooltip": "Using cookies from {source}. Click to change or clear them.",
|
||||||
|
"source_file": "cookie file"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user