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