From ec008fc1e9ae2d66c6587ce1d99ab983309d91c6 Mon Sep 17 00:00:00 2001 From: trav Date: Fri, 18 Sep 2026 23:06:38 -0700 Subject: [PATCH] v0.22.0: lookup on youtube Right-click a song -> "lookup on youtube" opens a YouTube search for " <artist>" in the default browser. Single selection only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- lintunes/__init__.py | 2 +- lintunes/gui/track_table.py | 26 +++++++++++++++++++++++-- tasks-done.md | 7 +++++++ tests/test_round58.py | 38 +++++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 tests/test_round58.py diff --git a/lintunes/__init__.py b/lintunes/__init__.py index d248bc1..d3585be 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.21.0" +__version__ = "0.22.0" diff --git a/lintunes/gui/track_table.py b/lintunes/gui/track_table.py index a962c5e..f4f7866 100644 --- a/lintunes/gui/track_table.py +++ b/lintunes/gui/track_table.py @@ -8,7 +8,7 @@ from PyQt6.QtWidgets import ( from PyQt6.QtCore import ( Qt, QAbstractTableModel, QModelIndex, QPersistentModelIndex, pyqtSignal, - QMimeData, QUrl, QPoint, QPointF, QRect, QRectF, QMetaType, QTimer, + QMimeData, QUrl, QUrlQuery, QPoint, QPointF, QRect, QRectF, QMetaType, QTimer, ) from PyQt6.QtGui import ( QDrag, QKeySequence, QShortcut, QPixmap, QPainter, QPen, QPolygonF, QColor, @@ -289,6 +289,17 @@ def reveal_paths(paths: list[str]) -> None: QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(paths[0]))) +def youtube_search_url(track) -> QUrl: + """A YouTube search for "<title> <artist>". QUrlQuery does the encoding, + so an "&", "#" or emoji in a title can't break the query.""" + query = " ".join(p for p in (track.name, track.artist) if p) + url = QUrl("https://www.youtube.com/results") + params = QUrlQuery() + params.addQueryItem("search_query", query) + url.setQuery(params) + return url + + def _sort_key(value): """Comparable key for one cell. Everything collapses to a (text, number) pair so a column holding a mix of strings, numbers and None never asks @@ -775,11 +786,19 @@ class TrackTableView(QTableView): # Fingerprinting reads the audio, so this needs a file on disk. identify_action = menu.addAction("Identify Track…") + # Single track only: one click shouldn't open a browser tab per song. + # Needs no file, so it's offered for a dangling location too. + selected = self.selected_track_ids() + youtube_action = None + if len(selected) == 1: + if not locations: + menu.addSeparator() + youtube_action = menu.addAction("lookup on youtube") + # "Show in Playlist" — only for a single track, listing the regular # playlists it belongs to. Hovering reveals the submenu; choosing an # entry jumps to that playlist and highlights the first instance. show_in_menu = None - selected = self.selected_track_ids() if len(selected) == 1 and self.playlists_for_track is not None: entries = self.playlists_for_track(selected[0]) if entries: @@ -819,6 +838,9 @@ class TrackTableView(QTableView): self.download_art_requested.emit(self.selected_track_ids()) elif identify_action is not None and chosen is identify_action: self.identify_requested.emit(self.selected_track_ids()) + elif youtube_action is not None and chosen is youtube_action: + track = self.model_.track_at(self.selected_source_rows()[0]) + QDesktopServices.openUrl(youtube_search_url(track)) elif show_in_menu is not None and chosen in show_in_menu.actions(): self.show_in_playlist_requested.emit(selected[0], chosen.data()) elif remove_action is not None and chosen is remove_action: diff --git a/tasks-done.md b/tasks-done.md index 38709fd..7c45929 100644 --- a/tasks-done.md +++ b/tasks-done.md @@ -1,5 +1,12 @@ ## Done +### Round 58 (2026-09-18) — lookup on youtube (v0.22.0) + +- [x] **"lookup on youtube" in a song's right-click menu.** Opens the default + browser on a YouTube search for "<title> <artist>" + (`track_table.youtube_search_url`, encoded via `QUrlQuery`). Offered for + a single selected track only, and without needing a file on disk. + ### Round 57 (2026-09-18) — click to rename (v0.21.0) - [x] **Click a selected playlist to rename it.** The sidebar tree gains diff --git a/tests/test_round58.py b/tests/test_round58.py new file mode 100644 index 0000000..3b4d883 --- /dev/null +++ b/tests/test_round58.py @@ -0,0 +1,38 @@ +"""Round 58: "lookup on youtube" in a song's right-click menu. + +The URL is built by a pure helper so the query encoding can be checked +without opening a browser. +""" + +from PyQt6.QtCore import QUrl, QUrlQuery + +from lintunes.models import Track +from lintunes.gui.track_table import youtube_search_url + + +def _query(url): + return QUrlQuery(url).queryItemValue( + "search_query", QUrl.ComponentFormattingOption.FullyDecoded) + + +def test_title_then_artist(qapp): + url = youtube_search_url(Track(track_id=1, name="I Know", artist="Dionne Farris")) + assert url.scheme() == "https" + assert url.host() == "www.youtube.com" + assert url.path() == "/results" + assert _query(url) == "I Know Dionne Farris" + + +def test_special_characters_survive(qapp): + track = Track(track_id=1, name="Rock & Roll #9 ✨", artist="Beyoncé") + url = youtube_search_url(track) + assert _query(url) == "Rock & Roll #9 ✨ Beyoncé" + encoded = url.toString(QUrl.ComponentFormattingOption.FullyEncoded) + assert "&" not in encoded.split("search_query=", 1)[1] + assert "#" not in encoded + + +def test_missing_artist_has_no_trailing_space(qapp): + url = youtube_search_url(Track(track_id=1, name="Zuuso")) + assert _query(url) == "Zuuso" +