#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)
|
# User-specific files (auto-created by the app if missing)
|
||||||
config/config.json
|
config/config.json
|
||||||
|
config/youtube_cookies.txt
|
||||||
database/music_library.db
|
database/music_library.db
|
||||||
database/music_library.db-shm
|
database/music_library.db-shm
|
||||||
database/music_library.db-wal
|
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.",
|
f"in Manage Profiles first.",
|
||||||
"members_without_password": _stranded}), 400
|
"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:
|
if 'active_media_server' in new_settings:
|
||||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
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():
|
def _youtube_cookie_opts():
|
||||||
"""yt-dlp cookie options matching the rest of the app (Settings → YouTube).
|
"""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:
|
try:
|
||||||
cb = config_manager.get('youtube.cookies_browser', '')
|
mode = config_manager.get('youtube.cookies_browser', '')
|
||||||
if cb:
|
path = config_manager.get('youtube.cookies_file', '')
|
||||||
opts['cookiesfrombrowser'] = (cb,)
|
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
|
except Exception: # noqa: S110 - cookie config is best-effort; resolve still works without it
|
||||||
pass
|
return {}
|
||||||
return opts
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_youtube_video_artist(video_id, cookie_opts):
|
def _fetch_youtube_video_artist(video_id, cookie_opts):
|
||||||
|
|
|
||||||
|
|
@ -5038,10 +5038,24 @@
|
||||||
<option value="brave">Brave</option>
|
<option value="brave">Brave</option>
|
||||||
<option value="opera">Opera</option>
|
<option value="opera">Opera</option>
|
||||||
<option value="safari">Safari</option>
|
<option value="safari">Safari</option>
|
||||||
|
<option value="custom">Paste cookies.txt (server / Docker)</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="setting-help-text">
|
<div class="setting-help-text">
|
||||||
If YouTube shows "Sign in to confirm you're not a bot", select your browser here.
|
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>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
||||||
|
|
@ -1223,6 +1223,25 @@ async function loadSettingsData() {
|
||||||
// Populate YouTube settings
|
// Populate YouTube settings
|
||||||
document.getElementById('youtube-cookies-browser').value = settings.youtube?.cookies_browser || '';
|
document.getElementById('youtube-cookies-browser').value = settings.youtube?.cookies_browser || '';
|
||||||
document.getElementById('youtube-download-delay').value = settings.youtube?.download_delay ?? 3;
|
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
|
// Update UI based on download source mode
|
||||||
updateDownloadSourceUI();
|
updateDownloadSourceUI();
|
||||||
|
|
@ -3223,6 +3242,9 @@ async function saveSettings(quiet = false) {
|
||||||
youtube: {
|
youtube: {
|
||||||
cookies_browser: document.getElementById('youtube-cookies-browser').value,
|
cookies_browser: document.getElementById('youtube-cookies-browser').value,
|
||||||
download_delay: parseInt(document.getElementById('youtube-download-delay').value) || 3,
|
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: {
|
security: {
|
||||||
require_pin_on_launch: document.getElementById('security-require-pin')?.checked || false,
|
require_pin_on_launch: document.getElementById('security-require-pin')?.checked || false,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue