diff --git a/rust/include/qobuz_backend.h b/rust/include/qobuz_backend.h index ea6895a..c874855 100644 --- a/rust/include/qobuz_backend.h +++ b/rust/include/qobuz_backend.h @@ -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); diff --git a/rust/src/api/client.rs b/rust/src/api/client.rs index 9c6429e..9221b56 100644 --- a/rust/src/api/client.rs +++ b/rust/src/api/client.rs @@ -384,6 +384,15 @@ impl QobuzClient { // --- Search --- + pub async fn most_popular_search(&self, query: &str, limit: u32) -> Result { + 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 { let (tracks, albums, artists) = tokio::try_join!( self.search_tracks(query, offset, limit), diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 4c13983..a54be9c 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -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] diff --git a/src/backend/qobuzbackend.cpp b/src/backend/qobuzbackend.cpp index 46e438e..9fbaa0d 100644 --- a/src/backend/qobuzbackend.cpp +++ b/src/backend/qobuzbackend.cpp @@ -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; diff --git a/src/backend/qobuzbackend.hpp b/src/backend/qobuzbackend.hpp index b13fb6b..79cdc0c 100644 --- a/src/backend/qobuzbackend.hpp +++ b/src/backend/qobuzbackend.hpp @@ -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); diff --git a/src/view/sidepanel/view.cpp b/src/view/sidepanel/view.cpp index a7e0100..60325f5 100644 --- a/src/view/sidepanel/view.cpp +++ b/src/view/sidepanel/view.cpp @@ -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> &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(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(content["id"].toDouble())); + } else if (type == QStringLiteral("albums")) { + item->setData(1, IdRole, content["id"].toString()); + } else { + item->setData(0, IdRole, static_cast(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(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(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); diff --git a/src/view/sidepanel/view.hpp b/src/view/sidepanel/view.hpp index b19c8c3..cf9dbd4 100644 --- a/src/view/sidepanel/view.hpp +++ b/src/view/sidepanel/view.hpp @@ -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); };