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:
parent
d30273985f
commit
b543db147a
2 changed files with 65 additions and 27 deletions
|
|
@ -38,6 +38,20 @@ from core.download_plugins.types import SearchResult, TrackResult, AlbumResult,
|
||||||
logger = get_logger("youtube_client")
|
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
|
@dataclass
|
||||||
class YouTubeSearchResult:
|
class YouTubeSearchResult:
|
||||||
"""YouTube search result with metadata parsing"""
|
"""YouTube search result with metadata parsing"""
|
||||||
|
|
@ -220,11 +234,9 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
'age_limit': None, # Don't skip age-restricted
|
'age_limit': None, # Don't skip age-restricted
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cookie support — use browser cookies for YouTube auth
|
# Cookie support — a logged-in browser store OR a pasted cookies.txt
|
||||||
from config.settings import config_manager
|
# (the 'custom' paste mode resolves to a cookiefile, not a browser name).
|
||||||
cookies_browser = config_manager.get('youtube.cookies_browser', '')
|
self.download_opts.update(_resolve_cookie_opts())
|
||||||
if cookies_browser:
|
|
||||||
self.download_opts['cookiesfrombrowser'] = (cookies_browser,)
|
|
||||||
|
|
||||||
# Track current download progress (mirrors Soulseek transfer tracking)
|
# Track current download progress (mirrors Soulseek transfer tracking)
|
||||||
self.current_download_id: Optional[str] = None
|
self.current_download_id: Optional[str] = None
|
||||||
|
|
@ -309,11 +321,12 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
"""Reload YouTube settings from config (called when settings are saved)."""
|
"""Reload YouTube settings from config (called when settings are saved)."""
|
||||||
from config.settings import config_manager
|
from config.settings import config_manager
|
||||||
self._download_delay = config_manager.get('youtube.download_delay', 3)
|
self._download_delay = config_manager.get('youtube.download_delay', 3)
|
||||||
cookies_browser = config_manager.get('youtube.cookies_browser', '')
|
# Clear both cookie sources, then re-apply from current settings (browser
|
||||||
if cookies_browser:
|
# store or pasted cookies.txt) so a mode switch doesn't leave a stale arg.
|
||||||
self.download_opts['cookiesfrombrowser'] = (cookies_browser,)
|
self.download_opts.pop('cookiesfrombrowser', None)
|
||||||
elif 'cookiesfrombrowser' in self.download_opts:
|
self.download_opts.pop('cookiefile', None)
|
||||||
del self.download_opts['cookiesfrombrowser']
|
_cookie_opts = _resolve_cookie_opts()
|
||||||
|
self.download_opts.update(_cookie_opts)
|
||||||
|
|
||||||
# Reload download path
|
# Reload download path
|
||||||
new_path = Path(config_manager.get('soulseek.download_path', './downloads'))
|
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')
|
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 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:
|
async def check_connection(self) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
@ -728,7 +741,6 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
def _search():
|
def _search():
|
||||||
from config.settings import config_manager
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
'no_warnings': True,
|
'no_warnings': True,
|
||||||
|
|
@ -736,9 +748,7 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
'default_search': 'ytsearch',
|
'default_search': 'ytsearch',
|
||||||
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
}
|
}
|
||||||
cookies_browser = config_manager.get('youtube.cookies_browser', '')
|
ydl_opts.update(_resolve_cookie_opts())
|
||||||
if cookies_browser:
|
|
||||||
ydl_opts['cookiesfrombrowser'] = (cookies_browser,)
|
|
||||||
|
|
||||||
search_query = self._escape_ytsearch_query(query)
|
search_query = self._escape_ytsearch_query(query)
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
|
@ -806,7 +816,6 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
def _search():
|
def _search():
|
||||||
from config.settings import config_manager
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
'no_warnings': True,
|
'no_warnings': True,
|
||||||
|
|
@ -816,9 +825,7 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add cookie support for search (avoids bot detection)
|
# Add cookie support for search (avoids bot detection)
|
||||||
cookies_browser = config_manager.get('youtube.cookies_browser', '')
|
ydl_opts.update(_resolve_cookie_opts())
|
||||||
if cookies_browser:
|
|
||||||
ydl_opts['cookiesfrombrowser'] = (cookies_browser,)
|
|
||||||
|
|
||||||
search_query = self._escape_ytsearch_query(query)
|
search_query = self._escape_ytsearch_query(query)
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
|
@ -1087,10 +1094,12 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
|
|
||||||
# On retry, try different strategies
|
# On retry, try different strategies
|
||||||
if attempt == 1:
|
if attempt == 1:
|
||||||
# Drop browser cookies — authenticated sessions sometimes get restricted formats
|
# Drop cookies — authenticated sessions (browser store OR a
|
||||||
if 'cookiesfrombrowser' in download_opts:
|
# pasted cookies.txt) sometimes get restricted formats.
|
||||||
logger.info(f"Retry {attempt + 1}/{max_retries} without browser cookies")
|
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('cookiesfrombrowser', None)
|
||||||
|
download_opts.pop('cookiefile', None)
|
||||||
else:
|
else:
|
||||||
logger.info(f"Retry {attempt + 1}/{max_retries} with web_creator client")
|
logger.info(f"Retry {attempt + 1}/{max_retries} with web_creator client")
|
||||||
download_opts['extractor_args'] = {
|
download_opts['extractor_args'] = {
|
||||||
|
|
@ -1100,6 +1109,7 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
logger.info(f"Retry {attempt + 1}/{max_retries} with 'best' format (video fallback)")
|
logger.info(f"Retry {attempt + 1}/{max_retries} with 'best' format (video fallback)")
|
||||||
download_opts['format'] = 'best'
|
download_opts['format'] = 'best'
|
||||||
download_opts.pop('cookiesfrombrowser', None)
|
download_opts.pop('cookiesfrombrowser', None)
|
||||||
|
download_opts.pop('cookiefile', None)
|
||||||
download_opts.pop('extractor_args', None)
|
download_opts.pop('extractor_args', None)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1160,8 +1170,6 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
Final file path if successful, None otherwise
|
Final file path if successful, None otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from config.settings import config_manager
|
|
||||||
|
|
||||||
def _progress_hook(d):
|
def _progress_hook(d):
|
||||||
if progress_callback and d.get('status') == 'downloading':
|
if progress_callback and d.get('status') == 'downloading':
|
||||||
total = d.get('total_bytes') or d.get('total_bytes_estimate') or 0
|
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',
|
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
}
|
}
|
||||||
|
|
||||||
cookies_browser = config_manager.get('youtube.cookies_browser', '')
|
download_opts.update(_resolve_cookie_opts())
|
||||||
if cookies_browser:
|
|
||||||
download_opts['cookiesfrombrowser'] = (cookies_browser,)
|
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(download_opts) as ydl:
|
with yt_dlp.YoutubeDL(download_opts) as ydl:
|
||||||
info = ydl.extract_info(video_url, download=True)
|
info = ydl.extract_info(video_url, download=True)
|
||||||
|
|
|
||||||
|
|
@ -98,3 +98,35 @@ def test_write_refuses_junk_without_clobbering_existing(tmp_path):
|
||||||
before = dest.read_text()
|
before = dest.read_text()
|
||||||
assert write_pasted_cookiefile("", str(dest)) == ""
|
assert write_pasted_cookiefile("", str(dest)) == ""
|
||||||
assert dest.read_text() == before
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue