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>
325 lines
13 KiB
Python
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)
|