v0.22.1: not a bot

Import from URL now gets past YouTube's "Sign in to confirm you're not a
bot": a run that downloads nothing for that reason retries once with
yt-dlp's --cookies-from-browser (Firefox first), and a browser that worked
is remembered in this machine's config.json so later imports send the
cookies from the start.
This commit is contained in:
2026-09-19 00:43:01 -07:00
parent 19fc71da17
commit 93399a1706
5 changed files with 263 additions and 19 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.22.0"
__version__ = "0.22.1"
+14 -1
View File
@@ -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 ""
+87 -16
View File
@@ -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