feat: most-popular search as default Top Results tab

Add /most-popular/get endpoint support (discovered via Burp):
- Returns mixed artists/albums/tracks ordered by popularity for a query
- New 'Top Results' tab shown first/default in the search panel
- Badge column: A (artist, blue), H (hi-res album, gold), A (album, gray), T (track, green)
- Full context menu support on all item types in the new tab
- Both most-popular and regular search fire on submit in parallel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-03-30 20:28:13 +02:00
parent 70810bd4b1
commit 200ef39d04
7 changed files with 216 additions and 10 deletions

View File

@@ -39,6 +39,16 @@ SearchTab::SearchTab(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
// Result tabs
m_resultTabs = new QTabWidget(this);
// Top Results tab (default) — mixed artists/albums/tracks from most-popular endpoint
m_popularResults = new QTreeWidget(this);
m_popularResults->setHeaderLabels({tr(""), tr("Name"), tr("Detail")});
m_popularResults->setRootIsDecorated(false);
m_popularResults->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
m_popularResults->header()->setSectionResizeMode(1, QHeaderView::Stretch);
m_popularResults->header()->setSectionResizeMode(2, QHeaderView::Stretch);
m_popularResults->header()->setStretchLastSection(false);
m_popularResults->setContextMenuPolicy(Qt::CustomContextMenu);
m_trackResults = new QTreeWidget(this);
m_trackResults->setHeaderLabels({tr("Title"), tr("Artist"), tr("Album")});
m_trackResults->setRootIsDecorated(false);
@@ -57,21 +67,26 @@ SearchTab::SearchTab(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
m_artistResults->setHeaderLabels({tr("Artist")});
m_artistResults->setRootIsDecorated(false);
m_resultTabs->addTab(m_trackResults, tr("Tracks"));
m_resultTabs->addTab(m_albumResults, tr("Albums"));
m_resultTabs->addTab(m_artistResults, tr("Artists"));
m_resultTabs->addTab(m_popularResults, tr("Top Results"));
m_resultTabs->addTab(m_trackResults, tr("Tracks"));
m_resultTabs->addTab(m_albumResults, tr("Albums"));
m_resultTabs->addTab(m_artistResults, tr("Artists"));
layout->addWidget(m_resultTabs);
connect(searchBtn, &QPushButton::clicked, this, &SearchTab::onSearchSubmit);
connect(m_searchBox, &QLineEdit::returnPressed, this, &SearchTab::onSearchSubmit);
connect(m_backend, &QobuzBackend::searchResult, this, &SearchTab::onSearchResult);
connect(m_backend, &QobuzBackend::searchResult, this, &SearchTab::onSearchResult);
connect(m_backend, &QobuzBackend::mostPopularResult, this, &SearchTab::onMostPopularResult);
connect(m_trackResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
connect(m_albumResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
connect(m_artistResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
connect(m_popularResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
connect(m_trackResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
connect(m_albumResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
connect(m_artistResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
// Context menus
connect(m_popularResults, &QTreeWidget::customContextMenuRequested,
this, &SearchTab::onPopularContextMenu);
connect(m_trackResults, &QTreeWidget::customContextMenuRequested,
this, &SearchTab::onTrackContextMenu);
connect(m_albumResults, &QTreeWidget::customContextMenuRequested,
@@ -86,8 +101,10 @@ void SearchTab::setUserPlaylists(const QVector<QPair<qint64, QString>> &playlist
void SearchTab::onSearchSubmit()
{
const QString q = m_searchBox->text().trimmed();
if (!q.isEmpty())
m_backend->search(q, 0, 20);
if (q.isEmpty()) return;
m_backend->mostPopularSearch(q, 30);
m_backend->search(q, 0, 20);
m_resultTabs->setCurrentIndex(0); // show Top Results tab
}
void SearchTab::onSearchResult(const QJsonObject &result)
@@ -145,6 +162,62 @@ void SearchTab::onSearchResult(const QJsonObject &result)
}
}
void SearchTab::onMostPopularResult(const QJsonObject &result)
{
m_popularResults->clear();
QFont badgeFont;
badgeFont.setBold(true);
badgeFont.setPointSizeF(badgeFont.pointSizeF() * 0.8);
const QJsonArray items = result["most_popular"].toObject()["items"].toArray();
for (const auto &v : items) {
const QJsonObject entry = v.toObject();
const QString type = entry["type"].toString();
const QJsonObject content = entry["content"].toObject();
QString badge, name, detail;
QColor badgeColor;
if (type == QStringLiteral("artists")) {
badge = QStringLiteral("A");
badgeColor = QColor(QStringLiteral("#6699CC"));
name = content["name"].toString();
detail = tr("%1 albums").arg(static_cast<int>(content["albums_count"].toDouble()));
} else if (type == QStringLiteral("albums")) {
const bool hiRes = content["hires_streamable"].toBool();
badge = hiRes ? QStringLiteral("H") : QStringLiteral("A");
badgeColor = hiRes ? QColor(QStringLiteral("#FFB232")) : QColor(QStringLiteral("#AAAAAA"));
name = content["title"].toString();
detail = content["artist"].toObject()["name"].toString();
} else if (type == QStringLiteral("tracks")) {
badge = QStringLiteral("T");
badgeColor = QColor(QStringLiteral("#66BB66"));
name = content["title"].toString();
detail = content["performer"].toObject()["name"].toString();
} else {
continue;
}
auto *item = new QTreeWidgetItem(m_popularResults, QStringList{badge, name, detail});
item->setForeground(0, badgeColor);
item->setFont(0, badgeFont);
item->setTextAlignment(0, Qt::AlignCenter);
item->setData(0, TypeRole, type == QStringLiteral("artists") ? QStringLiteral("artist")
: type == QStringLiteral("albums") ? QStringLiteral("album")
: QStringLiteral("track"));
item->setData(0, JsonRole, content);
if (type == QStringLiteral("artists")) {
item->setData(0, IdRole, static_cast<qint64>(content["id"].toDouble()));
} else if (type == QStringLiteral("albums")) {
item->setData(1, IdRole, content["id"].toString());
} else {
item->setData(0, IdRole, static_cast<qint64>(content["id"].toDouble()));
}
}
}
void SearchTab::onItemDoubleClicked(QTreeWidgetItem *item, int)
{
if (!item) return;
@@ -153,7 +226,10 @@ void SearchTab::onItemDoubleClicked(QTreeWidgetItem *item, int)
if (type == QStringLiteral("track")) {
emit trackPlayRequested(item->data(0, IdRole).toLongLong());
} else if (type == QStringLiteral("album")) {
emit albumSelected(item->data(1, IdRole).toString());
// Album ID may be in col 0 or col 1 depending on which tree it came from
QString albumId = item->data(1, IdRole).toString();
if (albumId.isEmpty()) albumId = item->data(0, IdRole).toString();
emit albumSelected(albumId);
} else if (type == QStringLiteral("artist")) {
emit artistSelected(item->data(0, IdRole).toLongLong());
}
@@ -268,6 +344,90 @@ void SearchTab::onAlbumContextMenu(const QPoint &pos)
menu.exec(m_albumResults->viewport()->mapToGlobal(pos));
}
void SearchTab::onPopularContextMenu(const QPoint &pos)
{
auto *item = m_popularResults->itemAt(pos);
if (!item) return;
const QString type = item->data(0, TypeRole).toString();
const QJsonObject json = item->data(0, JsonRole).toJsonObject();
QMenu menu(this);
if (type == QStringLiteral("track")) {
const qint64 trackId = item->data(0, IdRole).toLongLong();
if (trackId <= 0) return;
auto *playNow = menu.addAction(tr("Play now"));
auto *playNext = menu.addAction(tr("Play next"));
auto *addQueue = menu.addAction(tr("Add to queue"));
menu.addSeparator();
auto *addFav = menu.addAction(tr("Add to favorites"));
const QString albumId = json["album"].toObject()["id"].toString();
const qint64 artistId = static_cast<qint64>(json["performer"].toObject()["id"].toDouble());
const QString albumTitle = json["album"].toObject()["title"].toString();
const QString artistName = json["performer"].toObject()["name"].toString();
menu.addSeparator();
if (!albumId.isEmpty()) {
auto *openAlbum = menu.addAction(tr("Go to album: %1").arg(QString(albumTitle).replace(QLatin1Char('&'), QStringLiteral("&&"))));
connect(openAlbum, &QAction::triggered, this, [this, albumId] { emit albumSelected(albumId); });
}
if (artistId > 0) {
auto *openArtist = menu.addAction(tr("Go to artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&"))));
connect(openArtist, &QAction::triggered, this, [this, artistId] { emit artistSelected(artistId); });
}
if (!m_userPlaylists.isEmpty()) {
menu.addSeparator();
auto *plMenu = menu.addMenu(tr("Add to playlist"));
for (const auto &pl : m_userPlaylists) {
auto *act = plMenu->addAction(pl.second);
connect(act, &QAction::triggered, this, [this, trackId, plId = pl.first] {
emit addToPlaylistRequested(trackId, plId);
});
}
}
menu.addSeparator();
auto *info = menu.addAction(tr("Track info..."));
connect(playNow, &QAction::triggered, this, [this, trackId] { emit trackPlayRequested(trackId); });
connect(playNext, &QAction::triggered, this, [this, json] { m_queue->playNext(json); });
connect(addQueue, &QAction::triggered, this, [this, json] { m_queue->addToQueue(json); });
connect(addFav, &QAction::triggered, this, [this, trackId] { m_backend->addFavTrack(trackId); });
connect(info, &QAction::triggered, this, [this, json] { showTrackInfo(json); });
} else if (type == QStringLiteral("album")) {
const QString albumId = item->data(1, IdRole).toString();
if (albumId.isEmpty()) return;
auto *openAlbum = menu.addAction(tr("Open album"));
auto *addFav = menu.addAction(tr("Add to favorites"));
const qint64 artistId = static_cast<qint64>(json["artist"].toObject()["id"].toDouble());
const QString artistName = json["artist"].toObject()["name"].toString();
if (artistId > 0) {
menu.addSeparator();
auto *openArtist = menu.addAction(tr("Go to artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&"))));
connect(openArtist, &QAction::triggered, this, [this, artistId] { emit artistSelected(artistId); });
}
connect(openAlbum, &QAction::triggered, this, [this, albumId] { emit albumSelected(albumId); });
connect(addFav, &QAction::triggered, this, [this, albumId] { m_backend->addFavAlbum(albumId); });
} else if (type == QStringLiteral("artist")) {
const qint64 artistId = item->data(0, IdRole).toLongLong();
if (artistId <= 0) return;
auto *openArtist = menu.addAction(tr("Go to artist"));
connect(openArtist, &QAction::triggered, this, [this, artistId] { emit artistSelected(artistId); });
}
if (!menu.isEmpty())
menu.exec(m_popularResults->viewport()->mapToGlobal(pos));
}
void SearchTab::showTrackInfo(const QJsonObject &track)
{
TrackInfoDialog::show(track, this);