Files
lintunes/tests/test_round75.py
T
travandClaude Opus 5.5 f899794068 v0.38.0: several imported songs are identified as one batch
No identify dialog pops up mid-download any more: songs are looked up
quietly as they land and reviewed together once the last one is in, as
rows in the app's columns. Songs that are one album get the album-wide
fields once, unified, plus a cover from the album-art search. Ticked
rows apply as one undo step; unticked ones get the ordinary dialog
after. Popups wait for LinTunes to be the app in front.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:42:13 -07:00

424 lines
16 KiB
Python

"""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)
monkeypatch.setattr(win, "_show_batch_review", lambda analysis: None)
yield win
if win._url_batch is not None: # lookups still out: never review them
win._url_batch.cancel()
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 spotify_tracks(self):
return []
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 spotify_tracks(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