feat: add autoplay suggestions when queue runs out
Some checks failed
Build for Windows / build-windows (push) Has been cancelled

This commit is contained in:
joren
2026-03-30 23:14:43 +02:00
parent 2da934f3f6
commit cfd91f96b5
11 changed files with 208 additions and 0 deletions

View File

@@ -40,6 +40,7 @@ enum QobuzEvent {
EV_MOST_POPULAR_OK = 26, EV_MOST_POPULAR_OK = 26,
EV_GENRES_OK = 27, EV_GENRES_OK = 27,
EV_FEATURED_ALBUMS_OK = 28, EV_FEATURED_ALBUMS_OK = 28,
EV_DYNAMIC_SUGGEST_OK = 29,
}; };
// Callback signature // Callback signature
@@ -57,6 +58,7 @@ void qobuz_backend_get_user(QobuzBackendOpaque *backend);
// Catalog // Catalog
void qobuz_backend_search(QobuzBackendOpaque *backend, const char *query, uint32_t offset, uint32_t limit); 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_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_album(QobuzBackendOpaque *backend, const char *album_id);
void qobuz_backend_get_artist(QobuzBackendOpaque *backend, int64_t artist_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); void qobuz_backend_get_playlist(QobuzBackendOpaque *backend, int64_t playlist_id, uint32_t offset, uint32_t limit);

View File

@@ -471,6 +471,24 @@ impl QobuzClient {
Self::check_response(resp).await Self::check_response(resp).await
} }
pub async fn get_dynamic_suggestions(
&self,
limit: u32,
listened_tracks_ids: Value,
tracks_to_analyze: Value,
) -> Result<Value> {
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( async fn search_tracks(
&self, &self,
query: &str, query: &str,

View File

@@ -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_MOST_POPULAR_OK: c_int = 26;
pub const EV_GENRES_OK: c_int = 27; pub const EV_GENRES_OK: c_int = 27;
pub const EV_FEATURED_ALBUMS_OK: c_int = 28; pub const EV_FEATURED_ALBUMS_OK: c_int = 28;
pub const EV_DYNAMIC_SUGGEST_OK: c_int = 29;
// ---------- Callback ---------- // ---------- 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 ---------- // ---------- Album ----------
#[no_mangle] #[no_mangle]

View File

@@ -56,6 +56,17 @@ void QobuzBackend::mostPopularSearch(const QString &query, quint32 limit)
qobuz_backend_most_popular_search(m_backend, query.toUtf8().constData(), 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) void QobuzBackend::getAlbum(const QString &albumId)
{ {
qobuz_backend_get_album(m_backend, albumId.toUtf8().constData()); 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 case 25: // EV_DEEP_SHUFFLE_OK
emit deepShuffleTracksLoaded(obj["tracks"].toArray()); emit deepShuffleTracksLoaded(obj["tracks"].toArray());
break; break;
case 29: // EV_DYNAMIC_SUGGEST_OK
emit dynamicSuggestionsLoaded(obj);
break;
case 27: // EV_GENRES_OK case 27: // EV_GENRES_OK
emit genresLoaded(obj); emit genresLoaded(obj);
break; break;

View File

@@ -29,6 +29,7 @@ public:
// --- catalog --- // --- catalog ---
void search(const QString &query, quint32 offset = 0, quint32 limit = 20); void search(const QString &query, quint32 offset = 0, quint32 limit = 20);
void mostPopularSearch(const QString &query, quint32 limit = 8); 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 getAlbum(const QString &albumId);
void getArtist(qint64 artistId); void getArtist(qint64 artistId);
void getArtistReleases(qint64 artistId, const QString &releaseType, quint32 limit = 50, quint32 offset = 0); void getArtistReleases(qint64 artistId, const QString &releaseType, quint32 limit = 50, quint32 offset = 0);
@@ -89,6 +90,7 @@ signals:
void artistLoaded(const QJsonObject &artist); void artistLoaded(const QJsonObject &artist);
void artistReleasesLoaded(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset); void artistReleasesLoaded(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset);
void deepShuffleTracksLoaded(const QJsonArray &tracks); void deepShuffleTracksLoaded(const QJsonArray &tracks);
void dynamicSuggestionsLoaded(const QJsonObject &result);
void genresLoaded(const QJsonObject &result); void genresLoaded(const QJsonObject &result);
void featuredAlbumsLoaded(const QJsonObject &result); void featuredAlbumsLoaded(const QJsonObject &result);
void playlistLoaded(const QJsonObject &playlist); void playlistLoaded(const QJsonObject &playlist);

View File

@@ -45,6 +45,10 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent)
m_gapless->setChecked(AppSettings::instance().gaplessEnabled()); m_gapless->setChecked(AppSettings::instance().gaplessEnabled());
playLayout->addRow(m_gapless); 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); layout->addWidget(playGroup);
// --- Last.fm group --- // --- Last.fm group ---
@@ -101,6 +105,7 @@ void SettingsDialog::applyChanges()
AppSettings::instance().setPreferredFormat(m_formatBox->currentData().toInt()); AppSettings::instance().setPreferredFormat(m_formatBox->currentData().toInt());
AppSettings::instance().setReplayGainEnabled(m_replayGain->isChecked()); AppSettings::instance().setReplayGainEnabled(m_replayGain->isChecked());
AppSettings::instance().setGaplessEnabled(m_gapless->isChecked()); AppSettings::instance().setGaplessEnabled(m_gapless->isChecked());
AppSettings::instance().setAutoplayEnabled(m_autoplay->isChecked());
AppSettings::instance().setLastFmEnabled(m_lastFmEnabled->isChecked()); AppSettings::instance().setLastFmEnabled(m_lastFmEnabled->isChecked());
AppSettings::instance().setLastFmApiKey(m_lastFmApiKey->text().trimmed()); AppSettings::instance().setLastFmApiKey(m_lastFmApiKey->text().trimmed());
AppSettings::instance().setLastFmApiSecret(m_lastFmApiSecret->text().trimmed()); AppSettings::instance().setLastFmApiSecret(m_lastFmApiSecret->text().trimmed());

View File

@@ -20,6 +20,7 @@ private:
QComboBox *m_formatBox = nullptr; QComboBox *m_formatBox = nullptr;
QCheckBox *m_replayGain = nullptr; QCheckBox *m_replayGain = nullptr;
QCheckBox *m_gapless = nullptr; QCheckBox *m_gapless = nullptr;
QCheckBox *m_autoplay = nullptr;
// Last.fm // Last.fm
QCheckBox *m_lastFmEnabled = nullptr; QCheckBox *m_lastFmEnabled = nullptr;

View File

@@ -241,6 +241,17 @@ public:
emit queueChanged(); 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. /// Move an upcoming item (by its index in upcomingTracks()) to the front of playNext.
void moveUpcomingToTop(int upcomingIndex) void moveUpcomingToTop(int upcomingIndex)
{ {

View File

@@ -41,6 +41,9 @@ public:
bool gaplessEnabled() const { return m_settings.value("playback/gapless", false).toBool(); } bool gaplessEnabled() const { return m_settings.value("playback/gapless", false).toBool(); }
void setGaplessEnabled(bool v) { m_settings.setValue("playback/gapless", v); } 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 --- // --- Last.fm ---
bool lastFmEnabled() const { return m_settings.value("lastfm/enabled", false).toBool(); } bool lastFmEnabled() const { return m_settings.value("lastfm/enabled", false).toBool(); }
void setLastFmEnabled(bool v) { m_settings.setValue("lastfm/enabled", v); } void setLastFmEnabled(bool v) { m_settings.setValue("lastfm/enabled", v); }

View File

@@ -94,6 +94,11 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
m_shuffle->setCheckable(true); m_shuffle->setCheckable(true);
connect(m_shuffle, &QAction::toggled, this, &MainToolBar::onShuffleToggled); 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 ---- // ---- Volume ----
m_volume = new VolumeButton(this); m_volume = new VolumeButton(this);
addWidget(m_volume); 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::positionChanged, this, &MainToolBar::onPositionChanged);
connect(m_backend, &QobuzBackend::trackFinished, this, &MainToolBar::onTrackFinished); connect(m_backend, &QobuzBackend::trackFinished, this, &MainToolBar::onTrackFinished);
connect(m_backend, &QobuzBackend::trackTransitioned, this, &MainToolBar::onTrackTransitioned); connect(m_backend, &QobuzBackend::trackTransitioned, this, &MainToolBar::onTrackTransitioned);
connect(m_backend, &QobuzBackend::dynamicSuggestionsLoaded,
this, &MainToolBar::onDynamicSuggestionsLoaded);
// ---- Queue signals ---- // ---- Queue signals ----
connect(m_queue, &PlayQueue::queueChanged, this, &MainToolBar::onQueueChanged); connect(m_queue, &PlayQueue::queueChanged, this, &MainToolBar::onQueueChanged);
@@ -232,6 +239,12 @@ void MainToolBar::onBackendStateChanged(const QString &state)
void MainToolBar::onTrackChanged(const QJsonObject &track) void MainToolBar::onTrackChanged(const QJsonObject &track)
{ {
setCurrentTrack(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) void MainToolBar::onPositionChanged(quint64 position, quint64 duration)
@@ -243,6 +256,8 @@ void MainToolBar::onTrackFinished()
{ {
if (m_queue->canGoNext()) { if (m_queue->canGoNext()) {
onNext(); onNext();
} else if (AppSettings::instance().autoplayEnabled()) {
requestAutoplaySuggestions();
} else { } else {
setPlaying(false); setPlaying(false);
m_progress->setValue(0); m_progress->setValue(0);
@@ -272,6 +287,81 @@ void MainToolBar::onShuffleToggled(bool checked)
m_queue->setShuffle(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<qint64>(t["id"].toDouble());
if (trackId <= 0)
continue;
listenedIds.append(trackId);
if (i < qMax(0, n - 5))
continue;
const qint64 artistId = static_cast<qint64>(
t["performer"].toObject()["id"].toDouble());
const qint64 genreId = static_cast<qint64>(
t["album"].toObject()["genre"].toObject()["id"].toDouble());
const qint64 labelId = static_cast<qint64>(
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) void MainToolBar::fetchAlbumArt(const QString &url)
{ {
m_nam->get(QNetworkRequest(QUrl(url))); m_nam->get(QNetworkRequest(QUrl(url)));

View File

@@ -12,6 +12,7 @@
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QNetworkReply> #include <QNetworkReply>
#include <QJsonObject> #include <QJsonObject>
#include <QVector>
class MainToolBar : public QToolBar class MainToolBar : public QToolBar
{ {
@@ -47,6 +48,8 @@ private slots:
void onTrackTransitioned(); void onTrackTransitioned();
void onQueueChanged(); void onQueueChanged();
void onShuffleToggled(bool checked); void onShuffleToggled(bool checked);
void onAutoplayToggled(bool checked);
void onDynamicSuggestionsLoaded(const QJsonObject &result);
void fetchAlbumArt(const QString &url); void fetchAlbumArt(const QString &url);
void onAlbumArtReady(QNetworkReply *reply); void onAlbumArtReady(QNetworkReply *reply);
@@ -65,6 +68,7 @@ private:
QLabel *m_position = nullptr; QLabel *m_position = nullptr;
QWidget *m_rightSpacer = nullptr; QWidget *m_rightSpacer = nullptr;
QAction *m_shuffle = nullptr; QAction *m_shuffle = nullptr;
QAction *m_autoplay = nullptr;
VolumeButton *m_volume = nullptr; VolumeButton *m_volume = nullptr;
QAction *m_queueBtn = nullptr; QAction *m_queueBtn = nullptr;
QAction *m_search = nullptr; QAction *m_search = nullptr;
@@ -72,6 +76,10 @@ private:
QNetworkAccessManager *m_nam = nullptr; QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl; QString m_currentArtUrl;
QJsonObject m_currentTrack; QJsonObject m_currentTrack;
QVector<QJsonObject> m_recentTracks;
bool m_playing = false; bool m_playing = false;
bool m_seeking = false; bool m_seeking = false;
bool m_fetchingAutoplay = false;
void requestAutoplaySuggestions();
}; };