youtube: use pasted cookies.txt instead of passing 'custom' as a browser to yt-dlp

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.
This commit is contained in:
BoulderBadgeDad 2026-06-27 23:47:38 -07:00
parent d30273985f
commit b543db147a
2 changed files with 65 additions and 27 deletions

View file

@ -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)

View file

@ -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