v0.36.0: a playlist link says so before it downloads

Pasting a song from a YouTube Mix (watch?v=…&list=RD…) imported song
after song: yt-dlp takes the whole list by default, and a Mix runs to
thousands of rows and loops back on itself, so the linked song came in
again and again with nothing on screen saying why.

Now anything but a plain song link is listed first (--flat-playlist,
streamed into the dialog). One song goes straight through. Several become
a checklist: a song-in-a-list link ticks only its song and offers Just
This Song without waiting, and a playlist link ticks everything. Rows are
de-duplicated by id. The chosen songs download by their own pages, never
the list link, and more than one gets a per-song progress window (Hide
keeps downloading; the status-bar line brings it back).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 17:41:55 -07:00
co-authored by Claude Opus 5.5
parent ad1d152948
commit ac682774e9
9 changed files with 1185 additions and 102 deletions
+416
View File
@@ -0,0 +1,416 @@
"""Round 75 — a link that is a playlist says so before it downloads.
trav pasted a song from a YouTube Mix (``watch?v=…&list=RD…``). yt-dlp's
default is to take the whole list, so LinTunes started importing song after
song from a list of over 900, with nothing on screen saying why. Now:
- only a plain song link skips the listing (``link_kind``);
- anything else is listed first (``PlaylistProbe``). One song goes straight
through, and several become a checklist: a song-in-a-list link ticks only
its song, a playlist link ticks them all;
- the chosen songs are downloaded by their own pages, never the list link;
- an import of several songs gets a per-song progress window.
yt-dlp itself is never run. A fake on PATH speaks the same marker lines.
"""
import os
import stat
import sys
import time
import pytest
from PyQt6.QtCore import Qt
from lintunes import url_import
from lintunes.gui.url_import_dialog import (
UrlImportDialog, format_duration, linked_song_id,
)
from lintunes.gui.url_import_progress import (
IMPORTED, NOT_DOWNLOADED, SKIPPED, WAITING, UrlImportProgress,
)
from lintunes.library_manager import LibraryManager
from lintunes.models import Library
from lintunes.preferences import Preferences
from lintunes.url_import import (
PlaylistProbe, UrlImportWorker, error_item, link_kind,
)
MIX = "https://www.youtube.com/watch?app=desktop&v=bbb&list=RDbbb&start_radio=1"
# Lists $FAKE_ENTRIES songs (aaa, bbb, ccc, …) for --flat-playlist; downloads
# each URL it's given otherwise, failing any whose id starts with "bad".
# Every run appends its argv to $FAKE_YTDLP_LOG.
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("\\x1f".join(args) + "\\n")
urls = args[args.index("--") + 1:]
if "--flat-playlist" in args:
n = int(os.environ.get("FAKE_ENTRIES", "3"))
loops = int(os.environ.get("FAKE_LOOPS", "1"))
for i in list(range(n)) * loops:
vid = chr(ord("a") + i) * 3
print(f"LTENTRY {vid}\\t{60 * (i + 1)}.0\\t"
f"https://www.youtube.com/watch?v={vid}\\tSong {vid}", flush=True)
if n == 0:
print("ERROR: [generic] Unsupported URL: " + urls[0], file=sys.stderr,
flush=True)
sys.exit(1)
sys.exit(0)
dest = args[args.index("-P") + 1]
for url in urls:
vid = url.rsplit("=", 1)[-1]
if vid.startswith("bad"):
print(f"ERROR: [youtube] {vid}: Video unavailable", file=sys.stderr,
flush=True)
continue
print(f"LTSTART 1\\t1\\t{vid}\\tSong {vid}", flush=True)
print("LTPROG 50.0%", flush=True)
path = os.path.join(dest, f"Song {vid} [{vid}].mp3")
src = os.environ.get("FAKE_YTDLP_AUDIO")
with open(path, "wb") as f:
f.write(open(src, "rb").read() if src else b"ID3fake")
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("XDG_CONFIG_HOME", str(tmp_path / "config"))
return lambda: ([line.split("\x1f") for line in
log.read_text().splitlines()] if log.exists() else [])
def _pump(qapp, until, timeout=10.0):
deadline = time.monotonic() + timeout
while not until() and time.monotonic() < deadline:
qapp.processEvents()
time.sleep(0.01)
assert until(), "timed out waiting"
# --------------------------------------------------------------------------
# A. what a link names, from the URL alone
@pytest.mark.parametrize("url, kind", [
("https://www.youtube.com/watch?v=abc", "song"),
("https://m.youtube.com/watch?v=abc&t=30", "song"),
("https://music.youtube.com/watch?v=abc", "song"),
("https://youtu.be/abc", "song"),
("https://youtu.be/abc?list=PL1", "song_in_list"),
("https://www.youtube.com/shorts/abc", "song"),
(MIX, "song_in_list"),
("https://www.youtube.com/watch?v=abc&list=PLxyz&index=4", "song_in_list"),
("https://www.youtube.com/playlist?list=PLxyz", "list"),
("https://soundcloud.com/someone/sets/an-album", "unknown"),
("https://soundcloud.com/someone/a-song", "unknown"),
("https://www.youtube.com/@channel", "unknown"),
("not a url", "unknown"),
])
def test_link_kind(url, kind):
assert link_kind(url) == kind
def test_linked_song_id():
assert linked_song_id(MIX) == "bbb"
assert linked_song_id("https://youtu.be/xyz?list=PL") == "xyz"
assert linked_song_id("https://soundcloud.com/a/b") == ""
def test_format_duration():
assert format_duration(None) == ""
assert format_duration(65) == "1:05"
assert format_duration(3725) == "1:02:05"
def test_entry_and_error_lines():
assert url_import.parse_line(
"LTENTRY abc\t195.0\thttps://y.test/watch?v=abc\tA: Song\tX\n") == (
"entry", {"id": "abc", "duration": 195,
"url": "https://y.test/watch?v=abc", "title": "A: Song\tX"})
assert url_import.parse_line("LTENTRY abc\tNA\thttps://y/v\tT\n")[1][
"duration"] is None
# no page to download it from: not an entry we can use
assert url_import.parse_line("LTENTRY abc\t1\t\tT\n") is None
assert error_item("[youtube] -5o7lcpMDqs: Video unavailable") == (
"-5o7lcpMDqs", "Video unavailable")
assert error_item("Unable to download webpage") is None
def test_several_urls_all_follow_the_double_dash(tmp_path):
cmd = url_import.build_command(["https://a/1", "https://a/2"], tmp_path)
assert cmd[cmd.index("--"):] == ["--", "https://a/1", "https://a/2"]
probe = url_import.build_probe_command(MIX, "firefox")
assert "--flat-playlist" in probe and probe[-2:] == ["--", MIX]
assert probe[probe.index("--cookies-from-browser") + 1] == "firefox"
assert "--extract-audio" not in probe # listing downloads nothing
# --------------------------------------------------------------------------
# B. the probe and the worker
def test_probe_streams_entries_in_order(fake):
probe = PlaylistProbe(MIX)
got = []
probe.entry.connect(lambda e: got.append(e["id"]))
probe.finished.connect(lambda n: got.append(("finished", n)))
probe._run()
assert got == ["aaa", "bbb", "ccc", ("finished", 3)]
assert not probe.busy()
def test_a_probe_that_finds_nothing_fails(fake, monkeypatch):
monkeypatch.setenv("FAKE_ENTRIES", "0")
probe = PlaylistProbe("https://x.test/nothing")
got = []
probe.failed.connect(got.append)
probe._run()
assert got == ["[generic] Unsupported URL: https://x.test/nothing"]
def test_worker_counts_across_urls_and_names_failures(fake, tmp_path):
worker = UrlImportWorker(["https://y/watch?v=aaa",
"https://y/watch?v=badone",
"https://y/watch?v=ccc"])
worker.temp_dir = tmp_path / "dl"
worker.temp_dir.mkdir()
events = []
worker.item_started.connect(
lambda i, n, vid, t: events.append(("start", i, n, vid)))
worker.item_failed.connect(lambda vid, why: events.append(("bad", vid)))
worker.downloaded.connect(lambda p: events.append(("file",)))
worker.finished.connect(lambda d: events.append(("done", d["downloaded"])))
worker._run()
assert events == [("start", 1, 3, "aaa"), ("file",), ("bad", "badone"),
("start", 2, 3, "ccc"), ("file",), ("done", 2)]
# --------------------------------------------------------------------------
# C. the dialog
def _dialog(qapp, url):
qapp.clipboard().setText(url)
dialog = UrlImportDialog("Adds to the end of “Chill”")
assert dialog.url() == url
return dialog
def test_a_plain_song_link_is_never_listed(qapp, fake):
dialog = _dialog(qapp, "https://www.youtube.com/watch?v=abc")
dialog._on_import()
assert dialog.result() == dialog.DialogCode.Accepted
assert dialog.chosen_entries() == []
assert fake() == [] # yt-dlp was never run
def test_a_mix_ticks_only_the_linked_song(qapp, fake):
dialog = _dialog(qapp, MIX)
dialog._on_import()
assert not dialog._ok.isEnabled() # nothing to import while checking
_pump(qapp, lambda: dialog._probe is None)
assert dialog.result() != dialog.DialogCode.Accepted
assert dialog._list.count() == 3
assert dialog._ok.text() == "Import 1 Song"
assert [e["id"] for e in dialog.chosen_entries()] == ["bbb"]
assert "3 songs" in dialog._status.text()
dialog._list.item(2).setCheckState(Qt.CheckState.Checked)
assert dialog._ok.text() == "Import 2 Songs"
dialog._check_all(False)
assert not dialog._ok.isEnabled()
dialog._check_all(True)
assert dialog._ok.text() == "Import 3 Songs"
dialog._on_import()
assert dialog.result() == dialog.DialogCode.Accepted
assert [e["url"] for e in dialog.chosen_entries()] == [
"https://www.youtube.com/watch?v=aaa",
"https://www.youtube.com/watch?v=bbb",
"https://www.youtube.com/watch?v=ccc"]
def test_a_looping_mix_lists_each_song_once(qapp, fake, monkeypatch):
monkeypatch.setenv("FAKE_LOOPS", "3")
dialog = _dialog(qapp, MIX)
dialog._on_import()
_pump(qapp, lambda: dialog._probe is None)
assert dialog._list.count() == 3
assert [e["id"] for e in dialog.chosen_entries()] == ["bbb"]
def test_just_this_song_never_waits_for_the_list(qapp, fake):
dialog = _dialog(qapp, MIX)
dialog._on_import()
assert dialog._just.isVisibleTo(dialog)
dialog._just.click() # before a single row has arrived
assert dialog.result() == dialog.DialogCode.Accepted
assert dialog._probe is None
assert dialog.chosen_entries() == []
assert dialog.download_urls() == ["https://www.youtube.com/watch?v=bbb"]
def test_download_urls(qapp, fake):
song = _dialog(qapp, "https://www.youtube.com/watch?v=abc")
song._on_import()
assert song.download_urls() == ["https://www.youtube.com/watch?v=abc"]
playlist = _dialog(qapp, "https://www.youtube.com/playlist?list=PLx")
playlist._on_import()
assert not playlist._just.isVisibleTo(playlist) # no song to pick
_pump(qapp, lambda: playlist._probe is None)
assert len(playlist.download_urls()) == 3
def test_a_playlist_link_ticks_everything(qapp, fake):
dialog = _dialog(qapp, "https://www.youtube.com/playlist?list=PLx")
dialog._on_import()
_pump(qapp, lambda: dialog._probe is None)
assert len(dialog.chosen_entries()) == 3
def test_a_one_song_listing_goes_straight_through(qapp, fake, monkeypatch):
monkeypatch.setenv("FAKE_ENTRIES", "1")
dialog = _dialog(qapp, "https://soundcloud.com/someone/a-song")
dialog._on_import()
_pump(qapp, lambda: dialog.result() == dialog.DialogCode.Accepted)
assert dialog.chosen_entries() == [] # the link itself downloads
def test_a_failed_listing_stays_open_and_says_why(qapp, fake, monkeypatch):
monkeypatch.setenv("FAKE_ENTRIES", "0")
dialog = _dialog(qapp, "https://x.test/nothing")
dialog._on_import()
_pump(qapp, lambda: dialog._probe is None)
assert dialog.result() != dialog.DialogCode.Accepted
assert "Unsupported URL" in dialog._status.text()
assert dialog._ok.isEnabled() and dialog._url.isEnabled()
# --------------------------------------------------------------------------
# D. the progress window
def test_progress_rows_follow_the_download(qapp):
window = UrlImportProgress([{"id": "a", "title": "A"},
{"id": "b", "title": "B"},
{"id": "c", "title": "C"},
{"id": "d", "title": "D"}])
assert window.status("a") == WAITING
window.started("a")
window.progress(43)
assert window.status("a") == "Downloading 43%"
window.landed(True)
assert window.status("a") == IMPORTED
window.failed("b", "Video unavailable")
assert window.status("b") == "Couldn't download: Video unavailable"
window.started("c")
window.finish(cancelled=True)
assert window.status("c") == SKIPPED and window.status("d") == SKIPPED
assert window._summary.text().startswith("Done: 1 of 4 songs imported")
assert not window._stop.isEnabled()
quiet = UrlImportProgress([{"id": "x", "title": "X"}])
quiet.finish(cancelled=False)
assert quiet.status("x") == NOT_DOWNLOADED
# --------------------------------------------------------------------------
# E. through the window
@pytest.fixture
def window(qapp, tmp_path, fake, monkeypatch):
from lintunes.gui.main_window import MainWindow
library = Library(music_folder=str(tmp_path / "media"))
(tmp_path / "media").mkdir()
manager = LibraryManager(library, tmp_path / "data")
win = MainWindow(manager, Preferences(tmp_path / "data"))
monkeypatch.setattr(win, "_enqueue_identify", lambda tracks: None)
yield win
win.close()
def test_only_the_chosen_songs_download(window, qapp, fake, mp3_file,
monkeypatch):
from lintunes.gui import main_window as mw
monkeypatch.setenv("FAKE_YTDLP_AUDIO", str(mp3_file))
chosen = [{"id": "aaa", "title": "Song aaa",
"url": "https://www.youtube.com/watch?v=aaa"},
{"id": "ccc", "title": "Song ccc",
"url": "https://www.youtube.com/watch?v=ccc"}]
class _Dialog:
cookies_used = None
def __init__(self, description, parent=None, **_kw):
pass
def exec(self):
return True
def url(self):
return MIX
def add_to_playlist(self):
return False
def chosen_entries(self):
return chosen
def download_urls(self):
return [c["url"] for c in chosen]
monkeypatch.setattr(mw, "UrlImportDialog", _Dialog)
window._import_from_url()
worker = window._url_worker
_pump(qapp, lambda: worker.temp_dir is None)
[argv] = fake()
assert argv[argv.index("--") + 1:] == [c["url"] for c in chosen]
assert MIX not in argv # never the whole list
progress = window._url_progress
assert progress is not None and progress.isVisible()
assert progress.status("aaa") == IMPORTED
assert progress.status("ccc") == IMPORTED
assert len(window._manager.library.tracks) == 2
# Hidden, it comes back from the status bar only while downloading.
progress.close()
window._sync_label.clicked.emit()
assert not progress.isVisible() # done downloading: stays hidden
worker._busy = True # as if still going
window._sync_label.clicked.emit()
assert progress.isVisible()
worker._busy = False
def test_one_song_gets_no_progress_window(window, qapp, fake, mp3_file,
monkeypatch):
from lintunes.gui import main_window as mw
monkeypatch.setenv("FAKE_YTDLP_AUDIO", str(mp3_file))
class _Dialog:
cookies_used = None
def __init__(self, *a, **kw):
pass
def exec(self):
return True
def url(self):
return "https://www.youtube.com/watch?v=aaa"
def add_to_playlist(self):
return False
def chosen_entries(self):
return []
def download_urls(self):
return [self.url()]
monkeypatch.setattr(mw, "UrlImportDialog", _Dialog)
window._import_from_url()
worker = window._url_worker
_pump(qapp, lambda: worker.temp_dir is None)
assert window._url_progress is None
assert len(window._manager.library.tracks) == 1