v0.18.0: paste a link, get the song

File > Import from URL... runs trav's `song` helper (yt-dlp --extract-audio
--audio-format mp3) from inside the app. Everything at the link downloads to
a private temp dir and is imported as each song lands. "and add to current
playlist?" puts the batch above the selected song, else at the end of the
playing playlist, else at the end of the shown one, in the link's order.
Each song goes straight into Identify Track. With no AcoustID key, the
proposal comes from the filename alone.

Album art now opens on a single click, in a window shaped like the cover
and as big as the screen allows.

Also fixed the dev fixture's play journal, which crashed startup (wrong
shape) and would have been ignored anyway (wrong keys). The loader now
skips a journal it can't read instead of aborting.

Claude-Session: https://claude.ai/code/session_01R5m9mXFPHNro78BdD69mG2
This commit is contained in:
2026-09-10 23:44:45 -05:00
parent 3d19b0de0a
commit d335e9144b
13 changed files with 1179 additions and 33 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.17.1"
__version__ = "0.18.0"
+7
View File
@@ -364,6 +364,13 @@ class TrackIdentifier(QObject):
def work():
try:
# No key: there's nobody to ask, so skip fpcalc and the
# network entirely and fall through to the filename. Only an
# Import from URL reaches here, because the menu route insists
# on setup first. A missing fpcalc lands in the same place,
# via fingerprint_file raising.
if not api_key:
raise RuntimeError("no AcoustID key set")
duration, fp = fingerprint_file(location)
candidates = lookup_fingerprint(api_key, duration, fp, hint)
except Exception as e:
+55 -6
View File
@@ -1,7 +1,32 @@
from PyQt6.QtWidgets import QWidget, QApplication
from PyQt6.QtCore import Qt
from PyQt6.QtCore import Qt, QSize
from PyQt6.QtGui import QPainter, QPixmap
# Height left free for the window's own title bar. On Wayland the frame size
# isn't known until the window is mapped, so this is a fixed allowance.
DECORATION_ALLOWANCE = 48
def fit_size(art: QSize, available: QSize,
decorations: int = DECORATION_ALLOWANCE) -> QSize:
"""The largest size with the art's own shape that fits on the screen.
Scales *up* as well as down: a 300 px cover still opens screen-tall. The
window takes the art's shape rather than the screen's, so there are no
black bars.
"""
room = QSize(max(available.width(), 1),
max(available.height() - decorations, 1))
if art.isEmpty():
return room
return art.scaled(room, Qt.AspectRatioMode.KeepAspectRatio)
def _aspect(pixmap: QPixmap | None) -> float | None:
if pixmap is None or pixmap.isNull() or pixmap.height() == 0:
return None
return pixmap.width() / pixmap.height()
class ArtWindow(QWidget):
"""Non-modal window showing the current track's album art as large as
@@ -13,16 +38,40 @@ class ArtWindow(QWidget):
self.setMinimumSize(120, 120)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
def show_artwork(self, pixmap: QPixmap | None, title: str, screen=None):
"""Open (or re-raise) the window, sized to the art on ``screen``."""
self.set_artwork(pixmap, title)
self.fit_to_screen(screen)
self.show()
self.raise_()
self.activateWindow()
def set_artwork(self, pixmap: QPixmap | None, title: str):
self.setWindowTitle(title or "Album Art")
reshape = (self.isVisible() and _aspect(pixmap) is not None
and _aspect(self._pixmap) is not None
and abs(_aspect(pixmap) - _aspect(self._pixmap)) > 0.01)
self._pixmap = pixmap
if pixmap is not None and not self.isVisible():
screen = QApplication.primaryScreen().availableGeometry()
width = min(pixmap.width(), int(screen.width() * 0.8))
height = min(pixmap.height(), int(screen.height() * 0.8))
self.resize(max(width, 240), max(height, 240))
if reshape:
# A track change brought a different shape of cover: re-fit, or
# the new art would sit between black bars.
self.fit_to_screen()
self.update()
def fit_to_screen(self, screen=None):
if self._pixmap is None or self._pixmap.isNull():
return
screen = screen or self.screen() or QApplication.primaryScreen()
if screen is None:
return
geo = screen.availableGeometry()
size = fit_size(self._pixmap.size(), geo.size())
self.resize(size)
# Wayland ignores move() (the compositor places the window); X11
# honors it.
self.move(geo.x() + (geo.width() - size.width()) // 2,
geo.y() + (geo.height() - size.height()) // 2)
def paintEvent(self, event):
painter = QPainter(self)
painter.fillRect(self.rect(), Qt.GlobalColor.black)
+200 -14
View File
@@ -9,7 +9,7 @@ from PyQt6.QtWidgets import (
from PyQt6.QtCore import Qt, QEvent, QTimer
from PyQt6.QtGui import QAction, QKeySequence
from lintunes import device_sync, mpris, music_folder, tagging, theme
from lintunes import device_sync, mpris, music_folder, tagging, theme, url_import
from lintunes.andtunes import layout as andtunes_layout
from lintunes.andtunes import sync as andtunes_sync
from lintunes.art_search import AlbumArtFetcher
@@ -29,6 +29,7 @@ from lintunes.gui.sidebar import SidebarPanel
from lintunes.gui.library_view import LibraryView
from lintunes.gui.playlist_view import PlaylistView
from lintunes.gui.transport import TransportBar
from lintunes.gui.url_import_dialog import UrlImportDialog
from lintunes.gui.info_dialog import InfoDialog
from lintunes.gui.music_folder_dialog import MusicFolderDialog
from lintunes.gui.preferences_dialog import PreferencesDialog
@@ -170,6 +171,17 @@ class MainWindow(QMainWindow):
self._identify_queue = []
self._identify_total = 0
self._identifier = None
# True from the first lookup until the queue drains, so tracks queued
# mid-run (Import from URL) wait their turn instead of starting a
# second lookup alongside.
self._identify_running = False
# Import from URL: also shares the status-bar progress widgets.
self._url_worker = None
self._url_target = None # url_import.ImportTarget, or None
self._url_inserted = 0 # songs added to the target so far
self._url_imported = 0
self._url_identify = True # False after "Stop Identifying"
self._url_note = ""
# Separate instance from the playback inhibitor: pausing music
# mid-sync must not drop the sync's hold. Logout flag included so
# GNOME's shutdown/restart dialog names the transfer as the blocker.
@@ -298,9 +310,12 @@ class MainWindow(QMainWindow):
file_menu.addSeparator()
self._add_action(file_menu, "Add Files to Library…", "Ctrl+O",
self._open_files_dialog)
self._url_import_action = self._add_action(
file_menu, "Import from URL…", "", self._import_from_url)
self._export_action = self._add_action(
file_menu, "Export Playlist…", "", self._export_playlist)
file_menu.aboutToShow.connect(self._refresh_export_action)
file_menu.aboutToShow.connect(self._refresh_url_import_action)
file_menu.addSeparator()
self._add_action(file_menu, "Quit", "Ctrl+Q", self.close)
@@ -488,12 +503,15 @@ class MainWindow(QMainWindow):
def _busy_worker(self):
"""The transfer currently owning the status-bar progress widgets."""
for worker in (self._sync_worker, self._andtunes_worker,
self._export_worker):
self._export_worker, self._url_worker):
if worker is not None and worker.busy():
return worker
return None
def _confirm_cancel_sync(self):
if self._url_worker is not None and self._url_worker.busy():
self._confirm_cancel_url_import()
return
if self._export_worker is not None and self._export_worker.busy():
self._confirm_cancel_export()
return
@@ -1005,8 +1023,7 @@ class MainWindow(QMainWindow):
if paths:
self.import_files([Path(p) for p in paths])
def import_files(self, paths, playlist_pid: str = "",
position: int | None = None):
def _ensure_music_dir(self) -> Path | None:
music_dir = self._music_import_dir()
if music_dir is None:
# Ask rather than guess — better one question than music copied
@@ -1017,6 +1034,12 @@ class MainWindow(QMainWindow):
self.statusBar().showMessage(
"Choose where to keep your music first — Edit ▸ Preferences.",
8000)
return music_dir
def import_files(self, paths, playlist_pid: str = "",
position: int | None = None) -> list:
music_dir = self._ensure_music_dir()
if music_dir is None:
return []
self.statusBar().showMessage(f"Importing {len(paths)} item(s)…")
imported = file_importer.import_paths(
@@ -1025,10 +1048,136 @@ class MainWindow(QMainWindow):
self.statusBar().showMessage(f"Imported {len(imported)} track(s)", 5000)
if imported:
self._library_view.reload()
return imported
def _import_files_to_playlist(self, paths, pid, position=None):
self.import_files([Path(p) for p in paths], pid, position)
# ---- importing from a link (trav's `song` helper, in-process) ----
def _refresh_url_import_action(self):
"""One transfer owns the status-bar progress widgets at a time."""
self._url_import_action.setEnabled(self._busy_worker() is None)
def _url_import_target(self):
shown, row = "", None
if self._content.currentWidget() is self._playlist_view:
shown = self._playlist_view.playlist_id
rows = self._playlist_view.table.selected_source_rows()
row = rows[0] if rows else None
return url_import.resolve_target(
self._manager.library, shown, row, self._now_playing_context)
def _import_from_url(self):
if self._busy_worker() is not None:
return
missing = [name for name, ok in (
("yt-dlp", url_import.ytdlp_available()),
("ffmpeg", exporter.web_support.ffmpeg_available())) if not ok]
if missing:
QMessageBox.warning(
self, "Import from URL",
f"Importing from a link needs {' and '.join(missing)}, "
f"which {'is' if len(missing) == 1 else 'are'} not "
f"installed.\n\n"
"yt-dlp: pip install --user yt-dlp (or your distro's "
"package)\nffmpeg: your distro's “ffmpeg” package")
return
target = self._url_import_target()
dialog = UrlImportDialog(target.description if target else None, self)
if not dialog.exec():
return
if self._ensure_music_dir() is None:
return
self._url_target = target if dialog.add_to_playlist() else None
self._url_inserted = 0
self._url_imported = 0
self._url_identify = True
problem = self._identify_setup_problem()
self._url_note = (f"tags proposed from filenames only ({problem})"
if problem else "")
worker = url_import.UrlImportWorker(dialog.url(), self)
worker.item_started.connect(self._on_url_item_started)
worker.progress.connect(self._sync_progress.setValue)
worker.downloaded.connect(self._on_url_downloaded)
worker.finished.connect(self._on_url_finished)
worker.failed.connect(self._on_url_failed)
self._url_worker = worker
self._sync_label.setText("Downloading…")
self._sync_progress.setRange(0, 100)
self._sync_progress.setValue(0)
self._sync_cancel.show()
self._sync_label.show()
self._sync_progress.show()
worker.start()
def _on_url_item_started(self, index: int, count: int, title: str):
short = title if len(title) <= 40 else title[:39] + "…"
where = f"{index} of {count} · " if count > 1 else ""
self._sync_label.setText(f"Downloading {where}{short}")
self._sync_progress.setValue(0)
def _on_url_downloaded(self, path: str):
"""One song is off the network: file it and identify it now, so the
first dialog can be up while the rest are still downloading."""
target = self._url_target
pid, position = "", None
if target is not None and target.pid in self._manager.library.playlists:
pid = target.pid
if target.position is not None:
# Each song goes after the ones already added, so the batch
# keeps the link's order, all of it above the selected song.
position = target.position + self._url_inserted
imported = self.import_files([Path(path)], pid, position)
if pid:
self._url_inserted += len(imported)
self._url_imported += len(imported)
if imported and self._url_identify:
self._enqueue_identify(imported)
def _finish_url_import(self):
if self._url_worker is not None:
self._url_worker.cleanup()
self._hide_sync_widgets()
def _on_url_finished(self, summary: dict):
self._finish_url_import()
count = self._url_imported
if summary["cancelled"]:
msg = f"Import cancelled — {count} song(s) imported"
else:
msg = f"Imported {count} song(s) from the link"
target = self._url_target
playlist = (self._manager.library.playlists.get(target.pid)
if target is not None else None)
if playlist is not None and count:
msg += f" into “{playlist.name}”"
if summary["errors"]:
msg += f" · {len(summary['errors'])} couldn't be downloaded"
if self._url_note and count and self._url_identify:
msg += f" · {self._url_note}"
self.statusBar().showMessage(msg, 12000)
def _on_url_failed(self, message: str):
self._finish_url_import()
QMessageBox.warning(self, "Import from URL",
f"Couldn't import from that link:\n\n{message}")
def _confirm_cancel_url_import(self):
box = QMessageBox(self)
box.setWindowTitle("Stop downloading")
box.setText("Stop downloading from the link?\n"
"Songs already imported stay in your library.")
stop = box.addButton("Stop Downloading",
QMessageBox.ButtonRole.DestructiveRole)
keep = box.addButton("Keep Downloading",
QMessageBox.ButtonRole.RejectRole)
box.setDefaultButton(keep)
box.exec()
if box.clickedButton() is stop and self._url_worker is not None:
self._url_worker.cancel()
# ---- deleting tracks ----
def _delete_tracks(self, track_ids: list[int], delete_files: bool):
@@ -1229,14 +1378,37 @@ class MainWindow(QMainWindow):
f"Skipped {skipped} track(s) with no file to fingerprint", 6000)
if not tracks:
return
self._identify_queue = tracks
self._identify_total = len(tracks)
self._identify_next(api_key)
self._enqueue_identify(tracks)
def _identify_next(self, api_key: str):
def _identify_setup_problem(self) -> str:
"""Why an AcoustID lookup can't run on this machine, or ''."""
if not fpcalc_available():
return "fpcalc isn't installed"
if not self._prefs.acoustid.get("api_key", "").strip():
return "no AcoustID key in Preferences"
return ""
def _enqueue_identify(self, tracks: list):
"""Queue tracks for Identify and start the queue if it's idle. An
Import from URL adds songs one at a time while an earlier one is still
behind its dialog, so the queue can grow mid-run."""
if not self._identify_running:
self._identify_queue = []
self._identify_total = 0
self._identify_queue.extend(tracks)
self._identify_total += len(tracks)
if not self._identify_running:
self._identify_next()
def _identify_next(self):
if not self._identify_queue:
self._identify_running = False
return
self._identify_running = True
track = self._identify_queue.pop(0)
if track.track_id not in self._manager.library.tracks:
self._identify_next() # deleted while it waited its turn
return
done = self._identify_total - len(self._identify_queue)
counter = (f" ({done} of {self._identify_total})"
if self._identify_total > 1 else "")
@@ -1246,10 +1418,14 @@ class MainWindow(QMainWindow):
# source) outlives this method.
self._identifier = TrackIdentifier()
self._identifier.finished.connect(
lambda result, t=track, k=api_key: self._on_identified(t, k, result))
self._identifier.identify(track, api_key)
lambda result, t=track: self._on_identified(t, result))
# Read fresh for every track. With no key (only an Import from URL
# gets this far without one) the identifier proposes from the
# filename alone.
self._identifier.identify(
track, self._prefs.acoustid.get("api_key", "").strip())
def _on_identified(self, track, api_key: str, result: dict):
def _on_identified(self, track, result: dict):
if "error" in result:
self.statusBar().showMessage(
f"Couldn't identify “{track.name}”: {result['error']}", 6000)
@@ -1273,9 +1449,13 @@ class MainWindow(QMainWindow):
f"Updated tags for “{track.name}”", 5000)
elif dialog.cancel_all:
self._identify_queue.clear()
self._identify_running = False
# Also covers songs a URL import has yet to download: they
# still import, they just don't get a dialog each.
self._url_identify = False
self.statusBar().clearMessage()
return
self._identify_next(api_key)
self._identify_next()
# ---- relocating a moved/renamed file ----
@@ -1418,8 +1598,11 @@ class MainWindow(QMainWindow):
def closeEvent(self, event):
# Quitting mid-transfer abandons a partial file on the device (it
# self-heals next sync, but silently) — make it a deliberate choice.
if self._busy_worker() is not None and self._busy_worker() is not (
self._export_worker):
# A link import has nothing half-written on a device to warn about:
# it is simply stopped below.
busy = self._busy_worker()
if busy is not None and busy not in (self._export_worker,
self._url_worker):
box = QMessageBox(self)
box.setWindowTitle("Sync in progress")
box.setText(
@@ -1435,6 +1618,9 @@ class MainWindow(QMainWindow):
if box.clickedButton() is not quit_button:
event.ignore()
return
if self._url_worker is not None:
self._url_worker.cancel()
self._url_worker.cleanup()
self._inhibitor.release()
self._sync_inhibitor.release()
# Before the player: this stops the device and kills the HTTP server it
+11 -7
View File
@@ -88,7 +88,7 @@ class SidebarPanel(QWidget):
class SidebarArt(QWidget):
"""Square album art pinned to the sidebar bottom; gray square when the
current track has no embedded art. Double-click opens the big art window."""
current track has no embedded art. A click opens the big art window."""
def __init__(self, player, parent=None):
super().__init__(parent)
@@ -96,7 +96,7 @@ class SidebarArt(QWidget):
self._pixmap: QPixmap | None = None
self._track = None
self._art_window = ArtWindow()
self.setToolTip("Double-click to view full size")
self.setToolTip("Click to view full size")
player.track_changed.connect(self.set_track)
def set_track(self, track):
@@ -128,11 +128,15 @@ class SidebarArt(QWidget):
self.setFixedHeight(self.width())
super().resizeEvent(event)
def mouseDoubleClickEvent(self, event):
if self._pixmap is not None:
self._art_window.set_artwork(self._pixmap, self._title())
self._art_window.show()
self._art_window.raise_()
def mouseReleaseEvent(self, event):
# A click is a release that lands on the art. Opening on press would
# also fire when a drag merely starts here.
if (event.button() == Qt.MouseButton.LeftButton
and self.rect().contains(event.position().toPoint())
and self._pixmap is not None):
self._art_window.show_artwork(self._pixmap, self._title(),
self.screen())
super().mouseReleaseEvent(event)
def paintEvent(self, event):
painter = QPainter(self)
+63
View File
@@ -0,0 +1,63 @@
"""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 only
reports the link and whether the box was ticked.
"""
from PyQt6.QtWidgets import (
QApplication, QCheckBox, QDialog, QDialogButtonBox, QLabel, QLineEdit,
QVBoxLayout,
)
from lintunes.url_import import looks_like_url
class UrlImportDialog(QDialog):
def __init__(self, target_description: str | None, parent=None):
super().__init__(parent)
self.setWindowTitle("Import from URL")
self.setMinimumWidth(460)
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)
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")
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
self._url.textChanged.connect(self._update_ok)
self._update_ok()
def _update_ok(self):
self._ok.setEnabled(looks_like_url(self._url.text()))
def url(self) -> str:
return self._url.text().strip()
def add_to_playlist(self) -> bool:
return self._add.isEnabled() and self._add.isChecked()
+6 -1
View File
@@ -108,8 +108,13 @@ class PlayJournal:
entries = read_json(path)
except (ValueError, OSError):
continue # a half-synced journal must never abort startup
if not isinstance(entries, dict):
continue
# Nor must one in a shape we don't know. Round 48's dev
# fixture wrapped the map in {"machine", "tracks"}, and
# int("tracks") took startup down with it.
entries = {int(k): v for k, v in entries.items()
if isinstance(v, dict)}
if isinstance(v, dict) and str(k).isdigit()}
if path.stem == self.machine:
self._own = entries
else:
+271
View File
@@ -0,0 +1,271 @@
"""File ▸ Import from URL… — trav's `song` shell helper, run from inside LinTunes.
`song` is ``yt-dlp --extract-audio --audio-format mp3 "$@"``. This module runs
exactly those flags; everything else on the command line is plumbing for
reading back what yt-dlp did. yt-dlp is a *runtime* CLI tool, detected with
``shutil.which`` like fpcalc and ffmpeg, never a pip dependency.
Shaped like ``fingerprint.py``: pure helpers first (the command line, the
marker parser, and where the songs should go), then the subprocess on a daemon
thread behind ``UrlImportWorker``.
Downloads land in a private temp dir and are handed to the GUI one at a time,
which imports each as it arrives. The normal importer *copies* it into the
organized tree, so the temp dir only ever holds our own downloads. The GUI
deletes it (``cleanup``) once it has seen ``finished`` or ``failed``: those
signals are queued after every ``downloaded``, so by then every file has been
copied. yt-dlp's default filename, ``Title [id].mp3``, is kept on purpose,
because it's exactly what ``filename_tags.parse_filename`` reads.
"""
import os
import shutil
import signal
import subprocess
import tempfile
import threading
from dataclasses import dataclass
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSignal
from lintunes.models.playlist import PlaylistType
# The `song` helper's own flags, verbatim.
SONG_ARGS = ["--extract-audio", "--audio-format", "mp3"]
# Line prefixes we ask yt-dlp to print, so its output can be read reliably
# instead of scraped.
START = "LTSTART"
FILE = "LTFILE"
PROGRESS = "LTPROG"
def ytdlp_available() -> bool:
"""Whether the yt-dlp *binary* is on PATH (the fpcalc_available pattern)."""
return shutil.which("yt-dlp") is not None
def looks_like_url(text: str) -> bool:
text = (text or "").strip()
return (text.startswith(("http://", "https://"))
and len(text) > len("https://")
and not any(c.isspace() for c in text))
def build_command(url: str, dest_dir) -> list[str]:
"""yt-dlp with the `song` flags, downloading into ``dest_dir``.
``before_dl`` names each item as it starts (with its place in a playlist),
``after_move`` gives the final mp3's path once ffmpeg has finished with it,
and the progress template reduces the progress bar to one number per line.
yt-dlp's default already carries on past an unavailable video in a
playlist, so "import everything at the link" needs no extra flag.
"""
return [
"yt-dlp", *SONG_ARGS,
"-P", str(dest_dir),
"--print",
f"before_dl:{START} %(playlist_index|1)s\t%(n_entries|1)s\t%(title)s",
"--print", f"after_move:{FILE} %(filepath)s",
"--progress", "--newline",
"--progress-template", f"download:{PROGRESS} %(progress._percent_str)s",
"--", url,
]
def _int(text: str, default: int) -> int:
try:
return int(text)
except (TypeError, ValueError):
return default
def parse_line(line: str):
"""One line of yt-dlp output → ("start", i, n, title) | ("file", path) |
("progress", percent) | None for everything else."""
line = line.rstrip("\r\n")
if line.startswith(START + " "):
parts = line[len(START) + 1:].split("\t", 2)
if len(parts) != 3:
return None
return ("start", _int(parts[0], 1), _int(parts[1], 1), parts[2])
if line.startswith(FILE + " "):
path = line[len(FILE) + 1:]
return ("file", path) if path.strip() else None
if line.startswith(PROGRESS + " "):
text = line[len(PROGRESS) + 1:].strip().rstrip("%")
try:
return ("progress", max(0, min(100, int(float(text)))))
except ValueError:
return None
return None
# ---- where the songs go ----
@dataclass
class ImportTarget:
pid: str
position: int | None # playlist index to insert before; None appends
description: str # what the dialog tells the user
def accepts_adds(playlist) -> bool:
"""A playlist you can add songs to by hand: not a folder, not smart (its
membership comes from its rules), not a system list."""
return (playlist is not None
and playlist.playlist_type == PlaylistType.REGULAR
and not playlist.is_system)
def resolve_target(library, shown_pid: str, selected_row: int | None,
playing_context: str) -> ImportTarget | None:
"""Where "and add to current playlist?" puts the songs.
1. A playlist is shown and a song in it is selected: above that song.
2. Otherwise, the playlist that's playing: at its end.
3. Otherwise, the playlist that's shown: at its end.
4. Otherwise nowhere, and the checkbox is grayed out.
``selected_row`` is the selected song's row in the table's *source* model,
which is playlist order whatever the column sort is. That model skips
ids with no track behind them, so the row is mapped back to a real index
into ``track_ids``.
"""
shown = library.playlists.get(shown_pid) if shown_pid else None
if not accepts_adds(shown):
shown = None
if shown is not None and selected_row is not None:
present = [i for i, tid in enumerate(shown.track_ids)
if tid in library.tracks]
if 0 <= selected_row < len(present):
index = present[selected_row]
song = library.tracks[shown.track_ids[index]].name or "(untitled)"
return ImportTarget(shown.persistent_id, index,
f"Adds above “{song}” in “{shown.name}”")
if playing_context.startswith("playlist:"):
playing = library.playlists.get(playing_context.split(":", 1)[1])
if accepts_adds(playing):
return ImportTarget(playing.persistent_id, None,
f"Adds to the end of “{playing.name}” "
f"(playing)")
if shown is not None:
return ImportTarget(shown.persistent_id, None,
f"Adds to the end of “{shown.name}”")
return None
# ---- the download ----
class UrlImportWorker(QObject):
"""Runs yt-dlp on a daemon thread, reporting each finished file as it
lands. Threading matches ExportWorker: a plain daemon thread, not a
QThread.
``finished`` carries {"downloaded": int, "errors": [str], "cancelled":
bool}. ``failed`` means nothing downloaded at all, and carries yt-dlp's
last error line.
"""
item_started = pyqtSignal(int, int, str) # index, count, title
progress = pyqtSignal(int) # percent of the current item
downloaded = pyqtSignal(str) # final path of one mp3
finished = pyqtSignal(dict)
failed = pyqtSignal(str)
def __init__(self, url: str, parent=None):
super().__init__(parent)
self._url = url
self._busy = False
self._cancel = threading.Event()
self._proc = None
self.temp_dir: Path | None = None
def busy(self) -> bool:
return self._busy
def cancel(self):
self._cancel.set()
self._terminate()
def _terminate(self):
proc = self._proc
if proc is None or proc.poll() is not None:
return
# yt-dlp runs ffmpeg as a child. Signal the whole session, so a cancel
# during conversion doesn't leave ffmpeg writing into the temp dir.
try:
os.killpg(proc.pid, signal.SIGTERM)
except (OSError, AttributeError):
proc.terminate()
def start(self):
if self._busy:
return
self._busy = True
self.temp_dir = Path(tempfile.mkdtemp(prefix="lintunes-url-"))
threading.Thread(target=self._run_guarded, daemon=True).start()
def cleanup(self):
"""Remove our temp dir. Called by the GUI once it has imported every
file, meaning after ``finished`` or ``failed``."""
if self.temp_dir is not None:
shutil.rmtree(self.temp_dir, ignore_errors=True)
self.temp_dir = None
def _run_guarded(self):
try:
self._run()
except Exception as e: # never leave the GUI waiting forever
self._busy = False
self.failed.emit(str(e))
def _run(self):
errors: list[str] = []
count = 0
try:
proc = subprocess.Popen(
build_command(self._url, self.temp_dir),
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
# One stream: the markers are on stdout, the ERROR: lines
# on stderr, and one reader can't deadlock on the other.
stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace", bufsize=1,
start_new_session=True)
except OSError as e:
self._busy = False
self.failed.emit(f"couldn't run yt-dlp: {e}")
return
self._proc = proc
if self._cancel.is_set(): # cancelled before the process existed
self._terminate()
for line in proc.stdout:
parsed = parse_line(line)
if parsed is None:
if line.startswith("ERROR:"):
errors.append(line[len("ERROR:"):].strip())
continue
kind = parsed[0]
if kind == "start":
self.item_started.emit(parsed[1], parsed[2], parsed[3])
elif kind == "progress":
self.progress.emit(parsed[1])
elif kind == "file" and not self._cancel.is_set():
count += 1
self.downloaded.emit(parsed[1])
proc.wait()
cancelled = self._cancel.is_set()
self._busy = False
if count == 0 and not cancelled:
self.failed.emit(
errors[-1] if errors else
f"yt-dlp found nothing to download (exit code "
f"{proc.returncode})")
return
self.finished.emit({"downloaded": count, "errors": errors,
"cancelled": cancelled})