diff --git a/CLAUDE.md b/CLAUDE.md index f4235c2..2d9ba08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -279,7 +279,13 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal order (`position + songs already inserted`). With no AcoustID key, `TrackIdentifier` proposes from the filename alone and never touches fpcalc or the network. Only a URL import reaches that path, since the menu route - still insists on setup first. + still insists on setup first. YouTube's "Sign in to confirm you're not a + bot" is about the *network* (`song` hits it too), and yt-dlp's cure is the + browser's cookies: a run that downloads **nothing** for that reason is + retried once with `--cookies-from-browser` (`default_cookies_browser`, + Firefox first), and a browser that worked is saved as + `ytdlp_cookies_browser` in **`config.json`**, not preferences — which + browser holds a YouTube login is this machine's business (Round 59). - **`lintunes/mpris.py`** — registers `org.mpris.MediaPlayer2.lintunes` over D-Bus so the desktop's media keys / now-playing popup control playback. Spacebar and diff --git a/lintunes/__init__.py b/lintunes/__init__.py index d3585be..5613d7c 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.22.0" +__version__ = "0.22.1" diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 3e6d474..7ceac06 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -16,6 +16,7 @@ from lintunes.andtunes import sync as andtunes_sync from lintunes.art_search import AlbumArtFetcher from lintunes.cast.controller import CastController from lintunes.eventlog import log_control +from lintunes.config import load_config, save_config from lintunes.export import exporter from lintunes.fingerprint import TrackIdentifier, fpcalc_available from lintunes.inhibit import INHIBIT_LOGOUT, INHIBIT_SUSPEND, SleepInhibitor @@ -1161,7 +1162,10 @@ class MainWindow(QMainWindow): self._url_note = (f"tags proposed from filenames only ({problem})" if problem else "") - worker = url_import.UrlImportWorker(dialog.url(), self) + worker = url_import.UrlImportWorker( + dialog.url(), self, + cookies_browser=load_config().get(url_import.COOKIES_KEY)) + worker.cookies_used.connect(self._remember_cookies_browser) worker.item_started.connect(self._on_url_item_started) worker.progress.connect(self._sync_progress.setValue) worker.downloaded.connect(self._on_url_downloaded) @@ -1176,6 +1180,15 @@ class MainWindow(QMainWindow): self._sync_progress.show() worker.start() + def _remember_cookies_browser(self, browser: str): + """A bot-checked link got through on this browser's cookies: send + them from the start next time. config.json, not preferences — the + browser holding a YouTube login is this machine's business.""" + config = load_config() + if config.get(url_import.COOKIES_KEY) != browser: + config[url_import.COOKIES_KEY] = browser + save_config(config) + def _on_url_item_started(self, index: int, count: int, title: str): short = title if len(title) <= 40 else title[:39] + "…" where = f"{index} of {count} · " if count > 1 else "" diff --git a/lintunes/url_import.py b/lintunes/url_import.py index 400ba91..19e6074 100644 --- a/lintunes/url_import.py +++ b/lintunes/url_import.py @@ -16,6 +16,15 @@ deletes it (``cleanup``) once it has seen ``finished`` or ``failed``: those signals are queued after every ``downloaded``, so by then every file has been copied. yt-dlp's default filename, ``Title [id].mp3``, is kept on purpose, because it's exactly what ``filename_tags.parse_filename`` reads. + +YouTube sometimes answers an anonymous request with "Sign in to confirm +you're not a bot" (it flags the *network*, so cell connections see it +most). The cure yt-dlp names is the browser's own YouTube cookies. So a run +that downloads nothing because of that check is retried once with +``--cookies-from-browser``, and the GUI remembers the browser in this +machine's ``config.json`` (``ytdlp_cookies_browser``) so later imports send +the cookies from the start. Which browser holds a YouTube login is a fact +about this machine, which is why it isn't in the synced preferences. """ import os import shutil @@ -40,6 +49,31 @@ FILE = "LTFILE" PROGRESS = "LTPROG" +# What YouTube says when it wants a signed-in browser. Matched on a fragment +# that sidesteps the apostrophe, which yt-dlp prints as a curly ’. +BOT_CHECK = "to confirm you" + +# The config.json key naming the browser yt-dlp borrows cookies from. +COOKIES_KEY = "ytdlp_cookies_browser" + + +def is_bot_check(error: str) -> bool: + return BOT_CHECK in (error or "") and "bot" in error + + +def default_cookies_browser() -> str | None: + """The browser whose cookies to borrow: the first one with a profile on + this machine, or None when there's nothing to borrow from.""" + home = Path.home() + config = Path(os.environ.get("XDG_CONFIG_HOME", str(home / ".config"))) + for browser, profile in (("firefox", home / ".mozilla" / "firefox"), + ("chromium", config / "chromium"), + ("chrome", config / "google-chrome")): + if profile.is_dir(): + return browser + return None + + def ytdlp_available() -> bool: """Whether the yt-dlp *binary* is on PATH (the fpcalc_available pattern).""" return shutil.which("yt-dlp") is not None @@ -52,7 +86,8 @@ def looks_like_url(text: str) -> bool: and not any(c.isspace() for c in text)) -def build_command(url: str, dest_dir) -> list[str]: +def build_command(url: str, dest_dir, + cookies_browser: str | None = None) -> list[str]: """yt-dlp with the `song` flags, downloading into ``dest_dir``. ``before_dl`` names each item as it starts (with its place in a playlist), @@ -61,8 +96,11 @@ def build_command(url: str, dest_dir) -> list[str]: yt-dlp's default already carries on past an unavailable video in a playlist, so "import everything at the link" needs no extra flag. """ + cookies = (["--cookies-from-browser", cookies_browser] + if cookies_browser else []) return [ "yt-dlp", *SONG_ARGS, + *cookies, "-P", str(dest_dir), "--print", f"before_dl:{START} %(playlist_index|1)s\t%(n_entries|1)s\t%(title)s", @@ -167,7 +205,8 @@ class UrlImportWorker(QObject): ``finished`` carries {"downloaded": int, "errors": [str], "cancelled": bool}. ``failed`` means nothing downloaded at all, and carries yt-dlp's - last error line. + last error line. ``cookies_used`` names the browser whose cookies got a + bot-checked link through, so the caller can remember it. """ item_started = pyqtSignal(int, int, str) # index, count, title @@ -175,10 +214,13 @@ class UrlImportWorker(QObject): downloaded = pyqtSignal(str) # final path of one mp3 finished = pyqtSignal(dict) failed = pyqtSignal(str) + cookies_used = pyqtSignal(str) - def __init__(self, url: str, parent=None): + def __init__(self, url: str, parent=None, *, + cookies_browser: str | None = None): super().__init__(parent) self._url = url + self._cookies = cookies_browser self._busy = False self._cancel = threading.Event() self._proc = None @@ -224,11 +266,50 @@ class UrlImportWorker(QObject): self.failed.emit(str(e)) def _run(self): + cookies = self._cookies + count, errors, returncode = self._attempt(cookies) + if returncode is None: # yt-dlp couldn't even start + return + if (count == 0 and not self._cancel.is_set() and not cookies + and any(is_bot_check(e) for e in errors)): + # Nothing landed, so a second pass can't duplicate a song. + cookies = default_cookies_browser() + if cookies: + count, errors, returncode = self._attempt(cookies) + if returncode is None: + return + if count: + self.cookies_used.emit(cookies) + + cancelled = self._cancel.is_set() + self._busy = False + if count == 0 and not cancelled: + if errors and is_bot_check(errors[-1]): + self.failed.emit( + "YouTube wants to see a signed-in browser before it " + "will hand this over" + + (f" (tried {cookies}'s cookies)" if cookies else "") + + ".\n\nSign in to YouTube in " + + (cookies.title() if cookies else "your browser") + + " and try again.\n\nyt-dlp said: " + errors[-1]) + return + self.failed.emit( + errors[-1] if errors else + f"yt-dlp found nothing to download (exit code " + f"{returncode})") + return + self.finished.emit({"downloaded": count, "errors": errors, + "cancelled": cancelled}) + + def _attempt(self, cookies_browser): + """One yt-dlp run → (songs downloaded, ERROR lines, exit code). The + exit code is None when yt-dlp couldn't be started, in which case + ``failed`` has already been emitted.""" errors: list[str] = [] count = 0 try: proc = subprocess.Popen( - build_command(self._url, self.temp_dir), + build_command(self._url, self.temp_dir, cookies_browser), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, # One stream: the markers are on stdout, the ERROR: lines # on stderr, and one reader can't deadlock on the other. @@ -238,7 +319,7 @@ class UrlImportWorker(QObject): except OSError as e: self._busy = False self.failed.emit(f"couldn't run yt-dlp: {e}") - return + return 0, errors, None self._proc = proc if self._cancel.is_set(): # cancelled before the process existed self._terminate() @@ -258,14 +339,4 @@ class UrlImportWorker(QObject): count += 1 self.downloaded.emit(parsed[1]) proc.wait() - - cancelled = self._cancel.is_set() - self._busy = False - if count == 0 and not cancelled: - self.failed.emit( - errors[-1] if errors else - f"yt-dlp found nothing to download (exit code " - f"{proc.returncode})") - return - self.finished.emit({"downloaded": count, "errors": errors, - "cancelled": cancelled}) + return count, errors, proc.returncode diff --git a/tests/test_round59.py b/tests/test_round59.py new file mode 100644 index 0000000..ce5e7bc --- /dev/null +++ b/tests/test_round59.py @@ -0,0 +1,154 @@ +"""Round 59: YouTube's "Sign in to confirm you're not a bot". + +YouTube flags a network, not a program, so `song` hits it too. The cure is +the browser's YouTube cookies: a run that downloads nothing because of the +check is retried once with --cookies-from-browser, and the browser is then +remembered in this machine's config.json. +""" +import os +import stat +import sys + +import pytest + +from lintunes import config, url_import +from lintunes.url_import import UrlImportWorker, build_command, is_bot_check + +BOT_LINE = ("[youtube] m-7NEY4p5s0: Sign in to confirm you’re not a bot. " + "Use --cookies-from-browser or --cookies for the authentication.") + +# Refuses anonymous requests the way YouTube does; lets cookies through. +# Each run appends its argv to $FAKE_YTDLP_LOG so tests can count attempts. +FAKE_YTDLP = '''#!/usr/bin/env python3 +import os, sys +args = sys.argv[1:] +with open(os.environ["FAKE_YTDLP_LOG"], "a") as log: + log.write(" ".join(args) + "\\n") +if "--cookies-from-browser" not in args or os.environ.get("FAKE_ALWAYS_BOT"): + print("ERROR: " + os.environ["FAKE_BOT_LINE"], file=sys.stderr, flush=True) + sys.exit(1) +dest = args[args.index("-P") + 1] +path = os.path.join(dest, "Song [m-7NEY4p5s0].mp3") +open(path, "wb").write(b"ID3fake") +print("LTSTART 1\\t1\\tSong", flush=True) +print(f"LTFILE {path}", flush=True) +''' + + +@pytest.fixture +def fake(tmp_path, monkeypatch): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + script = bin_dir / "yt-dlp" + script.write_text(FAKE_YTDLP.replace("/usr/bin/env python3", sys.executable)) + script.chmod(script.stat().st_mode | stat.S_IEXEC) + log = tmp_path / "argv.log" + monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") + monkeypatch.setenv("FAKE_YTDLP_LOG", str(log)) + monkeypatch.setenv("FAKE_BOT_LINE", BOT_LINE) + monkeypatch.setattr(url_import, "default_cookies_browser", + lambda: "firefox") + dest = tmp_path / "dl" + dest.mkdir() + return dest, lambda: (log.read_text().splitlines() + if log.exists() else []) + + +def _run(dest, **kw): + worker = UrlImportWorker("https://www.youtube.com/watch?v=m-7NEY4p5s0", + **kw) + worker.temp_dir = dest + events = [] + worker.downloaded.connect(lambda p: events.append(("file", p))) + worker.finished.connect(lambda d: events.append(("finished", d))) + worker.failed.connect(lambda m: events.append(("failed", m))) + worker.cookies_used.connect(lambda b: events.append(("cookies", b))) + worker._run() + return events + + +def test_cookies_go_before_the_url_and_only_when_asked(tmp_path): + plain = build_command("https://x.test/v", tmp_path) + assert "--cookies-from-browser" not in plain + cmd = build_command("https://x.test/v", tmp_path, "firefox") + i = cmd.index("--cookies-from-browser") + assert cmd[i + 1] == "firefox" + assert i < cmd.index("--") and cmd[-1] == "https://x.test/v" + assert cmd[1:4] == url_import.SONG_ARGS + + +def test_is_bot_check(): + assert is_bot_check(BOT_LINE) + assert is_bot_check(BOT_LINE.replace("’", "'")) + assert not is_bot_check("[generic] Unsupported URL: https://x.test") + assert not is_bot_check("") + + +def test_a_bot_check_retries_with_the_browsers_cookies(fake): + dest, argvs = fake + events = _run(dest) + assert len(argvs()) == 2 + assert "--cookies-from-browser" not in argvs()[0] + assert "--cookies-from-browser firefox" in argvs()[1] + assert events == [("file", str(dest / "Song [m-7NEY4p5s0].mp3")), + ("cookies", "firefox"), + ("finished", {"downloaded": 1, "errors": [], + "cancelled": False})] + + +def test_a_remembered_browser_is_used_from_the_start(fake): + dest, argvs = fake + events = _run(dest, cookies_browser="firefox") + assert len(argvs()) == 1 + assert "--cookies-from-browser firefox" in argvs()[0] + assert ("cookies", "firefox") not in events # nothing new to remember + assert events[-1][0] == "finished" + + +def test_cookies_that_dont_help_fail_with_advice(fake, monkeypatch): + dest, argvs = fake + monkeypatch.setenv("FAKE_ALWAYS_BOT", "1") + events = _run(dest) + assert len(argvs()) == 2 # one retry, not a loop + [(kind, message)] = events + assert kind == "failed" + assert "Sign in to YouTube in Firefox" in message + assert BOT_LINE[:40] in message + + +def test_no_browser_means_no_retry(fake, monkeypatch): + dest, argvs = fake + monkeypatch.setattr(url_import, "default_cookies_browser", lambda: None) + events = _run(dest) + assert len(argvs()) == 1 + assert events[0][0] == "failed" + assert "your browser" in events[0][1] + + +def test_a_cancelled_run_is_not_retried(fake): + dest, argvs = fake + worker = UrlImportWorker("https://x.test/v") + worker.temp_dir = dest + worker._cancel.set() + worker._run() + assert len(argvs()) <= 1 + + +def test_default_browser_prefers_firefox(tmp_path, monkeypatch): + monkeypatch.setattr(url_import.Path, "home", lambda: tmp_path) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + assert url_import.default_cookies_browser() is None + (tmp_path / ".config" / "chromium").mkdir(parents=True) + assert url_import.default_cookies_browser() == "chromium" + (tmp_path / ".mozilla" / "firefox").mkdir(parents=True) + assert url_import.default_cookies_browser() == "firefox" + + +def test_the_window_remembers_the_browser_in_config_json( + qapp, tmp_path, monkeypatch): + from lintunes.gui.main_window import MainWindow + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg")) + config.save_config({"music_root": "/somewhere"}) + MainWindow._remember_cookies_browser(None, "firefox") + assert config.load_config() == {"music_root": "/somewhere", + url_import.COOKIES_KEY: "firefox"}