soulsync/tests/test_youtube_cookies.py
BoulderBadgeDad 49592f898c #902: YouTube Liked Music sync — paste a cookies.txt (server/Docker auth)
Private YT Music playlists (a user's Liked Music, list=LM) need auth, but the
only cookie option was cookiesfrombrowser — a browser on the same machine as
SoulSync, useless on a headless/Docker box (and locked to whatever account that
browser happens to be signed into). Add a 'Paste cookies.txt' mode so users can
supply the exact session they want from any machine.

- core/youtube_cookies.py: pure seam — build_youtube_cookie_opts (cookiefile vs
  cookiesfrombrowser precedence, mutually exclusive, fail-safe on a missing file),
  looks_like_cookiefile (needs a real cookie row; rejects junk/header-only),
  write_pasted_cookiefile (validate + 0600 write; blank/junk never clobbers a saved file).
- _youtube_cookie_opts() delegates to the seam, so every yt-dlp call site gets it.
- /api/settings pops cookies_paste before the generic persist, validates (400 on
  junk), writes config/youtube_cookies.txt, stores only the path (blob never hits config.json).
- Settings dropdown gains 'Paste cookies.txt'; selecting it reveals a textarea.
- tests/test_youtube_cookies.py: precedence, validation, fail-safe write (11 tests).
2026-06-22 14:41:04 -07:00

100 lines
4.2 KiB
Python

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