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:
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.17.1"
|
||||
__version__ = "0.18.0"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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})
|
||||
Reference in New Issue
Block a user