From cfd91f96b52fab4ad6c599645866ad906b0c72e1 Mon Sep 17 00:00:00 2001 From: joren Date: Mon, 30 Mar 2026 23:14:43 +0200 Subject: [PATCH] feat: add autoplay suggestions when queue runs out --- rust/include/qobuz_backend.h | 2 + rust/src/api/client.rs | 18 ++++++++ rust/src/lib.rs | 54 ++++++++++++++++++++++ src/backend/qobuzbackend.cpp | 14 ++++++ src/backend/qobuzbackend.hpp | 2 + src/dialog/settings.cpp | 5 ++ src/dialog/settings.hpp | 1 + src/playqueue.hpp | 11 +++++ src/util/settings.hpp | 3 ++ src/view/maintoolbar.cpp | 90 ++++++++++++++++++++++++++++++++++++ src/view/maintoolbar.hpp | 8 ++++ 11 files changed, 208 insertions(+) diff --git a/rust/include/qobuz_backend.h b/rust/include/qobuz_backend.h index 37a9633..97ea035 100644 --- a/rust/include/qobuz_backend.h +++ b/rust/include/qobuz_backend.h @@ -40,6 +40,7 @@ enum QobuzEvent { EV_MOST_POPULAR_OK = 26, EV_GENRES_OK = 27, EV_FEATURED_ALBUMS_OK = 28, + EV_DYNAMIC_SUGGEST_OK = 29, }; // Callback signature @@ -57,6 +58,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_dynamic_suggestions(QobuzBackendOpaque *backend, const char *listened_track_ids_json, const char *tracks_to_analyze_json, 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 8940e38..1556e0f 100644 --- a/rust/src/api/client.rs +++ b/rust/src/api/client.rs @@ -471,6 +471,24 @@ impl QobuzClient { Self::check_response(resp).await } + pub async fn get_dynamic_suggestions( + &self, + limit: u32, + listened_tracks_ids: Value, + tracks_to_analyze: Value, + ) -> Result { + let resp = self + .post_request("dynamic/suggest") + .json(&serde_json::json!({ + "limit": limit, + "listened_tracks_ids": listened_tracks_ids, + "track_to_analysed": tracks_to_analyze, + })) + .send() + .await?; + Self::check_response(resp).await + } + async fn search_tracks( &self, query: &str, diff --git a/rust/src/lib.rs b/rust/src/lib.rs index e234e2b..842c54e 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -48,6 +48,7 @@ pub const EV_DEEP_SHUFFLE_OK: c_int = 25; pub const EV_MOST_POPULAR_OK: c_int = 26; pub const EV_GENRES_OK: c_int = 27; pub const EV_FEATURED_ALBUMS_OK: c_int = 28; +pub const EV_DYNAMIC_SUGGEST_OK: c_int = 29; // ---------- Callback ---------- @@ -234,6 +235,59 @@ pub unsafe extern "C" fn qobuz_backend_most_popular_search( }); } +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_get_dynamic_suggestions( + ptr: *mut Backend, + listened_track_ids_json: *const c_char, + tracks_to_analyze_json: *const c_char, + limit: u32, +) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let cb = inner.cb; + let ud = inner.ud; + + let listened_str = CStr::from_ptr(listened_track_ids_json) + .to_string_lossy() + .into_owned(); + let analyze_str = CStr::from_ptr(tracks_to_analyze_json) + .to_string_lossy() + .into_owned(); + + let listened: serde_json::Value = match serde_json::from_str(&listened_str) { + Ok(v) => v, + Err(e) => { + call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())); + return; + } + }; + + let to_analyze: serde_json::Value = match serde_json::from_str(&analyze_str) { + Ok(v) => v, + Err(e) => { + call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())); + return; + } + }; + + spawn(inner, async move { + let result = client + .lock() + .await + .get_dynamic_suggestions(limit, listened, to_analyze) + .await; + match result { + Ok(r) => call_cb( + cb, + ud, + EV_DYNAMIC_SUGGEST_OK, + &serde_json::to_string(&r).unwrap_or_default(), + ), + Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())), + } + }); +} + // ---------- Album ---------- #[no_mangle] diff --git a/src/backend/qobuzbackend.cpp b/src/backend/qobuzbackend.cpp index d085bb4..d4bbd3a 100644 --- a/src/backend/qobuzbackend.cpp +++ b/src/backend/qobuzbackend.cpp @@ -56,6 +56,17 @@ void QobuzBackend::mostPopularSearch(const QString &query, quint32 limit) qobuz_backend_most_popular_search(m_backend, query.toUtf8().constData(), limit); } +void QobuzBackend::getDynamicSuggestions(const QJsonArray &listenedTrackIds, const QJsonArray &tracksToAnalyze, quint32 limit) +{ + const QByteArray listened = QJsonDocument(listenedTrackIds).toJson(QJsonDocument::Compact); + const QByteArray analyze = QJsonDocument(tracksToAnalyze).toJson(QJsonDocument::Compact); + qobuz_backend_get_dynamic_suggestions( + m_backend, + listened.constData(), + analyze.constData(), + limit); +} + void QobuzBackend::getAlbum(const QString &albumId) { qobuz_backend_get_album(m_backend, albumId.toUtf8().constData()); @@ -282,6 +293,9 @@ void QobuzBackend::onEvent(int eventType, const QString &json) case 25: // EV_DEEP_SHUFFLE_OK emit deepShuffleTracksLoaded(obj["tracks"].toArray()); break; + case 29: // EV_DYNAMIC_SUGGEST_OK + emit dynamicSuggestionsLoaded(obj); + break; case 27: // EV_GENRES_OK emit genresLoaded(obj); break; diff --git a/src/backend/qobuzbackend.hpp b/src/backend/qobuzbackend.hpp index 412be80..aada7f5 100644 --- a/src/backend/qobuzbackend.hpp +++ b/src/backend/qobuzbackend.hpp @@ -29,6 +29,7 @@ public: // --- catalog --- void search(const QString &query, quint32 offset = 0, quint32 limit = 20); void mostPopularSearch(const QString &query, quint32 limit = 8); + void getDynamicSuggestions(const QJsonArray &listenedTrackIds, const QJsonArray &tracksToAnalyze, quint32 limit = 50); void getAlbum(const QString &albumId); void getArtist(qint64 artistId); void getArtistReleases(qint64 artistId, const QString &releaseType, quint32 limit = 50, quint32 offset = 0); @@ -89,6 +90,7 @@ signals: void artistLoaded(const QJsonObject &artist); void artistReleasesLoaded(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset); void deepShuffleTracksLoaded(const QJsonArray &tracks); + void dynamicSuggestionsLoaded(const QJsonObject &result); void genresLoaded(const QJsonObject &result); void featuredAlbumsLoaded(const QJsonObject &result); void playlistLoaded(const QJsonObject &playlist); diff --git a/src/dialog/settings.cpp b/src/dialog/settings.cpp index 2b990a7..c51df5e 100644 --- a/src/dialog/settings.cpp +++ b/src/dialog/settings.cpp @@ -45,6 +45,10 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) m_gapless->setChecked(AppSettings::instance().gaplessEnabled()); playLayout->addRow(m_gapless); + m_autoplay = new QCheckBox(tr("Autoplay recommendations when queue ends"), playGroup); + m_autoplay->setChecked(AppSettings::instance().autoplayEnabled()); + playLayout->addRow(m_autoplay); + layout->addWidget(playGroup); // --- Last.fm group --- @@ -101,6 +105,7 @@ void SettingsDialog::applyChanges() AppSettings::instance().setPreferredFormat(m_formatBox->currentData().toInt()); AppSettings::instance().setReplayGainEnabled(m_replayGain->isChecked()); AppSettings::instance().setGaplessEnabled(m_gapless->isChecked()); + AppSettings::instance().setAutoplayEnabled(m_autoplay->isChecked()); AppSettings::instance().setLastFmEnabled(m_lastFmEnabled->isChecked()); AppSettings::instance().setLastFmApiKey(m_lastFmApiKey->text().trimmed()); AppSettings::instance().setLastFmApiSecret(m_lastFmApiSecret->text().trimmed()); diff --git a/src/dialog/settings.hpp b/src/dialog/settings.hpp index 10ff78b..9b31668 100644 --- a/src/dialog/settings.hpp +++ b/src/dialog/settings.hpp @@ -20,6 +20,7 @@ private: QComboBox *m_formatBox = nullptr; QCheckBox *m_replayGain = nullptr; QCheckBox *m_gapless = nullptr; + QCheckBox *m_autoplay = nullptr; // Last.fm QCheckBox *m_lastFmEnabled = nullptr; diff --git a/src/playqueue.hpp b/src/playqueue.hpp index cbe6a63..4ce4fd5 100644 --- a/src/playqueue.hpp +++ b/src/playqueue.hpp @@ -241,6 +241,17 @@ public: emit queueChanged(); } + /// Append tracks to the main queue tail (autoplay/discovery). + void appendToContext(const QJsonArray &tracks) + { + for (const auto &v : tracks) { + const QJsonObject t = v.toObject(); + if (t["streamable"].toBool(true)) + m_queue.append(t); + } + emit queueChanged(); + } + /// Move an upcoming item (by its index in upcomingTracks()) to the front of playNext. void moveUpcomingToTop(int upcomingIndex) { diff --git a/src/util/settings.hpp b/src/util/settings.hpp index e637ac6..d360580 100644 --- a/src/util/settings.hpp +++ b/src/util/settings.hpp @@ -41,6 +41,9 @@ public: bool gaplessEnabled() const { return m_settings.value("playback/gapless", false).toBool(); } void setGaplessEnabled(bool v) { m_settings.setValue("playback/gapless", v); } + bool autoplayEnabled() const { return m_settings.value("playback/autoplay", false).toBool(); } + void setAutoplayEnabled(bool v) { m_settings.setValue("playback/autoplay", v); } + // --- Last.fm --- bool lastFmEnabled() const { return m_settings.value("lastfm/enabled", false).toBool(); } void setLastFmEnabled(bool v) { m_settings.setValue("lastfm/enabled", v); } diff --git a/src/view/maintoolbar.cpp b/src/view/maintoolbar.cpp index 3512ea2..2bf17aa 100644 --- a/src/view/maintoolbar.cpp +++ b/src/view/maintoolbar.cpp @@ -94,6 +94,11 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren m_shuffle->setCheckable(true); connect(m_shuffle, &QAction::toggled, this, &MainToolBar::onShuffleToggled); + m_autoplay = addAction(Icon::repeat(), tr("Autoplay")); + m_autoplay->setCheckable(true); + m_autoplay->setChecked(AppSettings::instance().autoplayEnabled()); + connect(m_autoplay, &QAction::toggled, this, &MainToolBar::onAutoplayToggled); + // ---- Volume ---- m_volume = new VolumeButton(this); addWidget(m_volume); @@ -118,6 +123,8 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren connect(m_backend, &QobuzBackend::positionChanged, this, &MainToolBar::onPositionChanged); connect(m_backend, &QobuzBackend::trackFinished, this, &MainToolBar::onTrackFinished); connect(m_backend, &QobuzBackend::trackTransitioned, this, &MainToolBar::onTrackTransitioned); + connect(m_backend, &QobuzBackend::dynamicSuggestionsLoaded, + this, &MainToolBar::onDynamicSuggestionsLoaded); // ---- Queue signals ---- connect(m_queue, &PlayQueue::queueChanged, this, &MainToolBar::onQueueChanged); @@ -232,6 +239,12 @@ void MainToolBar::onBackendStateChanged(const QString &state) void MainToolBar::onTrackChanged(const QJsonObject &track) { setCurrentTrack(track); + + if (track.contains("id")) { + m_recentTracks.append(track); + while (m_recentTracks.size() > 32) + m_recentTracks.removeFirst(); + } } void MainToolBar::onPositionChanged(quint64 position, quint64 duration) @@ -243,6 +256,8 @@ void MainToolBar::onTrackFinished() { if (m_queue->canGoNext()) { onNext(); + } else if (AppSettings::instance().autoplayEnabled()) { + requestAutoplaySuggestions(); } else { setPlaying(false); m_progress->setValue(0); @@ -272,6 +287,81 @@ void MainToolBar::onShuffleToggled(bool checked) m_queue->setShuffle(checked); } +void MainToolBar::onAutoplayToggled(bool checked) +{ + AppSettings::instance().setAutoplayEnabled(checked); +} + +void MainToolBar::requestAutoplaySuggestions() +{ + if (m_fetchingAutoplay) + return; + + QJsonArray listenedIds; + QJsonArray analyze; + + const int n = m_recentTracks.size(); + for (int i = 0; i < n; ++i) { + const QJsonObject t = m_recentTracks.at(i); + const qint64 trackId = static_cast(t["id"].toDouble()); + if (trackId <= 0) + continue; + + listenedIds.append(trackId); + + if (i < qMax(0, n - 5)) + continue; + + const qint64 artistId = static_cast( + t["performer"].toObject()["id"].toDouble()); + const qint64 genreId = static_cast( + t["album"].toObject()["genre"].toObject()["id"].toDouble()); + const qint64 labelId = static_cast( + t["album"].toObject()["label"].toObject()["id"].toDouble()); + + analyze.append(QJsonObject{ + {"track_id", trackId}, + {"artist_id", artistId}, + {"genre_id", genreId}, + {"label_id", labelId}, + }); + } + + if (listenedIds.isEmpty() || analyze.isEmpty()) { + setPlaying(false); + m_progress->setValue(0); + m_position->setText(QStringLiteral("0:00 / 0:00")); + return; + } + + m_fetchingAutoplay = true; + m_backend->getDynamicSuggestions(listenedIds, analyze, 50); +} + +void MainToolBar::onDynamicSuggestionsLoaded(const QJsonObject &result) +{ + m_fetchingAutoplay = false; + + QJsonArray items; + if (result["tracks"].isObject()) + items = result["tracks"].toObject()["items"].toArray(); + if (items.isEmpty() && result["dynamic"].isObject()) + items = result["dynamic"].toObject()["items"].toArray(); + if (items.isEmpty()) + items = result["items"].toArray(); + + if (items.isEmpty()) { + setPlaying(false); + m_progress->setValue(0); + m_position->setText(QStringLiteral("0:00 / 0:00")); + return; + } + + m_queue->appendToContext(items); + if (m_queue->canGoNext()) + onNext(); +} + void MainToolBar::fetchAlbumArt(const QString &url) { m_nam->get(QNetworkRequest(QUrl(url))); diff --git a/src/view/maintoolbar.hpp b/src/view/maintoolbar.hpp index 095a3b3..5ef02e0 100644 --- a/src/view/maintoolbar.hpp +++ b/src/view/maintoolbar.hpp @@ -12,6 +12,7 @@ #include #include #include +#include class MainToolBar : public QToolBar { @@ -47,6 +48,8 @@ private slots: void onTrackTransitioned(); void onQueueChanged(); void onShuffleToggled(bool checked); + void onAutoplayToggled(bool checked); + void onDynamicSuggestionsLoaded(const QJsonObject &result); void fetchAlbumArt(const QString &url); void onAlbumArtReady(QNetworkReply *reply); @@ -65,6 +68,7 @@ private: QLabel *m_position = nullptr; QWidget *m_rightSpacer = nullptr; QAction *m_shuffle = nullptr; + QAction *m_autoplay = nullptr; VolumeButton *m_volume = nullptr; QAction *m_queueBtn = nullptr; QAction *m_search = nullptr; @@ -72,6 +76,10 @@ private: QNetworkAccessManager *m_nam = nullptr; QString m_currentArtUrl; QJsonObject m_currentTrack; + QVector m_recentTracks; bool m_playing = false; bool m_seeking = false; + bool m_fetchingAutoplay = false; + + void requestAutoplaySuggestions(); };