v0.22.0: lookup on youtube
Right-click a song -> "lookup on youtube" opens a YouTube search for "<title> <artist>" in the default browser. Single selection only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,3 @@
|
|||||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||||
|
|
||||||
__version__ = "0.21.0"
|
__version__ = "0.22.0"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from PyQt6.QtWidgets import (
|
|||||||
from PyQt6.QtCore import (
|
from PyQt6.QtCore import (
|
||||||
Qt, QAbstractTableModel, QModelIndex, QPersistentModelIndex,
|
Qt, QAbstractTableModel, QModelIndex, QPersistentModelIndex,
|
||||||
pyqtSignal,
|
pyqtSignal,
|
||||||
QMimeData, QUrl, QPoint, QPointF, QRect, QRectF, QMetaType, QTimer,
|
QMimeData, QUrl, QUrlQuery, QPoint, QPointF, QRect, QRectF, QMetaType, QTimer,
|
||||||
)
|
)
|
||||||
from PyQt6.QtGui import (
|
from PyQt6.QtGui import (
|
||||||
QDrag, QKeySequence, QShortcut, QPixmap, QPainter, QPen, QPolygonF, QColor,
|
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])))
|
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):
|
def _sort_key(value):
|
||||||
"""Comparable key for one cell. Everything collapses to a (text, number)
|
"""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
|
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.
|
# Fingerprinting reads the audio, so this needs a file on disk.
|
||||||
identify_action = menu.addAction("Identify Track…")
|
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
|
# "Show in Playlist" — only for a single track, listing the regular
|
||||||
# playlists it belongs to. Hovering reveals the submenu; choosing an
|
# playlists it belongs to. Hovering reveals the submenu; choosing an
|
||||||
# entry jumps to that playlist and highlights the first instance.
|
# entry jumps to that playlist and highlights the first instance.
|
||||||
show_in_menu = None
|
show_in_menu = None
|
||||||
selected = self.selected_track_ids()
|
|
||||||
if len(selected) == 1 and self.playlists_for_track is not None:
|
if len(selected) == 1 and self.playlists_for_track is not None:
|
||||||
entries = self.playlists_for_track(selected[0])
|
entries = self.playlists_for_track(selected[0])
|
||||||
if entries:
|
if entries:
|
||||||
@@ -819,6 +838,9 @@ class TrackTableView(QTableView):
|
|||||||
self.download_art_requested.emit(self.selected_track_ids())
|
self.download_art_requested.emit(self.selected_track_ids())
|
||||||
elif identify_action is not None and chosen is identify_action:
|
elif identify_action is not None and chosen is identify_action:
|
||||||
self.identify_requested.emit(self.selected_track_ids())
|
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():
|
elif show_in_menu is not None and chosen in show_in_menu.actions():
|
||||||
self.show_in_playlist_requested.emit(selected[0], chosen.data())
|
self.show_in_playlist_requested.emit(selected[0], chosen.data())
|
||||||
elif remove_action is not None and chosen is remove_action:
|
elif remove_action is not None and chosen is remove_action:
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
## Done
|
## 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)
|
### Round 57 (2026-09-18) — click to rename (v0.21.0)
|
||||||
|
|
||||||
- [x] **Click a selected playlist to rename it.** The sidebar tree gains
|
- [x] **Click a selected playlist to rename it.** The sidebar tree gains
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
Reference in New Issue
Block a user