"""Round 76: Spotify links in File ▸ Import from URL. trav's ``spotify-youtube`` script, brought inside: a Spotify track, album or playlist link is read off Spotify's public embed page, each song is found on YouTube (the result closest to Spotify's length wins), downloaded with the `song` flags, tagged with what Spotify said, then imported and identified like any other link. No network here: the page is canned and yt-dlp is fake. """ import json import os import stat import sys import time import pytest from lintunes import spotify_link, tagging, url_import from lintunes.gui.url_import_dialog import UrlImportDialog from lintunes.library_manager import LibraryManager from lintunes.models import Library from lintunes.preferences import Preferences from lintunes.spotify_link import ( SpotifyError, SpotifyTrack, parse_embed, rank_candidates, spotify_source, ) ALBUM_URL = "https://open.spotify.com/album/57F44c0MTziVzHPEuJtH9A?si=xyz" def _page(entity) -> str: data = {"props": {"pageProps": {"state": {"data": {"entity": entity}}}}} return ('") ALBUM = _page({ "type": "album", "name": "Last Splash", "title": "Last Splash", "subtitle": "The Breeders", "trackList": [ {"uri": "spotify:track:t1", "title": "New Year", "subtitle": "The Breeders", "duration": 116706}, {"uri": "spotify:track:t2", "title": "Cannonball", "subtitle": "The Breeders", "duration": 213000}, {"uri": "spotify:track:t3", "title": "", # dropped "subtitle": "The Breeders", "duration": 1000}, ]}) PLAYLIST = _page({ "type": "playlist", "name": "Chill", "trackList": [ {"uri": "spotify:track:p1", "title": "Song A", "subtitle": "X, Y", "duration": 200000}]}) TRACK = _page({ "type": "track", "name": "Never Gonna Give You Up", "title": "Never Gonna Give You Up", "id": "4uLU", "uri": "spotify:track:4uLU", "artists": [{"name": "Rick Astley"}], "duration": 213573}) # -------------------------------------------------------------------------- # A. reading a link @pytest.mark.parametrize("text, source", [ (ALBUM_URL, ("album", "57F44c0MTziVzHPEuJtH9A")), ("https://open.spotify.com/intl-de/track/4uLU", ("track", "4uLU")), ("https://open.spotify.com/embed/playlist/37i9", ("playlist", "37i9")), ("spotify:playlist:37i9", ("playlist", "37i9")), ("https://open.spotify.com/artist/0gxy", None), ("https://open.spotify.com/episode/0gxy", None), ("https://www.youtube.com/watch?v=abc", None), ("", None), ]) def test_spotify_source(text, source): assert spotify_source(text) == source def test_link_kind_and_uris_count_as_links(): assert url_import.link_kind(ALBUM_URL) == "spotify" assert url_import.link_kind("spotify:track:4uLU") == "spotify" assert url_import.looks_like_url("spotify:track:4uLU") assert url_import.link_kind("https://open.spotify.com/artist/x") \ == "unknown" def test_an_album_page_numbers_its_tracks(): name, tracks = parse_embed(ALBUM) assert name == "Last Splash" assert [(t.id, t.title, t.album, t.track_number, t.track_count, t.duration) for t in tracks] == [ ("t1", "New Year", "Last Splash", 1, 2, 117), ("t2", "Cannonball", "Last Splash", 2, 2, 213)] def test_a_playlist_page_has_no_album(): _, [track] = parse_embed(PLAYLIST) assert (track.artist, track.album, track.track_number) == ("X, Y", "", 0) assert spotify_link.tag_fields(track) == {"name": "Song A", "artist": "X, Y"} def test_a_track_page_is_one_song(): name, [track] = parse_embed(TRACK) assert name == "Never Gonna Give You Up" assert (track.id, track.artist, track.duration) == ("4uLU", "Rick Astley", 214) @pytest.mark.parametrize("html", [ "no data", _page({"type": "playlist", "name": "Empty", "trackList": []}), ]) def test_a_changed_page_says_so(html): with pytest.raises(SpotifyError): parse_embed(html) # -------------------------------------------------------------------------- # B. choosing the YouTube result def _e(vid, duration, title="x"): return {"id": vid, "duration": duration, "title": title, "url": f"https://www.youtube.com/watch?v={vid}"} def test_the_closest_length_wins(): track = SpotifyTrack("t", "Cannonball", "The Breeders", duration=213) ranked = rank_candidates([_e("video", 260), _e("audio", 214), _e("lyric", 220)], track) assert [e["id"] for e in ranked] == ["audio", "lyric", "video"] def test_live_and_covers_lose_unless_the_song_is_one(): track = SpotifyTrack("t", "Cannonball", "The Breeders", duration=213) ranked = rank_candidates([_e("live", 213, "Cannonball (Live 1994)"), _e("studio", 216, "Cannonball")], track) assert ranked[0]["id"] == "studio" live = SpotifyTrack("t", "Cannonball - Live", "The Breeders", duration=213) assert rank_candidates([_e("live", 213, "Cannonball (Live 1994)"), _e("studio", 216, "Cannonball")], live)[0]["id"] == "live" def test_without_lengths_youtube_order_stands(): track = SpotifyTrack("t", "Song", "A") assert [e["id"] for e in rank_candidates( [_e("a", None), _e("b", 100)], track)] == ["a", "b"] # -------------------------------------------------------------------------- # C. the worker, against a fake yt-dlp # Search: "ytsearch5:" lists $FAKE_SEARCH[query] as (id, seconds, # title). Download: writes ".mp3", failing ids starting with "bad". FAKE_YTDLP = '''#!/usr/bin/env python3 import json, 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: query = urls[0].split(":", 1)[1] for vid, secs, title in json.loads(os.environ["FAKE_SEARCH"]).get(query, []): print(f"LTENTRY {vid}\\t{secs}\\t" f"https://www.youtube.com/watch?v={vid}\\t{title}", flush=True) 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}\\t{vid}", flush=True) print("LTPROG 50.0%", flush=True) path = os.path.join(dest, f"{vid} [{vid}].mp3") with open(path, "wb") as f: f.write(open(os.environ["FAKE_YTDLP_AUDIO"], "rb").read()) print(f"LTFILE {path}", flush=True) ''' @pytest.fixture def fake(tmp_path, monkeypatch, mp3_file): 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("FAKE_YTDLP_AUDIO", str(mp3_file)) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) def search(results): monkeypatch.setenv("FAKE_SEARCH", json.dumps(results)) search({}) search.argv = lambda: ([line.split("\x1f") for line in log.read_text().splitlines()] if log.exists() else []) return search def _worker(tmp_path, tracks): worker = url_import.SpotifyImportWorker(tracks) worker.temp_dir = tmp_path / "dl" worker.temp_dir.mkdir() events = [] worker.item_started.connect( lambda i, n, sid, t: events.append(("start", i, n, sid))) worker.item_failed.connect(lambda sid, why: events.append(("bad", sid))) worker.downloaded.connect(lambda p: events.append(("file", p))) worker.finished.connect(lambda d: events.append(("done", d["downloaded"]))) worker.failed.connect(lambda m: events.append(("failed", m))) return worker, events def test_each_song_is_searched_picked_downloaded_and_tagged(fake, tmp_path): _, tracks = parse_embed(ALBUM) fake({"The Breeders - New Year": [["vid1", 300, "New Year (video)"], ["aud1", 117, "New Year"]], "The Breeders - Cannonball": [["bad2", 213, "Cannonball"], ["aud2", 214, "Cannonball"]]}) worker, events = _worker(tmp_path, tracks) worker._run() files = [e[1] for e in events if e[0] == "file"] assert [e for e in events if e[0] != "file"] == [ ("start", 1, 2, "t1"), ("start", 2, 2, "t2"), ("done", 2)] # The right length, then the next result when the best one fails. assert [os.path.basename(f) for f in files] == ["aud1 [aud1].mp3", "aud2 [aud2].mp3"] tags = tagging.read_tags(files[1]) assert (tags["name"], tags["artist"], tags["album"], tags["track_number"], tags["track_count"]) == ( "Cannonball", "The Breeders", "Last Splash", 2, 2) downloads = [a for a in fake.argv() if "--flat-playlist" not in a] assert all("--extract-audio" in a for a in downloads) # the `song` flags def test_a_song_youtube_doesnt_have_is_named(fake, tmp_path): _, tracks = parse_embed(ALBUM) fake({"The Breeders - Cannonball": [["aud2", 214, "Cannonball"]]}) worker, events = _worker(tmp_path, tracks) worker._run() assert [e for e in events if e[0] != "file"] == [ ("start", 1, 2, "t1"), ("bad", "t1"), ("start", 2, 2, "t2"), ("done", 1)] def test_nothing_found_at_all_fails(fake, tmp_path): _, tracks = parse_embed(PLAYLIST) worker, events = _worker(tmp_path, tracks) worker._run() assert events[-1][0] == "failed" assert not worker.busy() # -------------------------------------------------------------------------- # D. the dialog and the window 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" @pytest.fixture def canned(monkeypatch): pages = {"album": ALBUM, "track": TRACK, "playlist": PLAYLIST} monkeypatch.setattr(spotify_link, "fetch", lambda kind, _id: parse_embed(pages[kind])) def _dialog(qapp, url): qapp.clipboard().setText(url) dialog = UrlImportDialog(None) assert dialog.url() == url return dialog def test_an_album_is_a_checklist_of_spotify_songs(qapp, canned): dialog = _dialog(qapp, ALBUM_URL) dialog._on_import() _pump(qapp, lambda: dialog._probe is None) assert dialog._picking and dialog._list.count() == 2 assert dialog._ok.text() == "Import 2 Songs" dialog._list.item(0).setCheckState( dialog._list.item(0).checkState().Unchecked) dialog._on_import() assert [t.title for t in dialog.spotify_tracks()] == ["Cannonball"] def test_a_spotify_track_goes_straight_through(qapp, canned): dialog = _dialog(qapp, "https://open.spotify.com/track/4uLU") dialog._on_import() _pump(qapp, lambda: dialog.result() == dialog.DialogCode.Accepted) assert [t.artist for t in dialog.spotify_tracks()] == ["Rick Astley"] def test_a_youtube_link_has_no_spotify_tracks(qapp): dialog = _dialog(qapp, "https://www.youtube.com/watch?v=abc") dialog._on_import() assert dialog.spotify_tracks() == [] @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")) identified = [] monkeypatch.setattr(win, "_enqueue_identify", identified.extend) win.identified = identified yield win win.close() def test_spotify_songs_import_tagged_and_go_to_identify(window, qapp, fake, monkeypatch): from lintunes.gui import main_window as mw _, tracks = parse_embed(ALBUM) fake({"The Breeders - New Year": [["aud1", 117, "New Year"]], "The Breeders - Cannonball": [["aud2", 214, "Cannonball"]]}) chosen = [{"id": t.id, "title": t.label(), "spotify": t} for t in tracks] class _Dialog: cookies_used = None def __init__(self, *a, **kw): pass def exec(self): return True def add_to_playlist(self): return False def chosen_entries(self): return chosen def spotify_tracks(self): return tracks def download_urls(self): raise AssertionError("a Spotify link is never handed to yt-dlp") monkeypatch.setattr(mw, "UrlImportDialog", _Dialog) window._import_from_url() worker = window._url_worker assert isinstance(worker, url_import.SpotifyImportWorker) _pump(qapp, lambda: worker.temp_dir is None) assert window._url_progress.status("t1") == "Imported" assert window._url_progress.status("t2") == "Imported" lib = window._manager.library.tracks.values() assert sorted((t.artist, t.album, t.name) for t in lib) == [ ("The Breeders", "Last Splash", "Cannonball"), ("The Breeders", "Last Splash", "New Year")] assert len(window.identified) == 2