v0.37.0: Import from URL takes Spotify links
A Spotify track, album or playlist is read off Spotify's public embed page, each song found on YouTube by the result closest to Spotify's length, downloaded with the song flags, tagged with Spotify's artist/title/album/track number, then imported and identified. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -365,6 +365,19 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
|||||||
get `gui/url_import_progress.py`, a non-modal per-song list (Hide keeps
|
get `gui/url_import_progress.py`, a non-modal per-song list (Hide keeps
|
||||||
downloading; clicking the status-bar line reopens it). `LTSTART` carries
|
downloading; clicking the status-bar line reopens it). `LTSTART` carries
|
||||||
the id, and `ERROR: [site] <id>: …` lines become `item_failed`.
|
the id, and `ERROR: [site] <id>: …` lines become `item_failed`.
|
||||||
|
**A Spotify link** (Round 76, `spotify_link.py`, ported from trav's
|
||||||
|
`spotify-youtube` script) can't be downloaded, but Spotify's public
|
||||||
|
embed page (`open.spotify.com/embed/<kind>/<id>`, a `__NEXT_DATA__`
|
||||||
|
JSON block, no key) names every song with its **length**. `SpotifyProbe`
|
||||||
|
feeds those to the same checklist; `SpotifyImportWorker` (a
|
||||||
|
`UrlImportWorker` with the same signals, rows keyed by Spotify id) runs a
|
||||||
|
`ytsearch5:` listing per song, takes the result closest to Spotify's
|
||||||
|
length (`rank_candidates`: within 10 s, then no "live"/"cover" the
|
||||||
|
Spotify title doesn't have — the shazam-import lesson), falls back to the
|
||||||
|
next result if a download fails, and writes Spotify's title/artist/album/
|
||||||
|
track number into the mp3 **before** emitting `downloaded`, so the
|
||||||
|
import files it under the right artist and Identify ranks by that artist.
|
||||||
|
If the embed page changes shape, `parse_embed` raises `SpotifyError`.
|
||||||
|
|
||||||
- **`lintunes/mpris.py`** — registers `org.mpris.MediaPlayer2.lintunes` over D-Bus
|
- **`lintunes/mpris.py`** — registers `org.mpris.MediaPlayer2.lintunes` over D-Bus
|
||||||
so the desktop's media keys / now-playing popup control playback. Spacebar and
|
so the desktop's media keys / now-playing popup control playback. Spacebar and
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||||
|
|
||||||
__version__ = "0.36.0"
|
__version__ = "0.37.0"
|
||||||
|
|||||||
@@ -1340,6 +1340,13 @@ class MainWindow(QMainWindow):
|
|||||||
self._url_note = (f"tags proposed from filenames only ({problem})"
|
self._url_note = (f"tags proposed from filenames only ({problem})"
|
||||||
if problem else "")
|
if problem else "")
|
||||||
|
|
||||||
|
spotify = dialog.spotify_tracks()
|
||||||
|
if spotify:
|
||||||
|
# Spotify won't hand over audio: each song is found on YouTube
|
||||||
|
# and arrives already wearing Spotify's tags.
|
||||||
|
worker = url_import.SpotifyImportWorker(
|
||||||
|
spotify, self, cookies_browser=cookies)
|
||||||
|
else:
|
||||||
# The chosen songs by their own pages, never the list link: that
|
# The chosen songs by their own pages, never the list link: that
|
||||||
# would download the whole list again, and a Mix reshuffles.
|
# would download the whole list again, and a Mix reshuffles.
|
||||||
worker = url_import.UrlImportWorker(
|
worker = url_import.UrlImportWorker(
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ first row and returned 2,967 rows in which the linked song appeared 8 times
|
|||||||
(the Mix loops back on itself). So rows are de-duplicated by id, and a
|
(the Mix loops back on itself). So rows are de-duplicated by id, and a
|
||||||
song-in-a-list link gets **Just This Song**, which works from the first
|
song-in-a-list link gets **Just This Song**, which works from the first
|
||||||
moment and downloads the song's own page with the list stripped off.
|
moment and downloads the song's own page with the list stripped off.
|
||||||
|
|
||||||
|
A Spotify link (Round 76) is listed the same way, from Spotify's own page
|
||||||
|
(``url_import.SpotifyProbe``): a track goes straight through, an album or
|
||||||
|
playlist is a checklist with everything ticked. The caller reads the chosen
|
||||||
|
songs from ``spotify_tracks()`` and finds each one on YouTube.
|
||||||
"""
|
"""
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
@@ -28,7 +33,9 @@ from PyQt6.QtWidgets import (
|
|||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
)
|
)
|
||||||
|
|
||||||
from lintunes.url_import import PlaylistProbe, link_kind, looks_like_url
|
from lintunes.url_import import (
|
||||||
|
PlaylistProbe, SpotifyProbe, link_kind, looks_like_url,
|
||||||
|
)
|
||||||
|
|
||||||
ENTRY_ROLE = Qt.ItemDataRole.UserRole
|
ENTRY_ROLE = Qt.ItemDataRole.UserRole
|
||||||
|
|
||||||
@@ -72,7 +79,7 @@ class UrlImportDialog(QDialog):
|
|||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.addWidget(QLabel("Paste a link to a song, or to a playlist of "
|
layout.addWidget(QLabel("Paste a link to a song, or to a playlist of "
|
||||||
"songs:"))
|
"songs (YouTube, Spotify, …):"))
|
||||||
self._url = QLineEdit()
|
self._url = QLineEdit()
|
||||||
self._url.setPlaceholderText("https://…")
|
self._url.setPlaceholderText("https://…")
|
||||||
clipboard = QApplication.clipboard().text().strip()
|
clipboard = QApplication.clipboard().text().strip()
|
||||||
@@ -158,6 +165,15 @@ class UrlImportDialog(QDialog):
|
|||||||
chosen = self.chosen_entries()
|
chosen = self.chosen_entries()
|
||||||
return [e["url"] for e in chosen] if chosen else [self.url()]
|
return [e["url"] for e in chosen] if chosen else [self.url()]
|
||||||
|
|
||||||
|
def spotify_tracks(self) -> list:
|
||||||
|
"""The chosen songs of a Spotify link, as ``SpotifyTrack``s (empty
|
||||||
|
for any other link): the ticked ones, or the link's one song."""
|
||||||
|
if self._kind != "spotify":
|
||||||
|
return []
|
||||||
|
entries = self.chosen_entries() or (
|
||||||
|
[self._single] if self._single else [])
|
||||||
|
return [e["spotify"] for e in entries if e.get("spotify")]
|
||||||
|
|
||||||
def chosen_entries(self) -> list[dict]:
|
def chosen_entries(self) -> list[dict]:
|
||||||
"""The ticked songs, in the list's order. Empty means "download the
|
"""The ticked songs, in the list's order. Empty means "download the
|
||||||
link itself" (one song, or a link that was never listed)."""
|
link itself" (one song, or a link that was never listed)."""
|
||||||
@@ -201,12 +217,15 @@ class UrlImportDialog(QDialog):
|
|||||||
self._single = None
|
self._single = None
|
||||||
self._url.setEnabled(False)
|
self._url.setEnabled(False)
|
||||||
self._just.setVisible(bool(self._linked))
|
self._just.setVisible(bool(self._linked))
|
||||||
self._status.setText("Checking what's at the link…")
|
spotify = self._kind == "spotify"
|
||||||
|
self._status.setText("Reading the songs off Spotify…" if spotify
|
||||||
|
else "Checking what's at the link…")
|
||||||
self._status.show()
|
self._status.show()
|
||||||
self._busy.show()
|
self._busy.show()
|
||||||
# No parent: a cancelled probe's thread can outlive this dialog,
|
# No parent: a cancelled probe's thread can outlive this dialog,
|
||||||
# and must not be emitting from a deleted QObject when it does.
|
# and must not be emitting from a deleted QObject when it does.
|
||||||
probe = PlaylistProbe(url, cookies_browser=self._cookies)
|
probe = (SpotifyProbe if spotify else PlaylistProbe)(
|
||||||
|
url, cookies_browser=self._cookies)
|
||||||
probe.entry.connect(self._on_entry)
|
probe.entry.connect(self._on_entry)
|
||||||
probe.finished.connect(self._on_probe_finished)
|
probe.finished.connect(self._on_probe_finished)
|
||||||
probe.failed.connect(self._on_probe_failed)
|
probe.failed.connect(self._on_probe_failed)
|
||||||
@@ -266,6 +285,11 @@ class UrlImportDialog(QDialog):
|
|||||||
|
|
||||||
def _count_text(self, listing: bool) -> str:
|
def _count_text(self, listing: bool) -> str:
|
||||||
n = len(self._entries)
|
n = len(self._entries)
|
||||||
|
if self._kind == "spotify":
|
||||||
|
return (f"This Spotify link has {n} songs"
|
||||||
|
+ ("…" if listing else ".")
|
||||||
|
+ "\nEach one is found on YouTube by its artist, title "
|
||||||
|
"and length.")
|
||||||
text = f"This link is a playlist: {n} songs"
|
text = f"This link is a playlist: {n} songs"
|
||||||
if listing:
|
if listing:
|
||||||
text += " so far, still listing…"
|
text += " so far, still listing…"
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""Spotify links for File ▸ Import from URL… (Round 76).
|
||||||
|
|
||||||
|
Spotify won't hand over audio, but it will say what a link *is*: its public
|
||||||
|
embed page (``open.spotify.com/embed/<kind>/<id>``) carries the whole track
|
||||||
|
list as JSON in a ``__NEXT_DATA__`` script, with no API key and no login.
|
||||||
|
Each song is then looked up on YouTube as ``Artist - Title`` and downloaded
|
||||||
|
with the `song` flags like any other link. Ported from trav's
|
||||||
|
``spotify-youtube`` script.
|
||||||
|
|
||||||
|
Two things this does that the script didn't:
|
||||||
|
|
||||||
|
- **Length picks the YouTube result.** Every embed track carries its
|
||||||
|
duration, so of the first five search results the one closest in length
|
||||||
|
wins. A length that disagrees is a live take, a cover, a lyric video with
|
||||||
|
a minute of intro, or another song with the same name. Words like "live"
|
||||||
|
and "cover" in a result's title count against it unless the Spotify title
|
||||||
|
says so too.
|
||||||
|
- **Spotify's tags go on before the import**, so the song files under its
|
||||||
|
real artist and album rather than ``Unknown Artist``, and Identify ranks
|
||||||
|
AcoustID's recordings by the artist Spotify named.
|
||||||
|
|
||||||
|
Shaped like ``art_search.py``: pure helpers first (tested against canned
|
||||||
|
JSON), then the one network call.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
EMBED_URL = "https://open.spotify.com/embed/{kind}/{id}"
|
||||||
|
USER_AGENT = ("Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "
|
||||||
|
"Gecko/20100101 Firefox/131.0")
|
||||||
|
TIMEOUT = 20
|
||||||
|
|
||||||
|
# How many YouTube results each song is chosen from.
|
||||||
|
SEARCH_RESULTS = 5
|
||||||
|
# A result this much longer or shorter than Spotify's length is suspect.
|
||||||
|
DURATION_SLACK = 10
|
||||||
|
|
||||||
|
KINDS = ("track", "album", "playlist")
|
||||||
|
|
||||||
|
# open.spotify.com/track/<id>, …/intl-de/album/<id>?si=…, spotify:playlist:<id>
|
||||||
|
_SOURCE = re.compile(
|
||||||
|
r"^(?:https?://open\.spotify\.com/(?:intl-[\w-]+/)?(?:embed/)?"
|
||||||
|
r"(track|album|playlist)/([A-Za-z0-9]+)"
|
||||||
|
r"|spotify:(track|album|playlist):([A-Za-z0-9]+))(?:[/?#].*)?$")
|
||||||
|
|
||||||
|
_NEXT_DATA = re.compile(
|
||||||
|
r'<script\s+id="__NEXT_DATA__"\s+type="application/json">\s*(.*?)\s*'
|
||||||
|
r'</script>', re.DOTALL)
|
||||||
|
|
||||||
|
# Words in a YouTube title that mean "not the studio recording".
|
||||||
|
_UNWANTED = ("live", "cover", "karaoke", "instrumental", "remix", "reaction",
|
||||||
|
"8d", "slowed", "sped up", "nightcore")
|
||||||
|
|
||||||
|
|
||||||
|
class SpotifyError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SpotifyTrack:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
artist: str
|
||||||
|
album: str = ""
|
||||||
|
duration: int | None = None # seconds
|
||||||
|
track_number: int = 0
|
||||||
|
track_count: int = 0
|
||||||
|
|
||||||
|
def label(self) -> str:
|
||||||
|
return f"{self.artist} — {self.title}" if self.artist else self.title
|
||||||
|
|
||||||
|
|
||||||
|
def spotify_source(text: str) -> tuple[str, str] | None:
|
||||||
|
"""(kind, id) for a Spotify track, album or playlist link or URI, else
|
||||||
|
None. Artist, show and episode links aren't lists of songs."""
|
||||||
|
m = _SOURCE.match((text or "").strip())
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return (m.group(1), m.group(2)) if m.group(1) else (m.group(3),
|
||||||
|
m.group(4))
|
||||||
|
|
||||||
|
|
||||||
|
def _seconds(ms) -> int | None:
|
||||||
|
try:
|
||||||
|
return round(float(ms) / 1000) or None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _track_id(item: dict, fallback: str) -> str:
|
||||||
|
uri = item.get("uri") or ""
|
||||||
|
return uri.rsplit(":", 1)[-1] if uri.startswith("spotify:track:") \
|
||||||
|
else (item.get("id") or fallback)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_entity(data, depth=0):
|
||||||
|
"""The dict holding the track list (or the lone track), wherever the
|
||||||
|
page put it."""
|
||||||
|
if depth > 20 or data is None:
|
||||||
|
return None
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if isinstance(data.get("trackList"), list) and data["trackList"]:
|
||||||
|
return data
|
||||||
|
if data.get("type") == "track" and isinstance(data.get("artists"),
|
||||||
|
list):
|
||||||
|
return data
|
||||||
|
children = data.values()
|
||||||
|
elif isinstance(data, list):
|
||||||
|
children = data
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
for value in children:
|
||||||
|
found = _find_entity(value, depth + 1)
|
||||||
|
if found is not None:
|
||||||
|
return found
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_embed(html: str) -> tuple[str, list[SpotifyTrack]]:
|
||||||
|
"""An embed page → (its name, its songs in order). Raises SpotifyError
|
||||||
|
when the page isn't shaped the way it was in October 2026."""
|
||||||
|
m = _NEXT_DATA.search(html or "")
|
||||||
|
if not m:
|
||||||
|
raise SpotifyError("Spotify's page didn't have the track list in it "
|
||||||
|
"(Spotify may have changed its page format)")
|
||||||
|
try:
|
||||||
|
data = json.loads(m.group(1))
|
||||||
|
except ValueError as e:
|
||||||
|
raise SpotifyError(f"Spotify's track list couldn't be read: {e}")
|
||||||
|
entity = _find_entity(data)
|
||||||
|
if entity is None:
|
||||||
|
raise SpotifyError("Spotify's page had no songs in it")
|
||||||
|
|
||||||
|
name = entity.get("name") or entity.get("title") or "Spotify"
|
||||||
|
if "trackList" not in entity: # a single track
|
||||||
|
artist = ", ".join(a.get("name", "") for a in entity["artists"]
|
||||||
|
if a.get("name"))
|
||||||
|
title = entity.get("title") or entity.get("name") or ""
|
||||||
|
if not title:
|
||||||
|
raise SpotifyError("Spotify's page had no songs in it")
|
||||||
|
return title, [SpotifyTrack(
|
||||||
|
id=_track_id(entity, entity.get("id") or "0"), title=title,
|
||||||
|
artist=artist, duration=_seconds(entity.get("duration")))]
|
||||||
|
|
||||||
|
is_album = entity.get("type") == "album"
|
||||||
|
items = [item for item in entity["trackList"] if item.get("title")]
|
||||||
|
tracks = []
|
||||||
|
for n, item in enumerate(items, 1):
|
||||||
|
tracks.append(SpotifyTrack(
|
||||||
|
id=_track_id(item, str(n)), title=item["title"],
|
||||||
|
artist=item.get("subtitle") or "",
|
||||||
|
album=name if is_album else "",
|
||||||
|
duration=_seconds(item.get("duration")),
|
||||||
|
track_number=n if is_album else 0,
|
||||||
|
track_count=len(items) if is_album else 0))
|
||||||
|
if not tracks:
|
||||||
|
raise SpotifyError("Spotify's page had no songs in it")
|
||||||
|
return name, tracks
|
||||||
|
|
||||||
|
|
||||||
|
def search_query(track: SpotifyTrack) -> str:
|
||||||
|
return f"{track.artist} - {track.title}" if track.artist else track.title
|
||||||
|
|
||||||
|
|
||||||
|
def _unwanted(title: str, wanted_title: str) -> int:
|
||||||
|
"""How many "not the studio take" words a result has that the song's
|
||||||
|
own title doesn't."""
|
||||||
|
have = (title or "").lower()
|
||||||
|
want = (wanted_title or "").lower()
|
||||||
|
return sum(1 for word in _UNWANTED
|
||||||
|
if re.search(rf"\b{re.escape(word)}\b", have)
|
||||||
|
and not re.search(rf"\b{re.escape(word)}\b", want))
|
||||||
|
|
||||||
|
|
||||||
|
def rank_candidates(entries: list[dict],
|
||||||
|
track: SpotifyTrack) -> list[dict]:
|
||||||
|
"""YouTube search results (``PlaylistProbe`` entries), best first: within
|
||||||
|
``DURATION_SLACK`` of Spotify's length before outside it, no "live" /
|
||||||
|
"cover" before those words, then closest in length, then YouTube's own
|
||||||
|
order. With no lengths on either side, YouTube's order stands."""
|
||||||
|
def key(pair):
|
||||||
|
position, entry = pair
|
||||||
|
length = entry.get("duration")
|
||||||
|
if track.duration and length:
|
||||||
|
off = abs(length - track.duration)
|
||||||
|
return (off > DURATION_SLACK,
|
||||||
|
_unwanted(entry.get("title", ""), track.title),
|
||||||
|
off, position)
|
||||||
|
return (track.duration is not None,
|
||||||
|
_unwanted(entry.get("title", ""), track.title), 0, position)
|
||||||
|
return [e for _, e in sorted(enumerate(entries), key=key)]
|
||||||
|
|
||||||
|
|
||||||
|
def tag_fields(track: SpotifyTrack) -> dict:
|
||||||
|
"""What Spotify knows, as Track-style fields for ``tagging.write_tags``."""
|
||||||
|
fields = {"name": track.title, "artist": track.artist}
|
||||||
|
if track.album:
|
||||||
|
fields["album"] = track.album
|
||||||
|
if track.track_number:
|
||||||
|
fields["track_number"] = track.track_number
|
||||||
|
fields["track_count"] = track.track_count
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
# ---- network ----
|
||||||
|
|
||||||
|
def fetch(kind: str, source_id: str) -> tuple[str, list[SpotifyTrack]]:
|
||||||
|
url = EMBED_URL.format(kind=kind, id=source_id)
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, headers={"User-Agent": USER_AGENT},
|
||||||
|
timeout=TIMEOUT)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise SpotifyError(f"couldn't reach Spotify: {e}")
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise SpotifyError(f"Spotify has no {kind} with that link (it may be "
|
||||||
|
"private or deleted)")
|
||||||
|
if not resp.ok:
|
||||||
|
raise SpotifyError(f"Spotify answered {resp.status_code}")
|
||||||
|
return parse_embed(resp.text)
|
||||||
+185
-1
@@ -31,6 +31,13 @@ Anything but a plain song link is *listed* before it downloads
|
|||||||
``list=RD…``, and yt-dlp's default is to take the whole list. The songs the
|
``list=RD…``, and yt-dlp's default is to take the whole list. The songs the
|
||||||
user picks are then downloaded by their own pages, never by playlist
|
user picks are then downloaded by their own pages, never by playlist
|
||||||
position, because a Mix comes back reshuffled every time it's listed.
|
position, because a Mix comes back reshuffled every time it's listed.
|
||||||
|
|
||||||
|
A Spotify link (Round 76) can't be downloaded at all, but its embed page
|
||||||
|
names every song on it (``spotify_link``). ``SpotifyProbe`` lists those for
|
||||||
|
the same checklist, and ``SpotifyImportWorker`` takes them one at a time:
|
||||||
|
a five-result YouTube search, the result closest to Spotify's length, the
|
||||||
|
`song` download, then Spotify's tags written into the file *before* it's
|
||||||
|
handed over, so the import files it under the right artist and album.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -38,6 +45,7 @@ import signal
|
|||||||
import subprocess
|
import subprocess
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -45,8 +53,11 @@ from urllib.parse import parse_qs, urlsplit
|
|||||||
|
|
||||||
from PyQt6.QtCore import QObject, pyqtSignal
|
from PyQt6.QtCore import QObject, pyqtSignal
|
||||||
|
|
||||||
|
from lintunes import spotify_link, tagging
|
||||||
from lintunes.models.playlist import PlaylistType
|
from lintunes.models.playlist import PlaylistType
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# The `song` helper's own flags, verbatim.
|
# The `song` helper's own flags, verbatim.
|
||||||
SONG_ARGS = ["--extract-audio", "--audio-format", "mp3"]
|
SONG_ARGS = ["--extract-audio", "--audio-format", "mp3"]
|
||||||
|
|
||||||
@@ -90,6 +101,8 @@ def ytdlp_available() -> bool:
|
|||||||
|
|
||||||
def looks_like_url(text: str) -> bool:
|
def looks_like_url(text: str) -> bool:
|
||||||
text = (text or "").strip()
|
text = (text or "").strip()
|
||||||
|
if spotify_link.spotify_source(text) is not None:
|
||||||
|
return True # spotify:track:… URIs too
|
||||||
return (text.startswith(("http://", "https://"))
|
return (text.startswith(("http://", "https://"))
|
||||||
and len(text) > len("https://")
|
and len(text) > len("https://")
|
||||||
and not any(c.isspace() for c in text))
|
and not any(c.isspace() for c in text))
|
||||||
@@ -98,12 +111,16 @@ def looks_like_url(text: str) -> bool:
|
|||||||
def link_kind(url: str) -> str:
|
def link_kind(url: str) -> str:
|
||||||
"""What a link names, from the URL alone: "song", "song_in_list" (a song
|
"""What a link names, from the URL alone: "song", "song_in_list" (a song
|
||||||
opened from a playlist or a YouTube Mix — yt-dlp would take the whole
|
opened from a playlist or a YouTube Mix — yt-dlp would take the whole
|
||||||
list), "list", or "unknown" (another site; only a probe can tell).
|
list), "list", "spotify" (a Spotify track, album or playlist, which is
|
||||||
|
found on YouTube song by song), or "unknown" (another site; only a probe
|
||||||
|
can tell).
|
||||||
|
|
||||||
Only "song" skips the probe, so a plain song link imports as fast as
|
Only "song" skips the probe, so a plain song link imports as fast as
|
||||||
ever. A Mix is the one that bites: it's a song link with ``list=RD…``
|
ever. A Mix is the one that bites: it's a song link with ``list=RD…``
|
||||||
on the end, and the list behind it runs to hundreds of songs.
|
on the end, and the list behind it runs to hundreds of songs.
|
||||||
"""
|
"""
|
||||||
|
if spotify_link.spotify_source(url) is not None:
|
||||||
|
return "spotify"
|
||||||
try:
|
try:
|
||||||
parts = urlsplit((url or "").strip())
|
parts = urlsplit((url or "").strip())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -364,6 +381,7 @@ class _YtdlpRun(QObject):
|
|||||||
return None
|
return None
|
||||||
count, errors, returncode = attempt
|
count, errors, returncode = attempt
|
||||||
if count:
|
if count:
|
||||||
|
self._cookies = cookies # a later run starts with them
|
||||||
self.cookies_used.emit(cookies)
|
self.cookies_used.emit(cookies)
|
||||||
return count, errors, cookies, returncode
|
return count, errors, cookies, returncode
|
||||||
|
|
||||||
@@ -539,3 +557,169 @@ class UrlImportWorker(_YtdlpRun):
|
|||||||
return
|
return
|
||||||
self.finished.emit({"downloaded": count, "errors": errors,
|
self.finished.emit({"downloaded": count, "errors": errors,
|
||||||
"cancelled": cancelled})
|
"cancelled": cancelled})
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Spotify ----
|
||||||
|
|
||||||
|
class SpotifyProbe(QObject):
|
||||||
|
"""``PlaylistProbe``'s signals for a Spotify link: reads its embed page
|
||||||
|
on a daemon thread and emits one entry per song, carrying the
|
||||||
|
``SpotifyTrack`` under ``"spotify"``. One request, no yt-dlp."""
|
||||||
|
|
||||||
|
entry = pyqtSignal(dict)
|
||||||
|
finished = pyqtSignal(int)
|
||||||
|
failed = pyqtSignal(str)
|
||||||
|
cookies_used = pyqtSignal(str) # never: Spotify needs no cookies
|
||||||
|
|
||||||
|
def __init__(self, url: str, parent=None, **_kw):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._url = url
|
||||||
|
self._busy = False
|
||||||
|
self._cancel = threading.Event()
|
||||||
|
|
||||||
|
def busy(self) -> bool:
|
||||||
|
return self._busy
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self._cancel.set()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if not self._busy:
|
||||||
|
self._busy = True
|
||||||
|
threading.Thread(target=self._run, daemon=True).start()
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
try:
|
||||||
|
kind, source_id = spotify_link.spotify_source(self._url)
|
||||||
|
_name, tracks = spotify_link.fetch(kind, source_id)
|
||||||
|
except Exception as e:
|
||||||
|
self._busy = False
|
||||||
|
self.failed.emit(str(e))
|
||||||
|
return
|
||||||
|
count = 0
|
||||||
|
for track in tracks:
|
||||||
|
if self._cancel.is_set():
|
||||||
|
break
|
||||||
|
self.entry.emit({"id": track.id, "title": track.label(),
|
||||||
|
"duration": track.duration, "url": "",
|
||||||
|
"spotify": track})
|
||||||
|
count += 1
|
||||||
|
self._busy = False
|
||||||
|
self.finished.emit(count)
|
||||||
|
|
||||||
|
|
||||||
|
class SpotifyImportWorker(UrlImportWorker):
|
||||||
|
"""``UrlImportWorker``'s signals, for songs a Spotify link named: each
|
||||||
|
is searched for on YouTube, the best-length result downloaded (the next
|
||||||
|
one if that fails), and Spotify's tags written in before ``downloaded``.
|
||||||
|
Rows are keyed by the Spotify id, which is what the checklist and the
|
||||||
|
progress window know them by."""
|
||||||
|
|
||||||
|
# Results tried per song before giving up on it.
|
||||||
|
TRIES = 3
|
||||||
|
|
||||||
|
def __init__(self, tracks, parent=None, *,
|
||||||
|
cookies_browser: str | None = None):
|
||||||
|
super().__init__([], parent, cookies_browser=cookies_browser)
|
||||||
|
self._tracks = list(tracks)
|
||||||
|
self._stage = ""
|
||||||
|
self._query = ""
|
||||||
|
self._found: list[dict] = []
|
||||||
|
self._files: list[str] = []
|
||||||
|
|
||||||
|
def _command(self, cookies_browser):
|
||||||
|
if self._stage == "search":
|
||||||
|
return build_probe_command(
|
||||||
|
f"ytsearch{spotify_link.SEARCH_RESULTS}:{self._query}",
|
||||||
|
cookies_browser)
|
||||||
|
return build_command(self._query, self.temp_dir, cookies_browser)
|
||||||
|
|
||||||
|
def _on_parsed(self, parsed):
|
||||||
|
kind = parsed[0]
|
||||||
|
if self._stage == "search":
|
||||||
|
if kind == "entry":
|
||||||
|
self._found.append(parsed[1])
|
||||||
|
return True
|
||||||
|
elif kind == "progress":
|
||||||
|
self.progress.emit(parsed[1])
|
||||||
|
elif kind == "file" and not self._cancel.is_set():
|
||||||
|
self._files.append(parsed[1])
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _on_error(self, error):
|
||||||
|
pass # reported per song, by Spotify id
|
||||||
|
|
||||||
|
def _search(self, track):
|
||||||
|
"""→ (YouTube results best first, ERROR lines, cookies, code), or
|
||||||
|
None when yt-dlp couldn't run."""
|
||||||
|
self._stage, self._query, self._found = ("search",
|
||||||
|
spotify_link.search_query(
|
||||||
|
track), [])
|
||||||
|
result = self._run_with_retry()
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
_count, errors, cookies, code = result
|
||||||
|
return (spotify_link.rank_candidates(self._found, track), errors,
|
||||||
|
cookies, code)
|
||||||
|
|
||||||
|
def _download(self, url):
|
||||||
|
self._stage, self._query, self._files = "download", url, []
|
||||||
|
result = self._run_with_retry()
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
return self._files[-1] if self._files else "", result[1]
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
total = len(self._tracks)
|
||||||
|
downloaded = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
last = None # (ERROR lines, cookies, code) of a miss
|
||||||
|
for index, track in enumerate(self._tracks, 1):
|
||||||
|
if self._cancel.is_set():
|
||||||
|
break
|
||||||
|
self.item_started.emit(index, total, track.id, track.label())
|
||||||
|
searched = self._search(track)
|
||||||
|
if searched is None:
|
||||||
|
return
|
||||||
|
candidates, run_errors, cookies, code = searched
|
||||||
|
path, reason = "", ""
|
||||||
|
if not candidates:
|
||||||
|
reason = (run_errors[-1] if run_errors
|
||||||
|
else "no match on YouTube")
|
||||||
|
last = (run_errors, cookies, code)
|
||||||
|
for candidate in candidates[:self.TRIES]:
|
||||||
|
if self._cancel.is_set():
|
||||||
|
break
|
||||||
|
got = self._download(candidate["url"])
|
||||||
|
if got is None:
|
||||||
|
return
|
||||||
|
path, run_errors = got
|
||||||
|
if path:
|
||||||
|
break
|
||||||
|
reason = run_errors[-1] if run_errors else "download failed"
|
||||||
|
last = (run_errors, self._cookies, 0)
|
||||||
|
if self._cancel.is_set() and not path:
|
||||||
|
break
|
||||||
|
if not path:
|
||||||
|
errors.append(f"{track.label()}: {reason}")
|
||||||
|
self.item_failed.emit(track.id, reason)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
tagging.write_tags(path, spotify_link.tag_fields(track))
|
||||||
|
except Exception as e: # untagged still beats not imported
|
||||||
|
log.warning("spotify tags for %s: %s", path, e)
|
||||||
|
downloaded += 1
|
||||||
|
self.downloaded.emit(path)
|
||||||
|
cancelled = self._cancel.is_set()
|
||||||
|
self._busy = False
|
||||||
|
if downloaded == 0 and not cancelled:
|
||||||
|
if last is not None and last[0]:
|
||||||
|
self._fail_nothing(*last, "download")
|
||||||
|
else:
|
||||||
|
self.failed.emit(errors[-1] if errors else
|
||||||
|
"nothing on the Spotify link was found "
|
||||||
|
"on YouTube")
|
||||||
|
return
|
||||||
|
self.finished.emit({"downloaded": downloaded, "errors": errors,
|
||||||
|
"cancelled": cancelled})
|
||||||
|
|||||||
@@ -337,6 +337,8 @@ class TestWindow:
|
|||||||
assert "T2" in description
|
assert "T2" in description
|
||||||
def chosen_entries(self):
|
def chosen_entries(self):
|
||||||
return []
|
return []
|
||||||
|
def spotify_tracks(self):
|
||||||
|
return []
|
||||||
def download_urls(self):
|
def download_urls(self):
|
||||||
return [self.url()]
|
return [self.url()]
|
||||||
def exec(self):
|
def exec(self):
|
||||||
|
|||||||
@@ -362,6 +362,8 @@ def test_only_the_chosen_songs_download(window, qapp, fake, mp3_file,
|
|||||||
return False
|
return False
|
||||||
def chosen_entries(self):
|
def chosen_entries(self):
|
||||||
return chosen
|
return chosen
|
||||||
|
def spotify_tracks(self):
|
||||||
|
return []
|
||||||
def download_urls(self):
|
def download_urls(self):
|
||||||
return [c["url"] for c in chosen]
|
return [c["url"] for c in chosen]
|
||||||
monkeypatch.setattr(mw, "UrlImportDialog", _Dialog)
|
monkeypatch.setattr(mw, "UrlImportDialog", _Dialog)
|
||||||
@@ -406,6 +408,8 @@ def test_one_song_gets_no_progress_window(window, qapp, fake, mp3_file,
|
|||||||
return False
|
return False
|
||||||
def chosen_entries(self):
|
def chosen_entries(self):
|
||||||
return []
|
return []
|
||||||
|
def spotify_tracks(self):
|
||||||
|
return []
|
||||||
def download_urls(self):
|
def download_urls(self):
|
||||||
return [self.url()]
|
return [self.url()]
|
||||||
monkeypatch.setattr(mw, "UrlImportDialog", _Dialog)
|
monkeypatch.setattr(mw, "UrlImportDialog", _Dialog)
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
"""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 ('<html><script id="__NEXT_DATA__" type="application/json">'
|
||||||
|
+ json.dumps(data) + "</script></html>")
|
||||||
|
|
||||||
|
|
||||||
|
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", [
|
||||||
|
"<html>no data</html>",
|
||||||
|
_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:<query>" lists $FAKE_SEARCH[query] as (id, seconds,
|
||||||
|
# title). Download: writes "<id>.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
|
||||||
Reference in New Issue
Block a user