"""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 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