From b543db147a751f07aef75782b04fb338b61b8af8 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 27 Jun 2026 23:47:38 -0700 Subject: [PATCH] youtube: use pasted cookies.txt instead of passing 'custom' as a browser to yt-dlp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker report (HellRa1SeR, 2.7.8): pasting YouTube cookies threw `ERROR: unsupported browser: "custom"`. the 'Paste cookies.txt' dropdown value is the sentinel 'custom', and youtube_client built `cookiesfrombrowser=('custom',)` from it — yt-dlp rejects that since 'custom' isn't a browser. (server/Docker users have no local browser, so pasted cookies.txt is the ONLY way to authenticate yt-dlp — e.g. for private 'Liked Music'.) the correct precedence already existed in core.youtube_cookies.build_youtube_cookie_opts ('custom' -> cookiefile, browser name -> cookiesfrombrowser, falsy -> anonymous) and the web layer used it — but youtube_client never got migrated and kept 5 inline cookiesfrombrowser sites. route all of them through a shared _resolve_cookie_opts() that delegates to the tested helper (reads youtube.cookies_browser + youtube.cookies_file, checks the file exists). also: the no-cookies download retry now drops cookiefile too (not just cookiesfrombrowser), and removed 3 now-dead config_manager imports. 3 regression tests (custom->cookiefile, browser unchanged, custom+missing-file->anonymous); 97 youtube tests green. --- core/youtube_client.py | 60 +++++++++++++++++++---------------- tests/test_youtube_cookies.py | 32 +++++++++++++++++++ 2 files changed, 65 insertions(+), 27 deletions(-) diff --git a/core/youtube_client.py b/core/youtube_client.py index a1cef67b..8cd4b400 100644 --- a/core/youtube_client.py +++ b/core/youtube_client.py @@ -38,6 +38,20 @@ from core.download_plugins.types import SearchResult, TrackResult, AlbumResult, logger = get_logger("youtube_client") +def _resolve_cookie_opts() -> dict: + """yt-dlp cookie options from Settings → YouTube: either a browser store OR a + pasted cookies.txt. The 'Paste cookies.txt' dropdown value is the sentinel + 'custom' — which must become a yt-dlp ``cookiefile`` pointing at the saved file, + NOT be passed through as a browser name (yt-dlp rejects: 'unsupported browser: + custom'). Delegates to the shared, tested precedence in core.youtube_cookies.""" + from config.settings import config_manager + from core.youtube_cookies import build_youtube_cookie_opts + mode = config_manager.get('youtube.cookies_browser', '') + cookiefile = config_manager.get('youtube.cookies_file', '') + exists = bool(cookiefile) and os.path.exists(cookiefile) + return build_youtube_cookie_opts(mode, cookiefile, cookiefile_exists=exists) + + @dataclass class YouTubeSearchResult: """YouTube search result with metadata parsing""" @@ -220,11 +234,9 @@ class YouTubeClient(DownloadSourcePlugin): 'age_limit': None, # Don't skip age-restricted } - # Cookie support — use browser cookies for YouTube auth - from config.settings import config_manager - cookies_browser = config_manager.get('youtube.cookies_browser', '') - if cookies_browser: - self.download_opts['cookiesfrombrowser'] = (cookies_browser,) + # Cookie support — a logged-in browser store OR a pasted cookies.txt + # (the 'custom' paste mode resolves to a cookiefile, not a browser name). + self.download_opts.update(_resolve_cookie_opts()) # Track current download progress (mirrors Soulseek transfer tracking) self.current_download_id: Optional[str] = None @@ -309,11 +321,12 @@ class YouTubeClient(DownloadSourcePlugin): """Reload YouTube settings from config (called when settings are saved).""" from config.settings import config_manager self._download_delay = config_manager.get('youtube.download_delay', 3) - cookies_browser = config_manager.get('youtube.cookies_browser', '') - if cookies_browser: - self.download_opts['cookiesfrombrowser'] = (cookies_browser,) - elif 'cookiesfrombrowser' in self.download_opts: - del self.download_opts['cookiesfrombrowser'] + # Clear both cookie sources, then re-apply from current settings (browser + # store or pasted cookies.txt) so a mode switch doesn't leave a stale arg. + self.download_opts.pop('cookiesfrombrowser', None) + self.download_opts.pop('cookiefile', None) + _cookie_opts = _resolve_cookie_opts() + self.download_opts.update(_cookie_opts) # Reload download path new_path = Path(config_manager.get('soulseek.download_path', './downloads')) @@ -323,7 +336,7 @@ class YouTubeClient(DownloadSourcePlugin): self.download_opts['outtmpl'] = str(self.download_path / '%(title)s.%(ext)s') logger.info(f"YouTube download path updated to: {self.download_path}") - logger.info(f"YouTube settings reloaded (delay={self._download_delay}s, cookies={'enabled' if cookies_browser else 'disabled'})") + logger.info(f"YouTube settings reloaded (delay={self._download_delay}s, cookies={'enabled' if _cookie_opts else 'disabled'})") async def check_connection(self) -> bool: """ @@ -728,7 +741,6 @@ class YouTubeClient(DownloadSourcePlugin): loop = asyncio.get_event_loop() def _search(): - from config.settings import config_manager ydl_opts = { 'quiet': True, 'no_warnings': True, @@ -736,9 +748,7 @@ class YouTubeClient(DownloadSourcePlugin): 'default_search': 'ytsearch', 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', } - cookies_browser = config_manager.get('youtube.cookies_browser', '') - if cookies_browser: - ydl_opts['cookiesfrombrowser'] = (cookies_browser,) + ydl_opts.update(_resolve_cookie_opts()) search_query = self._escape_ytsearch_query(query) with yt_dlp.YoutubeDL(ydl_opts) as ydl: @@ -806,7 +816,6 @@ class YouTubeClient(DownloadSourcePlugin): loop = asyncio.get_event_loop() def _search(): - from config.settings import config_manager ydl_opts = { 'quiet': True, 'no_warnings': True, @@ -816,9 +825,7 @@ class YouTubeClient(DownloadSourcePlugin): } # Add cookie support for search (avoids bot detection) - cookies_browser = config_manager.get('youtube.cookies_browser', '') - if cookies_browser: - ydl_opts['cookiesfrombrowser'] = (cookies_browser,) + ydl_opts.update(_resolve_cookie_opts()) search_query = self._escape_ytsearch_query(query) with yt_dlp.YoutubeDL(ydl_opts) as ydl: @@ -1087,10 +1094,12 @@ class YouTubeClient(DownloadSourcePlugin): # On retry, try different strategies if attempt == 1: - # Drop browser cookies — authenticated sessions sometimes get restricted formats - if 'cookiesfrombrowser' in download_opts: - logger.info(f"Retry {attempt + 1}/{max_retries} without browser cookies") + # Drop cookies — authenticated sessions (browser store OR a + # pasted cookies.txt) sometimes get restricted formats. + if 'cookiesfrombrowser' in download_opts or 'cookiefile' in download_opts: + logger.info(f"Retry {attempt + 1}/{max_retries} without cookies") download_opts.pop('cookiesfrombrowser', None) + download_opts.pop('cookiefile', None) else: logger.info(f"Retry {attempt + 1}/{max_retries} with web_creator client") download_opts['extractor_args'] = { @@ -1100,6 +1109,7 @@ class YouTubeClient(DownloadSourcePlugin): logger.info(f"Retry {attempt + 1}/{max_retries} with 'best' format (video fallback)") download_opts['format'] = 'best' download_opts.pop('cookiesfrombrowser', None) + download_opts.pop('cookiefile', None) download_opts.pop('extractor_args', None) @@ -1160,8 +1170,6 @@ class YouTubeClient(DownloadSourcePlugin): Final file path if successful, None otherwise """ try: - from config.settings import config_manager - def _progress_hook(d): if progress_callback and d.get('status') == 'downloading': total = d.get('total_bytes') or d.get('total_bytes_estimate') or 0 @@ -1180,9 +1188,7 @@ class YouTubeClient(DownloadSourcePlugin): 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', } - cookies_browser = config_manager.get('youtube.cookies_browser', '') - if cookies_browser: - download_opts['cookiesfrombrowser'] = (cookies_browser,) + download_opts.update(_resolve_cookie_opts()) with yt_dlp.YoutubeDL(download_opts) as ydl: info = ydl.extract_info(video_url, download=True) diff --git a/tests/test_youtube_cookies.py b/tests/test_youtube_cookies.py index 0d7d3a04..ea852435 100644 --- a/tests/test_youtube_cookies.py +++ b/tests/test_youtube_cookies.py @@ -98,3 +98,35 @@ def test_write_refuses_junk_without_clobbering_existing(tmp_path): before = dest.read_text() assert write_pasted_cookiefile("", str(dest)) == "" assert dest.read_text() == before + + +# ── regression: youtube_client must USE the helper, not pass 'custom' as a browser ── +# (Docker bug: pasted cookies threw yt-dlp 'unsupported browser: "custom"' because the +# client built cookiesfrombrowser=('custom',) instead of a cookiefile.) + +def test_resolve_cookie_opts_routes_custom_to_cookiefile(monkeypatch, tmp_path): + import core.youtube_client as yt + cookiefile = tmp_path / "youtube_cookies.txt" + cookiefile.write_text(".youtube.com\tTRUE\t/\tTRUE\t123\tSID\tv\n") + cfg = {'youtube.cookies_browser': 'custom', 'youtube.cookies_file': str(cookiefile)} + monkeypatch.setattr('config.settings.config_manager.get', + lambda k, d=None: cfg.get(k, d)) + opts = yt._resolve_cookie_opts() + assert opts == {'cookiefile': str(cookiefile)} + assert 'cookiesfrombrowser' not in opts # never the bogus browser arg + + +def test_resolve_cookie_opts_browser_mode_unchanged(monkeypatch): + import core.youtube_client as yt + cfg = {'youtube.cookies_browser': 'firefox', 'youtube.cookies_file': ''} + monkeypatch.setattr('config.settings.config_manager.get', + lambda k, d=None: cfg.get(k, d)) + assert yt._resolve_cookie_opts() == {'cookiesfrombrowser': ('firefox',)} + + +def test_resolve_cookie_opts_custom_missing_file_is_anonymous(monkeypatch): + import core.youtube_client as yt + cfg = {'youtube.cookies_browser': 'custom', 'youtube.cookies_file': '/nope/gone.txt'} + monkeypatch.setattr('config.settings.config_manager.get', + lambda k, d=None: cfg.get(k, d)) + assert yt._resolve_cookie_opts() == {} # not a broken cookiefile arg