Files
lintunes/lintunes/gui/url_import_dialog.py
T
travandClaude Opus 5.5 ac682774e9 v0.36.0: a playlist link says so before it downloads
Pasting a song from a YouTube Mix (watch?v=…&list=RD…) imported song
after song: yt-dlp takes the whole list by default, and a Mix runs to
thousands of rows and loops back on itself, so the linked song came in
again and again with nothing on screen saying why.

Now anything but a plain song link is listed first (--flat-playlist,
streamed into the dialog). One song goes straight through. Several become
a checklist: a song-in-a-list link ticks only its song and offers Just
This Song without waiting, and a playlist link ticks everything. Rows are
de-duplicated by id. The chosen songs download by their own pages, never
the list link, and more than one gets a per-song progress window (Hide
keeps downloading; the status-bar line brings it back).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:41:55 -07:00

325 lines
13 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.
"""
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, 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:"))
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 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))
self._status.setText("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 = 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)
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)