diff --git a/.gitignore b/.gitignore index 8857f8d5..6e78b278 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ __pycache__/ # User-specific files (auto-created by the app if missing) config/config.json +config/youtube_cookies.txt database/music_library.db database/music_library.db-shm database/music_library.db-wal diff --git a/core/youtube_cookies.py b/core/youtube_cookies.py new file mode 100644 index 00000000..c61086fe --- /dev/null +++ b/core/youtube_cookies.py @@ -0,0 +1,105 @@ +"""YouTube cookie options for yt-dlp — a browser store *or* a pasted cookies.txt. + +Settings → YouTube offers two ways to authenticate yt-dlp: + +* a **browser dropdown** (Chrome/Firefox/…) → yt-dlp ``cookiesfrombrowser``, which + reads a logged-in browser's cookie store *on the same machine as SoulSync*. Great + for local installs, useless on a headless server / Docker box (no browser there). +* a **"Paste cookies.txt"** mode → yt-dlp ``cookiefile``, a Netscape-format cookie + file the user exports (e.g. with a "Get cookies.txt LOCALLY" extension) and pastes + in. This is the only path that works for server/Docker users, and it's what makes + *private* playlists — a user's "Liked Music" (``list=LM``) — actually visible. + +This module centralises the precedence and the pasted-file validation so the live +opts (:func:`build_youtube_cookie_opts`) and the settings-save write agree, and so +the seam is unit-testable without I/O. The web layer owns *where* the file lives +(next to ``config.json``); this module only decides the opts and validates content. +""" + +from __future__ import annotations + +import os +from typing import Any, Dict + +# Sentinel dropdown value meaning "use a pasted cookies.txt file" rather than a +# browser name. Anything else non-empty is treated as a browser for cookiesfrombrowser. +PASTE_MODE = "custom" + + +def build_youtube_cookie_opts( + mode: Any, + cookiefile_path: str = "", + *, + cookiefile_exists: bool = False, +) -> Dict[str, Any]: + """Return the yt-dlp cookie options for a given Settings→YouTube ``mode``. Pure. + + * ``mode == PASTE_MODE`` → ``{'cookiefile': path}`` when the file exists, else + ``{}`` (a stale/missing path must never become a broken cookiefile arg). + * ``mode`` is any other non-empty string → ``{'cookiesfrombrowser': (mode,)}``. + * ``mode`` falsy → ``{}`` (anonymous; public playlists only). + + Precedence is structural: a browser name is never ``PASTE_MODE``, so the two + cookie sources can't both be emitted. No I/O here — the caller passes + ``cookiefile_exists`` (the ``os.path.exists`` result) so this stays pure. + """ + m = str(mode or "").strip() + if m == PASTE_MODE: + if cookiefile_path and cookiefile_exists: + return {"cookiefile": str(cookiefile_path)} + return {} + if m: + return {"cookiesfrombrowser": (m,)} + return {} + + +def looks_like_cookiefile(content: Any) -> bool: + """True when ``content`` plausibly is a Netscape/Mozilla ``cookies.txt``. + + Requires at least one real cookie row — a non-comment line with >= 6 TAB-separated + fields (domain, flag, path, secure, expiry, name[, value]). The ``# Netscape HTTP + Cookie File`` header alone is NOT enough: a header-only paste carries no auth and + would silently save a useless file. This guards the save path so pasting junk (a + URL, JSON, or just the header) is rejected up front instead of being written out + and making yt-dlp raise mid-extraction. + """ + if not content or not isinstance(content, str): + return False + for raw in content.splitlines(): + line = raw.rstrip("\n") + if not line or line.lstrip().startswith("#"): + continue + if len(line.split("\t")) >= 6: + return True + return False + + +def write_pasted_cookiefile(content: Any, dest_path: str) -> str: + """Validate + write a pasted ``cookies.txt`` to ``dest_path``. + + Returns the written path on success, or ``""`` when the content is empty / + doesn't look like a cookie file / can't be written — in which case the caller + leaves any existing file untouched (a blank save must not wipe a saved cookie). + Best-effort ``0600`` perms since the file holds live session secrets. + """ + if not looks_like_cookiefile(content): + return "" + try: + text = content if content.endswith("\n") else content + "\n" + with open(dest_path, "w", encoding="utf-8") as fh: + fh.write(text) + try: + os.chmod(dest_path, 0o600) + except OSError: + pass + return str(dest_path) + except OSError: + return "" + + +__all__ = [ + "PASTE_MODE", + "build_youtube_cookie_opts", + "looks_like_cookiefile", + "write_pasted_cookiefile", +] diff --git a/tests/test_youtube_cookies.py b/tests/test_youtube_cookies.py new file mode 100644 index 00000000..0d7d3a04 --- /dev/null +++ b/tests/test_youtube_cookies.py @@ -0,0 +1,100 @@ +"""Settings → YouTube cookie options: browser store vs a pasted cookies.txt. + +#902: syncing a YouTube *Music* "Liked Music" playlist (list=LM) needs auth, and on +a server/Docker box there's no local browser for cookiesfrombrowser to read — so we +let users paste a cookies.txt (yt-dlp cookiefile). These pin the precedence (so the +two cookie sources can never both be emitted), the paste validation (junk must not be +written out and break yt-dlp), and the fail-safe write (a blank save never wipes a +saved file). +""" + +from __future__ import annotations + +from core.youtube_cookies import ( + PASTE_MODE, + build_youtube_cookie_opts, + looks_like_cookiefile, + write_pasted_cookiefile, +) + +NETSCAPE = ( + "# Netscape HTTP Cookie File\n" + ".youtube.com\tTRUE\t/\tTRUE\t1999999999\tLOGIN_INFO\tsecretvalue\n" + ".youtube.com\tTRUE\t/\tTRUE\t1999999999\tSID\tanother\n" +) + + +# ── precedence (pure opts) ────────────────────────────────────────────────── + +def test_empty_mode_is_anonymous(): + assert build_youtube_cookie_opts("") == {} + assert build_youtube_cookie_opts(None) == {} + + +def test_browser_mode_uses_cookiesfrombrowser(): + assert build_youtube_cookie_opts("firefox") == {"cookiesfrombrowser": ("firefox",)} + + +def test_paste_mode_uses_cookiefile_when_present(): + opts = build_youtube_cookie_opts(PASTE_MODE, "/cfg/youtube_cookies.txt", cookiefile_exists=True) + assert opts == {"cookiefile": "/cfg/youtube_cookies.txt"} + + +def test_paste_mode_without_a_real_file_is_anonymous_not_broken(): + # stale/missing path must NOT become a cookiefile arg yt-dlp would choke on + assert build_youtube_cookie_opts(PASTE_MODE, "/cfg/gone.txt", cookiefile_exists=False) == {} + assert build_youtube_cookie_opts(PASTE_MODE, "", cookiefile_exists=True) == {} + + +def test_sources_are_mutually_exclusive(): + # a browser name is never PASTE_MODE, so cookiefile + cookiesfrombrowser can't co-occur + for mode in ("chrome", "firefox", PASTE_MODE, ""): + opts = build_youtube_cookie_opts(mode, "/x.txt", cookiefile_exists=True) + assert not ("cookiefile" in opts and "cookiesfrombrowser" in opts) + + +# ── paste validation ──────────────────────────────────────────────────────── + +def test_accepts_netscape_header_and_cookie_rows(): + assert looks_like_cookiefile(NETSCAPE) is True + # no header but a valid tab-separated cookie row still counts + assert looks_like_cookiefile(".youtube.com\tTRUE\t/\tTRUE\t123\tSID\tv") is True + + +def test_rejects_junk_paste(): + assert looks_like_cookiefile("") is False + assert looks_like_cookiefile(" ") is False + assert looks_like_cookiefile(None) is False + assert looks_like_cookiefile("https://music.youtube.com/playlist?list=LM") is False + assert looks_like_cookiefile('{"cookies": []}') is False + assert looks_like_cookiefile("# Netscape HTTP Cookie File\n# only comments\n") is False + + +# ── fail-safe write ───────────────────────────────────────────────────────── + +def test_write_persists_valid_cookiefile(tmp_path): + dest = tmp_path / "youtube_cookies.txt" + out = write_pasted_cookiefile(NETSCAPE, str(dest)) + assert out == str(dest) + assert dest.read_text().startswith("# Netscape HTTP Cookie File") + + +def test_write_appends_trailing_newline(tmp_path): + dest = tmp_path / "c.txt" + write_pasted_cookiefile(NETSCAPE.rstrip("\n"), str(dest)) + assert dest.read_text().endswith("\n") + + +def test_write_refuses_junk_and_leaves_no_file(tmp_path): + dest = tmp_path / "c.txt" + assert write_pasted_cookiefile("not a cookie file", str(dest)) == "" + assert not dest.exists() + + +def test_write_refuses_junk_without_clobbering_existing(tmp_path): + # a blank/garbage save must NOT wipe a previously-saved cookie file + dest = tmp_path / "c.txt" + write_pasted_cookiefile(NETSCAPE, str(dest)) + before = dest.read_text() + assert write_pasted_cookiefile("", str(dest)) == "" + assert dest.read_text() == before diff --git a/web_server.py b/web_server.py index 7be627af..dc957222 100644 --- a/web_server.py +++ b/web_server.py @@ -3126,6 +3126,22 @@ def handle_settings(): f"in Manage Profiles first.", "members_without_password": _stranded}), 400 + # YouTube pasted cookies.txt (server/Docker path): pull it out BEFORE the + # generic persist so the raw cookie blob never lands in config.json — it's + # secret + bulky. We validate up front and store only a file path. + _yt_in = new_settings.get('youtube') + _yt_paste = _yt_in.pop('cookies_paste', None) if isinstance(_yt_in, dict) else None + if _yt_paste is not None and str(_yt_paste).strip(): + from core.youtube_cookies import looks_like_cookiefile, write_pasted_cookiefile + if not looks_like_cookiefile(_yt_paste): + return jsonify({"success": False, + "error": "That doesn't look like a cookies.txt file. Export it " + "with a 'Get cookies.txt LOCALLY' browser extension and " + "paste the whole file."}), 400 + _cookie_path = str(config_manager.config_path.parent / "youtube_cookies.txt") + if write_pasted_cookiefile(_yt_paste, _cookie_path): + config_manager.set('youtube.cookies_file', _cookie_path) + if 'active_media_server' in new_settings: config_manager.set_active_media_server(new_settings['active_media_server']) @@ -14910,15 +14926,20 @@ def clean_youtube_artist(artist_string): def _youtube_cookie_opts(): """yt-dlp cookie options matching the rest of the app (Settings → YouTube). - Per-video extraction needs these to get past YouTube's bot checks.""" - opts = {} + + Per-video extraction needs these to get past YouTube's bot checks, and private + playlists (a user's "Liked Music", list=LM) need them to be visible at all. The + dropdown is either a browser name (cookiesfrombrowser, local installs) or the + PASTE_MODE sentinel, in which case we point yt-dlp at the pasted cookies.txt that + server/Docker users supply. Precedence + emptiness live in core.youtube_cookies.""" + from core.youtube_cookies import build_youtube_cookie_opts try: - cb = config_manager.get('youtube.cookies_browser', '') - if cb: - opts['cookiesfrombrowser'] = (cb,) + mode = config_manager.get('youtube.cookies_browser', '') + path = config_manager.get('youtube.cookies_file', '') + exists = bool(path) and os.path.exists(path) + return build_youtube_cookie_opts(mode, path, cookiefile_exists=exists) except Exception: # noqa: S110 - cookie config is best-effort; resolve still works without it - pass - return opts + return {} def _fetch_youtube_video_artist(video_id, cookie_opts): diff --git a/webui/index.html b/webui/index.html index 5f1a3d3d..970255ba 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5038,10 +5038,24 @@ +
If YouTube shows "Sign in to confirm you're not a bot", select your browser here. - SoulSync will use your browser's YouTube cookies to authenticate. + SoulSync will use your browser's YouTube cookies to authenticate. Reading a browser + only works when that browser is on the same machine as SoulSync — on a + server/Docker box, choose Paste cookies.txt instead. +
+ +
diff --git a/webui/static/settings.js b/webui/static/settings.js index 8c550783..babf7463 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -1223,6 +1223,25 @@ async function loadSettingsData() { // Populate YouTube settings document.getElementById('youtube-cookies-browser').value = settings.youtube?.cookies_browser || ''; document.getElementById('youtube-download-delay').value = settings.youtube?.download_delay ?? 3; + // Show the cookies.txt paste box only in "custom" mode. We never echo the + // stored cookie back to the UI (it's secret + lives in a file, not config); + // if one is already saved, say so via placeholder so a blank save won't wipe it. + const _ytCookieSel = document.getElementById('youtube-cookies-browser'); + const _ytPasteBox = document.getElementById('youtube-cookies-paste'); + const _ytPasteGroup = document.getElementById('youtube-cookies-paste-group'); + if (_ytCookieSel && _ytPasteGroup) { + const _toggleYtPaste = () => { + _ytPasteGroup.style.display = _ytCookieSel.value === 'custom' ? '' : 'none'; + }; + if (_ytPasteBox && settings.youtube?.cookies_file) { + _ytPasteBox.placeholder = 'A cookies.txt is saved. Paste again to replace it, or leave blank to keep it.'; + } + _toggleYtPaste(); + if (!_ytCookieSel.dataset.pasteToggleBound) { + _ytCookieSel.addEventListener('change', _toggleYtPaste); + _ytCookieSel.dataset.pasteToggleBound = '1'; + } + } // Update UI based on download source mode updateDownloadSourceUI(); @@ -3223,6 +3242,9 @@ async function saveSettings(quiet = false) { youtube: { cookies_browser: document.getElementById('youtube-cookies-browser').value, download_delay: parseInt(document.getElementById('youtube-download-delay').value) || 3, + // Raw cookies.txt blob — backend validates, writes it to a file, and stores + // only the path (never echoed back). Blank = keep any already-saved file. + cookies_paste: document.getElementById('youtube-cookies-paste')?.value || '', }, security: { require_pin_on_launch: document.getElementById('security-require-pin')?.checked || false,