diff --git a/CHANGELOG.md b/CHANGELOG.md index 71c07ad..cf00d63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,18 @@ records. ### 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 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 @@ -76,6 +88,10 @@ records. ### 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, inputs, buttons and tables and nothing else, so the main tab bar, combo boxes, sliders, lists, menus, splitters, tooltips and the horizontal diff --git a/ytsage/gui/ytsage_gui_account.py b/ytsage/gui/ytsage_gui_account.py new file mode 100644 index 0000000..e69df71 --- /dev/null +++ b/ytsage/gui/ytsage_gui_account.py @@ -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)) diff --git a/ytsage/gui/ytsage_gui_feed.py b/ytsage/gui/ytsage_gui_feed.py index 3acd242..9987a20 100644 --- a/ytsage/gui/ytsage_gui_feed.py +++ b/ytsage/gui/ytsage_gui_feed.py @@ -128,9 +128,18 @@ class FeedPage(QWidget): self.refresh_btn = QPushButton(_("feed.refresh")) self.refresh_btn.setToolTip(_("feed.refresh_tooltip")) + self.refresh_btn.setProperty("sageIcon", "refresh") self.refresh_btn.clicked.connect(self.refresh) 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.setStyleSheet("color: #9aa0a6;") bar.addWidget(self.status_label, stretch=1) @@ -393,8 +402,17 @@ class FeedPage(QWidget): model_item = self.mode_combo.model().item(account_index) if model_item is not None: 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": 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: self._update_mode_availability() diff --git a/ytsage/gui/ytsage_gui_main.py b/ytsage/gui/ytsage_gui_main.py index 4f49ab5..4f728c7 100644 --- a/ytsage/gui/ytsage_gui_main.py +++ b/ytsage/gui/ytsage_gui_main.py @@ -567,6 +567,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): from .ytsage_gui_router import AppRouter from .ytsage_gui_search import SearchPage from .ytsage_gui_watch import WatchPage + from .ytsage_gui_account import AccountStatusButton from .ytsage_player_fullscreen import FullscreenController 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. 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: """ Tell a page it has become visible. @@ -1333,6 +1347,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): _("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 dialog = AboutDialog(self) 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.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: if self.current_download: self.current_download.cancelled = True diff --git a/ytsage/gui/ytsage_gui_player.py b/ytsage/gui/ytsage_gui_player.py index e05f7c0..d33ac2e 100644 --- a/ytsage/gui/ytsage_gui_player.py +++ b/ytsage/gui/ytsage_gui_player.py @@ -65,22 +65,38 @@ def _ytdl_format_for(height: Optional[int]) -> 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 = [] if ConfigManager.get("cookie_active"): if ConfigManager.get("cookie_source") == "file": path = ConfigManager.get("cookie_file_path") if path: - opts.append(f"cookies={path}") + opts.append(f"cookies={esc(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}") + opts.append(f"cookies-from-browser={esc(value)}") proxy = ConfigManager.get("proxy_url") 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) @@ -834,6 +850,32 @@ class PlayerPanel(QWidget): if 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]: return dict(self._current_entry) diff --git a/ytsage/gui/ytsage_gui_router.py b/ytsage/gui/ytsage_gui_router.py index a43f38b..ad7285b 100644 --- a/ytsage/gui/ytsage_gui_router.py +++ b/ytsage/gui/ytsage_gui_router.py @@ -17,3 +17,10 @@ class AppRouter(QObject): downloadVideo = Signal(str) # open Downloads tab with URL prefilled + analyzed openChannel = Signal(str) # open a channel 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() diff --git a/ytsage/languages/en.json b/ytsage/languages/en.json index 2af55ac..3ec3e80 100644 --- a/ytsage/languages/en.json +++ b/ytsage/languages/en.json @@ -757,6 +757,16 @@ "refreshed_with_failures": "{count} videos — {failed} channel(s) could not be reached", "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_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" } }