soulsync/core/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

105 lines
4.2 KiB
Python

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