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:
@@ -53,6 +53,7 @@ void qobuz_backend_get_user(QobuzBackendOpaque *backend);
|
||||
|
||||
// Catalog
|
||||
void qobuz_backend_search(QobuzBackendOpaque *backend, const char *query, uint32_t offset, uint32_t limit);
|
||||
void qobuz_backend_most_popular_search(QobuzBackendOpaque *backend, const char *query, uint32_t limit);
|
||||
void qobuz_backend_get_album(QobuzBackendOpaque *backend, const char *album_id);
|
||||
void qobuz_backend_get_artist(QobuzBackendOpaque *backend, int64_t artist_id);
|
||||
void qobuz_backend_get_playlist(QobuzBackendOpaque *backend, int64_t playlist_id, uint32_t offset, uint32_t limit);
|
||||
|
||||
@@ -384,6 +384,15 @@ impl QobuzClient {
|
||||
|
||||
// --- Search ---
|
||||
|
||||
pub async fn most_popular_search(&self, query: &str, limit: u32) -> Result<serde_json::Value> {
|
||||
let resp = self
|
||||
.get_request("most-popular/get")
|
||||
.query(&[("query", query), ("offset", "0"), ("limit", &limit.to_string())])
|
||||
.send()
|
||||
.await?;
|
||||
Self::check_response(resp).await
|
||||
}
|
||||
|
||||
pub async fn search(&self, query: &str, offset: u32, limit: u32) -> Result<SearchCatalogDto> {
|
||||
let (tracks, albums, artists) = tokio::try_join!(
|
||||
self.search_tracks(query, offset, limit),
|
||||
|
||||
@@ -70,6 +70,7 @@ pub const EV_TRACK_URL_ERR: c_int = 18;
|
||||
pub const EV_GENERIC_ERR: c_int = 19;
|
||||
pub const EV_ARTIST_RELEASES_OK: c_int = 24;
|
||||
pub const EV_DEEP_SHUFFLE_OK: c_int = 25;
|
||||
pub const EV_MOST_POPULAR_OK: c_int = 26;
|
||||
|
||||
// ---------- Callback ----------
|
||||
|
||||
@@ -222,6 +223,28 @@ pub unsafe extern "C" fn qobuz_backend_search(
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qobuz_backend_most_popular_search(
|
||||
ptr: *mut Backend,
|
||||
query: *const c_char,
|
||||
limit: u32,
|
||||
) {
|
||||
let inner = &(*ptr).0;
|
||||
let query = CStr::from_ptr(query).to_string_lossy().into_owned();
|
||||
let client = inner.client.clone();
|
||||
let cb = inner.cb;
|
||||
let ud = inner.ud;
|
||||
|
||||
spawn(inner, async move {
|
||||
let result = client.lock().await.most_popular_search(&query, limit).await;
|
||||
let (ev, json) = match result {
|
||||
Ok(r) => (EV_MOST_POPULAR_OK, serde_json::to_string(&r).unwrap_or_default()),
|
||||
Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())),
|
||||
};
|
||||
call_cb(cb, ud, ev, &json);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Album ----------
|
||||
|
||||
#[no_mangle]
|
||||
|
||||
@@ -51,6 +51,11 @@ void QobuzBackend::search(const QString &query, quint32 offset, quint32 limit)
|
||||
qobuz_backend_search(m_backend, query.toUtf8().constData(), offset, limit);
|
||||
}
|
||||
|
||||
void QobuzBackend::mostPopularSearch(const QString &query, quint32 limit)
|
||||
{
|
||||
qobuz_backend_most_popular_search(m_backend, query.toUtf8().constData(), limit);
|
||||
}
|
||||
|
||||
void QobuzBackend::getAlbum(const QString &albumId)
|
||||
{
|
||||
qobuz_backend_get_album(m_backend, albumId.toUtf8().constData());
|
||||
@@ -241,6 +246,9 @@ void QobuzBackend::onEvent(int eventType, const QString &json)
|
||||
case EV_SEARCH_OK:
|
||||
emit searchResult(obj);
|
||||
break;
|
||||
case 26: // EV_MOST_POPULAR_OK
|
||||
emit mostPopularResult(obj);
|
||||
break;
|
||||
case EV_SEARCH_ERR:
|
||||
emit error(obj["error"].toString());
|
||||
break;
|
||||
|
||||
@@ -28,6 +28,7 @@ public:
|
||||
|
||||
// --- catalog ---
|
||||
void search(const QString &query, quint32 offset = 0, quint32 limit = 20);
|
||||
void mostPopularSearch(const QString &query, quint32 limit = 30);
|
||||
void getAlbum(const QString &albumId);
|
||||
void getArtist(qint64 artistId);
|
||||
void getArtistReleases(qint64 artistId, const QString &releaseType, quint32 limit = 50, quint32 offset = 0);
|
||||
@@ -81,6 +82,7 @@ signals:
|
||||
|
||||
// catalog
|
||||
void searchResult(const QJsonObject &result);
|
||||
void mostPopularResult(const QJsonObject &result);
|
||||
void albumLoaded(const QJsonObject &album);
|
||||
void artistLoaded(const QJsonObject &artist);
|
||||
void artistReleasesLoaded(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset);
|
||||
|
||||
@@ -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,6 +67,7 @@ SearchTab::SearchTab(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
|
||||
m_artistResults->setHeaderLabels({tr("Artist")});
|
||||
m_artistResults->setRootIsDecorated(false);
|
||||
|
||||
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"));
|
||||
@@ -66,12 +77,16 @@ SearchTab::SearchTab(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
|
||||
connect(m_searchBox, &QLineEdit::returnPressed, this, &SearchTab::onSearchSubmit);
|
||||
|
||||
connect(m_backend, &QobuzBackend::searchResult, this, &SearchTab::onSearchResult);
|
||||
connect(m_backend, &QobuzBackend::mostPopularResult, this, &SearchTab::onMostPopularResult);
|
||||
|
||||
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())
|
||||
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);
|
||||
|
||||
@@ -30,6 +30,7 @@ namespace SidePanel
|
||||
|
||||
private slots:
|
||||
void onSearchResult(const QJsonObject &result);
|
||||
void onMostPopularResult(const QJsonObject &result);
|
||||
void onSearchSubmit();
|
||||
void onItemDoubleClicked(QTreeWidgetItem *item, int column);
|
||||
|
||||
@@ -38,6 +39,7 @@ namespace SidePanel
|
||||
PlayQueue *m_queue = nullptr;
|
||||
QLineEdit *m_searchBox = nullptr;
|
||||
QTabWidget *m_resultTabs = nullptr;
|
||||
QTreeWidget *m_popularResults = nullptr;
|
||||
QTreeWidget *m_trackResults = nullptr;
|
||||
QTreeWidget *m_albumResults = nullptr;
|
||||
QTreeWidget *m_artistResults = nullptr;
|
||||
@@ -45,6 +47,7 @@ namespace SidePanel
|
||||
|
||||
void onTrackContextMenu(const QPoint &pos);
|
||||
void onAlbumContextMenu(const QPoint &pos);
|
||||
void onPopularContextMenu(const QPoint &pos);
|
||||
void showTrackInfo(const QJsonObject &track);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user