#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).
This commit is contained in:
BoulderBadgeDad 2026-06-22 14:41:04 -07:00
parent b95a8f539e
commit 49592f898c
6 changed files with 271 additions and 8 deletions

1
.gitignore vendored
View file

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

105
core/youtube_cookies.py Normal file
View file

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

View file

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

View file

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

View file

@ -5038,10 +5038,24 @@
<option value="brave">Brave</option>
<option value="opera">Opera</option>
<option value="safari">Safari</option>
<option value="custom">Paste cookies.txt (server / Docker)</option>
</select>
<div class="setting-help-text">
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 &mdash; on a
server/Docker box, choose <strong>Paste cookies.txt</strong> instead.
</div>
</div>
<div class="form-group" id="youtube-cookies-paste-group" style="display:none;">
<label>Paste cookies.txt:</label>
<textarea id="youtube-cookies-paste" class="form-input" rows="6"
placeholder="# Netscape HTTP Cookie File&#10;.youtube.com&#9;TRUE&#9;/&#9;TRUE&#9;...&#9;NAME&#9;VALUE"></textarea>
<div class="setting-help-text">
For server/Docker installs with no local browser. Export your YouTube cookies
with a "Get cookies.txt LOCALLY" browser extension and paste the whole file.
Required for private playlists like your <strong>Liked Music</strong>.
Stored on the server and they expire periodically &mdash; re-paste if YouTube stops working.
</div>
</div>
<div class="form-group">

View file

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