#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:
parent
b95a8f539e
commit
49592f898c
6 changed files with 271 additions and 8 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
105
core/youtube_cookies.py
Normal 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",
|
||||
]
|
||||
100
tests/test_youtube_cookies.py
Normal file
100
tests/test_youtube_cookies.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 — 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 .youtube.com	TRUE	/	TRUE	...	NAME	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 — re-paste if YouTube stops working.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue