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>
349 lines
14 KiB
Python
349 lines
14 KiB
Python
"""File ▸ Import from URL…: paste a link, optionally add to the current playlist.
|
|
|
|
Passive, like IdentifyDialog: the caller works out where the songs would go
|
|
(``url_import.resolve_target``) and passes its description in. The dialog
|
|
reports the link, whether the box was ticked, and which songs to take.
|
|
|
|
Since Round 75 it also says when a link is a playlist, *before* anything
|
|
downloads. A song link opened from a YouTube Mix carries ``list=RD…``, and
|
|
yt-dlp's default is to take the whole list, which runs to hundreds of songs.
|
|
Pasting one imported song after song with no hint why. So anything but a
|
|
plain song link is listed first (``url_import.PlaylistProbe``). One song
|
|
goes straight through. Several are shown as a checklist that fills while
|
|
yt-dlp is still listing. A link to a song *in* a list ticks only that song,
|
|
and a playlist link ticks them all.
|
|
|
|
Two things measured against the real Mix: listing it took ~36 s before the
|
|
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
|
|
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.
|
|
|
|
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 PyQt6.QtCore import Qt
|
|
from PyQt6.QtWidgets import (
|
|
QApplication, QCheckBox, QDialog, QDialogButtonBox, QHBoxLayout, QLabel,
|
|
QLineEdit, QListWidget, QListWidgetItem, QProgressBar, QPushButton,
|
|
QVBoxLayout,
|
|
)
|
|
|
|
from lintunes.url_import import (
|
|
PlaylistProbe, SpotifyProbe, link_kind, looks_like_url,
|
|
)
|
|
|
|
ENTRY_ROLE = Qt.ItemDataRole.UserRole
|
|
|
|
|
|
def format_duration(seconds) -> str:
|
|
if not seconds:
|
|
return ""
|
|
seconds = int(seconds)
|
|
if seconds >= 3600:
|
|
return f"{seconds // 3600}:{seconds % 3600 // 60:02d}:{seconds % 60:02d}"
|
|
return f"{seconds // 60}:{seconds % 60:02d}"
|
|
|
|
|
|
def linked_song_id(url: str) -> str:
|
|
"""The ``v=`` a song-in-a-list link points at ("" when it has none)."""
|
|
try:
|
|
parts = urlsplit(url)
|
|
except ValueError:
|
|
return ""
|
|
if (parts.hostname or "").lower() == "youtu.be":
|
|
return parts.path.strip("/")
|
|
return (parse_qs(parts.query).get("v") or [""])[0]
|
|
|
|
|
|
class UrlImportDialog(QDialog):
|
|
def __init__(self, target_description: str | None, parent=None, *,
|
|
cookies_browser: str | None = None):
|
|
super().__init__(parent)
|
|
self.setWindowTitle("Import from URL")
|
|
self.setMinimumWidth(460)
|
|
self._cookies = cookies_browser
|
|
self._probe = None
|
|
self._kind = "song"
|
|
self._linked = ""
|
|
self._entries: list[dict] = [] # everything the probe found
|
|
self._picking = False # the checklist is showing
|
|
self._single = None # the one entry of a one-song link
|
|
self._seen: set[str] = set() # ids already listed (a Mix loops)
|
|
self._just_song = False # "Just This Song" was pressed
|
|
self.cookies_used = None # a browser the probe needed
|
|
|
|
layout = QVBoxLayout(self)
|
|
layout.addWidget(QLabel("Paste a link to a song, or to a playlist of "
|
|
"songs (YouTube, Spotify, …):"))
|
|
self._url = QLineEdit()
|
|
self._url.setPlaceholderText("https://…")
|
|
clipboard = QApplication.clipboard().text().strip()
|
|
if looks_like_url(clipboard):
|
|
self._url.setText(clipboard)
|
|
self._url.selectAll()
|
|
layout.addWidget(self._url)
|
|
|
|
# Listing what's at the link: a line of text and a busy bar.
|
|
self._status = QLabel()
|
|
self._status.setWordWrap(True)
|
|
self._status.hide()
|
|
layout.addWidget(self._status)
|
|
self._busy = QProgressBar()
|
|
self._busy.setRange(0, 0)
|
|
self._busy.setTextVisible(False)
|
|
self._busy.setMaximumHeight(8)
|
|
self._busy.hide()
|
|
layout.addWidget(self._busy)
|
|
|
|
# The checklist, once the link turns out to hold several songs.
|
|
self._list = QListWidget()
|
|
self._list.setMinimumHeight(280)
|
|
self._list.setUniformItemSizes(True)
|
|
self._list.hide()
|
|
self._list.itemChanged.connect(self._update_ok)
|
|
layout.addWidget(self._list, 1)
|
|
self._pick_row = QHBoxLayout()
|
|
self._all = QPushButton("Select All")
|
|
self._none = QPushButton("Select None")
|
|
self._all.clicked.connect(lambda: self._check_all(True))
|
|
self._none.clicked.connect(lambda: self._check_all(False))
|
|
self._pick_row.addWidget(self._all)
|
|
self._pick_row.addWidget(self._none)
|
|
self._pick_row.addStretch(1)
|
|
for button in (self._all, self._none):
|
|
button.hide()
|
|
layout.addLayout(self._pick_row)
|
|
|
|
self._add = QCheckBox("and add to current playlist?")
|
|
layout.addWidget(self._add)
|
|
self._hint = QLabel(target_description
|
|
or "Select or play a playlist to add songs to it.")
|
|
self._hint.setEnabled(False) # the greyed-hint idiom
|
|
self._hint.setWordWrap(True)
|
|
self._hint.setContentsMargins(22, 0, 0, 0)
|
|
layout.addWidget(self._hint)
|
|
if target_description is None:
|
|
self._add.setEnabled(False)
|
|
else:
|
|
self._add.setChecked(True)
|
|
|
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
|
|
| QDialogButtonBox.StandardButton.Cancel)
|
|
self._ok = buttons.button(QDialogButtonBox.StandardButton.Ok)
|
|
self._ok.setText("Import")
|
|
self._just = buttons.addButton("Just This Song",
|
|
QDialogButtonBox.ButtonRole.ActionRole)
|
|
self._just.setToolTip("Import only the song the link points at, "
|
|
"without waiting for the list")
|
|
self._just.hide()
|
|
self._just.clicked.connect(self._on_just_song)
|
|
buttons.accepted.connect(self._on_import)
|
|
buttons.rejected.connect(self.reject)
|
|
layout.addWidget(buttons)
|
|
|
|
self._url.textChanged.connect(self._update_ok)
|
|
self._update_ok()
|
|
|
|
# ---- what the caller reads ----
|
|
|
|
def url(self) -> str:
|
|
return self._url.text().strip()
|
|
|
|
def add_to_playlist(self) -> bool:
|
|
return self._add.isEnabled() and self._add.isChecked()
|
|
|
|
def download_urls(self) -> list[str]:
|
|
"""What to hand yt-dlp: the ticked songs' own pages, the linked song
|
|
alone, or the link itself — never a list link the user didn't see."""
|
|
if self._just_song:
|
|
return [f"https://www.youtube.com/watch?v={self._linked}"]
|
|
chosen = self.chosen_entries()
|
|
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]:
|
|
"""The ticked songs, in the list's order. Empty means "download the
|
|
link itself" (one song, or a link that was never listed)."""
|
|
if not self._picking or self._just_song:
|
|
return []
|
|
return [self._list.item(i).data(ENTRY_ROLE)
|
|
for i in range(self._list.count())
|
|
if self._list.item(i).checkState() == Qt.CheckState.Checked]
|
|
|
|
# ---- the states ----
|
|
|
|
def _checked_count(self) -> int:
|
|
return sum(1 for i in range(self._list.count())
|
|
if self._list.item(i).checkState() == Qt.CheckState.Checked)
|
|
|
|
def _update_ok(self, *_):
|
|
if self._picking:
|
|
n = self._checked_count()
|
|
self._ok.setText(f"Import {n} Song{'' if n == 1 else 's'}")
|
|
self._ok.setEnabled(n > 0)
|
|
elif self._probe is not None:
|
|
self._ok.setEnabled(False)
|
|
else:
|
|
self._ok.setText("Import")
|
|
self._ok.setEnabled(looks_like_url(self._url.text()))
|
|
|
|
def _on_import(self):
|
|
if self._picking:
|
|
self._stop_probe()
|
|
self.accept()
|
|
return
|
|
url = self.url()
|
|
self._kind = link_kind(url)
|
|
if self._kind == "song":
|
|
self.accept()
|
|
return
|
|
self._linked = linked_song_id(url) if self._kind == "song_in_list" \
|
|
else ""
|
|
self._entries = []
|
|
self._seen = set()
|
|
self._single = None
|
|
self._url.setEnabled(False)
|
|
self._just.setVisible(bool(self._linked))
|
|
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._busy.show()
|
|
# No parent: a cancelled probe's thread can outlive this dialog,
|
|
# and must not be emitting from a deleted QObject when it does.
|
|
probe = (SpotifyProbe if spotify else PlaylistProbe)(
|
|
url, cookies_browser=self._cookies)
|
|
probe.entry.connect(self._on_entry)
|
|
probe.finished.connect(self._on_probe_finished)
|
|
probe.failed.connect(self._on_probe_failed)
|
|
probe.cookies_used.connect(self._on_cookies_used)
|
|
self._probe = probe
|
|
self._update_ok()
|
|
probe.start()
|
|
|
|
def _on_just_song(self):
|
|
self._just_song = True
|
|
self._stop_probe()
|
|
self.accept()
|
|
|
|
def _on_cookies_used(self, browser: str):
|
|
self.cookies_used = browser
|
|
self._cookies = browser
|
|
|
|
def _on_entry(self, entry: dict):
|
|
if self.sender() is not self._probe:
|
|
return
|
|
if entry.get("id") in self._seen:
|
|
return
|
|
self._seen.add(entry.get("id"))
|
|
self._entries.append(entry)
|
|
if len(self._entries) == 1:
|
|
return # one song is not a playlist (yet)
|
|
if not self._picking:
|
|
self._show_picker()
|
|
for earlier in self._entries[:-1]:
|
|
self._add_row(earlier)
|
|
self._add_row(entry)
|
|
self._status.setText(self._count_text(listing=True))
|
|
|
|
def _show_picker(self):
|
|
self._picking = True
|
|
self._list.show()
|
|
self._all.show()
|
|
self._none.show()
|
|
self.resize(max(self.width(), 560), max(self.height(), 520))
|
|
|
|
def _add_row(self, entry: dict):
|
|
title = entry.get("title") or entry.get("id") or "(untitled)"
|
|
length = format_duration(entry.get("duration"))
|
|
item = QListWidgetItem(f"{title} {length}" if length else title)
|
|
item.setData(ENTRY_ROLE, entry)
|
|
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
|
if self._kind == "song_in_list":
|
|
checked = entry.get("id") == self._linked
|
|
else:
|
|
checked = True
|
|
self._list.blockSignals(True)
|
|
item.setCheckState(Qt.CheckState.Checked if checked
|
|
else Qt.CheckState.Unchecked)
|
|
self._list.addItem(item)
|
|
self._list.blockSignals(False)
|
|
self._update_ok()
|
|
|
|
def _count_text(self, listing: bool) -> str:
|
|
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"
|
|
if listing:
|
|
text += " so far, still listing…"
|
|
if self._kind == "song_in_list":
|
|
text += ("\nOnly the song you linked is ticked. Tick more to "
|
|
"import them too.")
|
|
return text
|
|
|
|
def _on_probe_finished(self, count: int):
|
|
if self.sender() is not self._probe:
|
|
return
|
|
self._probe = None
|
|
self._busy.hide()
|
|
if self._picking:
|
|
self._status.setText(self._count_text(listing=False))
|
|
if self._checked_count() == 0 and self._list.count():
|
|
# The linked song wasn't in the list it came with.
|
|
self._list.item(0).setCheckState(Qt.CheckState.Checked)
|
|
self._update_ok()
|
|
return
|
|
# One song (or none): nothing to choose, so import it as linked.
|
|
self._single = self._entries[0] if self._entries else None
|
|
self.accept()
|
|
|
|
def _on_probe_failed(self, message: str):
|
|
if self.sender() is not self._probe:
|
|
return
|
|
self._probe = None
|
|
self._busy.hide()
|
|
if self._picking: # it listed some songs, then gave up
|
|
self._status.setText(self._count_text(listing=False)
|
|
+ f"\n(yt-dlp stopped listing: {message})")
|
|
self._update_ok()
|
|
return
|
|
self._url.setEnabled(True)
|
|
self._just.hide()
|
|
self._status.setText(f"Couldn't read that link:\n{message}")
|
|
self._update_ok()
|
|
|
|
def _check_all(self, on: bool):
|
|
state = Qt.CheckState.Checked if on else Qt.CheckState.Unchecked
|
|
self._list.blockSignals(True)
|
|
for i in range(self._list.count()):
|
|
self._list.item(i).setCheckState(state)
|
|
self._list.blockSignals(False)
|
|
self._update_ok()
|
|
|
|
def _stop_probe(self):
|
|
if self._probe is not None:
|
|
probe, self._probe = self._probe, None
|
|
probe.cancel()
|
|
self._busy.hide()
|
|
|
|
def done(self, result):
|
|
self._stop_probe()
|
|
super().done(result)
|