From 5aaa5231b89e697ab0b4d000420c31bce68900d6 Mon Sep 17 00:00:00 2001 From: Jonas Kvinge Date: Sun, 30 Aug 2020 18:09:13 +0200 Subject: [PATCH] Add Last.fm import Fixes #247 --- src/CMakeLists.txt | 5 + src/collection/collection.cpp | 4 + src/collection/collectionbackend.cpp | 77 ++++ src/collection/collectionbackend.h | 4 + src/core/application.cpp | 4 + src/core/application.h | 2 + src/core/mainwindow.cpp | 11 + src/core/mainwindow.h | 3 + src/core/mainwindow.ui | 6 + src/dialogs/lastfmimportdialog.cpp | 178 ++++++++ src/dialogs/lastfmimportdialog.h | 68 ++++ src/dialogs/lastfmimportdialog.ui | 137 +++++++ src/scrobbler/lastfmimport.cpp | 588 +++++++++++++++++++++++++++ src/scrobbler/lastfmimport.h | 113 +++++ src/scrobbler/lastfmscrobbler.h | 2 +- src/scrobbler/scrobblingapi20.cpp | 2 +- src/scrobbler/scrobblingapi20.h | 4 +- 17 files changed, 1204 insertions(+), 4 deletions(-) create mode 100644 src/dialogs/lastfmimportdialog.cpp create mode 100644 src/dialogs/lastfmimportdialog.h create mode 100644 src/dialogs/lastfmimportdialog.ui create mode 100644 src/scrobbler/lastfmimport.cpp create mode 100644 src/scrobbler/lastfmimport.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0c36d12c..f75103ba 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -170,6 +170,7 @@ set(SOURCES dialogs/addstreamdialog.cpp dialogs/userpassdialog.cpp dialogs/deleteconfirmationdialog.cpp + dialogs/lastfmimportdialog.cpp widgets/autoexpandingtreeview.cpp widgets/busyindicator.cpp @@ -223,6 +224,7 @@ set(SOURCES scrobbler/lastfmscrobbler.cpp scrobbler/librefmscrobbler.cpp scrobbler/listenbrainzscrobbler.cpp + scrobbler/lastfmimport.cpp organize/organize.cpp organize/organizeformat.cpp @@ -369,6 +371,7 @@ set(HEADERS dialogs/addstreamdialog.h dialogs/userpassdialog.h dialogs/deleteconfirmationdialog.h + dialogs/lastfmimportdialog.h widgets/autoexpandingtreeview.h widgets/busyindicator.h @@ -420,6 +423,7 @@ set(HEADERS scrobbler/lastfmscrobbler.h scrobbler/librefmscrobbler.h scrobbler/listenbrainzscrobbler.h + scrobbler/lastfmimport.h organize/organize.h organize/organizedialog.h @@ -472,6 +476,7 @@ set(UI dialogs/trackselectiondialog.ui dialogs/addstreamdialog.ui dialogs/userpassdialog.ui + dialogs/lastfmimportdialog.ui widgets/trackslider.ui widgets/fileview.ui diff --git a/src/collection/collection.cpp b/src/collection/collection.cpp index 44276f0b..dab6c9dd 100644 --- a/src/collection/collection.cpp +++ b/src/collection/collection.cpp @@ -40,6 +40,7 @@ #include "collectionbackend.h" #include "collectionmodel.h" #include "playlist/playlistmanager.h" +#include "scrobbler/lastfmimport.h" const char *SCollection::kSongsTable = "songs"; const char *SCollection::kDirsTable = "directories"; @@ -110,6 +111,9 @@ void SCollection::Init() { connect(app_->playlist_manager(), SIGNAL(CurrentSongChanged(Song)), SLOT(CurrentSongChanged(Song))); connect(app_->player(), SIGNAL(Stopped()), SLOT(Stopped())); + connect(app_->lastfm_import(), SIGNAL(UpdateLastPlayed(QString, QString, QString, int)), backend_, SLOT(UpdateLastPlayed(QString, QString, QString, int))); + connect(app_->lastfm_import(), SIGNAL(UpdatePlayCount(QString, QString, int)), backend_, SLOT(UpdatePlayCount(QString, QString, int))); + // This will start the watcher checking for updates backend_->LoadDirectoriesAsync(); diff --git a/src/collection/collectionbackend.cpp b/src/collection/collectionbackend.cpp index 9b5c2a92..feb70023 100644 --- a/src/collection/collectionbackend.cpp +++ b/src/collection/collectionbackend.cpp @@ -42,6 +42,7 @@ #include #include +#include "core/logging.h" #include "core/database.h" #include "core/scopedtransaction.h" @@ -1297,3 +1298,79 @@ void CollectionBackend::DeleteAll() { emit DatabaseReset(); } + +SongList CollectionBackend::GetSongsBy(const QString &artist, const QString &album, const QString &title) { + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + + SongList songs; + QSqlQuery q(db); + if (album.isEmpty()) { + q.prepare(QString("SELECT ROWID, " + Song::kColumnSpec + " FROM %1 WHERE artist = :artist COLLATE NOCASE AND title = :title COLLATE NOCASE").arg(songs_table_)); + } + else { + q.prepare(QString("SELECT ROWID, " + Song::kColumnSpec + " FROM %1 WHERE artist = :artist COLLATE NOCASE AND album = :album COLLATE NOCASE AND title = :title COLLATE NOCASE").arg(songs_table_)); + } + q.bindValue(":artist", artist); + if (!album.isEmpty()) q.bindValue(":album", album); + q.bindValue(":title", title); + q.exec(); + if (db_->CheckErrors(q)) return SongList(); + while (q.next()) { + Song song(source_); + song.InitFromQuery(q, true); + songs << song; + } + + return songs; + +} + +void CollectionBackend::UpdateLastPlayed(const QString &artist, const QString &album, const QString &title, const int lastplayed) { + + SongList songs = GetSongsBy(artist, album, title); + if (songs.isEmpty()) { + qLog(Debug) << "Could not find a matching song in the database for" << artist << album << title; + return; + } + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + + for (const Song &song : songs) { + QSqlQuery q(db); + q.prepare(QString("UPDATE %1 SET lastplayed = :lastplayed WHERE ROWID = :id").arg(songs_table_)); + q.bindValue(":lastplayed", lastplayed); + q.bindValue(":id", song.id()); + q.exec(); + if (db_->CheckErrors(q)) continue; + } + + emit SongsStatisticsChanged(SongList() << songs); + +} + +void CollectionBackend::UpdatePlayCount(const QString &artist, const QString &title, const int playcount) { + + SongList songs = GetSongsBy(artist, QString(), title); + if (songs.isEmpty()) { + qLog(Debug) << "Could not find a matching song in the database for" << artist << title; + return; + } + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + + for (const Song &song : songs) { + QSqlQuery q(db); + q.prepare(QString("UPDATE %1 SET playcount = :playcount WHERE ROWID = :id").arg(songs_table_)); + q.bindValue(":playcount", playcount); + q.bindValue(":id", song.id()); + q.exec(); + if (db_->CheckErrors(q)) continue; + } + + emit SongsStatisticsChanged(SongList() << songs); + +} diff --git a/src/collection/collectionbackend.h b/src/collection/collectionbackend.h index e8f27a43..35fe1714 100644 --- a/src/collection/collectionbackend.h +++ b/src/collection/collectionbackend.h @@ -205,6 +205,10 @@ class CollectionBackend : public CollectionBackendInterface { void ResetStatistics(const int id); void SongPathChanged(const Song &song, const QFileInfo &new_file); + SongList GetSongsBy(const QString &artist, const QString &album, const QString &title); + void UpdateLastPlayed(const QString &artist, const QString &album, const QString &title, const int lastplayed); + void UpdatePlayCount(const QString &artist, const QString &title, const int playcount); + signals: void DirectoryDiscovered(const Directory &dir, const SubdirectoryList &subdirs); void DirectoryDeleted(const Directory &dir); diff --git a/src/core/application.cpp b/src/core/application.cpp index c5b68cd0..af7969f1 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -69,6 +69,7 @@ #include "lyrics/chartlyricsprovider.h" #include "scrobbler/audioscrobbler.h" +#include "scrobbler/lastfmimport.h" #include "internet/internetservices.h" @@ -160,6 +161,7 @@ class ApplicationImpl { return internet_services; }), scrobbler_([=]() { return new AudioScrobbler(app, app); }), + lastfm_import_([=]() { return new LastFMImport(app); }), #ifdef HAVE_MOODBAR moodbar_loader_([=]() { return new MoodbarLoader(app, app); }), @@ -187,6 +189,7 @@ class ApplicationImpl { Lazy lyrics_providers_; Lazy internet_services_; Lazy scrobbler_; + Lazy lastfm_import_; #ifdef HAVE_MOODBAR Lazy moodbar_loader_; Lazy moodbar_controller_; @@ -315,6 +318,7 @@ PlaylistBackend *Application::playlist_backend() const { return p_->playlist_bac PlaylistManager *Application::playlist_manager() const { return p_->playlist_manager_.get(); } InternetServices *Application::internet_services() const { return p_->internet_services_.get(); } AudioScrobbler *Application::scrobbler() const { return p_->scrobbler_.get(); } +LastFMImport *Application::lastfm_import() const { return p_->lastfm_import_.get(); } #ifdef HAVE_MOODBAR MoodbarController *Application::moodbar_controller() const { return p_->moodbar_controller_.get(); } MoodbarLoader *Application::moodbar_loader() const { return p_->moodbar_loader_.get(); } diff --git a/src/core/application.h b/src/core/application.h index 556ea0e9..80b8f42b 100644 --- a/src/core/application.h +++ b/src/core/application.h @@ -56,6 +56,7 @@ class CurrentAlbumCoverLoader; class CoverProviders; class LyricsProviders; class AudioScrobbler; +class LastFMImport; class InternetServices; #ifdef HAVE_MOODBAR class MoodbarController; @@ -93,6 +94,7 @@ class Application : public QObject { LyricsProviders *lyrics_providers() const; AudioScrobbler *scrobbler() const; + LastFMImport *lastfm_import() const; InternetServices *internet_services() const; diff --git a/src/core/mainwindow.cpp b/src/core/mainwindow.cpp index 730e88ec..700c1df6 100644 --- a/src/core/mainwindow.cpp +++ b/src/core/mainwindow.cpp @@ -103,6 +103,7 @@ #include "dialogs/edittagdialog.h" #include "dialogs/addstreamdialog.h" #include "dialogs/deleteconfirmationdialog.h" +#include "dialogs/lastfmimportdialog.h" #include "organize/organizedialog.h" #include "widgets/fancytabwidget.h" #include "widgets/playingwidget.h" @@ -169,6 +170,7 @@ #include "internet/internetsearchview.h" #include "scrobbler/audioscrobbler.h" +#include "scrobbler/lastfmimport.h" #if defined(HAVE_GSTREAMER) && defined(HAVE_CHROMAPRINT) # include "musicbrainz/tagfetcher.h" @@ -257,6 +259,7 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSDBase *osd #ifdef HAVE_TIDAL tidal_view_(new InternetTabsView(app_, app->internet_services()->ServiceBySource(Song::Source_Tidal), TidalSettingsPage::kSettingsGroup, SettingsDialog::Page_Tidal, this)), #endif + lastfm_import_dialog_(new LastFMImportDialog(app_->lastfm_import(), this)), collection_show_all_(nullptr), collection_show_duplicates_(nullptr), collection_show_untagged_(nullptr), @@ -417,6 +420,7 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSDBase *osd ui_->action_update_collection->setIcon(IconLoader::Load("view-refresh")); ui_->action_full_collection_scan->setIcon(IconLoader::Load("view-refresh")); ui_->action_settings->setIcon(IconLoader::Load("configure")); + ui_->action_import_data_from_last_fm->setIcon(IconLoader::Load("scrobble")); // Scrobble @@ -457,6 +461,7 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSDBase *osd connect(ui_->action_auto_complete_tags, SIGNAL(triggered()), SLOT(AutoCompleteTags())); #endif connect(ui_->action_settings, SIGNAL(triggered()), SLOT(OpenSettingsDialog())); + connect(ui_->action_import_data_from_last_fm, SIGNAL(triggered()), lastfm_import_dialog_, SLOT(show())); connect(ui_->action_toggle_show_sidebar, SIGNAL(toggled(bool)), SLOT(ToggleSidebar(bool))); connect(ui_->action_about_strawberry, SIGNAL(triggered()), SLOT(ShowAboutDialog())); connect(ui_->action_about_qt, SIGNAL(triggered()), qApp, SLOT(aboutQt())); @@ -822,6 +827,12 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSDBase *osd LoveButtonVisibilityChanged(app_->scrobbler()->LoveButton()); ScrobblingEnabledChanged(app_->scrobbler()->IsEnabled()); + // Last.fm ImportData + connect(app_->lastfm_import(), SIGNAL(Finished()), lastfm_import_dialog_, SLOT(Finished())); + connect(app_->lastfm_import(), SIGNAL(FinishedWithError(QString)), lastfm_import_dialog_, SLOT(FinishedWithError(QString))); + connect(app_->lastfm_import(), SIGNAL(UpdateTotal(int, int)), lastfm_import_dialog_, SLOT(UpdateTotal(int, int))); + connect(app_->lastfm_import(), SIGNAL(UpdateProgress(int, int)), lastfm_import_dialog_, SLOT(UpdateProgress(int, int))); + // Load settings qLog(Debug) << "Loading settings"; settings_.beginGroup(kSettingsGroup); diff --git a/src/core/mainwindow.h b/src/core/mainwindow.h index 2c1ff84e..158eb505 100644 --- a/src/core/mainwindow.h +++ b/src/core/mainwindow.h @@ -96,6 +96,7 @@ class InternetTabsView; class Windows7ThumbBar; #endif class AddStreamDialog; +class LastFMImportDialog; class MainWindow : public QMainWindow, public PlatformInterface { Q_OBJECT @@ -324,6 +325,8 @@ class MainWindow : public QMainWindow, public PlatformInterface { InternetSongsView *subsonic_view_; InternetTabsView *tidal_view_; + LastFMImportDialog *lastfm_import_dialog_; + QAction *collection_show_all_; QAction *collection_show_duplicates_; QAction *collection_show_untagged_; diff --git a/src/core/mainwindow.ui b/src/core/mainwindow.ui index 7b84709f..f9b1af2a 100644 --- a/src/core/mainwindow.ui +++ b/src/core/mainwindow.ui @@ -511,6 +511,7 @@ + @@ -844,6 +845,11 @@ Show sidebar + + + Import data from last.fm... + + diff --git a/src/dialogs/lastfmimportdialog.cpp b/src/dialogs/lastfmimportdialog.cpp new file mode 100644 index 00000000..24b886e4 --- /dev/null +++ b/src/dialogs/lastfmimportdialog.cpp @@ -0,0 +1,178 @@ +/* + * Strawberry Music Player + * Copyright 2020, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "lastfmimportdialog.h" +#include "ui_lastfmimportdialog.h" + +#include "core/iconloader.h" +#include "scrobbler/lastfmimport.h" + +LastFMImportDialog::LastFMImportDialog(LastFMImport *lastfm_import, QWidget *parent) : QDialog(parent), + ui_(new Ui_LastFMImportDialog), + lastfm_import_(lastfm_import), + finished_(false), + playcount_total_(0), + lastplayed_total_(0) + { + + ui_->setupUi(this); + + setWindowIcon(IconLoader::Load("scrobble")); + + ui_->stackedWidget->setCurrentWidget(ui_->page_start); + + Reset(); + + connect(ui_->button_close, SIGNAL(clicked()), SLOT(hide())); + connect(ui_->button_go, SIGNAL(clicked()), SLOT(Start())); + connect(ui_->button_cancel, SIGNAL(clicked()), SLOT(Cancel())); + + connect(ui_->checkbox_last_played, SIGNAL(stateChanged(int)), SLOT(UpdateGoButtonState())); + connect(ui_->checkbox_playcounts, SIGNAL(stateChanged(int)), SLOT(UpdateGoButtonState())); + +} + +LastFMImportDialog::~LastFMImportDialog() { delete ui_; } + +void LastFMImportDialog::showEvent(QShowEvent*) { + + if (ui_->stackedWidget->currentWidget() == ui_->page_start) { + Reset(); + } + +} + +void LastFMImportDialog::closeEvent(QCloseEvent*) { + + if (ui_->stackedWidget->currentWidget() == ui_->page_progress && finished_) { + finished_ = false; + Reset(); + ui_->stackedWidget->setCurrentWidget(ui_->page_start); + } + +} + +void LastFMImportDialog::Start() { + + if (ui_->stackedWidget->currentWidget() == ui_->page_start && (ui_->checkbox_last_played->isChecked() || ui_->checkbox_playcounts->isChecked())) { + ui_->stackedWidget->setCurrentWidget(ui_->page_progress); + ui_->button_go->hide(); + ui_->button_cancel->show(); + ui_->label_progress_top->setText(tr("Receiving initial data from last.fm...")); + lastfm_import_->ImportData(ui_->checkbox_last_played->isChecked(), ui_->checkbox_playcounts->isChecked()); + } + +} + +void LastFMImportDialog::Cancel() { + + if (ui_->stackedWidget->currentWidget() == ui_->page_progress) { + lastfm_import_->AbortAll(); + ui_->stackedWidget->setCurrentWidget(ui_->page_start); + Reset(); + } + +} + +void LastFMImportDialog::Reset() { + + ui_->button_go->show(); + ui_->button_cancel->hide(); + + playcount_total_ = 0; + lastplayed_total_ = 0; + + ui_->progressbar->setValue(0); + ui_->label_progress_top->clear(); + ui_->label_progress_bottom->clear(); + + UpdateGoButtonState(); + +} + +void LastFMImportDialog::UpdateTotal(const int lastplayed_total, const int playcount_total) { + + if (ui_->stackedWidget->currentWidget() != ui_->page_progress) return; + + playcount_total_ = playcount_total; + lastplayed_total_ = lastplayed_total; + + if (lastplayed_total > 0 && playcount_total > 0) { + ui_->label_progress_top->setText(tr("Receiving playcount for %1 songs and last played for %2 songs.").arg(playcount_total).arg(lastplayed_total)); + } + else if (lastplayed_total > 0) { + ui_->label_progress_top->setText(tr("Receiving last played for %1 songs.").arg(lastplayed_total)); + } + else if (playcount_total > 0) { + ui_->label_progress_top->setText(tr("Receiving playcounts for %1 songs.").arg(playcount_total)); + } + else { + ui_->label_progress_top->clear(); + } + + ui_->label_progress_bottom->clear(); + +} + +void LastFMImportDialog::UpdateProgress(const int lastplayed_received, const int playcount_received) { + + if (ui_->stackedWidget->currentWidget() != ui_->page_progress) return; + + ui_->progressbar->setValue(static_cast(static_cast(playcount_received + lastplayed_received) / static_cast(playcount_total_ + lastplayed_total_) * 100.0)); + + if (lastplayed_received > 0 && playcount_received > 0) { + ui_->label_progress_bottom->setText(tr("Playcounts for %1 songs and last played for %2 songs received.").arg(playcount_received).arg(lastplayed_received)); + } + else if (lastplayed_received > 0) { + ui_->label_progress_bottom->setText(tr("Last played for %1 songs received.").arg(lastplayed_received)); + } + else if (playcount_received > 0) { + ui_->label_progress_bottom->setText(tr("Playcounts for %1 songs received.").arg(playcount_received)); + } + else { + ui_->label_progress_bottom->clear(); + } + +} + +void LastFMImportDialog::Finished() { + + ui_->button_cancel->hide(); + finished_ = true; + +} + +void LastFMImportDialog::FinishedWithError(const QString &error) { + + Finished(); + ui_->label_progress_bottom->setText(error); + +} + +void LastFMImportDialog::UpdateGoButtonState() { + ui_->button_go->setEnabled(ui_->checkbox_last_played->isChecked() || ui_->checkbox_playcounts->isChecked()); +} diff --git a/src/dialogs/lastfmimportdialog.h b/src/dialogs/lastfmimportdialog.h new file mode 100644 index 00000000..57f297ea --- /dev/null +++ b/src/dialogs/lastfmimportdialog.h @@ -0,0 +1,68 @@ +/* + * Strawberry Music Player + * Copyright 2020, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef LASTFMIMPORTDIALOG_H +#define LASTFMIMPORTDIALOG_H + +#include "config.h" + +#include +#include +#include + +#include "ui_lastfmimportdialog.h" + +class QShowEvent; +class QCloseEvent; +class LastFMImport; + +class LastFMImportDialog : public QDialog { + Q_OBJECT + + public: + explicit LastFMImportDialog(LastFMImport *lastfm_import, QWidget *parent = nullptr); + ~LastFMImportDialog() override; + + protected: + void showEvent(QShowEvent*); + void closeEvent(QCloseEvent*); + + private: + void Reset(); + + private slots: + void Start(); + void Cancel(); + void UpdateGoButtonState(); + + void UpdateTotal(const int lastplayed_total, const int playcount_total); + void UpdateProgress(const int lastplayed_received, const int playcount_received); + void Finished(); + void FinishedWithError(const QString &error); + + private: + Ui_LastFMImportDialog *ui_; + LastFMImport *lastfm_import_; + + bool finished_; + int playcount_total_; + int lastplayed_total_; +}; + +#endif // LASTFMIMPORTDIALOG_H diff --git a/src/dialogs/lastfmimportdialog.ui b/src/dialogs/lastfmimportdialog.ui new file mode 100644 index 00000000..12d564af --- /dev/null +++ b/src/dialogs/lastfmimportdialog.ui @@ -0,0 +1,137 @@ + + + LastFMImportDialog + + + + 0 + 0 + 520 + 249 + + + + Import data from last.fm + + + + + + + + + + Choose data to import from last.fm + + + + + + + Last played + + + + + + + Play counts + + + + + + + Warning: Play counts and last played from last.fm will completely replace the same data for the matched songs. Play counts will replace the data based on artist and song title for the same albums! Please backup your database before you start. + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Go! + + + + + + + Close + + + + + + + Cancel + + + + + + + + + + diff --git a/src/scrobbler/lastfmimport.cpp b/src/scrobbler/lastfmimport.cpp new file mode 100644 index 00000000..6f044c75 --- /dev/null +++ b/src/scrobbler/lastfmimport.cpp @@ -0,0 +1,588 @@ +/* + * Strawberry Music Player + * Copyright 2020, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/network.h" +#include "core/timeconstants.h" +#include "core/logging.h" + +#include "lastfmimport.h" + +#include "scrobblingapi20.h" +#include "lastfmscrobbler.h" + +const int LastFMImport::kRequestsDelay = 2000; + +LastFMImport::LastFMImport(QObject *parent) : + QObject(parent), + network_(new NetworkAccessManager(this)), + timer_flush_requests_(new QTimer(this)), + lastplayed_(false), + playcount_(false), + playcount_total_(0), + lastplayed_total_(0), + playcount_received_(0), + lastplayed_received_(0) { + + timer_flush_requests_->setInterval(kRequestsDelay); + timer_flush_requests_->setSingleShot(false); + connect(timer_flush_requests_, SIGNAL(timeout()), this, SLOT(FlushRequests())); + +} + +LastFMImport::~LastFMImport() { + + AbortAll(); + +} + +void LastFMImport::AbortAll() { + + while (!replies_.isEmpty()) { + QNetworkReply *reply = replies_.takeFirst(); + disconnect(reply, nullptr, this, nullptr); + reply->abort(); + reply->deleteLater(); + } + + playcount_total_ = 0; + lastplayed_total_ = 0; + playcount_received_ = 0; + lastplayed_received_ = 0; + + recent_tracks_requests_.clear(); + top_tracks_requests_.clear(); + timer_flush_requests_->stop(); + +} + +void LastFMImport::ReloadSettings() { + + QSettings s; + s.beginGroup(LastFMScrobbler::kSettingsGroup); + username_ = s.value("username").toString(); + s.endGroup(); + +} + +QNetworkReply *LastFMImport::CreateRequest(const ParamList &request_params) { + + ParamList params = ParamList() + << Param("api_key", ScrobblingAPI20::kApiKey) + << Param("user", username_) + << Param("lang", QLocale().name().left(2).toLower()) + << Param("format", "json") + << request_params; + + std::sort(params.begin(), params.end()); + + QUrlQuery url_query; + for (const Param ¶m : params) { + url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + } + + QUrl url(LastFMScrobbler::kApiUrl); + url.setQuery(url_query); + QNetworkRequest req(url); +#if QT_VERSION >= QT_VERSION_CHECK(5, 9, 0) + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); +#else + req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); +#endif + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + + QNetworkReply *reply = network_->get(req); + replies_ << reply; + + //qLog(Debug) << "Sending request" << url_query.toString(QUrl::FullyDecoded); + + return reply; + +} + +QByteArray LastFMImport::GetReplyData(QNetworkReply *reply) { + + QByteArray data; + + if (reply->error() == QNetworkReply::NoError && reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 200) { + data = reply->readAll(); + } + else { + if (reply->error() != QNetworkReply::NoError && reply->error() < 200) { + // This is a network error, there is nothing more to do. + Error(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + } + else { + QString error; + // See if there is Json data containing "error" and "message" - then use that instead. + data = reply->readAll(); + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + int error_code = -1; + if (json_error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (json_obj.contains("error") && json_obj.contains("message")) { + error_code = json_obj["error"].toInt(); + QString error_message = json_obj["message"].toString(); + error = QString("%1 (%2)").arg(error_message).arg(error_code); + } + } + if (error.isEmpty()) { + if (reply->error() != QNetworkReply::NoError) { + error = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + else { + error = QString("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()); + } + } + Error(error); + } + return QByteArray(); + } + + return data; + +} + +QJsonObject LastFMImport::ExtractJsonObj(const QByteArray &data) { + + QJsonParseError error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &error); + + if (error.error != QJsonParseError::NoError) { + Error("Reply from server missing Json data.", data); + return QJsonObject(); + } + if (json_doc.isNull() || json_doc.isEmpty()) { + Error("Received empty Json document.", json_doc); + return QJsonObject(); + } + if (!json_doc.isObject()) { + Error("Json document is not an object.", json_doc); + return QJsonObject(); + } + QJsonObject json_obj = json_doc.object(); + if (json_obj.isEmpty()) { + Error("Received empty Json object.", json_doc); + return QJsonObject(); + } + + return json_obj; + +} + +void LastFMImport::ImportData(const bool lastplayed, const bool playcount) { + + if (!lastplayed && !playcount) return; + + ReloadSettings(); + + if (username_.isEmpty()) { + Error(tr("Missing username, please login to last.fm first!")); + return; + } + + AbortAll(); + + lastplayed_ = lastplayed; + playcount_ = playcount; + + if (lastplayed) AddGetRecentTracksRequest(0); + if (playcount) AddGetTopTracksRequest(0); + +} + +void LastFMImport::FlushRequests() { + + if (!recent_tracks_requests_.isEmpty()) { + SendGetRecentTracksRequest(recent_tracks_requests_.dequeue()); + return; + } + + if (!top_tracks_requests_.isEmpty()) { + SendGetTopTracksRequest(top_tracks_requests_.dequeue()); + return; + } + + timer_flush_requests_->stop(); + +} + +void LastFMImport::AddGetRecentTracksRequest(const int page) { + + recent_tracks_requests_.enqueue(GetRecentTracksRequest(page)); + + if (!timer_flush_requests_->isActive()) { + timer_flush_requests_->start(); + } + +} + +void LastFMImport::SendGetRecentTracksRequest(GetRecentTracksRequest request) { + + ParamList params = ParamList() << Param("method", "user.getRecentTracks"); + + if (request.page == 0) { + params << Param("page", "1"); + params << Param("limit", "1"); + } + else { + params << Param("page", QString::number(request.page)); + params << Param("limit", "500"); + } + + QNetworkReply *reply = CreateRequest(params); + connect(reply, &QNetworkReply::finished, [=] { GetRecentTracksRequestFinished(reply, request.page); }); + +} + +void LastFMImport::GetRecentTracksRequestFinished(QNetworkReply *reply, const int page) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + + QByteArray data = GetReplyData(reply); + if (data.isEmpty()) { + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + return; + } + + if (json_obj.contains("error") && json_obj.contains("message")) { + int error_code = json_obj["error"].toInt(); + QString error_message = json_obj["message"].toString(); + QString error_reason = QString("%1 (%2)").arg(error_message).arg(error_code); + Error(error_reason); + return; + } + + if (!json_obj.contains("recenttracks")) { + Error("JSON reply from server is missing recenttracks.", json_obj); + return; + } + + if (!json_obj["recenttracks"].isObject()) { + Error("Failed to pase JSON: recenttracks is not an object!", json_obj); + return; + } + json_obj = json_obj["recenttracks"].toObject(); + + if (!json_obj.contains("@attr")) { + Error("JSON reply from server is missing @attr.", json_obj); + return; + } + + if (!json_obj.contains("track")) { + Error("JSON reply from server is missing track.", json_obj); + return; + } + + if (!json_obj["@attr"].isObject()) { + Error("Failed to pase JSON: @attr is not an object.", json_obj); + return; + } + + if (!json_obj["track"].isArray()) { + Error("Failed to pase JSON: track is not an object.", json_obj); + return; + } + + QJsonObject obj_attr = json_obj["@attr"].toObject(); + + if (!obj_attr.contains("page")) { + Error("Failed to pase JSON: attr object is missing page.", json_obj); + return; + } + if (!obj_attr.contains("totalPages")) { + Error("Failed to pase JSON: attr object is missing totalPages.", json_obj); + return; + } + if (!obj_attr.contains("total")) { + Error("Failed to pase JSON: attr object is missing total.", json_obj); + return; + } + + int total = obj_attr["total"].toString().toInt(); + int pages = obj_attr["totalPages"].toString().toInt(); + + if (page == 0) { + lastplayed_total_ = total; + UpdateTotal(); + AddGetRecentTracksRequest(1); + } + else { + + QJsonArray array_track = json_obj["track"].toArray(); + + for (const QJsonValue &value_track : array_track) { + + ++lastplayed_received_; + + if (!value_track.isObject()) { + continue; + } + QJsonObject obj_track = value_track.toObject(); + if (!obj_track.contains("artist") || + !obj_track.contains("album") || + !obj_track.contains("name") || + !obj_track.contains("date") || + !obj_track["artist"].isObject() || + !obj_track["album"].isObject() || + !obj_track["date"].isObject() + ) { + continue; + } + + QJsonObject obj_artist = obj_track["artist"].toObject(); + QJsonObject obj_album = obj_track["album"].toObject(); + QJsonObject obj_date = obj_track["date"].toObject(); + + if (!obj_artist.contains("#text") || !obj_album.contains("#text") || !obj_date.contains("#text")) { + continue; + } + + QString artist = obj_artist["#text"].toString(); + QString album = obj_album["#text"].toString(); + QString date = obj_date["#text"].toString(); + QString title = obj_track["name"].toString(); + QDateTime datetime = QDateTime::fromString(date, "dd MMM yyyy, hh:mm"); + + emit UpdateLastPlayed(artist, album, title, datetime.toSecsSinceEpoch()); + UpdateProgress(); + + } + + if (page == 1) { + for (int i = 2 ; i <= pages ; ++i) { + AddGetRecentTracksRequest(i); + } + } + + } + + FinishCheck(); + +} + +void LastFMImport::AddGetTopTracksRequest(const int page) { + + top_tracks_requests_.enqueue(GetTopTracksRequest(page)); + + if (!timer_flush_requests_->isActive()) { + timer_flush_requests_->start(); + } + +} + +void LastFMImport::SendGetTopTracksRequest(GetTopTracksRequest request) { + + ParamList params = ParamList() << Param("method", "user.getTopTracks"); + + if (request.page == 0) { + params << Param("page", "1"); + params << Param("limit", "1"); + } + else { + params << Param("page", QString::number(request.page)); + params << Param("limit", "500"); + } + + QNetworkReply *reply = CreateRequest(params); + connect(reply, &QNetworkReply::finished, [=] { GetTopTracksRequestFinished(reply, request.page); }); + +} + +void LastFMImport::GetTopTracksRequestFinished(QNetworkReply *reply, const int page) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + + QByteArray data = GetReplyData(reply); + if (data.isEmpty()) { + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + return; + } + + if (json_obj.contains("error") && json_obj.contains("message")) { + int error_code = json_obj["error"].toInt(); + QString error_message = json_obj["message"].toString(); + QString error_reason = QString("%1 (%2)").arg(error_message).arg(error_code); + Error(error_reason); + return; + } + + if (!json_obj.contains("toptracks")) { + Error("JSON reply from server is missing toptracks.", json_obj); + return; + } + + if (!json_obj["toptracks"].isObject()) { + Error("Failed to pase JSON: toptracks is not an object!", json_obj); + return; + } + json_obj = json_obj["toptracks"].toObject(); + + if (!json_obj.contains("@attr")) { + Error("JSON reply from server is missing @attr.", json_obj); + return; + } + + if (!json_obj.contains("track")) { + Error("JSON reply from server is missing track.", json_obj); + return; + } + + if (!json_obj["@attr"].isObject()) { + Error("Failed to pase JSON: @attr is not an object.", json_obj); + return; + } + + if (!json_obj["track"].isArray()) { + Error("Failed to pase JSON: track is not an object.", json_obj); + return; + } + + QJsonObject obj_attr = json_obj["@attr"].toObject(); + + if (!obj_attr.contains("page")) { + Error("Failed to pase JSON: attr object is missing page.", json_obj); + return; + } + if (!obj_attr.contains("totalPages")) { + Error("Failed to pase JSON: attr object is missing page.", json_obj); + return; + } + if (!obj_attr.contains("total")) { + Error("Failed to pase JSON: attr object is missing total.", json_obj); + return; + } + + int pages = obj_attr["totalPages"].toString().toInt(); + int total = obj_attr["total"].toString().toInt(); + + if (page == 0) { + playcount_total_ = total; + UpdateTotal(); + AddGetTopTracksRequest(1); + } + else { + + QJsonArray array_track = json_obj["track"].toArray(); + for (const QJsonValue &value_track : array_track) { + + ++playcount_received_; + + if (!value_track.isObject()) { + continue; + } + + QJsonObject obj_track = value_track.toObject(); + if (!obj_track.contains("artist") || + !obj_track.contains("name") || + !obj_track.contains("playcount") || + !obj_track["artist"].isObject() + ) { + continue; + } + + QJsonObject obj_artist = obj_track["artist"].toObject(); + if (!obj_artist.contains("name")) { + continue; + } + + QString artist = obj_artist["name"].toString(); + QString title = obj_track["name"].toString(); + int playcount = obj_track["playcount"].toString().toInt(); + + if (playcount <= 0) continue; + + emit UpdatePlayCount(artist, title, playcount); + UpdateProgress(); + + } + + if (page == 1) { + for (int i = 2 ; i <= pages ; ++i) { + AddGetTopTracksRequest(i); + } + } + + } + + FinishCheck(); + +} + +void LastFMImport::UpdateTotal() { + + if ((!playcount_ || playcount_total_ > 0) && (!lastplayed_ || lastplayed_total_ > 0)) + emit UpdateTotal(lastplayed_total_, playcount_total_); + +} + +void LastFMImport::UpdateProgress() { + emit UpdateProgress(lastplayed_received_, playcount_received_); +} + +void LastFMImport::FinishCheck() { + if (replies_.isEmpty() && recent_tracks_requests_.isEmpty() && top_tracks_requests_.isEmpty()) emit Finished(); +} + +void LastFMImport::Error(const QString &error, const QVariant &debug) { + + qLog(Error) << error; + if (debug.isValid()) qLog(Debug) << debug; + + emit FinishedWithError(error); + + AbortAll(); + +} diff --git a/src/scrobbler/lastfmimport.h b/src/scrobbler/lastfmimport.h new file mode 100644 index 00000000..9ed60a19 --- /dev/null +++ b/src/scrobbler/lastfmimport.h @@ -0,0 +1,113 @@ +/* + * Strawberry Music Player + * Copyright 2020, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef LASTFMIMPORT_H +#define LASTFMIMPORT_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +class QTimer; +class QNetworkReply; + +class NetworkAccessManager; + +class LastFMImport : public QObject { + Q_OBJECT + + public: + explicit LastFMImport(QObject *parent = nullptr); + ~LastFMImport() override; + + void ReloadSettings(); + void ImportData(const bool lastplayed = true, const bool playcount = true); + void AbortAll(); + + private: + typedef QPair Param; + typedef QList ParamList; + + struct GetRecentTracksRequest { + explicit GetRecentTracksRequest(const int _page) : page(_page) {} + int page; + }; + struct GetTopTracksRequest { + explicit GetTopTracksRequest(const int _page) : page(_page) {} + int page; + }; + + private: + QNetworkReply *CreateRequest(const ParamList &request_params); + QByteArray GetReplyData(QNetworkReply *reply); + QJsonObject ExtractJsonObj(const QByteArray &data); + + void AddGetRecentTracksRequest(const int page = 0); + void AddGetTopTracksRequest(const int page = 0); + + void SendGetRecentTracksRequest(GetRecentTracksRequest request); + void SendGetTopTracksRequest(GetTopTracksRequest request); + + void Error(const QString &error, const QVariant &debug = QVariant()); + + void UpdateTotal(); + void UpdateProgress(); + + void FinishCheck(); + + signals: + void UpdatePlayCount(QString, QString, int); + void UpdateLastPlayed(QString, QString, QString, int); + void UpdateTotal(int, int); + void UpdateProgress(int, int); + void Finished(); + void FinishedWithError(QString); + + private slots: + void FlushRequests(); + void GetRecentTracksRequestFinished(QNetworkReply *reply, const int page); + void GetTopTracksRequestFinished(QNetworkReply *reply, const int page); + + private: + static const int kRequestsDelay; + + NetworkAccessManager *network_; + QTimer *timer_flush_requests_; + + QString username_; + bool lastplayed_; + bool playcount_; + int playcount_total_; + int lastplayed_total_; + int playcount_received_; + int lastplayed_received_; + QQueue recent_tracks_requests_; + QQueue top_tracks_requests_; + QList replies_; + +}; + +#endif // LASTFMIMPORT_H diff --git a/src/scrobbler/lastfmscrobbler.h b/src/scrobbler/lastfmscrobbler.h index afb6a5cc..5b4053e2 100644 --- a/src/scrobbler/lastfmscrobbler.h +++ b/src/scrobbler/lastfmscrobbler.h @@ -42,13 +42,13 @@ class LastFMScrobbler : public ScrobblingAPI20 { static const char *kName; static const char *kSettingsGroup; + static const char *kApiUrl; NetworkAccessManager *network() const override { return network_; } ScrobblerCache *cache() const override { return cache_; } private: static const char *kAuthUrl; - static const char *kApiUrl; static const char *kCacheFile; QString settings_group_; diff --git a/src/scrobbler/scrobblingapi20.cpp b/src/scrobbler/scrobblingapi20.cpp index a4ea6563..9d143eba 100644 --- a/src/scrobbler/scrobblingapi20.cpp +++ b/src/scrobbler/scrobblingapi20.cpp @@ -1013,7 +1013,7 @@ void ScrobblingAPI20::Error(const QString &error, const QVariant &debug) { } -QString ScrobblingAPI20::ErrorString(const ScrobbleErrorCode error) const { +QString ScrobblingAPI20::ErrorString(const ScrobbleErrorCode error) { switch (error) { case ScrobbleErrorCode::NoError: diff --git a/src/scrobbler/scrobblingapi20.h b/src/scrobbler/scrobblingapi20.h index d0d829a5..c43cb228 100644 --- a/src/scrobbler/scrobblingapi20.h +++ b/src/scrobbler/scrobblingapi20.h @@ -47,6 +47,7 @@ class ScrobblingAPI20 : public ScrobblerService { explicit ScrobblingAPI20(const QString &name, const QString &settings_group, const QString &auth_url, const QString &api_url, const bool batch, Application *app, QObject *parent = nullptr); ~ScrobblingAPI20() override; + static const char *kApiKey; static const char *kRedirectUrl; void ReloadSettings() override; @@ -118,7 +119,6 @@ class ScrobblingAPI20 : public ScrobblerService { RateLimitExceeded = 29, }; - static const char *kApiKey; static const char *kSecret; static const int kScrobblesPerRequest; @@ -129,7 +129,7 @@ class ScrobblingAPI20 : public ScrobblerService { void AuthError(const QString &error); void SendSingleScrobble(ScrobblerCacheItemPtr item); void Error(const QString &error, const QVariant &debug = QVariant()) override; - QString ErrorString(const ScrobbleErrorCode error) const; + static QString ErrorString(const ScrobbleErrorCode error); void DoSubmit() override; void CheckScrobblePrevSong();