Files
lintunes/lintunes/gui/url_import_dialog.py
T
travandClaude Opus 5.5 581be8d732 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>
2026-10-09 01:09:46 -07:00

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)