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>
424 lines
16 KiB
Python
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
|