From b9b47f80e732d581e78fc83eb094c470c927cb44 Mon Sep 17 00:00:00 2001 From: joren Date: Tue, 24 Mar 2026 01:13:06 +0100 Subject: [PATCH] feat: seeking support and Last.fm scrobbling Seeking: - Rust player: seek_requested/seek_target_secs atomics on PlayerStatus - Decoder loop checks for seek each iteration, calls format.seek() and resets decoder - New qobuz_backend_seek C FFI + QobuzBackend::seek(quint64) - Progress slider onProgressReleased now seeks to the dragged position Last.fm: - LastFmScrobbler: now-playing + scrobble (50% or 240s threshold, min 30s) - API signature follows Last.fm spec (sorted params, md5) - Settings dialog: API key/secret, username/password, Connect button with status - AppSettings: lastfm/enabled, api_key, api_secret, session_key - Scrobbler wired to trackChanged, positionChanged, trackFinished in MainWindow Co-Authored-By: Claude Sonnet 4.6 --- rust/include/qobuz_backend.h | 1 + rust/src/lib.rs | 5 + rust/src/player/decoder.rs | 18 +++- rust/src/player/mod.rs | 16 +++- src/CMakeLists.txt | 3 + src/backend/qobuzbackend.cpp | 5 + src/backend/qobuzbackend.hpp | 1 + src/dialog/settings.cpp | 94 ++++++++++++++++-- src/dialog/settings.hpp | 16 ++++ src/mainwindow.cpp | 9 ++ src/mainwindow.hpp | 2 + src/scrobbler/lastfm.hpp | 179 +++++++++++++++++++++++++++++++++++ src/util/settings.hpp | 13 +++ src/view/maintoolbar.cpp | 5 + 14 files changed, 357 insertions(+), 10 deletions(-) create mode 100644 src/scrobbler/lastfm.hpp diff --git a/rust/include/qobuz_backend.h b/rust/include/qobuz_backend.h index 783c9bb..3f3e0f9 100644 --- a/rust/include/qobuz_backend.h +++ b/rust/include/qobuz_backend.h @@ -62,6 +62,7 @@ void qobuz_backend_pause(QobuzBackendOpaque *backend); void qobuz_backend_resume(QobuzBackendOpaque *backend); void qobuz_backend_stop(QobuzBackendOpaque *backend); void qobuz_backend_set_volume(QobuzBackendOpaque *backend, uint8_t volume); +void qobuz_backend_seek(QobuzBackendOpaque *backend, uint64_t position_secs); uint64_t qobuz_backend_get_position(const QobuzBackendOpaque *backend); uint64_t qobuz_backend_get_duration(const QobuzBackendOpaque *backend); uint8_t qobuz_backend_get_volume(const QobuzBackendOpaque *backend); diff --git a/rust/src/lib.rs b/rust/src/lib.rs index f0d78ba..ad41159 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -402,6 +402,11 @@ pub unsafe extern "C" fn qobuz_backend_set_volume(ptr: *mut Backend, volume: u8) (*ptr).0.player.set_volume(volume); } +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_seek(ptr: *mut Backend, position_secs: u64) { + (*ptr).0.player.seek(position_secs); +} + #[no_mangle] pub unsafe extern "C" fn qobuz_backend_get_position(ptr: *const Backend) -> u64 { (*ptr).0.player.status.get_position() diff --git a/rust/src/player/decoder.rs b/rust/src/player/decoder.rs index 59d074f..7de0e20 100644 --- a/rust/src/player/decoder.rs +++ b/rust/src/player/decoder.rs @@ -8,10 +8,11 @@ use std::sync::{ use symphonia::core::{ codecs::DecoderOptions, errors::Error as SymphoniaError, - formats::FormatOptions, + formats::{FormatOptions, SeekMode, SeekTo}, io::{MediaSource, MediaSourceStream}, meta::MetadataOptions, probe::Hint, + units::Time, }; use crate::player::{output::AudioOutput, PlayerStatus}; @@ -172,6 +173,21 @@ pub fn play_track( } } + if status.seek_requested.load(Ordering::SeqCst) { + status.seek_requested.store(false, Ordering::SeqCst); + let target = status.seek_target_secs.load(Ordering::Relaxed); + let seeked = format.seek( + SeekMode::Coarse, + SeekTo::Time { time: Time::from(target), track_id: None }, + ); + if let Ok(s) = seeked { + let actual = s.actual_ts / sample_rate as u64; + status.position_secs.store(actual, Ordering::Relaxed); + } + decoder.reset(); + continue; + } + let packet = match format.next_packet() { Ok(p) => p, Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { diff --git a/rust/src/player/mod.rs b/rust/src/player/mod.rs index 6bc1683..3b12ff6 100644 --- a/rust/src/player/mod.rs +++ b/rust/src/player/mod.rs @@ -44,6 +44,9 @@ pub struct PlayerStatus { pub current_track: Arc>>, /// Set to true by the decode thread when a track finishes naturally. pub track_finished: Arc, + /// Set by the player loop when a seek command arrives; cleared by the decode thread. + pub seek_requested: Arc, + pub seek_target_secs: Arc, } impl PlayerStatus { @@ -55,6 +58,8 @@ impl PlayerStatus { volume: Arc::new(AtomicU8::new(80)), current_track: Arc::new(std::sync::Mutex::new(None)), track_finished: Arc::new(AtomicBool::new(false)), + seek_requested: Arc::new(AtomicBool::new(false)), + seek_target_secs: Arc::new(AtomicU64::new(0)), } } @@ -121,6 +126,11 @@ impl Player { self.status.volume.store(vol, Ordering::Relaxed); self.send(PlayerCommand::SetVolume(vol)); } + + pub fn seek(&self, secs: u64) { + self.status.seek_target_secs.store(secs, Ordering::Relaxed); + self.status.seek_requested.store(true, Ordering::SeqCst); + } } fn player_loop(rx: std::sync::mpsc::Receiver, status: PlayerStatus) { @@ -177,7 +187,11 @@ fn player_loop(rx: std::sync::mpsc::Receiver, status: PlayerStatu status.position_secs.store(0, Ordering::Relaxed); status.duration_secs.store(0, Ordering::Relaxed); } - PlayerCommand::SetVolume(_) | PlayerCommand::Seek(_) => {} + PlayerCommand::SetVolume(_) => {} + PlayerCommand::Seek(secs) => { + status.seek_target_secs.store(secs, Ordering::Relaxed); + status.seek_requested.store(true, Ordering::SeqCst); + } }, Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f3d1063..f494cc0 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -44,6 +44,9 @@ target_sources(qobuz-qt PRIVATE dialog/settings.hpp dialog/settings.cpp + # Scrobbler (header-only) + scrobbler/lastfm.hpp + # Widgets (header-only) widget/clickableslider.hpp widget/volumebutton.hpp diff --git a/src/backend/qobuzbackend.cpp b/src/backend/qobuzbackend.cpp index 1f59fa6..5646c18 100644 --- a/src/backend/qobuzbackend.cpp +++ b/src/backend/qobuzbackend.cpp @@ -128,6 +128,11 @@ void QobuzBackend::setVolume(int volume) qobuz_backend_set_volume(m_backend, static_cast(qBound(0, volume, 100))); } +void QobuzBackend::seek(quint64 positionSecs) +{ + qobuz_backend_seek(m_backend, positionSecs); +} + quint64 QobuzBackend::position() const { return qobuz_backend_get_position(m_backend); } quint64 QobuzBackend::duration() const { return qobuz_backend_get_duration(m_backend); } int QobuzBackend::volume() const { return qobuz_backend_get_volume(m_backend); } diff --git a/src/backend/qobuzbackend.hpp b/src/backend/qobuzbackend.hpp index f8a1534..725a522 100644 --- a/src/backend/qobuzbackend.hpp +++ b/src/backend/qobuzbackend.hpp @@ -48,6 +48,7 @@ public: void resume(); void stop(); void setVolume(int volume); + void seek(quint64 positionSecs); quint64 position() const; quint64 duration() const; diff --git a/src/dialog/settings.cpp b/src/dialog/settings.cpp index 9bf791d..f3766d9 100644 --- a/src/dialog/settings.cpp +++ b/src/dialog/settings.cpp @@ -1,15 +1,20 @@ #include "settings.hpp" #include "../util/settings.hpp" +#include "../scrobbler/lastfm.hpp" #include #include #include #include +#include +#include SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) { setWindowTitle(tr("Settings")); - setMinimumWidth(400); + setMinimumWidth(420); + + m_nam = new QNetworkAccessManager(this); auto *layout = new QVBoxLayout(this); @@ -30,25 +35,98 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) break; } } - playLayout->addRow(tr("Preferred quality:"), m_formatBox); layout->addWidget(playGroup); + // --- Last.fm group --- + auto *lfmGroup = new QGroupBox(tr("Last.fm Scrobbling"), this); + auto *lfmLayout = new QFormLayout(lfmGroup); + + m_lastFmEnabled = new QCheckBox(tr("Enable scrobbling"), lfmGroup); + m_lastFmEnabled->setChecked(AppSettings::instance().lastFmEnabled()); + lfmLayout->addRow(m_lastFmEnabled); + + m_lastFmApiKey = new QLineEdit(AppSettings::instance().lastFmApiKey(), lfmGroup); + m_lastFmApiKey->setPlaceholderText(tr("Get one at last.fm/api")); + lfmLayout->addRow(tr("API Key:"), m_lastFmApiKey); + + m_lastFmApiSecret = new QLineEdit(AppSettings::instance().lastFmApiSecret(), lfmGroup); + m_lastFmApiSecret->setEchoMode(QLineEdit::Password); + lfmLayout->addRow(tr("API Secret:"), m_lastFmApiSecret); + + m_lastFmUsername = new QLineEdit(lfmGroup); + m_lastFmUsername->setPlaceholderText(tr("Last.fm username")); + lfmLayout->addRow(tr("Username:"), m_lastFmUsername); + + m_lastFmPassword = new QLineEdit(lfmGroup); + m_lastFmPassword->setEchoMode(QLineEdit::Password); + m_lastFmPassword->setPlaceholderText(tr("Last.fm password")); + lfmLayout->addRow(tr("Password:"), m_lastFmPassword); + + auto *connectRow = new QHBoxLayout; + m_lastFmConnect = new QPushButton(tr("Connect"), lfmGroup); + m_lastFmStatus = new QLabel(lfmGroup); + + const bool hasSession = !AppSettings::instance().lastFmSessionKey().isEmpty(); + m_lastFmStatus->setText(hasSession ? tr("Connected ✓") : tr("Not connected")); + m_lastFmStatus->setStyleSheet(hasSession ? "color: green;" : "color: gray;"); + + connectRow->addWidget(m_lastFmConnect); + connectRow->addWidget(m_lastFmStatus, 1); + lfmLayout->addRow(connectRow); + + layout->addWidget(lfmGroup); layout->addStretch(); auto *buttons = new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); layout->addWidget(buttons); - connect(buttons, &QDialogButtonBox::accepted, this, [this] { - applyChanges(); - accept(); - }); + connect(m_lastFmConnect, &QPushButton::clicked, this, &SettingsDialog::onLastFmConnect); + connect(buttons, &QDialogButtonBox::accepted, this, [this] { applyChanges(); accept(); }); connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); } void SettingsDialog::applyChanges() { - AppSettings::instance().setPreferredFormat( - m_formatBox->currentData().toInt()); + AppSettings::instance().setPreferredFormat(m_formatBox->currentData().toInt()); + AppSettings::instance().setLastFmEnabled(m_lastFmEnabled->isChecked()); + AppSettings::instance().setLastFmApiKey(m_lastFmApiKey->text().trimmed()); + AppSettings::instance().setLastFmApiSecret(m_lastFmApiSecret->text().trimmed()); +} + +void SettingsDialog::onLastFmConnect() +{ + const QString apiKey = m_lastFmApiKey->text().trimmed(); + const QString apiSecret = m_lastFmApiSecret->text().trimmed(); + const QString username = m_lastFmUsername->text().trimmed(); + const QString password = m_lastFmPassword->text(); + + if (apiKey.isEmpty() || apiSecret.isEmpty() || username.isEmpty() || password.isEmpty()) { + m_lastFmStatus->setText(tr("Fill in all fields first.")); + m_lastFmStatus->setStyleSheet("color: red;"); + return; + } + + // Temporarily apply so the scrobbler util can use them for signing + AppSettings::instance().setLastFmApiKey(apiKey); + AppSettings::instance().setLastFmApiSecret(apiSecret); + + m_lastFmConnect->setEnabled(false); + m_lastFmStatus->setText(tr("Connecting…")); + m_lastFmStatus->setStyleSheet("color: gray;"); + + // Reuse LastFmScrobbler::authenticate as a utility (temp instance) + auto *tmp = new LastFmScrobbler(this); + tmp->authenticate(username, password, [this, tmp](bool ok, const QString &err) { + tmp->deleteLater(); + m_lastFmConnect->setEnabled(true); + if (ok) { + m_lastFmStatus->setText(tr("Connected ✓")); + m_lastFmStatus->setStyleSheet("color: green;"); + } else { + m_lastFmStatus->setText(tr("Error: %1").arg(err)); + m_lastFmStatus->setStyleSheet("color: red;"); + } + }); } diff --git a/src/dialog/settings.hpp b/src/dialog/settings.hpp index 365fcd7..dc04606 100644 --- a/src/dialog/settings.hpp +++ b/src/dialog/settings.hpp @@ -3,6 +3,10 @@ #include #include #include +#include +#include +#include +#include class SettingsDialog : public QDialog { @@ -12,7 +16,19 @@ public: explicit SettingsDialog(QWidget *parent = nullptr); private: + // Playback QComboBox *m_formatBox = nullptr; + // Last.fm + QCheckBox *m_lastFmEnabled = nullptr; + QLineEdit *m_lastFmApiKey = nullptr; + QLineEdit *m_lastFmApiSecret = nullptr; + QLineEdit *m_lastFmUsername = nullptr; + QLineEdit *m_lastFmPassword = nullptr; + QPushButton *m_lastFmConnect = nullptr; + QLabel *m_lastFmStatus = nullptr; + QNetworkAccessManager *m_nam = nullptr; + void applyChanges(); + void onLastFmConnect(); }; diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 7e8c610..bf0644d 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -60,6 +60,15 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent) setupMenuBar(); statusBar()->showMessage(tr("Ready")); + // ---- Scrobbler ---- + m_scrobbler = new LastFmScrobbler(this); + connect(m_backend, &QobuzBackend::trackChanged, + m_scrobbler, &LastFmScrobbler::onTrackStarted); + connect(m_backend, &QobuzBackend::positionChanged, + m_scrobbler, &LastFmScrobbler::onPositionChanged); + connect(m_backend, &QobuzBackend::trackFinished, + m_scrobbler, &LastFmScrobbler::onTrackFinished); + // ---- Backend signals ---- connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess); connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError); diff --git a/src/mainwindow.hpp b/src/mainwindow.hpp index 243596c..7381dac 100644 --- a/src/mainwindow.hpp +++ b/src/mainwindow.hpp @@ -8,6 +8,7 @@ #include "view/queuepanel.hpp" #include "view/sidepanel/view.hpp" #include "list/library.hpp" +#include "scrobbler/lastfm.hpp" #include #include @@ -51,6 +52,7 @@ private: QueuePanel *m_queuePanel = nullptr; SidePanel::View *m_sidePanel = nullptr; QDockWidget *m_libraryDock = nullptr; + LastFmScrobbler *m_scrobbler = nullptr; void setupMenuBar(); void tryRestoreSession(); diff --git a/src/scrobbler/lastfm.hpp b/src/scrobbler/lastfm.hpp new file mode 100644 index 0000000..68bfa7e --- /dev/null +++ b/src/scrobbler/lastfm.hpp @@ -0,0 +1,179 @@ +#pragma once + +#include "../util/settings.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// Handles Last.fm now-playing updates and scrobbling. +/// Connect to QobuzBackend signals and call onTrackStarted / onPositionChanged / onTrackFinished. +class LastFmScrobbler : public QObject +{ + Q_OBJECT + +public: + explicit LastFmScrobbler(QObject *parent = nullptr) + : QObject(parent) + , m_nam(new QNetworkAccessManager(this)) + {} + + /// Authenticate via auth.getMobileSession and store the session key. + /// callback(success, errorMessage) + void authenticate(const QString &username, const QString &password, + std::function callback) + { + const QString apiKey = AppSettings::instance().lastFmApiKey(); + const QString apiSecret = AppSettings::instance().lastFmApiSecret(); + if (apiKey.isEmpty() || apiSecret.isEmpty()) { + callback(false, tr("API key or secret is not set.")); + return; + } + + QMap params; + params["method"] = QStringLiteral("auth.getMobileSession"); + params["api_key"] = apiKey; + params["username"] = username; + params["password"] = password; + params["api_sig"] = buildSig(params, apiSecret); + params["format"] = QStringLiteral("json"); + + auto *reply = m_nam->post(apiRequest(), encodeBody(params)); + connect(reply, &QNetworkReply::finished, this, [reply, callback] { + reply->deleteLater(); + const QJsonObject obj = QJsonDocument::fromJson(reply->readAll()).object(); + const QString key = obj["session"].toObject()["key"].toString(); + if (!key.isEmpty()) { + AppSettings::instance().setLastFmSessionKey(key); + callback(true, {}); + } else { + callback(false, obj["message"].toString(tr("Authentication failed."))); + } + }); + } + + bool isEnabled() const + { + return AppSettings::instance().lastFmEnabled() + && !AppSettings::instance().lastFmApiKey().isEmpty() + && !AppSettings::instance().lastFmApiSecret().isEmpty() + && !AppSettings::instance().lastFmSessionKey().isEmpty(); + } + +public slots: + void onTrackStarted(const QJsonObject &track) + { + m_title = track["title"].toString(); + m_artist = track["performer"].toObject()["name"].toString(); + if (m_artist.isEmpty()) + m_artist = track["album"].toObject()["artist"].toObject()["name"].toString(); + m_album = track["album"].toObject()["title"].toString(); + m_duration = static_cast(track["duration"].toDouble()); + m_startTime = QDateTime::currentSecsSinceEpoch(); + m_playedSecs = 0; + m_scrobbled = false; + + if (!isEnabled() || m_title.isEmpty() || m_duration < 30) return; + updateNowPlaying(); + } + + void onPositionChanged(quint64 positionSecs, quint64 /*duration*/) + { + m_playedSecs = positionSecs; + if (!isEnabled() || m_scrobbled || m_title.isEmpty() || m_duration < 30) return; + // Scrobble after 50% or 240 seconds played, whichever comes first, min 30 seconds. + const quint64 threshold = static_cast(qMin((qint64)240, m_duration / 2)); + if (positionSecs >= 30 && positionSecs >= threshold) + scrobble(); + } + + void onTrackFinished() + { + if (!isEnabled() || m_scrobbled || m_title.isEmpty() || m_duration < 30) return; + if (m_playedSecs >= 30) scrobble(); + } + +private: + QNetworkAccessManager *m_nam = nullptr; + + QString m_title; + QString m_artist; + QString m_album; + qint64 m_duration = 0; + qint64 m_startTime = 0; + quint64 m_playedSecs = 0; + bool m_scrobbled = false; + + void updateNowPlaying() + { + QMap params; + params["method"] = QStringLiteral("track.updateNowPlaying"); + params["api_key"] = AppSettings::instance().lastFmApiKey(); + params["sk"] = AppSettings::instance().lastFmSessionKey(); + params["artist"] = m_artist; + params["track"] = m_title; + params["album"] = m_album; + params["duration"] = QString::number(m_duration); + params["api_sig"] = buildSig(params, AppSettings::instance().lastFmApiSecret()); + params["format"] = QStringLiteral("json"); + + auto *reply = m_nam->post(apiRequest(), encodeBody(params)); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + } + + void scrobble() + { + m_scrobbled = true; + QMap params; + params["method"] = QStringLiteral("track.scrobble"); + params["api_key"] = AppSettings::instance().lastFmApiKey(); + params["sk"] = AppSettings::instance().lastFmSessionKey(); + params["artist"] = m_artist; + params["track"] = m_title; + params["album"] = m_album; + params["timestamp"] = QString::number(m_startTime); + params["duration"] = QString::number(m_duration); + params["api_sig"] = buildSig(params, AppSettings::instance().lastFmApiSecret()); + params["format"] = QStringLiteral("json"); + + auto *reply = m_nam->post(apiRequest(), encodeBody(params)); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + } + + /// Build the Last.fm API signature: sort params, concatenate key+value, append secret, md5. + static QString buildSig(const QMap ¶ms, const QString &secret) + { + QString s; + for (auto it = params.constBegin(); it != params.constEnd(); ++it) { + if (it.key() != "format" && it.key() != "api_sig") + s += it.key() + it.value(); + } + s += secret; + return QCryptographicHash::hash(s.toUtf8(), QCryptographicHash::Md5).toHex(); + } + + static QNetworkRequest apiRequest() + { + QNetworkRequest req(QUrl(QStringLiteral("https://ws.audioscrobbler.com/2.0/"))); + req.setHeader(QNetworkRequest::ContentTypeHeader, + QStringLiteral("application/x-www-form-urlencoded")); + return req; + } + + static QByteArray encodeBody(const QMap ¶ms) + { + QUrlQuery q; + for (auto it = params.constBegin(); it != params.constEnd(); ++it) + q.addQueryItem(it.key(), it.value()); + return q.toString(QUrl::FullyEncoded).toUtf8(); + } +}; diff --git a/src/util/settings.hpp b/src/util/settings.hpp index 564cbfc..d08a339 100644 --- a/src/util/settings.hpp +++ b/src/util/settings.hpp @@ -31,6 +31,19 @@ public: bool rememberLogin() const { return m_settings.value("auth/remember", true).toBool(); } void setRememberLogin(bool r) { m_settings.setValue("auth/remember", r); } + // --- Last.fm --- + bool lastFmEnabled() const { return m_settings.value("lastfm/enabled", false).toBool(); } + void setLastFmEnabled(bool v) { m_settings.setValue("lastfm/enabled", v); } + + QString lastFmApiKey() const { return m_settings.value("lastfm/api_key").toString(); } + void setLastFmApiKey(const QString &v) { m_settings.setValue("lastfm/api_key", v); } + + QString lastFmApiSecret() const { return m_settings.value("lastfm/api_secret").toString(); } + void setLastFmApiSecret(const QString &v) { m_settings.setValue("lastfm/api_secret", v); } + + QString lastFmSessionKey() const { return m_settings.value("lastfm/session_key").toString(); } + void setLastFmSessionKey(const QString &v){ m_settings.setValue("lastfm/session_key", v); } + private: AppSettings() : m_settings("qobuz-qt", "qobuz-qt") {} QSettings m_settings; diff --git a/src/view/maintoolbar.cpp b/src/view/maintoolbar.cpp index 6a7c30b..998e4bf 100644 --- a/src/view/maintoolbar.cpp +++ b/src/view/maintoolbar.cpp @@ -177,6 +177,11 @@ void MainToolBar::onNext() void MainToolBar::onProgressReleased() { m_seeking = false; + const quint64 dur = m_backend->duration(); + if (dur > 0) { + const quint64 target = dur * static_cast(m_progress->value()) / 1000; + m_backend->seek(target); + } } void MainToolBar::onVolumeChanged(int volume)