From f861f4e802528ffa8443d12400d21d5cb0bdeeff Mon Sep 17 00:00:00 2001 From: Bart De Vries Date: Fri, 29 Oct 2021 17:00:52 +0200 Subject: [PATCH] Add podcast subscription and episode play state synchronization This implements the gpodder API from scratch. It turned out that libmygpo-qt has several critical bugs, and there's no response to pull requests upstream. So using that library was not an option. The implementation into kasts consists of the following: - Can sync with gpodder.net or with a nextcloud server that has the nextcloud-gpodder app installed. (This app is mostly API compatible with gpodder.) - Passwords are stored using qtkeychain. If the keychain is unavailable it will fallback to file. - It syncs podcast subscriptions and episode play positions, including marking episodes as played. Episodes that have a non-zero play position will be added to the queue automatically. - It will check for a metered connection before syncing. This is coupled to the allowMeteredFeedUpdates setting. - Full synchronization can be performed either manually (from the settings page) or through automatic triggers: on startup and/or on feed refresh. - There is an additional possibility to trigger quick upload-only syncs to make sure that the local changes are immediately uploaded to the server (if the connection allows). This will trigger when subscriptions are added or removed, when the pause/play button is toggled or an episode is marked as played. - This implements a few safeguards to avoid having multiple feed URLS pointing to the same underlying feed (e.g. http vs https). This solves part of #17 Solves #13 --- CMakeLists.txt | 5 + README.md | 1 + src/CMakeLists.txt | 33 +- src/audiomanager.cpp | 7 +- src/audiomanager.h | 5 + src/database.cpp | 16 + src/database.h | 1 + src/datamanager.cpp | 284 ++++-- src/datamanager.h | 9 +- src/enclosure.cpp | 10 + src/enclosure.h | 3 +- src/entry.cpp | 3 + src/error.cpp | 6 + src/error.h | 1 + src/fetcher.cpp | 38 +- src/fetcher.h | 1 + src/fetchfeedsjob.cpp | 20 +- src/fetchfeedsjob.h | 6 +- src/main.cpp | 6 + src/qml/DiscoverPage.qml | 2 +- src/qml/ErrorListOverlay.qml | 1 - src/qml/ErrorNotification.qml | 2 +- src/qml/FeedDetailsPage.qml | 2 +- src/qml/FeedListDelegate.qml | 2 +- src/qml/FeedListPage.qml | 2 +- src/qml/Settings/SettingsPage.qml | 5 + .../Settings/SynchronizationSettingsPage.qml | 463 +++++++++ src/qml/SyncPasswordOverlay.qml | 73 ++ src/qml/UpdateNotification.qml | 5 +- src/qml/main.qml | 45 +- src/resources.qrc | 2 + src/settingsmanager.kcfg | 34 + src/storagemanager.cpp | 5 + src/storagemanager.h | 2 + src/sync/gpodder/devicerequest.cpp | 54 + src/sync/gpodder/devicerequest.h | 29 + src/sync/gpodder/episodeactionrequest.cpp | 111 ++ src/sync/gpodder/episodeactionrequest.h | 31 + src/sync/gpodder/genericrequest.cpp | 49 + src/sync/gpodder/genericrequest.h | 40 + src/sync/gpodder/gpodder.cpp | 276 +++++ src/sync/gpodder/gpodder.h | 55 + src/sync/gpodder/loginrequest.cpp | 35 + src/sync/gpodder/loginrequest.h | 28 + src/sync/gpodder/logoutrequest.cpp | 35 + src/sync/gpodder/logoutrequest.h | 28 + src/sync/gpodder/subscriptionrequest.cpp | 65 ++ src/sync/gpodder/subscriptionrequest.h | 33 + src/sync/gpodder/syncrequest.cpp | 62 ++ src/sync/gpodder/syncrequest.h | 32 + src/sync/gpodder/updatedevicerequest.cpp | 36 + src/sync/gpodder/updatedevicerequest.h | 28 + src/sync/gpodder/updatesyncrequest.cpp | 68 ++ src/sync/gpodder/updatesyncrequest.h | 34 + .../gpodder/uploadepisodeactionrequest.cpp | 58 ++ src/sync/gpodder/uploadepisodeactionrequest.h | 33 + .../gpodder/uploadsubscriptionrequest.cpp | 58 ++ src/sync/gpodder/uploadsubscriptionrequest.h | 33 + src/sync/sync.cpp | 945 ++++++++++++++++++ src/sync/sync.h | 146 +++ src/sync/syncjob.cpp | 873 ++++++++++++++++ src/sync/syncjob.h | 99 ++ src/sync/syncutils.cpp | 7 + src/sync/syncutils.h | 64 ++ src/updatefeedjob.cpp | 10 +- 65 files changed, 4397 insertions(+), 158 deletions(-) create mode 100644 src/qml/Settings/SynchronizationSettingsPage.qml create mode 100644 src/qml/SyncPasswordOverlay.qml create mode 100644 src/sync/gpodder/devicerequest.cpp create mode 100644 src/sync/gpodder/devicerequest.h create mode 100644 src/sync/gpodder/episodeactionrequest.cpp create mode 100644 src/sync/gpodder/episodeactionrequest.h create mode 100644 src/sync/gpodder/genericrequest.cpp create mode 100644 src/sync/gpodder/genericrequest.h create mode 100644 src/sync/gpodder/gpodder.cpp create mode 100644 src/sync/gpodder/gpodder.h create mode 100644 src/sync/gpodder/loginrequest.cpp create mode 100644 src/sync/gpodder/loginrequest.h create mode 100644 src/sync/gpodder/logoutrequest.cpp create mode 100644 src/sync/gpodder/logoutrequest.h create mode 100644 src/sync/gpodder/subscriptionrequest.cpp create mode 100644 src/sync/gpodder/subscriptionrequest.h create mode 100644 src/sync/gpodder/syncrequest.cpp create mode 100644 src/sync/gpodder/syncrequest.h create mode 100644 src/sync/gpodder/updatedevicerequest.cpp create mode 100644 src/sync/gpodder/updatedevicerequest.h create mode 100644 src/sync/gpodder/updatesyncrequest.cpp create mode 100644 src/sync/gpodder/updatesyncrequest.h create mode 100644 src/sync/gpodder/uploadepisodeactionrequest.cpp create mode 100644 src/sync/gpodder/uploadepisodeactionrequest.h create mode 100644 src/sync/gpodder/uploadsubscriptionrequest.cpp create mode 100644 src/sync/gpodder/uploadsubscriptionrequest.h create mode 100644 src/sync/sync.cpp create mode 100644 src/sync/sync.h create mode 100644 src/sync/syncjob.cpp create mode 100644 src/sync/syncjob.h create mode 100644 src/sync/syncutils.cpp create mode 100644 src/sync/syncutils.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 1762fc39..36359a08 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,11 @@ ecm_setup_version(${PROJECT_VERSION} find_package(Qt5 ${QT_MIN_VERSION} REQUIRED NO_MODULE COMPONENTS Core Quick Test Gui QuickControls2 Sql Multimedia) find_package(KF5 ${KF5_MIN_VERSION} REQUIRED COMPONENTS CoreAddons Syndication Config I18n) find_package(Taglib REQUIRED) +find_package(Qt5Keychain) +set_package_properties(Qt5Keychain PROPERTIES + TYPE REQUIRED + PURPOSE "Secure storage of account secrets" +) find_package(KF5 ${KF5_MIN_VERSION} OPTIONAL_COMPONENTS NetworkManagerQt) diff --git a/README.md b/README.md index 84fb4f96..8ab56243 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Note: When using versions of kasts built from git-master, it's possible that the - Kirigami - Syndication - TagLib + - QtKeychain ## Linux diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 330dc83f..5c82fcff 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -32,6 +32,21 @@ set(SRCS_base models/podcastsearchmodel.cpp mpris2/mpris2.cpp powermanagementinterface.cpp + sync/sync.cpp + sync/syncjob.cpp + sync/syncutils.cpp + sync/gpodder/gpodder.cpp + sync/gpodder/genericrequest.cpp + sync/gpodder/loginrequest.cpp + sync/gpodder/logoutrequest.cpp + sync/gpodder/devicerequest.cpp + sync/gpodder/syncrequest.cpp + sync/gpodder/updatesyncrequest.cpp + sync/gpodder/updatedevicerequest.cpp + sync/gpodder/subscriptionrequest.cpp + sync/gpodder/uploadsubscriptionrequest.cpp + sync/gpodder/episodeactionrequest.cpp + sync/gpodder/uploadepisodeactionrequest.cpp resources.qrc ) @@ -70,6 +85,13 @@ ecm_qt_declare_logging_category(SRCS_base DEFAULT_SEVERITY Info ) +ecm_qt_declare_logging_category(SRCS_base + HEADER "synclogging.h" + IDENTIFIER "kastsSync" + CATEGORY_NAME "org.kde.kasts.sync" + DEFAULT_SEVERITY Info +) + ecm_qt_declare_logging_category(SRCS_base HEADER "models/downloadmodellogging.h" IDENTIFIER "kastsDownloadModel" @@ -123,7 +145,7 @@ add_executable(kasts ${SRCS}) kconfig_add_kcfg_files(kasts settingsmanager.kcfgc GENERATE_MOC) target_include_directories(kasts PRIVATE ${CMAKE_BINARY_DIR}) -target_link_libraries(kasts PRIVATE Qt::Core Qt::Qml Qt::Quick Qt::QuickControls2 Qt::Sql Qt::Multimedia KF5::Syndication KF5::CoreAddons KF5::ConfigGui KF5::I18n Taglib::Taglib SolidExtras) +target_link_libraries(kasts PRIVATE Qt::Core Qt::Qml Qt::Quick Qt::QuickControls2 Qt::Sql Qt::Multimedia KF5::Syndication KF5::CoreAddons KF5::ConfigGui KF5::I18n Taglib::Taglib SolidExtras ${QTKEYCHAIN_LIBRARIES}) if(ANDROID) target_link_libraries(kasts PRIVATE @@ -175,13 +197,22 @@ if(ANDROID) view-media-playlist source-playlist arrow-down + go-next overflow-menu checkbox error search kt-add-feeds + state-sync network-connect drive-harddisk-symbolic + dialog-ok + dialog-cancel + computer + computer-laptop + network-server-database + smartphone + emblem-music-symbolic ) else() target_link_libraries(kasts PRIVATE Qt::Widgets Qt::DBus) diff --git a/src/audiomanager.cpp b/src/audiomanager.cpp index 4a75d66e..7bc68c95 100644 --- a/src/audiomanager.cpp +++ b/src/audiomanager.cpp @@ -22,11 +22,6 @@ #include "powermanagementinterface.h" #include "settingsmanager.h" -static const double MAX_RATE = 1.0; -static const double MIN_RATE = 2.5; -static const qint64 SKIP_STEP = 10000; -static const qint64 SKIP_TRACK_END = 15000; - class AudioManagerPrivate { private: @@ -214,8 +209,8 @@ void AudioManager::setEntry(Entry *entry) qCDebug(kastsAudio) << "MediaStatus" << d->m_player.mediaStatus(); if (((duration() > 0) && (position() > 0) && ((duration() - position()) < SKIP_TRACK_END)) || (d->m_player.mediaStatus() == QMediaPlayer::EndOfMedia)) { qCDebug(kastsAudio) << "Mark as read:" << oldEntry->title(); - oldEntry->setRead(true); oldEntry->enclosure()->setPlayPosition(0); + oldEntry->setRead(true); d->m_continuePlayback = SettingsManager::self()->continuePlayingNextEntry(); } } diff --git a/src/audiomanager.h b/src/audiomanager.h index d0ad9d35..56cd6d9f 100644 --- a/src/audiomanager.h +++ b/src/audiomanager.h @@ -44,6 +44,11 @@ class AudioManager : public QObject Q_PROPERTY(QString formattedPosition READ formattedPosition NOTIFY positionChanged) public: + const double MAX_RATE = 1.0; + const double MIN_RATE = 2.5; + const qint64 SKIP_STEP = 10000; + const qint64 SKIP_TRACK_END = 15000; + static AudioManager &instance() { static AudioManager _instance; diff --git a/src/database.cpp b/src/database.cpp index cf000119..72c852f1 100644 --- a/src/database.cpp +++ b/src/database.cpp @@ -49,6 +49,8 @@ bool Database::migrate() TRUE_OR_RETURN(migrateTo4()); if (dbversion < 5) TRUE_OR_RETURN(migrateTo5()); + if (dbversion < 6) + TRUE_OR_RETURN(migrateTo6()); return true; } @@ -124,6 +126,20 @@ bool Database::migrateTo5() return true; } +bool Database::migrateTo6() +{ + qDebug() << "Migrating database to version 6"; + TRUE_OR_RETURN(transaction()); + TRUE_OR_RETURN(execute(QStringLiteral("CREATE TABLE IF NOT EXISTS SyncTimestamps (syncservice TEXT, timestamp INTEGER);"))); + TRUE_OR_RETURN(execute(QStringLiteral("CREATE TABLE IF NOT EXISTS FeedActions (url TEXT, action TEXT, timestamp INTEGER);"))); + TRUE_OR_RETURN( + execute(QStringLiteral("CREATE TABLE IF NOT EXISTS EpisodeActions (podcast TEXT, url TEXT, id TEXT, action TEXT, started INTEGER, position INTEGER, " + "total INTEGER, timestamp INTEGER);"))); + TRUE_OR_RETURN(execute(QStringLiteral("PRAGMA user_version = 6;"))); + TRUE_OR_RETURN(commit()); + return true; +} + bool Database::execute(const QString &query) { QSqlQuery q; diff --git a/src/database.h b/src/database.h index ab6d661c..ba7fd728 100644 --- a/src/database.h +++ b/src/database.h @@ -37,5 +37,6 @@ private: bool migrateTo3(); bool migrateTo4(); bool migrateTo5(); + bool migrateTo6(); void cleanup(); }; diff --git a/src/datamanager.cpp b/src/datamanager.cpp index eeddc280..918942bd 100644 --- a/src/datamanager.cpp +++ b/src/datamanager.cpp @@ -23,6 +23,7 @@ #include "fetcher.h" #include "settingsmanager.h" #include "storagemanager.h" +#include "sync/sync.h" DataManager::DataManager() { @@ -191,84 +192,122 @@ int DataManager::newEntryCount(const Feed *feed) const void DataManager::removeFeed(Feed *feed) { - qCDebug(kastsDataManager) << "deleting feed" << feed->url() << "with index" << m_feedmap.indexOf(feed->url()); - removeFeed(m_feedmap.indexOf(feed->url())); + QList feeds; + feeds << feed; + removeFeeds(feeds); } void DataManager::removeFeed(const int index) { // Get feed pointer Feed *feed = getFeed(m_feedmap[index]); - const QString feedurl = feed->url(); + removeFeed(feed); +} - // Delete the object instances and mappings - // First delete entries in Queue - qCDebug(kastsDataManager) << "delete queueentries of" << feedurl; - QStringList removeFromQueueList; - for (auto &id : m_queuemap) { - if (getEntry(id)->feed()->url() == feedurl) { - if (AudioManager::instance().entry() == getEntry(id)) { - AudioManager::instance().next(); - } - removeFromQueueList += id; +void DataManager::removeFeeds(const QStringList &feedurls) +{ + QList feeds; + for (QString feedurl : feedurls) { + feeds << getFeed(feedurl); + } + removeFeeds(feeds); +} + +void DataManager::removeFeeds(const QVariantList feedVariantList) +{ + QList feeds; + for (QVariant feedVariant : feedVariantList) { + if (feedVariant.canConvert()) { + feeds << feedVariant.value(); } } - bulkQueueStatus(false, removeFromQueueList); + removeFeeds(feeds); +} - // Delete entries themselves - qCDebug(kastsDataManager) << "delete entries of" << feedurl; - for (auto &id : m_entrymap[feedurl]) { - if (getEntry(id)->hasEnclosure()) - getEntry(id)->enclosure()->deleteFile(); // delete enclosure (if it exists) - if (!getEntry(id)->image().isEmpty()) - StorageManager::instance().removeImage(getEntry(id)->image()); // delete entry images - delete m_entries[id]; // delete pointer - m_entries.remove(id); // delete the hash key +void DataManager::removeFeeds(const QList &feeds) +{ + for (Feed *feed : feeds) { + const QString feedurl = feed->url(); + int index = m_feedmap.indexOf(feedurl); + + qCDebug(kastsDataManager) << "deleting feed" << feedurl << "with index" << index; + + // Delete the object instances and mappings + // First delete entries in Queue + qCDebug(kastsDataManager) << "delete queueentries of" << feedurl; + QStringList removeFromQueueList; + for (auto &id : m_queuemap) { + if (getEntry(id)->feed()->url() == feedurl) { + if (AudioManager::instance().entry() == getEntry(id)) { + AudioManager::instance().next(); + } + removeFromQueueList += id; + } + } + bulkQueueStatus(false, removeFromQueueList); + + // Delete entries themselves + qCDebug(kastsDataManager) << "delete entries of" << feedurl; + for (auto &id : m_entrymap[feedurl]) { + if (getEntry(id)->hasEnclosure()) + getEntry(id)->enclosure()->deleteFile(); // delete enclosure (if it exists) + if (!getEntry(id)->image().isEmpty()) + StorageManager::instance().removeImage(getEntry(id)->image()); // delete entry images + delete m_entries[id]; // delete pointer + m_entries.remove(id); // delete the hash key + } + m_entrymap.remove(feedurl); // remove all the entry mappings belonging to the feed + + qCDebug(kastsDataManager) << "Remove feed image" << feed->image() << "for feed" << feedurl; + if (!feed->image().isEmpty()) + StorageManager::instance().removeImage(feed->image()); + m_feeds.remove(m_feedmap[index]); // remove from m_feeds + m_feedmap.removeAt(index); // remove from m_feedmap + delete feed; // remove the pointer + + // Then delete everything from the database + qCDebug(kastsDataManager) << "delete database part of" << feedurl; + + // Delete related Errors + QSqlQuery query; + query.prepare(QStringLiteral("DELETE FROM Errors WHERE url=:url;")); + query.bindValue(QStringLiteral(":url"), feedurl); + Database::instance().execute(query); + + // Delete Authors + query.prepare(QStringLiteral("DELETE FROM Authors WHERE feed=:feed;")); + query.bindValue(QStringLiteral(":feed"), feedurl); + Database::instance().execute(query); + + // Delete Chapters + query.prepare(QStringLiteral("DELETE FROM Chapters WHERE feed=:feed;")); + query.bindValue(QStringLiteral(":feed"), feedurl); + Database::instance().execute(query); + + // Delete Entries + query.prepare(QStringLiteral("DELETE FROM Entries WHERE feed=:feed;")); + query.bindValue(QStringLiteral(":feed"), feedurl); + Database::instance().execute(query); + + // Delete Enclosures + query.prepare(QStringLiteral("DELETE FROM Enclosures WHERE feed=:feed;")); + query.bindValue(QStringLiteral(":feed"), feedurl); + Database::instance().execute(query); + + // Delete Feed + query.prepare(QStringLiteral("DELETE FROM Feeds WHERE url=:url;")); + query.bindValue(QStringLiteral(":url"), feedurl); + Database::instance().execute(query); + + // Save this action to the database (including timestamp) in order to be + // able to sync with remote services + Sync::instance().storeRemoveFeedAction(feedurl); + + Q_EMIT feedRemoved(index); } - m_entrymap.remove(feedurl); // remove all the entry mappings belonging to the feed - qCDebug(kastsDataManager) << "Remove feed image" << feed->image() << "for feed" << feedurl; - if (!feed->image().isEmpty()) - StorageManager::instance().removeImage(feed->image()); - m_feeds.remove(m_feedmap[index]); // remove from m_feeds - m_feedmap.removeAt(index); // remove from m_feedmap - delete feed; // remove the pointer - - // Then delete everything from the database - qCDebug(kastsDataManager) << "delete database part of" << feedurl; - - // Delete related Errors - QSqlQuery query; - query.prepare(QStringLiteral("DELETE FROM Errors WHERE url=:url;")); - query.bindValue(QStringLiteral(":url"), feedurl); - Database::instance().execute(query); - - // Delete Authors - query.prepare(QStringLiteral("DELETE FROM Authors WHERE feed=:feed;")); - query.bindValue(QStringLiteral(":feed"), feedurl); - Database::instance().execute(query); - - // Delete Chapters - query.prepare(QStringLiteral("DELETE FROM Chapters WHERE feed=:feed;")); - query.bindValue(QStringLiteral(":feed"), feedurl); - Database::instance().execute(query); - - // Delete Entries - query.prepare(QStringLiteral("DELETE FROM Entries WHERE feed=:feed;")); - query.bindValue(QStringLiteral(":feed"), feedurl); - Database::instance().execute(query); - - // Delete Enclosures - query.prepare(QStringLiteral("DELETE FROM Enclosures WHERE feed=:feed;")); - query.bindValue(QStringLiteral(":feed"), feedurl); - Database::instance().execute(query); - - // Delete Feed - query.prepare(QStringLiteral("DELETE FROM Feeds WHERE url=:url;")); - query.bindValue(QStringLiteral(":url"), feedurl); - Database::instance().execute(query); - - Q_EMIT feedRemoved(index); + // if settings allow, then upload these changes immediately to sync server + Sync::instance().doQuickSync(); } void DataManager::addFeed(const QString &url) @@ -278,52 +317,65 @@ void DataManager::addFeed(const QString &url) void DataManager::addFeed(const QString &url, const bool fetch) { - // This method will add the relevant internal data structures, and then add - // a preliminary entry into the database. Those details (as well as entries, - // authors and enclosures) will be updated by calling Fetcher::fetch() which - // will trigger a full update of the feed and all related items. - qCDebug(kastsDataManager) << "Adding feed"; - if (feedExists(url)) { - qCDebug(kastsDataManager) << "Feed already exists"; - return; - } - qCDebug(kastsDataManager) << "Feed does not yet exist"; - - QUrl urlFromInput = QUrl::fromUserInput(url); - QSqlQuery query; - query.prepare(QStringLiteral( - "INSERT INTO Feeds VALUES (:name, :url, :image, :link, :description, :deleteAfterCount, :deleteAfterType, :subscribed, :lastUpdated, :new, :notify);")); - query.bindValue(QStringLiteral(":name"), urlFromInput.toString()); - query.bindValue(QStringLiteral(":url"), urlFromInput.toString()); - query.bindValue(QStringLiteral(":image"), QLatin1String("")); - query.bindValue(QStringLiteral(":link"), QLatin1String("")); - query.bindValue(QStringLiteral(":description"), QLatin1String("")); - query.bindValue(QStringLiteral(":deleteAfterCount"), 0); - query.bindValue(QStringLiteral(":deleteAfterType"), 0); - query.bindValue(QStringLiteral(":subscribed"), QDateTime::currentDateTime().toSecsSinceEpoch()); - query.bindValue(QStringLiteral(":lastUpdated"), 0); - query.bindValue(QStringLiteral(":new"), true); - query.bindValue(QStringLiteral(":notify"), false); - Database::instance().execute(query); - - m_feeds[urlFromInput.toString()] = nullptr; - m_feedmap.append(urlFromInput.toString()); - - Q_EMIT feedAdded(urlFromInput.toString()); - - if (fetch) - Fetcher::instance().fetch(urlFromInput.toString()); + addFeeds(QStringList(url), fetch); } void DataManager::addFeeds(const QStringList &urls) +{ + addFeeds(urls, true); +} + +void DataManager::addFeeds(const QStringList &urls, const bool fetch) { if (urls.count() == 0) return; - for (int i = 0; i < urls.count(); i++) { - addFeed(urls[i], false); // add preliminary feed entries, but do not fetch yet + // This method will add the relevant internal data structures, and then add + // a preliminary entry into the database. Those details (as well as entries, + // authors and enclosures) will be updated by calling Fetcher::fetch() which + // will trigger a full update of the feed and all related items. + for (QString url : urls) { + qCDebug(kastsDataManager) << "Adding feed"; + if (feedExists(url)) { + qCDebug(kastsDataManager) << "Feed already exists"; + return; + } + qCDebug(kastsDataManager) << "Feed does not yet exist"; + + QUrl urlFromInput = QUrl::fromUserInput(url); + QSqlQuery query; + query.prepare( + QStringLiteral("INSERT INTO Feeds VALUES (:name, :url, :image, :link, :description, :deleteAfterCount, :deleteAfterType, :subscribed, " + ":lastUpdated, :new, :notify);")); + query.bindValue(QStringLiteral(":name"), urlFromInput.toString()); + query.bindValue(QStringLiteral(":url"), urlFromInput.toString()); + query.bindValue(QStringLiteral(":image"), QLatin1String("")); + query.bindValue(QStringLiteral(":link"), QLatin1String("")); + query.bindValue(QStringLiteral(":description"), QLatin1String("")); + query.bindValue(QStringLiteral(":deleteAfterCount"), 0); + query.bindValue(QStringLiteral(":deleteAfterType"), 0); + query.bindValue(QStringLiteral(":subscribed"), QDateTime::currentDateTime().toSecsSinceEpoch()); + query.bindValue(QStringLiteral(":lastUpdated"), 0); + query.bindValue(QStringLiteral(":new"), true); + query.bindValue(QStringLiteral(":notify"), false); + Database::instance().execute(query); + + m_feeds[urlFromInput.toString()] = nullptr; + m_feedmap.append(urlFromInput.toString()); + + // Save this action to the database (including timestamp) in order to be + // able to sync with remote services + Sync::instance().storeAddFeedAction(urlFromInput.toString()); + + Q_EMIT feedAdded(urlFromInput.toString()); } - Fetcher::instance().fetch(urls); + + if (fetch) { + Fetcher::instance().fetch(urls); + } + + // if settings allow, upload these changes immediately to sync servers + Sync::instance().doQuickSync(); } Entry *DataManager::getQueueEntry(int index) const @@ -505,7 +557,7 @@ void DataManager::exportFeeds(const QString &path) xmlWriter.writeEndDocument(); } -void DataManager::loadFeed(const QString feedurl) const +void DataManager::loadFeed(const QString &feedurl) const { QSqlQuery query; query.prepare(QStringLiteral("SELECT url FROM Feeds WHERE url=:feedurl;")); @@ -537,7 +589,17 @@ void DataManager::loadEntry(const QString id) const bool DataManager::feedExists(const QString &url) { - return m_feeds.contains(url); + // Try to account for some common cases where the URL is different but is + // actually pointing to the same data. Currently covering: + // - http vs https + // - encoded vs non-encoded URLs + QString cleanUrl = QUrl(url).authority() + QUrl(url).path(QUrl::FullyDecoded); + for (QString listUrl : m_feedmap) { + if (cleanUrl == (QUrl(listUrl).authority() + QUrl(listUrl).path(QUrl::FullyDecoded))) { + return true; + } + } + return false; } void DataManager::updateQueueListnrs() const @@ -551,11 +613,6 @@ void DataManager::updateQueueListnrs() const } } -bool DataManager::isFeedExists(const QString &url) -{ - return m_feeds.contains(url); -} - void DataManager::bulkMarkReadByIndex(bool state, QModelIndexList list) { bulkMarkRead(state, getIdsFromModelIndexList(list)); @@ -580,6 +637,11 @@ void DataManager::bulkMarkRead(bool state, QStringList list) Database::instance().commit(); Q_EMIT bulkReadStatusActionFinished(); + + // if settings allow, upload these changes immediately to sync servers + if (state) { + Sync::instance().doQuickSync(); + } } void DataManager::bulkMarkNewByIndex(bool state, QModelIndexList list) diff --git a/src/datamanager.h b/src/datamanager.h index a492a45f..36c187c1 100644 --- a/src/datamanager.h +++ b/src/datamanager.h @@ -41,8 +41,12 @@ public: Q_INVOKABLE void addFeed(const QString &url); void addFeed(const QString &url, const bool fetch); void addFeeds(const QStringList &urls); + void addFeeds(const QStringList &urls, const bool fetch); Q_INVOKABLE void removeFeed(Feed *feed); void removeFeed(const int index); + void removeFeeds(const QStringList &feedurls); + Q_INVOKABLE void removeFeeds(const QVariantList feedsVariantList); + void removeFeeds(const QList &feeds); Entry *getQueueEntry(int index) const; int queueCount() const; @@ -60,7 +64,7 @@ public: Q_INVOKABLE void importFeeds(const QString &path); Q_INVOKABLE void exportFeeds(const QString &path); - Q_INVOKABLE bool isFeedExists(const QString &url); + Q_INVOKABLE bool feedExists(const QString &url); Q_INVOKABLE void bulkMarkRead(bool state, QStringList list); Q_INVOKABLE void bulkMarkNew(bool state, QStringList list); @@ -90,9 +94,8 @@ Q_SIGNALS: private: DataManager(); - void loadFeed(QString feedurl) const; + void loadFeed(const QString &feedurl) const; void loadEntry(QString id) const; - bool feedExists(const QString &url); void updateQueueListnrs() const; QStringList getIdsFromModelIndexList(const QModelIndexList &list) const; diff --git a/src/enclosure.cpp b/src/enclosure.cpp index 43493ea9..e5af7aa6 100644 --- a/src/enclosure.cpp +++ b/src/enclosure.cpp @@ -24,6 +24,7 @@ #include "models/errorlogmodel.h" #include "settingsmanager.h" #include "storagemanager.h" +#include "sync/sync.h" #include @@ -221,6 +222,11 @@ void Enclosure::deleteFile() Q_EMIT sizeOnDiskChanged(); } +QString Enclosure::url() const +{ + return m_url; +} + QString Enclosure::path() const { return StorageManager::instance().enclosurePath(m_url); @@ -283,6 +289,10 @@ void Enclosure::setPlayPosition(const qint64 &position) query.bindValue(QStringLiteral(":playposition"), m_playposition); Database::instance().execute(query); m_playposition_dbsave = m_playposition; + + // Also store position change to make sure that it can be synced to + // e.g. gpodder + Sync::instance().storePlayEpisodeAction(m_entry->id(), m_playposition_dbsave, m_playposition); } Q_EMIT playPositionChanged(); diff --git a/src/enclosure.h b/src/enclosure.h index 121f8ea4..6a44782a 100644 --- a/src/enclosure.h +++ b/src/enclosure.h @@ -26,7 +26,7 @@ class Enclosure : public QObject Q_PROPERTY(qint64 sizeOnDisk READ sizeOnDisk NOTIFY sizeOnDiskChanged) Q_PROPERTY(QString title MEMBER m_title CONSTANT) Q_PROPERTY(QString type MEMBER m_type CONSTANT) - Q_PROPERTY(QString url MEMBER m_url CONSTANT) + Q_PROPERTY(QString url READ url CONSTANT) Q_PROPERTY(Status status READ status WRITE setStatus NOTIFY statusChanged) Q_PROPERTY(double downloadProgress MEMBER m_downloadProgress NOTIFY downloadProgressChanged) Q_PROPERTY(QString formattedDownloadSize READ formattedDownloadSize NOTIFY downloadProgressChanged) @@ -56,6 +56,7 @@ public: Q_INVOKABLE void deleteFile(); QString path() const; + QString url() const; Status status() const; qint64 playPosition() const; qint64 duration() const; diff --git a/src/entry.cpp b/src/entry.cpp index d07903d3..7a062e11 100644 --- a/src/entry.cpp +++ b/src/entry.cpp @@ -16,6 +16,7 @@ #include "feed.h" #include "fetcher.h" #include "settingsmanager.h" +#include "sync/sync.h" Entry::Entry(Feed *feed, const QString &id) : QObject(&DataManager::instance()) @@ -171,6 +172,8 @@ void Entry::setReadInternal(bool read) m_enclosure->deleteFile(); } } + // 5) Log a sync action to sync this state with (gpodder) server + Sync::instance().storePlayedEpisodeAction(m_id); } } } diff --git a/src/error.cpp b/src/error.cpp index afb4b833..895f6d65 100644 --- a/src/error.cpp +++ b/src/error.cpp @@ -56,6 +56,8 @@ QString Error::description() const return i18n("Nothing Found"); case Error::Type::StorageMoveError: return i18n("Error moving storage path"); + case Error::Type::SyncError: + return i18n("Error Syncing Feed and/or Episode Status"); default: return QString(); } @@ -76,6 +78,8 @@ int Error::typeToDb(Error::Type type) return 4; case Error::Type::StorageMoveError: return 5; + case Error::Type::SyncError: + return 6; default: return -1; } @@ -96,6 +100,8 @@ Error::Type Error::dbToType(int value) return Error::Type::DiscoverError; case 5: return Error::Type::StorageMoveError; + case 6: + return Error::Type::SyncError; default: return Error::Type::Unknown; } diff --git a/src/error.h b/src/error.h index 13f283d2..4364f3b8 100644 --- a/src/error.h +++ b/src/error.h @@ -23,6 +23,7 @@ public: InvalidMedia, DiscoverError, StorageMoveError, + SyncError, }; Q_ENUM(Type) diff --git a/src/fetcher.cpp b/src/fetcher.cpp index 17da91a9..37c87b62 100644 --- a/src/fetcher.cpp +++ b/src/fetcher.cpp @@ -29,6 +29,7 @@ #include "models/errorlogmodel.h" #include "settingsmanager.h" #include "storagemanager.h" +#include "sync/sync.h" #include @@ -54,16 +55,20 @@ void Fetcher::fetch(const QString &url) void Fetcher::fetchAll() { - QStringList urls; - QSqlQuery query; - query.prepare(QStringLiteral("SELECT url FROM Feeds;")); - Database::instance().execute(query); - while (query.next()) { - urls += query.value(0).toString(); - } + if (Sync::instance().syncEnabled() && SettingsManager::self()->syncWhenUpdatingFeeds()) { + Sync::instance().doRegularSync(true); + } else { + QStringList urls; + QSqlQuery query; + query.prepare(QStringLiteral("SELECT url FROM Feeds;")); + Database::instance().execute(query); + while (query.next()) { + urls += query.value(0).toString(); + } - if (urls.count() > 0) { - fetch(urls); + if (urls.count() > 0) { + fetch(urls); + } } } @@ -91,7 +96,7 @@ void Fetcher::fetch(const QStringList &urls) }); connect(fetchFeedsJob, &FetchFeedsJob::result, this, [this, fetchFeedsJob]() { qCDebug(kastsFetcher) << "result slot of FetchFeedsJob"; - if (fetchFeedsJob->error()) { + if (fetchFeedsJob->error() && !fetchFeedsJob->aborted()) { Q_EMIT error(Error::Type::FeedUpdate, QString(), QString(), fetchFeedsJob->error(), fetchFeedsJob->errorString(), QString()); } if (m_updating) { @@ -126,7 +131,8 @@ QString Fetcher::image(const QString &url) // if image has not yet been cached, then check for network connectivity if // possible; and download the image SolidExtras::NetworkStatus networkStatus; - if (networkStatus.connectivity() == SolidExtras::NetworkStatus::No || (networkStatus.metered() == SolidExtras::NetworkStatus::Yes && !SettingsManager::self()->allowMeteredImageDownloads())) { + if (networkStatus.connectivity() == SolidExtras::NetworkStatus::No + || (networkStatus.metered() == SolidExtras::NetworkStatus::Yes && !SettingsManager::self()->allowMeteredImageDownloads())) { return QLatin1String("no-image"); } @@ -155,10 +161,9 @@ QNetworkReply *Fetcher::download(const QString &url, const QString &filePath) co request.setTransferTimeout(); QFile *file = new QFile(filePath); - int resumedAt = 0; if (file->exists() && file->size() > 0) { // try to resume download - resumedAt = file->size(); + int resumedAt = file->size(); qCDebug(kastsFetcher) << "Resuming download at" << resumedAt << "bytes"; QByteArray rangeHeaderValue = QByteArray("bytes=") + QByteArray::number(resumedAt) + QByteArray("-"); request.setRawHeader(QByteArray("Range"), rangeHeaderValue); @@ -205,6 +210,13 @@ QNetworkReply *Fetcher::get(QNetworkRequest &request) const return manager->get(request); } +QNetworkReply *Fetcher::post(QNetworkRequest &request, const QByteArray &data) const +{ + setHeader(request); + request.setHeader(QNetworkRequest::ContentTypeHeader, QLatin1String("application/json")); + return manager->post(request, data); +} + QNetworkReply *Fetcher::head(QNetworkRequest &request) const { setHeader(request); diff --git a/src/fetcher.h b/src/fetcher.h index 6eb9e6eb..62fa8f97 100644 --- a/src/fetcher.h +++ b/src/fetcher.h @@ -39,6 +39,7 @@ public: Q_INVOKABLE QNetworkReply *download(const QString &url, const QString &fileName) const; QNetworkReply *get(QNetworkRequest &request) const; + QNetworkReply *post(QNetworkRequest &request, const QByteArray &data) const; Q_SIGNALS: void entryAdded(const QString &feedurl, const QString &id); diff --git a/src/fetchfeedsjob.cpp b/src/fetchfeedsjob.cpp index 4115a63e..c862b479 100644 --- a/src/fetchfeedsjob.cpp +++ b/src/fetchfeedsjob.cpp @@ -35,14 +35,10 @@ void FetchFeedsJob::start() void FetchFeedsJob::fetch() { - /* We remove this because otherwise 'Allow Once' would not work ... - if (Fetcher::instance().isMeteredConnection() && !SettingsManager::self()->allowMeteredFeedUpdates()) { - setError(0); - setErrorText(i18n("Podcast updates not allowed due to user setting")); + if (m_urls.count() == 0) { emitResult(); return; } - */ setTotalAmount(KJob::Unit::Items, m_urls.count()); setProcessedAmount(KJob::Unit::Items, 0); @@ -52,7 +48,7 @@ void FetchFeedsJob::fetch() UpdateFeedJob *updateFeedJob = new UpdateFeedJob(url, this); m_feedjobs[i] = updateFeedJob; - connect(this, &FetchFeedsJob::abort, updateFeedJob, &UpdateFeedJob::abort); + connect(this, &FetchFeedsJob::aborting, updateFeedJob, &UpdateFeedJob::abort); connect(updateFeedJob, &UpdateFeedJob::result, this, [this, url, updateFeedJob]() { if (updateFeedJob->error()) { Q_EMIT logError(Error::Type::FeedUpdate, url, QString(), updateFeedJob->error(), updateFeedJob->errorString(), QString()); @@ -71,3 +67,15 @@ void FetchFeedsJob::monitorProgress() emitResult(); } } + +bool FetchFeedsJob::aborted() +{ + return m_abort; +} + +void FetchFeedsJob::abort() +{ + qCDebug(kastsFetcher) << "Fetching aborted"; + m_abort = true; + Q_EMIT aborting(); +} diff --git a/src/fetchfeedsjob.h b/src/fetchfeedsjob.h index bdcac5ee..f0393414 100644 --- a/src/fetchfeedsjob.h +++ b/src/fetchfeedsjob.h @@ -20,9 +20,11 @@ public: explicit FetchFeedsJob(const QStringList &urls, QObject *parent = nullptr); void start() override; + bool aborted(); + void abort(); Q_SIGNALS: - void abort(); + void aborting(); void logError(Error::Type type, const QString &url, const QString &id, const int errorId, const QString &errorString, const QString &title); private: @@ -31,6 +33,6 @@ private: void fetch(); void monitorProgress(); - bool m_abort = false; QVector m_feedjobs; + bool m_abort = false; }; diff --git a/src/main.cpp b/src/main.cpp index 73c9da3c..d0c900ba 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -52,6 +52,8 @@ #include "mpris2/mpris2.h" #include "settingsmanager.h" #include "storagemanager.h" +#include "sync/sync.h" +#include "sync/syncutils.h" #ifdef Q_OS_ANDROID Q_DECL_EXPORT @@ -140,9 +142,13 @@ int main(int argc, char *argv[]) qmlRegisterSingletonInstance("org.kde.kasts", 1, 0, "ErrorLogModel", &ErrorLogModel::instance()); qmlRegisterSingletonInstance("org.kde.kasts", 1, 0, "AudioManager", &AudioManager::instance()); qmlRegisterSingletonInstance("org.kde.kasts", 1, 0, "StorageManager", &StorageManager::instance()); + qmlRegisterSingletonInstance("org.kde.kasts", 1, 0, "Sync", &Sync::instance()); + + qmlRegisterUncreatableMetaObject(SyncUtils::staticMetaObject, "org.kde.kasts", 1, 0, "SyncUtils", QStringLiteral("Error: only enums and structs")); qRegisterMetaType("const Entry*"); // "hack" to make qml understand Entry* qRegisterMetaType("const Feed*"); // "hack" to make qml understand Feed* + qRegisterMetaType>("QVector"); // "hack" to make qml understand QVector of SyncUtils::Device // Make sure that settings are saved before the application exits QObject::connect(&app, &QCoreApplication::aboutToQuit, SettingsManager::self(), &SettingsManager::save); diff --git a/src/qml/DiscoverPage.qml b/src/qml/DiscoverPage.qml index b6c12a85..993ab4c3 100644 --- a/src/qml/DiscoverPage.qml +++ b/src/qml/DiscoverPage.qml @@ -65,7 +65,7 @@ Kirigami.ScrollablePage { Kirigami.Action { text: enabled ? i18n("Subscribe") : i18n("Subscribed") icon.name: "kt-add-feeds" - enabled: !DataManager.isFeedExists(model.url) + enabled: !DataManager.feedExists(model.url) onTriggered: { DataManager.addFeed(model.url) } diff --git a/src/qml/ErrorListOverlay.qml b/src/qml/ErrorListOverlay.qml index a779eb51..1e409035 100644 --- a/src/qml/ErrorListOverlay.qml +++ b/src/qml/ErrorListOverlay.qml @@ -33,7 +33,6 @@ Kirigami.OverlaySheet { } } - Kirigami.PlaceholderMessage { id: placeholder visible: errorList.count == 0 diff --git a/src/qml/ErrorNotification.qml b/src/qml/ErrorNotification.qml index 26eaf57f..c58b0ea5 100644 --- a/src/qml/ErrorNotification.qml +++ b/src/qml/ErrorNotification.qml @@ -18,7 +18,7 @@ Kirigami.InlineMessage { bottom: parent.bottom right: parent.right left: parent.left - margins: Kirigami.Units.gridUnit + margins: Kirigami.Settings.isMobile ? Kirigami.Units.largeSpacing : Kirigami.Units.gridUnit * 4 bottomMargin: bottomMessageSpacing } type: Kirigami.MessageType.Error diff --git a/src/qml/FeedDetailsPage.qml b/src/qml/FeedDetailsPage.qml index a6f09822..c66f4f43 100644 --- a/src/qml/FeedDetailsPage.qml +++ b/src/qml/FeedDetailsPage.qml @@ -39,7 +39,7 @@ Kirigami.ScrollablePage { onClicked: { DataManager.addFeed(feed.url) } - enabled: !DataManager.isFeedExists(feed.url) + enabled: !DataManager.feedExists(feed.url) } } diff --git a/src/qml/FeedListDelegate.qml b/src/qml/FeedListDelegate.qml index 37e3baab..1feaad4f 100644 --- a/src/qml/FeedListDelegate.qml +++ b/src/qml/FeedListDelegate.qml @@ -240,7 +240,7 @@ Controls.ItemDelegate { Kirigami.OverlaySheet { id: actionOverlay - parent: applicationWindow().overlay + // parent: applicationWindow().overlay showCloseButton: true header: Kirigami.Heading { diff --git a/src/qml/FeedListPage.qml b/src/qml/FeedListPage.qml index 62baa6e9..16699563 100644 --- a/src/qml/FeedListPage.qml +++ b/src/qml/FeedListPage.qml @@ -262,8 +262,8 @@ Kirigami.ScrollablePage { pageStack.pop(); } } - DataManager.removeFeed(feeds[i]); } + DataManager.removeFeeds(feeds); } } diff --git a/src/qml/Settings/SettingsPage.qml b/src/qml/Settings/SettingsPage.qml index 1834e3c0..fed95d4e 100644 --- a/src/qml/Settings/SettingsPage.qml +++ b/src/qml/Settings/SettingsPage.qml @@ -27,6 +27,11 @@ Kirigami.CategorizedSettings { icon.name: "network-connect" page: "qrc:/NetworkSettingsPage.qml" }, + Kirigami.SettingAction { + text: i18n("Synchronization") + icon.name: "state-sync" + page: "qrc:/SynchronizationSettingsPage.qml" + }, Kirigami.SettingAction { text: i18n("About") icon.name: "help-about-symbolic" diff --git a/src/qml/Settings/SynchronizationSettingsPage.qml b/src/qml/Settings/SynchronizationSettingsPage.qml new file mode 100644 index 00000000..a224f7a3 --- /dev/null +++ b/src/qml/Settings/SynchronizationSettingsPage.qml @@ -0,0 +1,463 @@ +/** + * SPDX-FileCopyrightText: 2020 Tobias Fella + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +import QtQuick 2.14 +import QtQuick.Controls 2.14 as Controls +import QtQuick.Layouts 1.14 + +import org.kde.kirigami 2.12 as Kirigami + +import org.kde.kasts 1.0 + +Kirigami.ScrollablePage { + title: i18n("Synchronization Settings") + + Kirigami.FormLayout { + + Controls.Label { + Kirigami.FormData.label: i18n("Current Status:") + text: Sync.syncEnabled ? i18n("Logged into account \"%1\" on server \"%2\"", Sync.username, Sync.provider == SyncUtils.GPodderNet ? "gpodder.net" : Sync.hostname) : i18n("Syncing Disabled") + wrapMode: Text.WordWrap + } + + Controls.Label { + Kirigami.FormData.label: i18n("Last full sync with server:") + text: Sync.lastSuccessfulDownloadSync + wrapMode: Text.WordWrap + } + + Controls.Label { + Kirigami.FormData.label: i18n("Last quick upload to sync server:") + text: Sync.lastSuccessfulUploadSync + wrapMode: Text.WordWrap + } + + Controls.Button { + text: i18n("Login") + enabled: !Sync.syncEnabled + onClicked: syncProviderOverlay.open() + } + + Kirigami.OverlaySheet { + id: syncProviderOverlay + showCloseButton: true + header: Kirigami.Heading { + text: i18n("Select Sync Provider") + elide: Text.ElideRight + } + + contentItem: ListView { + focus: syncProviderOverlay.sheetOpen + implicitWidth: Math.max(contentItem.childrenRect.width, Kirigami.Units.gridUnit * 20) + + model: ListModel { + id: providerModel + } + Component.onCompleted: { + providerModel.append({"name": i18n("gpodder.net"), + "subtitle": i18n("Synchronize with official gpodder.net server"), + //"icon": "qrc:/gpoddernet.svg", + "provider": Sync.GPodderNet}); + providerModel.append({"name": i18n("GPodder Nextcloud"), + "subtitle": i18n("Synchronize with GPodder Nextcloud app"), + //"icon": "qrc:/nextcloud-icon.svg", + "provider": Sync.GPodderNextcloud}); + } + + delegate: Kirigami.BasicListItem { + label: model.name + subtitle: model.subtitle + icon: model.icon + //highlighted: false + iconSize: Kirigami.Units.gridUnit * 3 + Keys.onReturnPressed: clicked() + onClicked: { + Sync.provider = model.provider; + syncProviderOverlay.close(); + syncLoginOverlay.open(); + } + } + } + } + + Kirigami.OverlaySheet { + id: syncLoginOverlay + showCloseButton: true + + header: Kirigami.Heading { + text: i18n("Sync Login Credentials") + elide: Text.ElideRight + } + + contentItem: Column { + Layout.preferredWidth: Kirigami.Units.gridUnit * 25 + spacing: Kirigami.Units.largeSpacing + RowLayout { + width: parent.width + spacing: Kirigami.Units.largeSpacing + // Disable images until licensing has been sorted out + // Image { + // sourceSize.height: Kirigami.Units.gridUnit * 4 + // sourceSize.width: Kirigami.Units.gridUnit * 4 + // fillMode: Image.PreserveAspectFit + // source: Sync.provider === Sync.GPodderNextcloud ? "qrc:/nextcloud-icon.svg" : "qrc:/gpoddernet.svg" + // } + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + Kirigami.Heading { + clip: true + level: 2 + text: Sync.provider === Sync.GPodderNextcloud ? i18n("Sync with GPodder Nextcloud app") : i18n("Sync with gpodder.net service") + } + TextEdit { + Layout.fillWidth: true + readOnly: true + wrapMode: Text.WordWrap + textFormat: Text.RichText + onLinkActivated: Qt.openUrlExternally(link) + text: Sync.provider === Sync.GPodderNextcloud ? + i18nc("argument is a weblink", "Sync with a Nextcloud server that has the GPodder Sync app installed: %1.
It is advised to manually create an app password for Kasts through the web interface and use those credentials." , "https://apps.nextcloud.com/apps/gpoddersync") : + i18nc("argument is a weblink", "If you don't already have an account, you should first create one at %1", "https://gpodder.net") + } + } + } + GridLayout { + width: parent.width + columns: 2 + rowSpacing: Kirigami.Units.smallSpacing + columnSpacing: Kirigami.Units.smallSpacing + Controls.Label { + Layout.alignment: Qt.AlignRight + text: i18n("Username:") + } + Controls.TextField { + id: usernameField + Layout.fillWidth: true + text: Sync.username + Keys.onReturnPressed: credentialsButtons.accepted(); + focus: syncLoginOverlay.sheetOpen + } + Controls.Label { + Layout.alignment: Qt.AlignRight + text: i18n("Password:") + } + Controls.TextField { + id: passwordField + Layout.fillWidth: true + echoMode: TextInput.Password + text: Sync.password + Keys.onReturnPressed: credentialsButtons.accepted(); + } + Controls.Label { + visible: Sync.provider === Sync.GPodderNextcloud + Layout.alignment: Qt.AlignRight + text: i18n("Hostname:") + } + Controls.TextField { + visible: Sync.provider === Sync.GPodderNextcloud + id: hostnameField + Layout.fillWidth: true + placeholderText: "https://nextcloud.mydomain.org" + text: Sync.hostname + Keys.onReturnPressed: credentialsButtons.accepted(); + } + } + } + + footer: Controls.DialogButtonBox { + id: credentialsButtons + standardButtons: Controls.DialogButtonBox.Ok | Controls.DialogButtonBox.Cancel + onAccepted: { + if (Sync.provider === Sync.GPodderNextcloud) { + Sync.hostname = hostnameField.text; + } + Sync.login(usernameField.text, passwordField.text); + syncLoginOverlay.close(); + } + onRejected: syncLoginOverlay.close(); + } + } + + Connections { + target: Sync + function onDeviceListReceived() { + syncDeviceOverlay.open(); + syncDeviceOverlay.update(); + } + function onLoginSucceeded() { + if (Sync.provider === Sync.GPodderNextcloud) { + firstSyncOverlay.open(); + } + } + } + + Kirigami.OverlaySheet { + id: syncDeviceOverlay + showCloseButton: true + + header: Kirigami.Heading { + text: i18n("Sync Device Settings") + elide: Text.ElideRight + } + contentItem: Column { + Layout.preferredWidth: Kirigami.Units.gridUnit * 25 + spacing: Kirigami.Units.largeSpacing * 2 + Kirigami.Heading { + level: 2 + text: i18n("Create a new device") + } + GridLayout { + columns: 2 + width: parent.width + Controls.Label { + text: i18n("Device Name:") + } + Controls.TextField { + id: deviceField + Layout.fillWidth: true + text: Sync.suggestedDevice + Keys.onReturnPressed: createDeviceButton.clicked(); + focus: syncDeviceOverlay.sheetOpen + } + Controls.Label { + text: i18n("Device Description:") + } + Controls.TextField { + id: deviceNameField + Layout.fillWidth: true + text: Sync.suggestedDeviceName + Keys.onReturnPressed: createDeviceButton.clicked(); + } + Controls.Label { + text: i18n("Device Type:") + } + Controls.ComboBox { + id: deviceTypeField + textRole: "text" + valueRole: "value" + popup.z: 102 // popup has to go in front of OverlaySheet + model: [{"text": i18n("other"), "value": "other"}, + {"text": i18n("desktop"), "value": "desktop"}, + {"text": i18n("laptop"), "value": "laptop"}, + {"text": i18n("server"), "value": "server"}, + {"text": i18n("mobile"), "value": "mobile"}] + } + } + Controls.Button { + id: createDeviceButton + text: i18n("Create Device") + icon.name: "list-add" + onClicked: { + Sync.registerNewDevice(deviceField.text, deviceNameField.text, deviceTypeField.currentValue); + syncDeviceOverlay.close(); + } + } + ListView { + id: deviceList + width: parent.width + height: contentItem.childrenRect.height + visible: deviceListModel.count !== 0 + + header: Kirigami.Heading { + topPadding: Kirigami.Units.gridUnit + bottomPadding: Kirigami.Units.largeSpacing + level: 2 + text: i18n("or select an existing device") + } + model: ListModel { + id: deviceListModel + } + + delegate: Kirigami.BasicListItem { + label: model.device.caption + highlighted: false + icon: model.device.type == "desktop" ? "computer" : + model.device.type == "laptop" ? "computer-laptop" : + model.device.type == "server" ? "network-server-database" : + model.device.type == "mobile" ? "smartphone" : + "emblem-music-symbolic" + onClicked: { + syncDeviceOverlay.close(); + Sync.device = model.device.id; + Sync.deviceName = model.device.caption; + Sync.syncEnabled = true; + syncGroupOverlay.open(); + } + } + } + } + + function update() { + deviceListModel.clear(); + for (var index in Sync.deviceList) { + deviceListModel.append({"device": Sync.deviceList[index]}); + } + } + } + + Connections { + target: Sync + function onDeviceCreated() { + syncGroupOverlay.open(); + } + } + + Kirigami.OverlaySheet { + id: syncGroupOverlay + showCloseButton: true + + header: Kirigami.Heading { + text: i18n("Device Sync Settings") + elide: Text.ElideRight + } + contentItem: RowLayout { + Layout.preferredWidth: Kirigami.Units.gridUnit * 25 + spacing: Kirigami.Units.largeSpacing + // Disable images until licensing has been sorted out + // Image { + // sourceSize.height: Kirigami.Units.gridUnit * 4 + // sourceSize.width: Kirigami.Units.gridUnit * 4 + // fillMode: Image.PreserveAspectFit + // source: "qrc:/gpoddernet.svg" + // } + TextEdit { + Layout.fillWidth: true + Layout.fillHeight: true + readOnly: true + wrapMode: Text.WordWrap + text: i18n("Should all podcast subscriptions on this gpodder.net account be synced across all devices?\nIf you don't know what this means, you should probably select \"Ok\".") + } + } + + footer: Controls.DialogButtonBox { + standardButtons: Controls.DialogButtonBox.Ok | Controls.DialogButtonBox.Cancel + focus: syncGroupOverlay.sheetOpen + Keys.onReturnPressed: accepted(); + onAccepted: { + Sync.linkUpAllDevices(); + syncGroupOverlay.close(); + } + onRejected: { + syncGroupOverlay.close(); + } + } + + onSheetOpenChanged: { + if (!sheetOpen) { + firstSyncOverlay.open(); + } + } + } + + Kirigami.OverlaySheet { + id: firstSyncOverlay + showCloseButton: true + + header: Kirigami.Heading { + text: i18n("Sync Now?") + elide: Text.ElideRight + } + contentItem: RowLayout { + Layout.preferredWidth: Kirigami.Units.gridUnit * 2 + spacing: Kirigami.Units.largeSpacing + // Disable images until licensing has been sorted out + // Image { + // sourceSize.height: Kirigami.Units.gridUnit * 4 + // sourceSize.width: Kirigami.Units.gridUnit * 4 + // fillMode: Image.PreserveAspectFit + // source: Sync.provider === Sync.GPodderNextcloud ? "qrc:/nextcloud-icon.svg" : "qrc:/gpoddernet.svg" + // } + TextEdit { + Layout.fillWidth: true + Layout.fillHeight: true + readOnly: true + wrapMode: Text.WordWrap + text: i18n("Perform a first sync now?") + } + } + + footer: Controls.DialogButtonBox { + standardButtons: Controls.DialogButtonBox.Ok | Controls.DialogButtonBox.Cancel + focus: firstSyncOverlay.sheetOpen + Keys.onReturnPressed: accepted(); + onAccepted: { + firstSyncOverlay.close(); + Sync.doRegularSync(); + } + onRejected: firstSyncOverlay.close(); + } + } + + Controls.Button { + text: i18n("Logout") + enabled: Sync.syncEnabled + onClicked: { + Sync.logout(); + } + } + + Controls.CheckBox { + Kirigami.FormData.label: i18n("Automatic Syncing:") + enabled: Sync.syncEnabled + checked: SettingsManager.refreshOnStartup + text: i18n("Do full sync on startup") + onToggled: SettingsManager.refreshOnStartup = checked + } + + Controls.CheckBox { + enabled: Sync.syncEnabled + checked: SettingsManager.syncWhenUpdatingFeeds + text: i18n("Do full sync when fetching podcasts") + onToggled: SettingsManager.syncWhenUpdatingFeeds = checked + } + + Controls.CheckBox { + enabled: Sync.syncEnabled + checked: SettingsManager.syncWhenPlayerstateChanges + text: i18n("Upload episode play positions on play/pause toggle") + onToggled: SettingsManager.syncWhenPlayerstateChanges = checked + } + + Controls.Button { + Kirigami.FormData.label: i18n("Manual Syncing:") + text: i18n("Sync Now") + enabled: Sync.syncEnabled + onClicked: { + syncFeedsAndEpisodes.run(); + } + } + + // This item can be used to trigger an update of all feeds; it will open an + // overlay with options in case the operation is not allowed by the settings + ConnectionCheckAction { + id: syncFeedsAndEpisodes + + function action() { + Sync.doRegularSync(); + } + } + + Controls.Button { + text: i18n("Force Sync Now") + enabled: Sync.syncEnabled + onClicked: { + forceSyncFeedsAndEpisodes.run(); + } + } + + // This item can be used to trigger an update of all feeds; it will open an + // overlay with options in case the operation is not allowed by the settings + ConnectionCheckAction { + id: forceSyncFeedsAndEpisodes + + function action() { + Sync.doForceSync(); + } + } + } +} diff --git a/src/qml/SyncPasswordOverlay.qml b/src/qml/SyncPasswordOverlay.qml new file mode 100644 index 00000000..a87df894 --- /dev/null +++ b/src/qml/SyncPasswordOverlay.qml @@ -0,0 +1,73 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +import QtQuick 2.14 +import QtQuick.Controls 2.14 as Controls +import QtQuick.Layouts 1.14 + +import org.kde.kirigami 2.12 as Kirigami + +import org.kde.kasts 1.0 + +Kirigami.OverlaySheet { + id: syncPasswordOverlay + parent: applicationWindow().overlay + showCloseButton: true + + header: Kirigami.Heading { + text: i18n("Sync Password Required") + elide: Text.ElideRight + } + + contentItem: Column { + Layout.preferredWidth: Kirigami.Units.gridUnit * 20 + spacing: Kirigami.Units.largeSpacing + RowLayout { + width: parent.width + spacing: Kirigami.Units.largeSpacing + // Disable images until licensing has been sorted out + // Image { + // sourceSize.height: Kirigami.Units.gridUnit * 4 + // sourceSize.width: Kirigami.Units.gridUnit * 4 + // fillMode: Image.PreserveAspectFit + // source: Sync.provider === Sync.GPodderNextcloud ? "qrc:/nextcloud-icon.svg" : "qrc:/gpoddernet.svg" + // } + TextEdit { + id: passwordField + Layout.fillWidth: true + readOnly: true + wrapMode: Text.WordWrap + text: Sync.provider === Sync.GPodderNextcloud ? + i18n("The password for user \"%1\" on Nextcloud server \"%2\" could not be retrieved.", SettingsManager.syncUsername, SettingsManager.syncHostname) : + i18n("The password for user \"%1\" on \"gpodder.net\" could not be retrieved.", SettingsManager.syncUsername) + } + } + RowLayout { + width: parent.width + Controls.Label { + text: i18n("Password:") + } + Controls.TextField { + id: passwordField2 + Layout.fillWidth: true + Keys.onReturnPressed: passwordButtons.accepted(); + focus: syncPasswordOverlay.sheetOpen + echoMode: TextInput.Password + text: Sync.password + } + } + } + + footer: Controls.DialogButtonBox { + id: passwordButtons + standardButtons: Controls.DialogButtonBox.Ok | Controls.DialogButtonBox.Cancel + onAccepted: { + Sync.password = passwordField2.text; + syncPasswordOverlay.close(); + } + onRejected: syncPasswordOverlay.close(); + } +} diff --git a/src/qml/UpdateNotification.qml b/src/qml/UpdateNotification.qml index 709c7f43..aa252c44 100644 --- a/src/qml/UpdateNotification.qml +++ b/src/qml/UpdateNotification.qml @@ -26,8 +26,10 @@ Rectangle { z: 2 anchors { - horizontalCenter: parent.horizontalCenter bottom: parent.bottom + left: parent.left + right: parent.right + margins: Kirigami.Settings.isMobile ? Kirigami.Units.largeSpacing : Kirigami.Units.gridUnit * 4 bottomMargin: bottomMessageSpacing + ( errorNotification.visible ? errorNotification.height : 0 ) } @@ -72,6 +74,7 @@ Rectangle { id: feedUpdateCountLabel text: rootComponent.text color: Kirigami.Theme.textColor + wrapMode: Text.WordWrap Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter diff --git a/src/qml/main.qml b/src/qml/main.qml index 1a9fbdf5..1ce7c23f 100644 --- a/src/qml/main.qml +++ b/src/qml/main.qml @@ -64,8 +64,10 @@ Kirigami.ApplicationWindow { } // Refresh feeds on startup if allowed - if (SettingsManager.refreshOnStartup) { - if (SettingsManager.allowMeteredFeedUpdates || NetworkStatus.metered !== NetworkStatus.Yes) { + // NOTE: refresh+sync on startup is handled in Sync and not here, since it + // requires credentials to be loaded before starting a refresh+sync + if (NetworkStatus.connectivity != NetworkStatus.No && (SettingsManager.allowMeteredFeedUpdates || NetworkStatus.metered !== NetworkStatus.Yes)) { + if (SettingsManager.refreshOnStartup && !(SettingsManager.syncEnabled && SettingsManager.syncWhenUpdatingFeeds)) { Fetcher.fetchAll(); } } @@ -232,9 +234,9 @@ Kirigami.ApplicationWindow { target: Fetcher function onUpdatingChanged() { if (Fetcher.updating) { - updateNotification.open() + updateNotification.open(); } else { - updateNotification.close() + updateNotification.close(); } } } @@ -265,6 +267,29 @@ Kirigami.ApplicationWindow { } } + // Notification that shows the progress of feed and episode syncing + UpdateNotification { + id: updateSyncNotification + text: Sync.syncProgressText + showAbortButton: true + + function abortAction() { + Sync.abortSync(); + } + + Connections { + target: Sync + function onSyncProgressChanged() { + if (Sync.syncStatus != SyncUtils.NoSync && Sync.syncProgress === 0) { + updateSyncNotification.open(); + } else if (Sync.syncStatus === SyncUtils.NoSync) { + updateSyncNotification.close(); + } + } + } + } + + // This InlineMessage is used for displaying error messages ErrorNotification { id: errorNotification @@ -312,10 +337,22 @@ Kirigami.ApplicationWindow { action(); } } + PlaybackRateDialog { id: playbackRateDialog } + Connections { + target: Sync + function onPasswordInputRequired() { + syncPasswordOverlay.open(); + } + } + + SyncPasswordOverlay { + id: syncPasswordOverlay + } + //Global Shortcuts Shortcut { sequence: "space" diff --git a/src/resources.qrc b/src/resources.qrc index 0a761ee6..42449ebf 100755 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -28,10 +28,12 @@ qml/ErrorNotification.qml qml/ConnectionCheckAction.qml qml/ChapterListDelegate.qml + qml/SyncPasswordOverlay.qml qml/Settings/SettingsPage.qml qml/Settings/GeneralSettingsPage.qml qml/Settings/NetworkSettingsPage.qml qml/Settings/StorageSettingsPage.qml + qml/Settings/SynchronizationSettingsPage.qml qtquickcontrols2.conf ../kasts.svg diff --git a/src/settingsmanager.kcfg b/src/settingsmanager.kcfg index b3826e28..b53c25aa 100644 --- a/src/settingsmanager.kcfg +++ b/src/settingsmanager.kcfg @@ -81,4 +81,38 @@ FeedListPage
+ + + + false + + + + true + + + + true + + + + 0 + + + + + + + + + + + + + + + + + + diff --git a/src/storagemanager.cpp b/src/storagemanager.cpp index f9826e9a..6f4e98c7 100644 --- a/src/storagemanager.cpp +++ b/src/storagemanager.cpp @@ -174,3 +174,8 @@ QString StorageManager::formattedImageDirSize() const { return m_kformat.formatByteSize(imageDirSize()); } + +QString StorageManager::passwordFilePath(const QString &username) const +{ + return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/") + username; +} diff --git a/src/storagemanager.h b/src/storagemanager.h index c6880982..f57b1f1f 100644 --- a/src/storagemanager.h +++ b/src/storagemanager.h @@ -51,6 +51,8 @@ public: void removeImage(const QString &url); Q_INVOKABLE void clearImageCache(); + QString passwordFilePath(const QString &username) const; + Q_SIGNALS: void error(Error::Type type, const QString &url, const QString &id, const int errorId, const QString &errorString, const QString &title); diff --git a/src/sync/gpodder/devicerequest.cpp b/src/sync/gpodder/devicerequest.cpp new file mode 100644 index 00000000..50c94bca --- /dev/null +++ b/src/sync/gpodder/devicerequest.cpp @@ -0,0 +1,54 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "devicerequest.h" + +#include +#include +#include +#include + +#include "synclogging.h" + +DeviceRequest::DeviceRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent) + : GenericRequest(provider, reply, parent) +{ +} + +QVector DeviceRequest::devices() const +{ + return m_devices; +} + +void DeviceRequest::processResults() +{ + if (m_reply->error()) { + m_error = m_reply->error(); + m_errorString = m_reply->errorString(); + qCDebug(kastsSync) << "m_reply error" << m_reply->errorString(); + } else if (!m_abort) { + QJsonParseError *error = nullptr; + QJsonDocument data = QJsonDocument::fromJson(m_reply->readAll(), error); + if (error) { + qCDebug(kastsSync) << "parse error" << error->errorString(); + m_error = 1; + m_errorString = error->errorString(); + } else if (!m_abort) { + for (auto jsonDevice : data.array()) { + SyncUtils::Device device; + device.id = jsonDevice.toObject().value(QStringLiteral("id")).toString(); + device.caption = jsonDevice.toObject().value(QStringLiteral("caption")).toString(); + device.type = jsonDevice.toObject().value(QStringLiteral("type")).toString(); + device.subscriptions = jsonDevice.toObject().value(QStringLiteral("subscriptions")).toInt(); + + m_devices += device; + } + } + } + Q_EMIT finished(); + m_reply->deleteLater(); + deleteLater(); +} diff --git a/src/sync/gpodder/devicerequest.h b/src/sync/gpodder/devicerequest.h new file mode 100644 index 00000000..2f626d99 --- /dev/null +++ b/src/sync/gpodder/devicerequest.h @@ -0,0 +1,29 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include +#include +#include + +#include "sync/gpodder/genericrequest.h" +#include "sync/syncutils.h" + +class DeviceRequest : public GenericRequest +{ + Q_OBJECT + +public: + DeviceRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent); + + QVector devices() const; + +private: + void processResults() override; + + QVector m_devices; +}; diff --git a/src/sync/gpodder/episodeactionrequest.cpp b/src/sync/gpodder/episodeactionrequest.cpp new file mode 100644 index 00000000..0265d952 --- /dev/null +++ b/src/sync/gpodder/episodeactionrequest.cpp @@ -0,0 +1,111 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "episodeactionrequest.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "database.h" +#include "synclogging.h" + +EpisodeActionRequest::EpisodeActionRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent) + : GenericRequest(provider, reply, parent) +{ +} + +QVector EpisodeActionRequest::episodeActions() const +{ + return m_episodeActions; +} + +qulonglong EpisodeActionRequest::timestamp() const +{ + return m_timestamp; +} + +void EpisodeActionRequest::processResults() +{ + if (m_reply->error()) { + m_error = m_reply->error(); + m_errorString = m_reply->errorString(); + qCDebug(kastsSync) << "m_reply error" << m_reply->errorString(); + } else { + QJsonParseError *error = nullptr; + QJsonDocument data = QJsonDocument::fromJson(m_reply->readAll(), error); + if (error) { + qCDebug(kastsSync) << "parse error" << error->errorString(); + m_error = 1; + m_errorString = error->errorString(); + } else if (!m_abort) { + for (const auto &jsonAction : data.object().value(QStringLiteral("actions")).toArray()) { + SyncUtils::EpisodeAction episodeAction; + episodeAction.id = jsonAction.toObject().value(QStringLiteral("guid")).toString(); + episodeAction.url = jsonAction.toObject().value(QStringLiteral("episode")).toString(); + episodeAction.podcast = cleanupUrl(jsonAction.toObject().value(QStringLiteral("podcast")).toString()); + episodeAction.device = jsonAction.toObject().value(QStringLiteral("device")).toString(); + episodeAction.action = jsonAction.toObject().value(QStringLiteral("action")).toString().toLower(); + if (episodeAction.action == QStringLiteral("play")) { + episodeAction.started = jsonAction.toObject().value(QStringLiteral("started")).toInt(); + episodeAction.position = jsonAction.toObject().value(QStringLiteral("position")).toInt(); + episodeAction.total = jsonAction.toObject().value(QStringLiteral("total")).toInt(); + } else { + episodeAction.started = 0; + episodeAction.position = 0; + episodeAction.total = 0; + } + QString actionTimestamp = jsonAction.toObject().value(QStringLiteral("timestamp")).toString(); + episodeAction.timestamp = static_cast( + QDateTime::fromString(actionTimestamp.section(QStringLiteral("."), 0, 0), QStringLiteral("yyyy-MM-dd'T'hh:mm:ss")).toMSecsSinceEpoch() + / 1000); + + // Finally we try to find the id for the entry based on the episode URL if + // no GUID is available. + // We also retrieve the feedUrl from the database to avoid problems with + // different URLs pointing to the same feed (e.g. http vs https) + if (!episodeAction.id.isEmpty()) { + // First check if the GUID we got from the service is in the DB + QSqlQuery query; + query.prepare(QStringLiteral("SELECT id, feed FROM Entries WHERE id=:id;")); + query.bindValue(QStringLiteral(":id"), episodeAction.id); + Database::instance().execute(query); + if (!query.next()) { + qCDebug(kastsSync) << "cannot find episode with id:" << episodeAction.id; + episodeAction.id.clear(); + } else { + episodeAction.podcast = query.value(QStringLiteral("feed")).toString(); + } + } + + if (episodeAction.id.isEmpty()) { + // There either was no GUID specified or we couldn't find it in the DB + // Try to find the episode based on the enclosure URL + QSqlQuery query; + query.prepare(QStringLiteral("SELECT id, feed FROM Enclosures WHERE url=:url;")); + query.bindValue(QStringLiteral(":url"), episodeAction.url); + Database::instance().execute(query); + if (!query.next()) { + qCDebug(kastsSync) << "cannot find episode with url:" << episodeAction.url; + continue; + } else { + episodeAction.id = query.value(QStringLiteral("id")).toString(); + episodeAction.podcast = query.value(QStringLiteral("feed")).toString(); + } + } + m_episodeActions += episodeAction; + } + m_timestamp = data.object().value(QStringLiteral("timestamp")).toInt(); + } + } + Q_EMIT finished(); + m_reply->deleteLater(); + deleteLater(); +} diff --git a/src/sync/gpodder/episodeactionrequest.h b/src/sync/gpodder/episodeactionrequest.h new file mode 100644 index 00000000..78152256 --- /dev/null +++ b/src/sync/gpodder/episodeactionrequest.h @@ -0,0 +1,31 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include +#include +#include + +#include "sync/gpodder/genericrequest.h" +#include "sync/syncutils.h" + +class EpisodeActionRequest : public GenericRequest +{ + Q_OBJECT + +public: + EpisodeActionRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent); + + QVector episodeActions() const; + qulonglong timestamp() const; + +private: + void processResults() override; + + QVector m_episodeActions; + qulonglong m_timestamp = 0; +}; diff --git a/src/sync/gpodder/genericrequest.cpp b/src/sync/gpodder/genericrequest.cpp new file mode 100644 index 00000000..5622082a --- /dev/null +++ b/src/sync/gpodder/genericrequest.cpp @@ -0,0 +1,49 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "genericrequest.h" + +#include +#include + +GenericRequest::GenericRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent) + : QObject(parent) + , m_reply(reply) + , m_provider(provider) +{ + connect(m_reply, &QNetworkReply::finished, this, &GenericRequest::processResults); + connect(this, &GenericRequest::aborting, m_reply, &QNetworkReply::abort); +} + +int GenericRequest::error() const +{ + return m_error; +} + +QString GenericRequest::errorString() const +{ + return m_errorString; +} + +bool GenericRequest::aborted() const +{ + return m_abort; +} + +void GenericRequest::abort() +{ + m_abort = true; + Q_EMIT aborting(); +} + +QString GenericRequest::cleanupUrl(const QString &url) const +{ + if (m_provider == SyncUtils::Provider::GPodderNet) { + return QUrl::fromPercentEncoding(url.toUtf8()); + } else { + return url; + } +} diff --git a/src/sync/gpodder/genericrequest.h b/src/sync/gpodder/genericrequest.h new file mode 100644 index 00000000..919ff264 --- /dev/null +++ b/src/sync/gpodder/genericrequest.h @@ -0,0 +1,40 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include +#include + +#include "sync/syncutils.h" + +class GenericRequest : public QObject +{ + Q_OBJECT + +public: + GenericRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent); + + int error() const; + QString errorString() const; + bool aborted() const; + void abort(); + +Q_SIGNALS: + void finished(); + void aborting(); + +protected: + virtual void processResults() = 0; + + QString cleanupUrl(const QString &url) const; + + QNetworkReply *m_reply; + SyncUtils::Provider m_provider; + int m_error = 0; + QString m_errorString; + bool m_abort = false; +}; diff --git a/src/sync/gpodder/gpodder.cpp b/src/sync/gpodder/gpodder.cpp new file mode 100644 index 00000000..e5346919 --- /dev/null +++ b/src/sync/gpodder/gpodder.cpp @@ -0,0 +1,276 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "gpodder.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "database.h" +#include "fetcher.h" +#include "sync/gpodder/devicerequest.h" +#include "sync/gpodder/episodeactionrequest.h" +#include "sync/gpodder/loginrequest.h" +#include "sync/gpodder/logoutrequest.h" +#include "sync/gpodder/subscriptionrequest.h" +#include "sync/gpodder/syncrequest.h" +#include "sync/gpodder/updatedevicerequest.h" +#include "sync/gpodder/updatesyncrequest.h" +#include "sync/gpodder/uploadepisodeactionrequest.h" +#include "sync/gpodder/uploadsubscriptionrequest.h" +#include "sync/sync.h" +#include "sync/syncutils.h" +#include "synclogging.h" + +GPodder::GPodder(const QString &username, const QString &password, QObject *parent) + : QObject(parent) + , m_username(username) + , m_password(password) +{ +} + +GPodder::GPodder(const QString &username, const QString &password, const QString &hostname, const SyncUtils::Provider provider, QObject *parent) + : QObject(parent) + , m_username(username) + , m_password(password) + , m_hostname(hostname) + , m_provider(provider) +{ +} + +LoginRequest *GPodder::login() +{ + QString url = QStringLiteral("%1/api/2/auth/%2/login.json").arg(baseUrl()).arg(m_username); + QNetworkRequest request((QUrl(url))); + request.setTransferTimeout(); + addAuthentication(request); + + QNetworkReply *reply = Fetcher::instance().post(request, QByteArray()); + LoginRequest *loginRequest = new LoginRequest(m_provider, reply, this); + return loginRequest; +} + +LogoutRequest *GPodder::logout() +{ + QString url = QStringLiteral("%1/api/2/auth/%2/logout.json").arg(baseUrl()).arg(m_username); + QNetworkRequest request((QUrl(url))); + request.setTransferTimeout(); + addAuthentication(request); + + QNetworkReply *reply = Fetcher::instance().post(request, QByteArray()); + LogoutRequest *logoutRequest = new LogoutRequest(m_provider, reply, this); + return logoutRequest; +} + +DeviceRequest *GPodder::getDevices() +{ + QString url = QStringLiteral("%1/api/2/devices/%2.json").arg(baseUrl()).arg(m_username); + QNetworkRequest request((QUrl(url))); + request.setTransferTimeout(); + addAuthentication(request); + + QNetworkReply *reply = Fetcher::instance().get(request); + DeviceRequest *deviceRequest = new DeviceRequest(m_provider, reply, this); + return deviceRequest; +} + +UpdateDeviceRequest *GPodder::updateDevice(const QString &id, const QString &caption, const QString &type) +{ + QString url = QStringLiteral("%1/api/2/devices/%2/%3.json").arg(baseUrl()).arg(m_username).arg(id); + QNetworkRequest request((QUrl(url))); + request.setTransferTimeout(); + addAuthentication(request); + + QJsonObject deviceObject; + deviceObject.insert(QStringLiteral("caption"), caption); + deviceObject.insert(QStringLiteral("type"), type); + QJsonDocument json(deviceObject); + + QByteArray data = json.toJson(QJsonDocument::Compact); + + QNetworkReply *reply = Fetcher::instance().post(request, data); + + UpdateDeviceRequest *updateDeviceRequest = new UpdateDeviceRequest(m_provider, reply, this); + return updateDeviceRequest; +} +SyncRequest *GPodder::getSyncStatus() +{ + QString url = QStringLiteral("%1/api/2/sync-devices/%2.json").arg(baseUrl()).arg(m_username); + QNetworkRequest request((QUrl(url))); + request.setTransferTimeout(); + addAuthentication(request); + + QNetworkReply *reply = Fetcher::instance().get(request); + SyncRequest *syncRequest = new SyncRequest(m_provider, reply, this); + return syncRequest; +} + +UpdateSyncRequest *GPodder::updateSyncStatus(const QVector &syncedDevices, const QStringList &unsyncedDevices) +{ + QString url = QStringLiteral("%1/api/2/sync-devices/%2.json").arg(baseUrl()).arg(m_username); + QNetworkRequest request((QUrl(url))); + request.setTransferTimeout(); + addAuthentication(request); + + QJsonArray syncGroupArray; + for (QStringList syncGroup : syncedDevices) { + syncGroupArray.push_back(QJsonArray::fromStringList(syncGroup)); + } + QJsonArray unsyncArray = QJsonArray::fromStringList(unsyncedDevices); + + QJsonObject uploadObject; + uploadObject.insert(QStringLiteral("synchronize"), syncGroupArray); + uploadObject.insert(QStringLiteral("stop-synchronize"), unsyncArray); + QJsonDocument json(uploadObject); + + QByteArray data = json.toJson(QJsonDocument::Compact); + + QNetworkReply *reply = Fetcher::instance().post(request, data); + + UpdateSyncRequest *updateSyncRequest = new UpdateSyncRequest(m_provider, reply, this); + return updateSyncRequest; +} + +SubscriptionRequest *GPodder::getSubscriptionChanges(const qulonglong &oldtimestamp, const QString &device) +{ + QString url; + if (m_provider == SyncUtils::Provider::GPodderNextcloud) { + url = QStringLiteral("%1/index.php/apps/gpoddersync/subscriptions?since=%2").arg(baseUrl()).arg(QString::number(oldtimestamp)); + } else { + url = QStringLiteral("%1/api/2/subscriptions/%2/%3.json?since=%4").arg(baseUrl()).arg(m_username).arg(device).arg(QString::number(oldtimestamp)); + } + QNetworkRequest request((QUrl(url))); + request.setTransferTimeout(); + addAuthentication(request); + + QNetworkReply *reply = Fetcher::instance().get(request); + + SubscriptionRequest *subscriptionRequest = new SubscriptionRequest(m_provider, reply, this); + return subscriptionRequest; +} + +UploadSubscriptionRequest *GPodder::uploadSubscriptionChanges(const QStringList &add, const QStringList &remove, const QString &device) +{ + QString url; + if (m_provider == SyncUtils::Provider::GPodderNextcloud) { + url = QStringLiteral("%1/index.php/apps/gpoddersync/subscription_change/create").arg(baseUrl()); + } else { + url = QStringLiteral("%1/api/2/subscriptions/%2/%3.json").arg(baseUrl()).arg(m_username).arg(device); + } + QNetworkRequest request((QUrl(url))); + request.setTransferTimeout(); + addAuthentication(request); + + QJsonArray addArray = QJsonArray::fromStringList(add); + QJsonArray removeArray = QJsonArray::fromStringList(remove); + + QJsonObject uploadObject; + uploadObject.insert(QStringLiteral("add"), addArray); + uploadObject.insert(QStringLiteral("remove"), removeArray); + QJsonDocument json(uploadObject); + + QByteArray data = json.toJson(QJsonDocument::Compact); + + QNetworkReply *reply = Fetcher::instance().post(request, data); + + UploadSubscriptionRequest *uploadSubscriptionRequest = new UploadSubscriptionRequest(m_provider, reply, this); + return uploadSubscriptionRequest; +} + +EpisodeActionRequest *GPodder::getEpisodeActions(const qulonglong ×tamp, bool aggregated) +{ + QString url; + if (m_provider == SyncUtils::Provider::GPodderNextcloud) { + url = QStringLiteral("%1/index.php/apps/gpoddersync/episode_action?since=%2").arg(baseUrl()).arg(QString::number(timestamp)); + } else { + url = QStringLiteral("%1/api/2/episodes/%2.json?since=%3&aggregated=%4") + .arg(baseUrl()) + .arg(m_username) + .arg(QString::number(timestamp)) + .arg(aggregated ? QStringLiteral("true") : QStringLiteral("false")); + } + QNetworkRequest request((QUrl(url))); + request.setTransferTimeout(); + addAuthentication(request); + + QNetworkReply *reply = Fetcher::instance().get(request); + + EpisodeActionRequest *episodeActionRequest = new EpisodeActionRequest(m_provider, reply, this); + return episodeActionRequest; +} + +UploadEpisodeActionRequest *GPodder::uploadEpisodeActions(const QVector &episodeActions) +{ + QString url; + if (m_provider == SyncUtils::Provider::GPodderNextcloud) { + url = QStringLiteral("%1/index.php/apps/gpoddersync/episode_action/create").arg(baseUrl()); + } else { + url = QStringLiteral("%1/api/2/episodes/%2.json").arg(baseUrl()).arg(m_username); + } + QNetworkRequest request((QUrl(url))); + request.setTransferTimeout(); + addAuthentication(request); + + QJsonArray actionArray; + for (SyncUtils::EpisodeAction episodeAction : episodeActions) { + QJsonObject actionObject; + actionObject.insert(QStringLiteral("podcast"), episodeAction.podcast); + if (!episodeAction.url.isEmpty()) { + actionObject.insert(QStringLiteral("episode"), episodeAction.url); + } else if (!episodeAction.id.isEmpty()) { + QSqlQuery query; + query.prepare(QStringLiteral("SELECT url FROM Enclosures WHERE id=:id;")); + query.bindValue(QStringLiteral(":id"), episodeAction.id); + Database::instance().execute(query); + if (!query.next()) { + qCDebug(kastsSync) << "cannot find episode with id:" << episodeAction.id; + continue; + } else { + actionObject.insert(QStringLiteral("episode"), query.value(QStringLiteral("url")).toString()); + } + } + actionObject.insert(QStringLiteral("guid"), episodeAction.id); + actionObject.insert(QStringLiteral("device"), episodeAction.device); + actionObject.insert(QStringLiteral("action"), episodeAction.action); + + QString dateTime = QDateTime::fromSecsSinceEpoch(episodeAction.timestamp).toUTC().toString(Qt::ISODate); + // Qt::ISODate adds "Z" to the end of the string; cut it off since + // GPodderNextcloud cannot handle it + dateTime.chop(1); + actionObject.insert(QStringLiteral("timestamp"), dateTime); + if (episodeAction.action == QStringLiteral("play")) { + actionObject.insert(QStringLiteral("started"), static_cast(episodeAction.started)); + actionObject.insert(QStringLiteral("position"), static_cast(episodeAction.position)); + actionObject.insert(QStringLiteral("total"), static_cast(episodeAction.total)); + } + actionArray.push_back(actionObject); + } + QJsonDocument json(actionArray); + + QByteArray data = json.toJson(QJsonDocument::Compact); + + QNetworkReply *reply = Fetcher::instance().post(request, data); + + UploadEpisodeActionRequest *uploadEpisodeActionRequest = new UploadEpisodeActionRequest(m_provider, reply, this); + return uploadEpisodeActionRequest; +} + +QString GPodder::baseUrl() +{ + return m_hostname; +} + +void GPodder::addAuthentication(QNetworkRequest &request) +{ + QByteArray headerData = "Basic " + QString(m_username + QStringLiteral(":") + m_password).toLocal8Bit().toBase64(); + request.setRawHeader("Authorization", headerData); +} diff --git a/src/sync/gpodder/gpodder.h b/src/sync/gpodder/gpodder.h new file mode 100644 index 00000000..c362f763 --- /dev/null +++ b/src/sync/gpodder/gpodder.h @@ -0,0 +1,55 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include +#include +#include +#include + +#include "sync/sync.h" +#include "sync/syncutils.h" + +class LoginRequest; +class LogoutRequest; +class DeviceRequest; +class UpdateDeviceRequest; +class SyncRequest; +class UpdateSyncRequest; +class SubscriptionRequest; +class UploadSubscriptionRequest; +class EpisodeActionRequest; +class UploadEpisodeActionRequest; + +class GPodder : public QObject +{ + Q_OBJECT + +public: + GPodder(const QString &username, const QString &password, QObject *parent = nullptr); + GPodder(const QString &username, const QString &password, const QString &hostname, const SyncUtils::Provider provider, QObject *parent = nullptr); + + LoginRequest *login(); + LogoutRequest *logout(); + DeviceRequest *getDevices(); + UpdateDeviceRequest *updateDevice(const QString &id, const QString &caption, const QString &type = QStringLiteral("other")); + SyncRequest *getSyncStatus(); + UpdateSyncRequest *updateSyncStatus(const QVector &syncedDevices, const QStringList &unsyncedDevices); + SubscriptionRequest *getSubscriptionChanges(const qulonglong &oldtimestamp, const QString &device); + UploadSubscriptionRequest *uploadSubscriptionChanges(const QStringList &add, const QStringList &remove, const QString &device); + EpisodeActionRequest *getEpisodeActions(const qulonglong ×tamp, bool aggregated = false); + UploadEpisodeActionRequest *uploadEpisodeActions(const QVector &episodeActions); + +private: + QString baseUrl(); + void addAuthentication(QNetworkRequest &request); + + QString m_username; + QString m_password; + QString m_hostname = QLatin1String("https://gpodder.net"); + SyncUtils::Provider m_provider = SyncUtils::Provider::GPodderNet; +}; diff --git a/src/sync/gpodder/loginrequest.cpp b/src/sync/gpodder/loginrequest.cpp new file mode 100644 index 00000000..c6d9b079 --- /dev/null +++ b/src/sync/gpodder/loginrequest.cpp @@ -0,0 +1,35 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "loginrequest.h" + +#include + +#include "synclogging.h" + +LoginRequest::LoginRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent) + : GenericRequest(provider, reply, parent) +{ +} + +bool LoginRequest::success() const +{ + return m_success; +} + +void LoginRequest::processResults() +{ + if (m_reply->error()) { + m_error = m_reply->error(); + m_errorString = m_reply->errorString(); + qCDebug(kastsSync) << "m_reply error" << m_reply->errorString(); + } else if (!m_abort) { + m_success = true; + } + Q_EMIT finished(); + m_reply->deleteLater(); + deleteLater(); +} diff --git a/src/sync/gpodder/loginrequest.h b/src/sync/gpodder/loginrequest.h new file mode 100644 index 00000000..5181ecee --- /dev/null +++ b/src/sync/gpodder/loginrequest.h @@ -0,0 +1,28 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include +#include + +#include "sync/gpodder/genericrequest.h" +#include "sync/syncutils.h" + +class LoginRequest : public GenericRequest +{ + Q_OBJECT + +public: + LoginRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent); + + bool success() const; + +private: + void processResults() override; + + bool m_success = false; +}; diff --git a/src/sync/gpodder/logoutrequest.cpp b/src/sync/gpodder/logoutrequest.cpp new file mode 100644 index 00000000..3dfd9047 --- /dev/null +++ b/src/sync/gpodder/logoutrequest.cpp @@ -0,0 +1,35 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "logoutrequest.h" + +#include + +#include "synclogging.h" + +LogoutRequest::LogoutRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent) + : GenericRequest(provider, reply, parent) +{ +} + +bool LogoutRequest::success() const +{ + return m_success; +} + +void LogoutRequest::processResults() +{ + if (m_reply->error()) { + m_error = m_reply->error(); + m_errorString = m_reply->errorString(); + qCDebug(kastsSync) << "m_reply error" << m_reply->errorString(); + } else if (!m_abort) { + m_success = true; + } + Q_EMIT finished(); + m_reply->deleteLater(); + deleteLater(); +} diff --git a/src/sync/gpodder/logoutrequest.h b/src/sync/gpodder/logoutrequest.h new file mode 100644 index 00000000..00c01865 --- /dev/null +++ b/src/sync/gpodder/logoutrequest.h @@ -0,0 +1,28 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include +#include + +#include "sync/gpodder/genericrequest.h" +#include "sync/syncutils.h" + +class LogoutRequest : public GenericRequest +{ + Q_OBJECT + +public: + LogoutRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent); + + bool success() const; + +private: + void processResults() override; + + bool m_success = false; +}; diff --git a/src/sync/gpodder/subscriptionrequest.cpp b/src/sync/gpodder/subscriptionrequest.cpp new file mode 100644 index 00000000..7df80b91 --- /dev/null +++ b/src/sync/gpodder/subscriptionrequest.cpp @@ -0,0 +1,65 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "subscriptionrequest.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "synclogging.h" + +SubscriptionRequest::SubscriptionRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent) + : GenericRequest(provider, reply, parent) +{ +} + +QStringList SubscriptionRequest::addList() const +{ + return m_add; +} + +QStringList SubscriptionRequest::removeList() const +{ + return m_remove; +} + +qulonglong SubscriptionRequest::timestamp() const +{ + return m_timestamp; +} + +void SubscriptionRequest::processResults() +{ + if (m_reply->error()) { + m_error = m_reply->error(); + m_errorString = m_reply->errorString(); + qCDebug(kastsSync) << "m_reply error" << m_reply->errorString(); + } else { + QJsonParseError *error = nullptr; + QJsonDocument data = QJsonDocument::fromJson(m_reply->readAll(), error); + if (error) { + qCDebug(kastsSync) << "parse error" << error->errorString(); + m_error = 1; + m_errorString = error->errorString(); + } else { + for (auto jsonFeed : data.object().value(QStringLiteral("add")).toArray()) { + m_add += cleanupUrl(jsonFeed.toString()); + } + for (auto jsonFeed : data.object().value(QStringLiteral("remove")).toArray()) { + m_remove += cleanupUrl(jsonFeed.toString()); + } + m_timestamp = data.object().value(QStringLiteral("timestamp")).toInt(); + } + } + Q_EMIT finished(); + m_reply->deleteLater(); + deleteLater(); +} diff --git a/src/sync/gpodder/subscriptionrequest.h b/src/sync/gpodder/subscriptionrequest.h new file mode 100644 index 00000000..7b5f62ee --- /dev/null +++ b/src/sync/gpodder/subscriptionrequest.h @@ -0,0 +1,33 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include +#include +#include + +#include "sync/gpodder/genericrequest.h" +#include "sync/syncutils.h" + +class SubscriptionRequest : public GenericRequest +{ + Q_OBJECT + +public: + SubscriptionRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent); + + QStringList addList() const; + QStringList removeList() const; + qulonglong timestamp() const; + +private: + void processResults() override; + + QStringList m_add; + QStringList m_remove; + qulonglong m_timestamp = 0; +}; diff --git a/src/sync/gpodder/syncrequest.cpp b/src/sync/gpodder/syncrequest.cpp new file mode 100644 index 00000000..e5d5b0f8 --- /dev/null +++ b/src/sync/gpodder/syncrequest.cpp @@ -0,0 +1,62 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "syncrequest.h" + +#include +#include +#include +#include +#include +#include + +#include "synclogging.h" + +SyncRequest::SyncRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent) + : GenericRequest(provider, reply, parent) +{ +} + +QVector SyncRequest::syncedDevices() const +{ + return m_syncedDevices; +} + +QStringList SyncRequest::unsyncedDevices() const +{ + return m_unsyncedDevices; +} + +void SyncRequest::processResults() +{ + if (m_reply->error()) { + m_error = m_reply->error(); + m_errorString = m_reply->errorString(); + qCDebug(kastsSync) << "m_reply error" << m_reply->errorString(); + } else if (!m_abort) { + QJsonParseError *error = nullptr; + QJsonDocument data = QJsonDocument::fromJson(m_reply->readAll(), error); + if (error) { + qCDebug(kastsSync) << "parse error" << error->errorString(); + m_error = 1; + m_errorString = error->errorString(); + } else if (!m_abort) { + for (auto jsonGroup : data.object().value(QStringLiteral("synchronized")).toArray()) { + QStringList syncedGroup; + for (auto jsonDevice : jsonGroup.toArray()) { + syncedGroup += jsonDevice.toString(); + } + m_syncedDevices += syncedGroup; + } + for (auto jsonDevice : data.object().value(QStringLiteral("not-synchronized")).toArray()) { + m_unsyncedDevices += jsonDevice.toString(); + } + } + } + Q_EMIT finished(); + m_reply->deleteLater(); + deleteLater(); +} diff --git a/src/sync/gpodder/syncrequest.h b/src/sync/gpodder/syncrequest.h new file mode 100644 index 00000000..0a338115 --- /dev/null +++ b/src/sync/gpodder/syncrequest.h @@ -0,0 +1,32 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include +#include +#include +#include + +#include "sync/gpodder/genericrequest.h" +#include "sync/syncutils.h" + +class SyncRequest : public GenericRequest +{ + Q_OBJECT + +public: + SyncRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent); + + QVector syncedDevices() const; + QStringList unsyncedDevices() const; + +private: + void processResults() override; + + QVector m_syncedDevices; + QStringList m_unsyncedDevices; +}; diff --git a/src/sync/gpodder/updatedevicerequest.cpp b/src/sync/gpodder/updatedevicerequest.cpp new file mode 100644 index 00000000..06319cab --- /dev/null +++ b/src/sync/gpodder/updatedevicerequest.cpp @@ -0,0 +1,36 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "updatedevicerequest.h" + +#include + +#include "sync/syncutils.h" +#include "synclogging.h" + +UpdateDeviceRequest::UpdateDeviceRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent) + : GenericRequest(provider, reply, parent) +{ +} + +bool UpdateDeviceRequest::success() const +{ + return m_success; +} + +void UpdateDeviceRequest::processResults() +{ + if (m_reply->error()) { + m_error = m_reply->error(); + m_errorString = m_reply->errorString(); + qCDebug(kastsSync) << "m_reply error" << m_reply->errorString(); + } else if (!m_abort) { + m_success = true; + } + Q_EMIT finished(); + m_reply->deleteLater(); + deleteLater(); +} diff --git a/src/sync/gpodder/updatedevicerequest.h b/src/sync/gpodder/updatedevicerequest.h new file mode 100644 index 00000000..488df767 --- /dev/null +++ b/src/sync/gpodder/updatedevicerequest.h @@ -0,0 +1,28 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include +#include + +#include "sync/gpodder/genericrequest.h" +#include "sync/syncutils.h" + +class UpdateDeviceRequest : public GenericRequest +{ + Q_OBJECT + +public: + UpdateDeviceRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent); + + bool success() const; + +private: + void processResults() override; + + bool m_success = false; +}; diff --git a/src/sync/gpodder/updatesyncrequest.cpp b/src/sync/gpodder/updatesyncrequest.cpp new file mode 100644 index 00000000..a5b8e78f --- /dev/null +++ b/src/sync/gpodder/updatesyncrequest.cpp @@ -0,0 +1,68 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "updatesyncrequest.h" + +#include +#include +#include +#include +#include +#include + +#include "synclogging.h" + +UpdateSyncRequest::UpdateSyncRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent) + : GenericRequest(provider, reply, parent) +{ +} + +bool UpdateSyncRequest::success() const +{ + return m_success; +} + +QVector UpdateSyncRequest::syncedDevices() const +{ + return m_syncedDevices; +} + +QStringList UpdateSyncRequest::unsyncedDevices() const +{ + return m_unsyncedDevices; +} + +void UpdateSyncRequest::processResults() +{ + if (m_reply->error()) { + m_error = m_reply->error(); + m_errorString = m_reply->errorString(); + qCDebug(kastsSync) << "m_reply error" << m_reply->errorString(); + } else if (!m_abort) { + QJsonParseError *error = nullptr; + QJsonDocument data = QJsonDocument::fromJson(m_reply->readAll(), error); + if (error) { + qCDebug(kastsSync) << "parse error" << error->errorString(); + m_error = 1; + m_errorString = error->errorString(); + } else if (!m_abort) { + for (auto jsonGroup : data.object().value(QStringLiteral("synchronized")).toArray()) { + QStringList syncedGroup; + for (auto jsonDevice : jsonGroup.toArray()) { + syncedGroup += jsonDevice.toString(); + } + m_syncedDevices += syncedGroup; + } + for (auto jsonDevice : data.object().value(QStringLiteral("not-synchronized")).toArray()) { + m_unsyncedDevices += jsonDevice.toString(); + } + m_success = true; + } + } + Q_EMIT finished(); + m_reply->deleteLater(); + deleteLater(); +} diff --git a/src/sync/gpodder/updatesyncrequest.h b/src/sync/gpodder/updatesyncrequest.h new file mode 100644 index 00000000..125f3883 --- /dev/null +++ b/src/sync/gpodder/updatesyncrequest.h @@ -0,0 +1,34 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include +#include +#include +#include + +#include "sync/gpodder/genericrequest.h" +#include "sync/syncutils.h" + +class UpdateSyncRequest : public GenericRequest +{ + Q_OBJECT + +public: + UpdateSyncRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent); + + bool success() const; + QVector syncedDevices() const; + QStringList unsyncedDevices() const; + +private: + void processResults() override; + + QVector m_syncedDevices; + QStringList m_unsyncedDevices; + bool m_success = false; +}; diff --git a/src/sync/gpodder/uploadepisodeactionrequest.cpp b/src/sync/gpodder/uploadepisodeactionrequest.cpp new file mode 100644 index 00000000..ae073c08 --- /dev/null +++ b/src/sync/gpodder/uploadepisodeactionrequest.cpp @@ -0,0 +1,58 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "uploadepisodeactionrequest.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "synclogging.h" + +UploadEpisodeActionRequest::UploadEpisodeActionRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent) + : GenericRequest(provider, reply, parent) +{ +} + +QVector> UploadEpisodeActionRequest::updateUrls() const +{ + return m_updateUrls; +} + +qulonglong UploadEpisodeActionRequest::timestamp() const +{ + return m_timestamp; +} + +void UploadEpisodeActionRequest::processResults() +{ + if (m_reply->error()) { + m_error = m_reply->error(); + m_errorString = m_reply->errorString(); + qCDebug(kastsSync) << "m_reply error" << m_reply->errorString(); + } else if (!m_abort) { + QJsonParseError *error = nullptr; + QJsonDocument data = QJsonDocument::fromJson(m_reply->readAll(), error); + if (error) { + qCDebug(kastsSync) << "parse error" << error->errorString(); + m_error = 1; + m_errorString = error->errorString(); + } else { + for (auto jsonFeed : data.object().value(QStringLiteral("update_urls")).toArray()) { + m_updateUrls += QPair(jsonFeed.toArray().at(0).toString(), jsonFeed.toArray().at(1).toString()); + } + m_timestamp = data.object().value(QStringLiteral("timestamp")).toInt(); + } + } + Q_EMIT finished(); + m_reply->deleteLater(); + deleteLater(); +} diff --git a/src/sync/gpodder/uploadepisodeactionrequest.h b/src/sync/gpodder/uploadepisodeactionrequest.h new file mode 100644 index 00000000..92f99518 --- /dev/null +++ b/src/sync/gpodder/uploadepisodeactionrequest.h @@ -0,0 +1,33 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include "sync/gpodder/genericrequest.h" +#include "sync/syncutils.h" + +class UploadEpisodeActionRequest : public GenericRequest +{ + Q_OBJECT + +public: + UploadEpisodeActionRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent); + + QVector> updateUrls() const; + qulonglong timestamp() const; + +private: + void processResults() override; + + QVector> m_updateUrls; + qulonglong m_timestamp = 0; +}; diff --git a/src/sync/gpodder/uploadsubscriptionrequest.cpp b/src/sync/gpodder/uploadsubscriptionrequest.cpp new file mode 100644 index 00000000..0931703d --- /dev/null +++ b/src/sync/gpodder/uploadsubscriptionrequest.cpp @@ -0,0 +1,58 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "uploadsubscriptionrequest.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "synclogging.h" + +UploadSubscriptionRequest::UploadSubscriptionRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent) + : GenericRequest(provider, reply, parent) +{ +} + +QVector> UploadSubscriptionRequest::updateUrls() const +{ + return m_updateUrls; +} + +qulonglong UploadSubscriptionRequest::timestamp() const +{ + return m_timestamp; +} + +void UploadSubscriptionRequest::processResults() +{ + if (m_reply->error()) { + m_error = m_reply->error(); + m_errorString = m_reply->errorString(); + qCDebug(kastsSync) << "m_reply error" << m_reply->errorString(); + } else if (!m_abort) { + QJsonParseError *error = nullptr; + QJsonDocument data = QJsonDocument::fromJson(m_reply->readAll(), error); + if (error) { + qCDebug(kastsSync) << "parse error" << error->errorString(); + m_error = 1; + m_errorString = error->errorString(); + } else { + for (auto jsonFeed : data.object().value(QStringLiteral("update_urls")).toArray()) { + m_updateUrls += QPair(jsonFeed.toArray().at(0).toString(), jsonFeed.toArray().at(1).toString()); + } + m_timestamp = data.object().value(QStringLiteral("timestamp")).toInt(); + } + } + Q_EMIT finished(); + m_reply->deleteLater(); + deleteLater(); +} diff --git a/src/sync/gpodder/uploadsubscriptionrequest.h b/src/sync/gpodder/uploadsubscriptionrequest.h new file mode 100644 index 00000000..601b2ff9 --- /dev/null +++ b/src/sync/gpodder/uploadsubscriptionrequest.h @@ -0,0 +1,33 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include "sync/gpodder/genericrequest.h" +#include "sync/syncutils.h" + +class UploadSubscriptionRequest : public GenericRequest +{ + Q_OBJECT + +public: + UploadSubscriptionRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent); + + QVector> updateUrls() const; + qulonglong timestamp() const; + +private: + void processResults() override; + + QVector> m_updateUrls; + qulonglong m_timestamp = 0; +}; diff --git a/src/sync/sync.cpp b/src/sync/sync.cpp new file mode 100644 index 00000000..447619c5 --- /dev/null +++ b/src/sync/sync.cpp @@ -0,0 +1,945 @@ +/** + * SPDX-FileCopyrightText: 2021 Tobias Fella + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "sync.h" +#include "synclogging.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "audiomanager.h" +#include "database.h" +#include "datamanager.h" +#include "entry.h" +#include "fetcher.h" +#include "fetchfeedsjob.h" +#include "models/errorlogmodel.h" +#include "settingsmanager.h" +#include "storagemanager.h" +#include "sync/gpodder/devicerequest.h" +#include "sync/gpodder/episodeactionrequest.h" +#include "sync/gpodder/gpodder.h" +#include "sync/gpodder/logoutrequest.h" +#include "sync/gpodder/subscriptionrequest.h" +#include "sync/gpodder/syncrequest.h" +#include "sync/gpodder/updatedevicerequest.h" +#include "sync/gpodder/updatesyncrequest.h" +#include "sync/gpodder/uploadepisodeactionrequest.h" +#include "sync/gpodder/uploadsubscriptionrequest.h" +#include "sync/syncjob.h" +#include "sync/syncutils.h" + +#include + +using namespace SyncUtils; + +Sync::Sync() + : QObject() +{ + connect(this, &Sync::error, &ErrorLogModel::instance(), &ErrorLogModel::monitorErrorMessages); + connect(&AudioManager::instance(), &AudioManager::playbackStateChanged, this, &Sync::doQuickSync); + + retrieveCredentialsFromConfig(); +} + +void Sync::retrieveCredentialsFromConfig() +{ + if (!SettingsManager::self()->syncEnabled()) { + m_syncEnabled = false; + Q_EMIT syncEnabledChanged(); + } else if (!SettingsManager::self()->syncUsername().isEmpty()) { + m_username = SettingsManager::self()->syncUsername(); + m_hostname = SettingsManager::self()->syncHostname(); + m_provider = static_cast(SettingsManager::self()->syncProvider()); + + connect(this, &Sync::passwordRetrievalFinished, this, [=](QString password) { + disconnect(this, &Sync::passwordRetrievalFinished, this, nullptr); + if (!password.isEmpty()) { + m_syncEnabled = SettingsManager::self()->syncEnabled(); + m_password = password; + + if (m_provider == Provider::GPodderNet) { + m_device = SettingsManager::self()->syncDevice(); + m_deviceName = SettingsManager::self()->syncDeviceName(); + + if (m_syncEnabled && !m_username.isEmpty() && !m_password.isEmpty() && !m_device.isEmpty()) { + m_gpodder = new GPodder(m_username, m_password, this); + } + } else if (m_provider == Provider::GPodderNextcloud) { + m_hostname = SettingsManager::self()->syncHostname(); + + if (m_syncEnabled && !m_username.isEmpty() && !m_password.isEmpty() && !m_hostname.isEmpty()) { + m_gpodder = new GPodder(m_username, m_password, m_hostname, m_provider, this); + } + } + + m_syncEnabled = SettingsManager::self()->syncEnabled(); + Q_EMIT syncEnabledChanged(); + + // Now that we have all credentials we can do the initial sync if + // it's enabled in the config. If it's not enabled, then we handle + // the automatic refresh through main.qml + SolidExtras::NetworkStatus networkStatus; + if (networkStatus.connectivity() != SolidExtras::NetworkStatus::No + && (networkStatus.metered() != SolidExtras::NetworkStatus::Yes || SettingsManager::self()->allowMeteredFeedUpdates())) { + if (SettingsManager::self()->refreshOnStartup() && SettingsManager::self()->syncWhenUpdatingFeeds()) { + doRegularSync(true); + } + } + } else { + // Ask for password and try to log in; if it succeeds, try + // again to save the password. + m_syncEnabled = false; + QTimer::singleShot(0, this, [this]() { + Q_EMIT passwordInputRequired(); + }); + } + }); + retrievePasswordFromKeyChain(m_username); + } +} + +bool Sync::syncEnabled() const +{ + return m_syncEnabled; +} + +QString Sync::username() const +{ + return m_username; +} + +QString Sync::password() const +{ + return m_password; +} + +QString Sync::device() const +{ + return m_device; +} + +QString Sync::deviceName() const +{ + return m_deviceName; +} + +QString Sync::hostname() const +{ + return m_hostname; +} + +Provider Sync::provider() const +{ + return m_provider; +} + +QVector Sync::deviceList() const +{ + return m_deviceList; +} + +QString Sync::lastSuccessfulSync(const QStringList &matchingLabels) const +{ + qulonglong timestamp = 0; + QSqlQuery query; + query.prepare(QStringLiteral("SELECT * FROM SyncTimeStamps;")); + Database::instance().execute(query); + while (query.next()) { + QString label = query.value(QStringLiteral("syncservice")).toString(); + bool match = matchingLabels.isEmpty() || matchingLabels.contains(label); + if (match) { + qulonglong timestampDB = query.value(QStringLiteral("timestamp")).toULongLong(); + if (timestampDB > timestamp) { + timestamp = timestampDB; + } + } + } + + if (timestamp > 1) { + QDateTime datetime = QDateTime::fromSecsSinceEpoch(timestamp); + return m_kformat.formatRelativeDateTime(datetime, QLocale::ShortFormat); + } else { + return i18n("Never"); + } +} + +QString Sync::lastSuccessfulDownloadSync() const +{ + QStringList labels = {subscriptionTimestampLabel, episodeTimestampLabel}; + return lastSuccessfulSync(labels); +} + +QString Sync::lastSuccessfulUploadSync() const +{ + QStringList labels = {uploadSubscriptionTimestampLabel, uploadEpisodeTimestampLabel}; + return lastSuccessfulSync(labels); +} + +QString Sync::suggestedDevice() const +{ + return QStringLiteral("kasts-") + QSysInfo::machineHostName(); +} + +QString Sync::suggestedDeviceName() const +{ + return i18nc("Suggested description for this device on gpodder sync service; argument is the hostname", "Kasts on %1", QSysInfo::machineHostName()); +} + +void Sync::setSyncEnabled(bool status) +{ + m_syncEnabled = status; + SettingsManager::self()->setSyncEnabled(m_syncEnabled); + SettingsManager::self()->save(); + Q_EMIT syncEnabledChanged(); +} + +void Sync::setPassword(const QString &password) +{ + // this method is used to set the password if the proper credentials could + // not be retrieved from the keychain or file + connect(this, &Sync::passwordSaveFinished, this, [=]() { + disconnect(this, &Sync::passwordSaveFinished, this, nullptr); + QTimer::singleShot(0, this, [this]() { + retrieveCredentialsFromConfig(); + }); + }); + savePasswordToKeyChain(m_username, password); +} + +void Sync::setDevice(const QString &device) +{ + m_device = device; + SettingsManager::self()->setSyncDevice(m_device); + SettingsManager::self()->save(); + Q_EMIT deviceChanged(); +} + +void Sync::setDeviceName(const QString &deviceName) +{ + m_deviceName = deviceName; + SettingsManager::self()->setSyncDeviceName(m_deviceName); + SettingsManager::self()->save(); + Q_EMIT deviceNameChanged(); +} + +void Sync::setHostname(const QString &hostname) +{ + QString cleanedHostname = hostname; + QUrl hostUrl = QUrl(hostname); + + if (hostUrl.scheme().isEmpty()) { + hostUrl.setScheme(QStringLiteral("https")); + if (hostUrl.authority().isEmpty() && !hostUrl.path().isEmpty()) { + hostUrl.setAuthority(hostUrl.path()); + hostUrl.setPath(QStringLiteral("")); + } + cleanedHostname = hostUrl.toString(); + } + + m_hostname = cleanedHostname; + SettingsManager::self()->setSyncHostname(m_hostname); + SettingsManager::self()->save(); + Q_EMIT hostnameChanged(); +} + +void Sync::setProvider(const Provider provider) +{ + m_provider = provider; + SettingsManager::self()->setSyncProvider(m_provider); + SettingsManager::self()->save(); + Q_EMIT providerChanged(); +} + +void Sync::login(const QString &username, const QString &password) +{ + if (m_gpodder) { + delete m_gpodder; + m_gpodder = nullptr; + } + + m_deviceList.clear(); + + if (m_provider == Provider::GPodderNextcloud) { + m_gpodder = new GPodder(username, password, m_hostname, Provider::GPodderNextcloud, this); + + SubscriptionRequest *subRequest = m_gpodder->getSubscriptionChanges(0, QStringLiteral("")); + connect(subRequest, &SubscriptionRequest::finished, this, [=]() { + if (subRequest->error() || subRequest->aborted()) { + if (subRequest->error()) { + Q_EMIT error(Error::Type::SyncError, + QStringLiteral(""), + QStringLiteral(""), + subRequest->error(), + subRequest->errorString(), + i18n("Could not log into GPodder-nextcloud server")); + } + if (m_syncEnabled) { + setSyncEnabled(false); + } + } else { + connect(this, &Sync::passwordSaveFinished, this, [=](bool success) { + disconnect(this, &Sync::passwordSaveFinished, this, nullptr); + if (success) { + m_username = username; + m_password = password; + SettingsManager::self()->setSyncUsername(username); + SettingsManager::self()->save(); + Q_EMIT credentialsChanged(); + + setSyncEnabled(true); + Q_EMIT loginSucceeded(); + } + }); + savePasswordToKeyChain(username, password); + } + subRequest->deleteLater(); + }); + } else { // official gpodder.net server + m_gpodder = new GPodder(username, password, this); + + DeviceRequest *deviceRequest = m_gpodder->getDevices(); + connect(deviceRequest, &DeviceRequest::finished, this, [=]() { + if (deviceRequest->error() || deviceRequest->aborted()) { + if (deviceRequest->error()) { + Q_EMIT error(Error::Type::SyncError, + QStringLiteral(""), + QStringLiteral(""), + deviceRequest->error(), + deviceRequest->errorString(), + i18n("Could not log into GPodder server")); + } + m_gpodder->deleteLater(); + m_gpodder = nullptr; + if (m_syncEnabled) { + setSyncEnabled(false); + } + } else { + m_deviceList = deviceRequest->devices(); + + connect(this, &Sync::passwordSaveFinished, this, [=](bool success) { + disconnect(this, &Sync::passwordSaveFinished, this, nullptr); + if (success) { + m_username = username; + m_password = password; + SettingsManager::self()->setSyncUsername(username); + SettingsManager::self()->save(); + Q_EMIT credentialsChanged(); + + Q_EMIT loginSucceeded(); + Q_EMIT deviceListReceived(); // required in order to open follow-up device-pick dialog + } + }); + savePasswordToKeyChain(username, password); + } + deviceRequest->deleteLater(); + }); + } +} + +void Sync::logout() +{ + if (m_provider == Provider::GPodderNextcloud) { + clearSettings(); + } else { + if (!m_gpodder) { + clearSettings(); + return; + } + LogoutRequest *logoutRequest = m_gpodder->logout(); + connect(logoutRequest, &LogoutRequest::finished, this, [=]() { + if (logoutRequest->error() || logoutRequest->aborted()) { + if (logoutRequest->error()) { + // Let's not report this error, since it doesn't matter anyway: + // 1) If we're not logged in, there's no problem + // 2) If we are logged in, but somehow cannot log out, then it + // shouldn't matter either, since the session probably expired + /* + Q_EMIT error(Error::Type::SyncError, + QStringLiteral(""), + QStringLiteral(""), + logoutRequest->error(), + logoutRequest->errorString(), + i18n("Could not log out of GPodder server")); + */ + } + } + clearSettings(); + }); + } +} + +void Sync::clearSettings() +{ + if (m_gpodder) { + m_gpodder->deleteLater(); + m_gpodder = nullptr; + } + + QSqlQuery query; + // Delete pending EpisodeActions + query.prepare(QStringLiteral("DELETE FROM EpisodeActions;")); + Database::instance().execute(query); + + // Delete pending FeedActions + query.prepare(QStringLiteral("DELETE FROM FeedActions;")); + Database::instance().execute(query); + + // Delete SyncTimestamps + query.prepare(QStringLiteral("DELETE FROM SyncTimestamps;")); + Database::instance().execute(query); + + setSyncEnabled(false); + + // Delete password from keychain and password file + deletePasswordFromKeychain(m_username); + + m_username.clear(); + m_password.clear(); + m_device.clear(); + m_deviceName.clear(); + m_hostname.clear(); + m_provider = Provider::GPodderNet; + SettingsManager::self()->setSyncUsername(m_username); + SettingsManager::self()->setSyncDevice(m_device); + SettingsManager::self()->setSyncDeviceName(m_deviceName); + SettingsManager::self()->setSyncHostname(m_hostname); + SettingsManager::self()->setSyncProvider(static_cast(m_provider)); + SettingsManager::self()->save(); + + Q_EMIT credentialsChanged(); + Q_EMIT syncProgressChanged(); +} + +void Sync::savePasswordToKeyChain(const QString &username, const QString &password) +{ + qCDebug(kastsSync) << "Save the password to the keychain for" << username; + + QKeychain::WritePasswordJob *job = new QKeychain::WritePasswordJob(qAppName(), this); + job->setAutoDelete(false); + job->setKey(username); + job->setTextData(password); + + QKeychain::WritePasswordJob::connect(job, &QKeychain::Job::finished, this, [=]() { + if (job->error()) { + qCDebug(kastsSync) << "Could not save password to the keychain: " << qPrintable(job->errorString()); + // fall back to file + savePasswordToFile(username, password); + } else { + qCDebug(kastsSync) << "Password saved to keychain"; + Q_EMIT passwordSaveFinished(true); + } + job->deleteLater(); + }); + job->start(); +} + +void Sync::savePasswordToFile(const QString &username, const QString &password) +{ + qCDebug(kastsSync) << "Save the password to file for" << username; + + // NOTE: Store in the same location as database, which can be different from + // the storagePath + QString filePath = StorageManager::instance().passwordFilePath(username); + + QFile passwordFile(filePath); + passwordFile.remove(); + + QDir fileDir = QFileInfo(passwordFile).dir(); + if (!((fileDir.exists() || fileDir.mkpath(QStringLiteral("."))) && passwordFile.open(QFile::WriteOnly))) { + Q_EMIT error(Error::Type::SyncError, + passwordFile.fileName(), + QStringLiteral(""), + 0, + i18n("I/O Denied: Cannot save password."), + i18n("I/O Denied: Cannot save password.")); + Q_EMIT passwordSaveFinished(false); + } else { + passwordFile.write(password.toUtf8()); + passwordFile.close(); + Q_EMIT passwordSaveFinished(true); + } +} + +void Sync::retrievePasswordFromKeyChain(const QString &username) +{ + // Workaround: first try and store a dummy entry to the keychain to ensure + // that the keychain is unlocked before we try to retrieve the real password + + QKeychain::WritePasswordJob *writeDummyJob = new QKeychain::WritePasswordJob(qAppName(), this); + writeDummyJob->setAutoDelete(false); + writeDummyJob->setKey(QStringLiteral("dummy")); + writeDummyJob->setTextData(QStringLiteral("dummy")); + + QKeychain::WritePasswordJob::connect(writeDummyJob, &QKeychain::Job::finished, this, [=]() { + if (writeDummyJob->error()) { + qCDebug(kastsSync) << "Could not open keychain: " << qPrintable(writeDummyJob->errorString()); + // fall back to password from file + Q_EMIT passwordRetrievalFinished(retrievePasswordFromFile(username)); + } else { + // opening keychain succeeded, let's try to read the password + + QKeychain::ReadPasswordJob *readJob = new QKeychain::ReadPasswordJob(qAppName()); + readJob->setAutoDelete(false); + readJob->setKey(username); + + connect(readJob, &QKeychain::Job::finished, this, [=]() { + if (readJob->error() == QKeychain::Error::NoError) { + Q_EMIT passwordRetrievalFinished(readJob->textData()); + // if a password file is present, delete it + QFile(StorageManager::instance().passwordFilePath(username)).remove(); + } else { + qCDebug(kastsSync) << "Could not read the access token from the keychain: " << qPrintable(readJob->errorString()); + // no password from the keychain, try token file + QString password = retrievePasswordFromFile(username); + Q_EMIT passwordRetrievalFinished(password); + if (readJob->error() == QKeychain::Error::EntryNotFound) { + if (!password.isEmpty()) { + qCDebug(kastsSync) << "Migrating password from file to the keychain for " << username; + connect(this, &Sync::passwordSaveFinished, this, [=](bool saved) { + disconnect(this, &Sync::passwordSaveFinished, this, nullptr); + bool removed = false; + if (saved) { + QFile passwordFile(StorageManager::instance().passwordFilePath(username)); + removed = passwordFile.remove(); + } + if (!(saved && removed)) { + qCDebug(kastsSync) << "Migrating password from the file to the keychain failed"; + } + }); + savePasswordToKeyChain(username, password); + } + } + } + readJob->deleteLater(); + }); + readJob->start(); + } + writeDummyJob->deleteLater(); + }); + writeDummyJob->start(); +} + +QString Sync::retrievePasswordFromFile(const QString &username) +{ + QFile passwordFile(StorageManager::instance().passwordFilePath(username)); + + if (passwordFile.open(QFile::ReadOnly)) { + qCDebug(kastsSync) << "Retrieved password from file for user" << username; + return QString::fromUtf8(passwordFile.readAll()); + } else { + Q_EMIT error(Error::Type::SyncError, + passwordFile.fileName(), + QStringLiteral(""), + 0, + i18n("I/O Denied: Cannot access password file."), + i18n("I/O Denied: Cannot access password file.")); + + return QStringLiteral(""); + } +} + +void Sync::deletePasswordFromKeychain(const QString &username) +{ + // Workaround: first try and store a dummy entry to the keychain to ensure + // that the keychain is unlocked before we try to delete the real password + + QKeychain::WritePasswordJob *writeDummyJob = new QKeychain::WritePasswordJob(qAppName(), this); + writeDummyJob->setAutoDelete(false); + writeDummyJob->setKey(QStringLiteral("dummy")); + writeDummyJob->setTextData(QStringLiteral("dummy")); + + QKeychain::WritePasswordJob::connect(writeDummyJob, &QKeychain::Job::finished, this, [=]() { + if (writeDummyJob->error()) { + qCDebug(kastsSync) << "Could not open keychain: " << qPrintable(writeDummyJob->errorString()); + } else { + // opening keychain succeeded, let's try to delete the password + + QFile(StorageManager::instance().passwordFilePath(username)).remove(); + + QKeychain::DeletePasswordJob *deleteJob = new QKeychain::DeletePasswordJob(qAppName()); + deleteJob->setAutoDelete(true); + deleteJob->setKey(username); + + QKeychain::DeletePasswordJob::connect(deleteJob, &QKeychain::Job::finished, this, [=]() { + if (deleteJob->error() == QKeychain::Error::NoError) { + qCDebug(kastsSync) << "Password for username" << username << "successfully deleted from keychain"; + + // now also delete the dummy entry + QKeychain::DeletePasswordJob *deleteDummyJob = new QKeychain::DeletePasswordJob(qAppName()); + deleteDummyJob->setAutoDelete(true); + deleteDummyJob->setKey(QStringLiteral("dummy")); + + QKeychain::DeletePasswordJob::connect(deleteDummyJob, &QKeychain::Job::finished, this, [=]() { + if (deleteDummyJob->error()) { + qCDebug(kastsSync) << "Deleting dummy from keychain unsuccessful"; + } else { + qCDebug(kastsSync) << "Deleting dummy from keychain successful"; + } + }); + deleteDummyJob->start(); + } else if (deleteJob->error() == QKeychain::Error::EntryNotFound) { + qCDebug(kastsSync) << "No password for username" << username << "found in keychain"; + } else { + qCDebug(kastsSync) << "Could not access keychain to delete password for username" << username; + } + }); + deleteJob->start(); + } + writeDummyJob->deleteLater(); + }); + writeDummyJob->start(); +} + +void Sync::registerNewDevice(const QString &id, const QString &caption, const QString &type) +{ + if (!m_gpodder) { + return; + } + UpdateDeviceRequest *updateDeviceRequest = m_gpodder->updateDevice(id, caption, type); + connect(updateDeviceRequest, &UpdateDeviceRequest::finished, this, [=]() { + if (updateDeviceRequest->error() || updateDeviceRequest->aborted()) { + if (updateDeviceRequest->error()) { + Q_EMIT error(Error::Type::SyncError, + QStringLiteral(""), + QStringLiteral(""), + updateDeviceRequest->error(), + updateDeviceRequest->errorString(), + i18n("Could not create GPodder device")); + } + } else { + setDevice(id); + setDeviceName(caption); + setSyncEnabled(true); + Q_EMIT deviceCreated(); + } + updateDeviceRequest->deleteLater(); + }); +} + +void Sync::linkUpAllDevices() +{ + if (!m_gpodder) { + return; + } + SyncRequest *syncRequest = m_gpodder->getSyncStatus(); + connect(syncRequest, &SyncRequest::finished, this, [=]() { + if (syncRequest->error() || syncRequest->aborted()) { + if (syncRequest->error()) { + Q_EMIT error(Error::Type::SyncError, + QStringLiteral(""), + QStringLiteral(""), + syncRequest->error(), + syncRequest->errorString(), + i18n("Could not retrieve synced device status")); + } + syncRequest->deleteLater(); + return; + } + + QSet syncDevices; + for (const QStringList &group : syncRequest->syncedDevices()) { + syncDevices += group.toSet(); + } + syncDevices += syncRequest->unsyncedDevices().toSet(); + + QVector syncDeviceGroups; + syncDeviceGroups += QStringList(syncDevices.values()); + if (!m_gpodder) { + return; + } + UpdateSyncRequest *upSyncRequest = m_gpodder->updateSyncStatus(syncDeviceGroups, QStringList()); + connect(upSyncRequest, &UpdateSyncRequest::finished, this, [=]() { + // For some reason, the response is always "Internal Server Error" + // even though the request is processed properly. So we just + // continue rather than abort... + if (upSyncRequest->error() || upSyncRequest->aborted()) { + if (upSyncRequest->error()) { + // Q_EMIT error(Error::Type::SyncError, + // QStringLiteral(""), + // QStringLiteral(""), + // upSyncRequest->error(), + // upSyncRequest->errorString(), + // i18n("Could not update synced device status")); + } + // upSyncRequest->deleteLater(); + // return; + } + + // Assemble a list of all subscriptions of all devices + m_syncUpAllSubscriptions.clear(); + m_deviceResponses = 0; + for (const QString &device : syncDevices) { + if (!m_gpodder) { + return; + } + SubscriptionRequest *subRequest = m_gpodder->getSubscriptionChanges(0, device); + connect(subRequest, &SubscriptionRequest::finished, this, [=]() { + if (subRequest->error() || subRequest->aborted()) { + if (subRequest->error()) { + Q_EMIT error(Error::Type::SyncError, + QStringLiteral(""), + QStringLiteral(""), + subRequest->error(), + subRequest->errorString(), + i18n("Could not retrieve subscriptions for device %1", device)); + } + } else { + m_syncUpAllSubscriptions += subRequest->addList(); + } + if (syncDevices.count() == ++m_deviceResponses) { + // We have now received all responses for all devices + for (const QString &device : syncDevices) { + if (!m_gpodder) { + return; + } + UploadSubscriptionRequest *upSubRequest = m_gpodder->uploadSubscriptionChanges(m_syncUpAllSubscriptions, QStringList(), device); + connect(upSubRequest, &UploadSubscriptionRequest::finished, this, [=]() { + if (upSubRequest->error()) { + Q_EMIT error(Error::Type::SyncError, + QStringLiteral(""), + QStringLiteral(""), + upSubRequest->error(), + upSubRequest->errorString(), + i18n("Could not upload subscriptions for device %1", device)); + } + upSubRequest->deleteLater(); + }); + } + } + subRequest->deleteLater(); + }); + } + upSyncRequest->deleteLater(); + }); + syncRequest->deleteLater(); + }); +} + +void Sync::doSync(SyncStatus status, bool forceFetchAll) +{ + if (!m_syncEnabled || !m_gpodder || !(m_syncStatus == SyncStatus::NoSync || m_syncStatus == SyncStatus::UploadOnlySync)) { + return; + } + + if (m_provider == Provider::GPodderNet && (m_username.isEmpty() || m_device.isEmpty())) { + return; + } + + if (m_provider == Provider::GPodderNextcloud && (m_username.isEmpty() || m_hostname.isEmpty())) { + return; + } + + // If a quick upload-only sync is running, abort it + if (m_syncStatus == SyncStatus::UploadOnlySync) { + abortSync(); + } + + m_syncStatus = status; + + SyncJob *syncJob = new SyncJob(status, m_gpodder, m_device, forceFetchAll, this); + connect(this, &Sync::abortSync, syncJob, &SyncJob::abort); + connect(syncJob, &SyncJob::infoMessage, this, [this](KJob *job, const QString &message) { + m_syncProgressTotal = job->totalAmount(KJob::Unit::Items); + m_syncProgress = job->processedAmount(KJob::Unit::Items); + m_syncProgressText = message; + Q_EMIT syncProgressChanged(); + }); + connect(syncJob, &SyncJob::finished, this, [this](KJob *job) { + if (job->error()) { + Q_EMIT error(Error::Type::SyncError, QStringLiteral(""), QStringLiteral(""), job->error(), job->errorText(), job->errorString()); + } + m_syncStatus = SyncStatus::NoSync; + Q_EMIT syncProgressChanged(); + }); + syncJob->start(); +} + +void Sync::doRegularSync(bool forceFetchAll) +{ + doSync(SyncStatus::RegularSync, forceFetchAll); +} + +void Sync::doForceSync() +{ + doSync(SyncStatus::ForceSync, true); +} + +void Sync::doQuickSync() +{ + if (!SettingsManager::self()->syncWhenPlayerstateChanges()) { + return; + } + + // since this method is supposed to be called automatically, we cannot check + // the network state from the UI, so we have to do it here + SolidExtras::NetworkStatus networkStatus; + if (networkStatus.connectivity() == SolidExtras::NetworkStatus::No + || (networkStatus.metered() == SolidExtras::NetworkStatus::Yes && !SettingsManager::self()->allowMeteredFeedUpdates())) { + qCDebug(kastsSync) << "Not uploading episode actions on metered connection due to settings"; + return; + } + + if (!m_syncEnabled || !m_gpodder || m_syncStatus != SyncStatus::NoSync) { + return; + } + + if (m_provider == Provider::GPodderNet && (m_username.isEmpty() || m_device.isEmpty())) { + return; + } + + if (m_provider == Provider::GPodderNextcloud && (m_username.isEmpty() || m_hostname.isEmpty())) { + return; + } + + m_syncStatus = SyncStatus::UploadOnlySync; + + SyncJob *syncJob = new SyncJob(m_syncStatus, m_gpodder, m_device, false, this); + connect(this, &Sync::abortSync, syncJob, &SyncJob::abort); + connect(syncJob, &SyncJob::finished, this, [this]() { + // don't do error reporting or status updates on quick upload-only syncs + m_syncStatus = SyncStatus::NoSync; + }); + syncJob->start(); +} + +void Sync::applySubscriptionChangesLocally(const QStringList &addList, const QStringList &removeList) +{ + m_allowSyncActionLogging = false; + + // removals + DataManager::instance().removeFeeds(removeList); + + // additions + DataManager::instance().addFeeds(addList, false); + + m_allowSyncActionLogging = true; +} + +void Sync::applyEpisodeActionsLocally(const QHash> &episodeActionHash) +{ + m_allowSyncActionLogging = false; + + for (const QHash &actions : episodeActionHash) { + for (const EpisodeAction &action : actions) { + if (action.action == QStringLiteral("play")) { + Entry *entry = DataManager::instance().getEntry(action.id); + if (entry && entry->hasEnclosure()) { + if ((action.position >= action.total - AudioManager::instance().SKIP_TRACK_END + || static_cast(action.position) >= entry->enclosure()->duration() - AudioManager::instance().SKIP_TRACK_END) + && action.total > 0) { + // Episode has been played + qCDebug(kastsSync) << "mark as played:" << entry->title(); + entry->setRead(true); + } else if (action.position > 0 && static_cast(action.position) * 1000 >= entry->enclosure()->duration()) { + // Episode is being listened to + qCDebug(kastsSync) << "set play position and add to queue:" << entry->title(); + entry->enclosure()->setPlayPosition(action.position * 1000); + entry->setQueueStatus(true); + if (AudioManager::instance().entry() == entry) { + AudioManager::instance().setPosition(action.position * 1000); + } + } else { + // Episode has not been listened to yet + qCDebug(kastsSync) << "reset play position:" << entry->title(); + entry->enclosure()->setPlayPosition(0); + } + } + } + + if (action.action == QStringLiteral("delete")) { + Entry *entry = DataManager::instance().getEntry(action.id); + if (entry && entry->hasEnclosure()) { + // "delete" means that at least the Episode has been played + qCDebug(kastsSync) << "mark as played:" << entry->title(); + entry->setRead(true); + } + } + + QCoreApplication::processEvents(); // keep the main thread semi-responsive + } + } + + m_allowSyncActionLogging = true; + + // Don't sync the download or delete status since it's broken in gpodder.net: + // the service only allows to upload only one download or delete action per + // episode; afterwards, it's not possible to override it with a similar action + // with a newer timestamp. Hence we consider this information not reliable. +} + +void Sync::storeAddFeedAction(const QString &url) +{ + if (syncEnabled() && m_allowSyncActionLogging) { + QSqlQuery query; + query.prepare(QStringLiteral("INSERT INTO FeedActions VALUES (:url, :action, :timestamp);")); + query.bindValue(QStringLiteral(":url"), url); + query.bindValue(QStringLiteral(":action"), QStringLiteral("add")); + query.bindValue(QStringLiteral(":timestamp"), QDateTime::currentSecsSinceEpoch()); + Database::instance().execute(query); + qCDebug(kastsSync) << "Logged a feed add action for" << url; + } +} + +void Sync::storeRemoveFeedAction(const QString &url) +{ + if (syncEnabled() && m_allowSyncActionLogging) { + QSqlQuery query; + query.prepare(QStringLiteral("INSERT INTO FeedActions VALUES (:url, :action, :timestamp);")); + query.bindValue(QStringLiteral(":url"), url); + query.bindValue(QStringLiteral(":action"), QStringLiteral("remove")); + query.bindValue(QStringLiteral(":timestamp"), QDateTime::currentSecsSinceEpoch()); + Database::instance().execute(query); + qCDebug(kastsSync) << "Logged a feed remove action for" << url; + } +} + +void Sync::storePlayEpisodeAction(const QString &id, const qulonglong started, const qulonglong position) +{ + if (syncEnabled() && m_allowSyncActionLogging) { + Entry *entry = DataManager::instance().getEntry(id); + if (entry && entry->hasEnclosure()) { + const qulonglong started_sec = started / 1000; // convert to seconds + const qulonglong position_sec = position / 1000; // convert to seconds + const qulonglong total = entry->enclosure()->duration(); // is already in seconds + + QSqlQuery query; + query.prepare(QStringLiteral("INSERT INTO EpisodeActions VALUES (:podcast, :url, :id, :action, :started, :position, :total, :timestamp);")); + query.bindValue(QStringLiteral(":podcast"), entry->feed()->url()); + query.bindValue(QStringLiteral(":url"), entry->enclosure()->url()); + query.bindValue(QStringLiteral(":id"), entry->id()); + query.bindValue(QStringLiteral(":action"), QStringLiteral("play")); + query.bindValue(QStringLiteral(":started"), started_sec); + query.bindValue(QStringLiteral(":position"), position_sec); + query.bindValue(QStringLiteral(":total"), total); + query.bindValue(QStringLiteral(":timestamp"), QDateTime::currentSecsSinceEpoch()); + Database::instance().execute(query); + + qCDebug(kastsSync) << "Logged an episode play action for" << entry->title() << "play position changed:" << started_sec << position_sec << total; + } + } +} + +void Sync::storePlayedEpisodeAction(const QString &id) +{ + if (syncEnabled() && m_allowSyncActionLogging) { + if (DataManager::instance().getEntry(id)->hasEnclosure()) { + const qulonglong duration = DataManager::instance().getEntry(id)->enclosure()->duration(); + storePlayEpisodeAction(id, duration * 1000, duration * 1000); + } + } +} diff --git a/src/sync/sync.h b/src/sync/sync.h new file mode 100644 index 00000000..4f9c982e --- /dev/null +++ b/src/sync/sync.h @@ -0,0 +1,146 @@ +/** + * SPDX-FileCopyrightText: 2021 Tobias Fella + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include + +#include + +#include "error.h" +#include "sync/syncutils.h" + +class GPodder; + +class Sync : public QObject +{ + Q_OBJECT + + Q_PROPERTY(bool syncEnabled READ syncEnabled WRITE setSyncEnabled NOTIFY syncEnabledChanged) + Q_PROPERTY(QString username READ username NOTIFY credentialsChanged) + Q_PROPERTY(QString password READ password WRITE setPassword NOTIFY credentialsChanged) + Q_PROPERTY(QString device READ device WRITE setDevice NOTIFY deviceChanged) + Q_PROPERTY(QString deviceName READ deviceName WRITE setDeviceName NOTIFY deviceNameChanged) + Q_PROPERTY(QString hostname READ hostname WRITE setHostname NOTIFY hostnameChanged) + Q_PROPERTY(SyncUtils::Provider provider READ provider WRITE setProvider NOTIFY providerChanged) + + Q_PROPERTY(QString suggestedDevice READ suggestedDevice CONSTANT) + Q_PROPERTY(QString suggestedDeviceName READ suggestedDeviceName CONSTANT) + Q_PROPERTY(QVector deviceList READ deviceList NOTIFY deviceListReceived) + + Q_PROPERTY(SyncUtils::SyncStatus syncStatus MEMBER m_syncStatus NOTIFY syncProgressChanged) + Q_PROPERTY(int syncProgress MEMBER m_syncProgress NOTIFY syncProgressChanged) + Q_PROPERTY(int syncProgressTotal MEMBER m_syncProgressTotal CONSTANT) + Q_PROPERTY(QString syncProgressText MEMBER m_syncProgressText NOTIFY syncProgressChanged) + Q_PROPERTY(QString lastSuccessfulDownloadSync READ lastSuccessfulDownloadSync NOTIFY syncProgressChanged) + Q_PROPERTY(QString lastSuccessfulUploadSync READ lastSuccessfulUploadSync NOTIFY syncProgressChanged) + +public: + static Sync &instance() + { + static Sync _instance; + return _instance; + } + + bool syncEnabled() const; + QString username() const; + QString password() const; + QString device() const; + QString deviceName() const; + QString hostname() const; + SyncUtils::Provider provider() const; + QString lastSuccessfulSync(const QStringList &matchingLabels = {}) const; + QString lastSuccessfulDownloadSync() const; + QString lastSuccessfulUploadSync() const; + + QString suggestedDevice() const; + QString suggestedDeviceName() const; + QVector deviceList() const; + + void setSyncEnabled(bool status); + void setPassword(const QString &password); + void setDevice(const QString &device); + void setDeviceName(const QString &deviceName); + void setHostname(const QString &hostname); + void setProvider(const SyncUtils::Provider provider); + + Q_INVOKABLE void login(const QString &username, const QString &password); + Q_INVOKABLE void retrieveCredentialsFromConfig(); + Q_INVOKABLE void logout(); + Q_INVOKABLE void registerNewDevice(const QString &id, const QString &caption, const QString &type = QStringLiteral("other")); + Q_INVOKABLE void linkUpAllDevices(); + + void doSync(SyncUtils::SyncStatus status, bool forceFetchAll = false); // base method for syncing + Q_INVOKABLE void doRegularSync(bool forceFetchAll = false); // regular sync; can be forced to update all feeds + Q_INVOKABLE void doForceSync(); // force a full re-sync with the server; discarding local eposide acions + Q_INVOKABLE void doQuickSync(); // only upload pending local episode actions; intended to be run directly after an episode action has been created + + // Next are some generic methods to store and apply local changes to be synced + void storeAddFeedAction(const QString &url); + void storeRemoveFeedAction(const QString &url); + void storePlayEpisodeAction(const QString &id, const qulonglong started, const qulonglong position); + void storePlayedEpisodeAction(const QString &id); + void applySubscriptionChangesLocally(const QStringList &addList, const QStringList &removeList); + void applyEpisodeActionsLocally(const QHash> &episodeActionHash); + +Q_SIGNALS: + void syncEnabledChanged(); + void credentialsChanged(); + void deviceChanged(); + void deviceNameChanged(); + void hostnameChanged(); + void providerChanged(); + void deviceCreated(); + + void passwordSaveFinished(bool success); + void passwordRetrievalFinished(QString password); + void passwordInputRequired(); + + void loginSucceeded(); + void deviceListReceived(); + + void error(Error::Type type, const QString &url, const QString &id, const int errorId, const QString &errorString, const QString &title); + void syncProgressChanged(); + void abortSync(); + +private: + Sync(); + + void clearSettings(); + void savePasswordToKeyChain(const QString &username, const QString &password); + void savePasswordToFile(const QString &username, const QString &password); + void retrievePasswordFromKeyChain(const QString &username); + QString retrievePasswordFromFile(const QString &username); + void deletePasswordFromKeychain(const QString &username); + + GPodder *m_gpodder = nullptr; + + bool m_syncEnabled; + QString m_username; + QString m_password; + QString m_device; + QString m_deviceName; + QString m_hostname; + SyncUtils::Provider m_provider; + + QVector m_deviceList; + + KFormat m_kformat; + + // variables needed for linkUpAllDevices() + QStringList m_syncUpAllSubscriptions; + int m_deviceResponses; + + // internal variables used while syncing + bool m_allowSyncActionLogging = true; + + // internal variables used for UI notifications + SyncUtils::SyncStatus m_syncStatus = SyncUtils::SyncStatus::NoSync; + int m_syncProgress = 0; + int m_syncProgressTotal = 7; + QString m_syncProgressText = QLatin1String(""); +}; diff --git a/src/sync/syncjob.cpp b/src/sync/syncjob.cpp new file mode 100644 index 00000000..c1b00a51 --- /dev/null +++ b/src/sync/syncjob.cpp @@ -0,0 +1,873 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "syncjob.h" +#include "synclogging.h" + +#include +#include +#include +#include +#include + +#include + +#include "audiomanager.h" +#include "database.h" +#include "datamanager.h" +#include "entry.h" +#include "fetchfeedsjob.h" +#include "models/errorlogmodel.h" +#include "settingsmanager.h" +#include "sync/gpodder/episodeactionrequest.h" +#include "sync/gpodder/gpodder.h" +#include "sync/gpodder/subscriptionrequest.h" +#include "sync/gpodder/uploadepisodeactionrequest.h" +#include "sync/gpodder/uploadsubscriptionrequest.h" +#include "sync/sync.h" +#include "sync/syncutils.h" + +#include + +using namespace SyncUtils; + +SyncJob::SyncJob(SyncStatus syncStatus, GPodder *gpodder, const QString &device, bool forceFetchAll, QObject *parent) + : KJob(parent) + , m_syncStatus(syncStatus) + , m_gpodder(gpodder) + , m_device(device) + , m_forceFetchAll(forceFetchAll) +{ + connect(&Sync::instance(), &Sync::abortSync, this, &SyncJob::aborting); + + setProgressUnit(KJob::Unit::Items); +} + +void SyncJob::start() +{ + QTimer::singleShot(0, this, &SyncJob::doSync); +} + +void SyncJob::abort() +{ + m_abort = true; + Q_EMIT aborting(); +} + +bool SyncJob::aborted() +{ + return m_abort; +} + +QString SyncJob::errorString() const +{ + switch (error()) { + case SyncJobError::SubscriptionDownloadError: + return i18n("Could not retrieve subscription updates from server"); + break; + case SyncJobError::SubscriptionUploadError: + return i18n("Could not upload subscription changes to server"); + break; + case SyncJobError::EpisodeDownloadError: + return i18n("Could not retrieve episode updates from server"); + break; + case SyncJobError::EpisodeUploadError: + return i18n("Could not upload episode updates to server"); + break; + case SyncJobError::InternalDataError: + return i18n("Internal data error"); + break; + default: + return KJob::errorString(); + break; + } +} + +void SyncJob::doSync() +{ + switch (m_syncStatus) { + case SyncStatus::RegularSync: + doRegularSync(); + break; + case SyncStatus::ForceSync: + doForceSync(); + break; + case SyncStatus::UploadOnlySync: + doQuickSync(); + break; + case SyncStatus::NoSync: + default: + qCDebug(kastsSync) << "Something's wrong. Sync job started with invalid sync type."; + setError(SyncJobError::InternalDataError); + emitResult(); + break; + } +} + +void SyncJob::doRegularSync() +{ + setTotalAmount(KJob::Unit::Items, 7); + setProcessedAmount(KJob::Unit::Items, 0); + Q_EMIT infoMessage(this, getProgressMessage(Started)); + + syncSubscriptions(); +} + +void SyncJob::doForceSync() +{ + // Delete SyncTimestamps such that all feed and episode actions will be + // retrieved again from the server + QSqlQuery query; + query.prepare(QStringLiteral("DELETE FROM SyncTimestamps;")); + Database::instance().execute(query); + + m_forceFetchAll = true; + doRegularSync(); +} + +void SyncJob::doQuickSync() +{ + setTotalAmount(KJob::Unit::Items, 2); + setProcessedAmount(KJob::Unit::Items, 0); + Q_EMIT infoMessage(this, getProgressMessage(Started)); + + // Quick sync of local subscription changes + QPair localChanges = getLocalSubscriptionChanges(); + // store the local changes in a member variable such that the exact changes can be deleted from DB when processed + m_localSubscriptionChanges = localChanges; + + QStringList addList = localChanges.first; + QStringList removeList = localChanges.second; + removeSubscriptionChangeConflicts(addList, removeList); + uploadSubscriptions(addList, removeList); + + // Quick sync of local episodeActions + // store these actions in member variable to be able to delete these exact same changes from DB when processed + m_localEpisodeActions = getLocalEpisodeActions(); + + QHash> localEpisodeActionHash; + for (const EpisodeAction &action : m_localEpisodeActions) { + addToHashIfNewer(localEpisodeActionHash, action); + } + qCDebug(kastsSync) << "local hash"; + debugEpisodeActionHash(localEpisodeActionHash); + + uploadEpisodeActions(createListFromHash(localEpisodeActionHash)); +} + +void SyncJob::syncSubscriptions() +{ + setProcessedAmount(KJob::Unit::Items, processedAmount(KJob::Unit::Items) + 1); + Q_EMIT infoMessage(this, getProgressMessage(SubscriptionDownload)); + + bool subscriptionTimestampExists = false; + qulonglong subscriptionTimestamp = 0; + + // First check the database for previous timestamps + QSqlQuery query; + query.prepare(QStringLiteral("SELECT timestamp FROM SyncTimeStamps WHERE syncservice=:syncservice;")); + query.bindValue(QStringLiteral(":syncservice"), subscriptionTimestampLabel); + Database::instance().execute(query); + if (query.next()) { + subscriptionTimestamp = query.value(0).toULongLong(); + subscriptionTimestampExists = true; + qCDebug(kastsSync) << "Previous gpodder subscription timestamp" << subscriptionTimestamp; + } + + // Check for local changes + // If no timestamp exists then upload all subscriptions. Otherwise, check + // the database for actions since previous sync. + QStringList localAddFeedList, localRemoveFeedList; + if (subscriptionTimestamp == 0) { + query.prepare(QStringLiteral("SELECT url FROM Feeds;")); + Database::instance().execute(query); + while (query.next()) { + localAddFeedList << query.value(QStringLiteral("url")).toString(); + } + } else { + QPair localChanges = getLocalSubscriptionChanges(); + // immediately store the local changes such that the exact changes can be deleted from DB when processed + m_localSubscriptionChanges = localChanges; + + localAddFeedList = localChanges.first; + localRemoveFeedList = localChanges.second; + } + + removeSubscriptionChangeConflicts(localAddFeedList, localRemoveFeedList); + + if (!m_gpodder) { + setError(SyncJobError::InternalDataError); + Q_EMIT infoMessage(this, getProgressMessage(Error)); + emitResult(); + return; + } + // Check the gpodder service for updates + SubscriptionRequest *subRequest = m_gpodder->getSubscriptionChanges(subscriptionTimestamp, m_device); + connect(this, &SyncJob::aborting, subRequest, &SubscriptionRequest::abort); + connect(subRequest, &SubscriptionRequest::finished, this, [=]() { + if (subRequest->error() || subRequest->aborted()) { + if (subRequest->aborted()) { + Q_EMIT infoMessage(this, getProgressMessage(Aborted)); + } else if (subRequest->error()) { + setError(SyncJobError::SubscriptionDownloadError); + setErrorText(subRequest->errorString()); + Q_EMIT infoMessage(this, getProgressMessage(Error)); + } + emitResult(); + return; + } + qCDebug(kastsSync) << "Finished device update request"; + + qulonglong newSubscriptionTimestamp = subRequest->timestamp(); + QStringList remoteAddFeedList, remoteRemoveFeedList; + + removeSubscriptionChangeConflicts(remoteAddFeedList, remoteRemoveFeedList); + + for (const QString &url : subRequest->addList()) { + qCDebug(kastsSync) << "Sync add feed:" << url; + if (DataManager::instance().feedExists(url)) { + qCDebug(kastsSync) << "this one we have; do nothing"; + } else { + qCDebug(kastsSync) << "this one we don't have; add this feed"; + remoteAddFeedList << url; + } + } + + for (const QString &url : subRequest->removeList()) { + qCDebug(kastsSync) << "Sync remove feed:" << url; + if (DataManager::instance().feedExists(url)) { + qCDebug(kastsSync) << "this one we have; needs to be removed"; + remoteRemoveFeedList << url; + } else { + qCDebug(kastsSync) << "this one we don't have; it was already removed locally; do nothing"; + } + } + + qCDebug(kastsSync) << "localAddFeedList" << localAddFeedList; + qCDebug(kastsSync) << "localRemoveFeedList" << localRemoveFeedList; + qCDebug(kastsSync) << "remoteAddFeedList" << remoteAddFeedList; + qCDebug(kastsSync) << "remoteRemoveFeedList" << remoteRemoveFeedList; + + // Now we apply the remote changes locally: + Sync::instance().applySubscriptionChangesLocally(remoteAddFeedList, remoteRemoveFeedList); + + // We defer fetching the new feeds, since we will fetch them later on. + // if this is the first sync or a force sync, then add all local feeds to + // be updated + if (!subscriptionTimestampExists || m_forceFetchAll) { + QSqlQuery query; + query.prepare(QStringLiteral("SELECT url FROM Feeds;")); + Database::instance().execute(query); + while (query.next()) { + QString url = query.value(0).toString(); + if (!m_feedsToBeUpdatedSubs.contains(url)) { + m_feedsToBeUpdatedSubs += url; + } + } + } + + // Add the new feeds to the list of feeds that need to be refreshed. + // We check with feedExists to make sure not to add the same podcast + // with a slightly different url + for (const QString &url : remoteAddFeedList) { + if (!DataManager::instance().feedExists(url)) { + m_feedsToBeUpdatedSubs += url; + } + } + m_feedUpdateTotal = m_feedsToBeUpdatedSubs.count(); + + qCDebug(kastsSync) << "newSubscriptionTimestamp" << newSubscriptionTimestamp; + updateDBTimestamp(newSubscriptionTimestamp, subscriptionTimestampLabel); + + setProcessedAmount(KJob::Unit::Items, processedAmount(KJob::Unit::Items) + 1); + Q_EMIT infoMessage(this, getProgressMessage(SubscriptionUpload)); + + QTimer::singleShot(0, this, [=]() { + uploadSubscriptions(localAddFeedList, localRemoveFeedList); + }); + }); +} + +void SyncJob::uploadSubscriptions(const QStringList &localAddFeedUrlList, const QStringList &localRemoveFeedUrlList) +{ + if (localAddFeedUrlList.isEmpty() && localRemoveFeedUrlList.isEmpty()) { + qCDebug(kastsSync) << "No subscription changes to upload to server"; + + // if this is not a quick upload only sync, continue with the feed updates + if (m_syncStatus != SyncStatus::UploadOnlySync) { + QTimer::singleShot(0, this, &SyncJob::fetchModifiedSubscriptions); + } + + // Delete the uploaded changes from the database + removeAppliedSubscriptionChangesFromDB(); + + setProcessedAmount(KJob::Unit::Items, processedAmount(KJob::Unit::Items) + 1); + Q_EMIT infoMessage(this, getProgressMessage(SubscriptionFetch)); + } else { + qCDebug(kastsSync) << "Uploading subscription changes:\n\tadd" << localAddFeedUrlList << "\n\tremove" << localRemoveFeedUrlList; + if (!m_gpodder) { + setError(SyncJobError::InternalDataError); + Q_EMIT infoMessage(this, getProgressMessage(Error)); + emitResult(); + return; + } + UploadSubscriptionRequest *upSubRequest = m_gpodder->uploadSubscriptionChanges(localAddFeedUrlList, localRemoveFeedUrlList, m_device); + connect(this, &SyncJob::aborting, upSubRequest, &UploadSubscriptionRequest::abort); + connect(upSubRequest, &UploadSubscriptionRequest::finished, this, [=]() { + if (upSubRequest->error() || upSubRequest->aborted()) { + if (upSubRequest->aborted()) { + Q_EMIT infoMessage(this, getProgressMessage(Aborted)); + } else if (upSubRequest->error()) { + setError(SyncJobError::SubscriptionUploadError); + setErrorText(upSubRequest->errorString()); + Q_EMIT infoMessage(this, getProgressMessage(Error)); + } + emitResult(); + return; + } + + // Upload has succeeded + qulonglong timestamp = upSubRequest->timestamp(); + qCDebug(kastsSync) << "timestamp after uploading local changes" << timestamp; + updateDBTimestamp(timestamp, (m_syncStatus == SyncStatus::UploadOnlySync) ? uploadSubscriptionTimestampLabel : subscriptionTimestampLabel); + + // Delete the uploaded changes from the database + removeAppliedSubscriptionChangesFromDB(); + + // TODO: deal with updateUrlsList -> needs on-the-fly feed URL renaming + QVector> updateUrlsList = upSubRequest->updateUrls(); + qCDebug(kastsSync) << "updateUrlsList:" << updateUrlsList; + + // if this is a quick upload only sync, then stop here, otherwise continue with + // updating feeds that were added remotely. + + setProcessedAmount(KJob::Unit::Items, processedAmount(KJob::Unit::Items) + 1); + Q_EMIT infoMessage(this, getProgressMessage(SubscriptionFetch)); + + if (m_syncStatus != SyncStatus::UploadOnlySync) { + QTimer::singleShot(0, this, &SyncJob::fetchModifiedSubscriptions); + } + }); + } +} + +void SyncJob::fetchModifiedSubscriptions() +{ + // Update the feeds that need to be updated such that we can find the + // episodes in the database when we are receiving the remote episode + // actions. + m_feedUpdateTotal = m_feedsToBeUpdatedSubs.count(); + m_feedUpdateProgress = 0; + FetchFeedsJob *fetchFeedsJob = new FetchFeedsJob(m_feedsToBeUpdatedSubs, this); + connect(this, &SyncJob::aborting, fetchFeedsJob, &FetchFeedsJob::abort); + connect(fetchFeedsJob, &FetchFeedsJob::processedAmountChanged, this, [this, fetchFeedsJob](KJob *job, KJob::Unit unit, qulonglong amount) { + qCDebug(kastsSync) << "FetchFeedsJob::processedAmountChanged:" << amount; + Q_UNUSED(job); + Q_ASSERT(unit == KJob::Unit::Items); + m_feedUpdateProgress = amount; + if (!fetchFeedsJob->aborted() && !fetchFeedsJob->error()) { + Q_EMIT infoMessage(this, getProgressMessage(SubscriptionFetch)); + } + }); + connect(fetchFeedsJob, &FetchFeedsJob::result, this, [=]() { + qCDebug(kastsSync) << "Feed update finished"; + if (fetchFeedsJob->error() || fetchFeedsJob->aborted()) { + if (fetchFeedsJob->aborted()) { + Q_EMIT infoMessage(this, getProgressMessage(Aborted)); + } else if (fetchFeedsJob->error()) { + // FetchFeedsJob takes care of its own error reporting + Q_EMIT infoMessage(this, getProgressMessage(Error)); + } + emitResult(); + return; + } + Q_EMIT infoMessage(this, getProgressMessage(SubscriptionFetch)); + + qCDebug(kastsSync) << "Done updating subscriptions and fetching updates"; + // We're ready to sync the episode states now + QTimer::singleShot(0, this, &SyncJob::syncEpisodeStates); + }); + fetchFeedsJob->start(); +} + +void SyncJob::syncEpisodeStates() +{ + qCDebug(kastsSync) << "Start syncing episode states"; + + qulonglong episodeTimestamp = 0; + + // First check the database for previous timestamps + QSqlQuery query; + query.prepare(QStringLiteral("SELECT timestamp FROM SyncTimeStamps WHERE syncservice=:syncservice;")); + query.bindValue(QStringLiteral(":syncservice"), episodeTimestampLabel); + Database::instance().execute(query); + if (query.next()) { + episodeTimestamp = query.value(0).toULongLong(); + qCDebug(kastsSync) << "Previous gpodder episode timestamp" << episodeTimestamp; + } + + setProcessedAmount(KJob::Unit::Items, processedAmount(KJob::Unit::Items) + 1); + Q_EMIT infoMessage(this, getProgressMessage(EpisodeDownload)); + + if (!m_gpodder) { + setError(SyncJobError::InternalDataError); + Q_EMIT infoMessage(this, getProgressMessage(Error)); + emitResult(); + return; + } + // Check the gpodder service for episode action updates + EpisodeActionRequest *epRequest = m_gpodder->getEpisodeActions(episodeTimestamp, (episodeTimestamp == 0)); + connect(this, &SyncJob::aborting, epRequest, &EpisodeActionRequest::abort); + connect(epRequest, &EpisodeActionRequest::finished, this, [=]() { + qCDebug(kastsSync) << "Finished episode action request"; + if (epRequest->error() || epRequest->aborted()) { + if (epRequest->aborted()) { + Q_EMIT infoMessage(this, getProgressMessage(Aborted)); + } else if (epRequest->error()) { + setError(SyncJobError::EpisodeUploadError); + setErrorText(epRequest->errorString()); + Q_EMIT infoMessage(this, getProgressMessage(Error)); + } + emitResult(); + return; + } + + qulonglong newEpisodeTimestamp = epRequest->timestamp(); + QList remoteEpisodeActionList; + QHash> remoteEpisodeActionHash, localEpisodeActionHash; + qCDebug(kastsSync) << newEpisodeTimestamp; + + for (const EpisodeAction &action : epRequest->episodeActions()) { + addToHashIfNewer(remoteEpisodeActionHash, action); + + qCDebug(kastsSync) << action.podcast << action.url << action.id << action.device << action.action << action.started << action.position + << action.total << action.timestamp; + } + + // store these actions in member variable to be able to delete these exact same changes from DB when processed + m_localEpisodeActions = getLocalEpisodeActions(); + + for (const EpisodeAction &action : m_localEpisodeActions) { + addToHashIfNewer(localEpisodeActionHash, action); + } + + qCDebug(kastsSync) << "local hash"; + debugEpisodeActionHash(localEpisodeActionHash); + + qCDebug(kastsSync) << "remote hash"; + debugEpisodeActionHash(remoteEpisodeActionHash); + + // now remove conflicts between local and remote episode actions + // based on the timestamp + removeEpisodeActionConflicts(localEpisodeActionHash, remoteEpisodeActionHash); + + qCDebug(kastsSync) << "local hash"; + debugEpisodeActionHash(localEpisodeActionHash); + + qCDebug(kastsSync) << "remote hash"; + debugEpisodeActionHash(remoteEpisodeActionHash); + + // Now we update the feeds that need updating (don't update feeds that have + // already been updated after the subscriptions were updated). + for (const QString &url : getFeedsFromHash(remoteEpisodeActionHash)) { + if (!m_feedsToBeUpdatedSubs.contains(url) && !m_feedsToBeUpdatedEps.contains(url)) { + m_feedsToBeUpdatedEps += url; + } + } + qCDebug(kastsSync) << "Feeds to be updated:" << m_feedsToBeUpdatedEps; + m_feedUpdateTotal = m_feedsToBeUpdatedEps.count(); + m_feedUpdateProgress = 0; + + setProcessedAmount(KJob::Unit::Items, processedAmount(KJob::Unit::Items) + 1); + Q_EMIT infoMessage(this, getProgressMessage(SubscriptionFetch)); + + FetchFeedsJob *fetchFeedsJob = new FetchFeedsJob(m_feedsToBeUpdatedEps, this); + connect(this, &SyncJob::aborting, fetchFeedsJob, &FetchFeedsJob::abort); + connect(fetchFeedsJob, &FetchFeedsJob::processedAmountChanged, this, [this, fetchFeedsJob](KJob *job, KJob::Unit unit, qulonglong amount) { + qCDebug(kastsSync) << "FetchFeedsJob::processedAmountChanged:" << amount; + Q_UNUSED(job); + Q_ASSERT(unit == KJob::Unit::Items); + m_feedUpdateProgress = amount; + if (!fetchFeedsJob->aborted() && !fetchFeedsJob->error()) { + Q_EMIT infoMessage(this, getProgressMessage(SubscriptionFetch)); + } + }); + connect(fetchFeedsJob, &FetchFeedsJob::result, this, [=]() { + qCDebug(kastsSync) << "Feed update finished"; + if (fetchFeedsJob->error() || fetchFeedsJob->aborted()) { + if (fetchFeedsJob->aborted()) { + Q_EMIT infoMessage(this, getProgressMessage(Aborted)); + } else if (fetchFeedsJob->error()) { + // FetchFeedsJob takes care of its own error reporting + Q_EMIT infoMessage(this, getProgressMessage(Error)); + } + emitResult(); + return; + } + Q_EMIT infoMessage(this, getProgressMessage(SubscriptionFetch)); + + // Apply the remote changes locally + Sync::instance().applyEpisodeActionsLocally(remoteEpisodeActionHash); + + updateDBTimestamp(newEpisodeTimestamp, episodeTimestampLabel); + + // Upload the local changes to the server + QVector localEpisodeActionList = createListFromHash(localEpisodeActionHash); + + setProcessedAmount(KJob::Unit::Items, processedAmount(KJob::Unit::Items) + 1); + Q_EMIT infoMessage(this, getProgressMessage(EpisodeUpload)); + // Now upload the episode actions to the server + QTimer::singleShot(0, this, [this, localEpisodeActionList]() { + uploadEpisodeActions(localEpisodeActionList); + }); + }); + fetchFeedsJob->start(); + }); +} + +void SyncJob::uploadEpisodeActions(const QVector &episodeActions) +{ + // We have to upload episode actions in batches because otherwise the server + // will reject them. + uploadEpisodeActionsPartial(episodeActions, 0); +} + +void SyncJob::uploadEpisodeActionsPartial(const QVector &episodeActionList, const int startIndex) +{ + if (episodeActionList.count() == 0) { + // nothing to upload; we don't have to contact the server + qCDebug(kastsSync) << "No episode actions to upload to server"; + + removeAppliedEpisodeActionsFromDB(); + + setProcessedAmount(KJob::Unit::Items, processedAmount(KJob::Unit::Items) + 1); + Q_EMIT infoMessage(this, getProgressMessage(Finished)); + emitResult(); + return; + } + + qCDebug(kastsSync) << "Uploading episode actions" << startIndex << "to" << std::min(startIndex + maxAmountEpisodeUploads, episodeActionList.count()) << "of" + << episodeActionList.count() << "total episode actions"; + + if (!m_gpodder) { + setError(SyncJobError::InternalDataError); + Q_EMIT infoMessage(this, getProgressMessage(Error)); + emitResult(); + return; + } + UploadEpisodeActionRequest *upEpRequest = m_gpodder->uploadEpisodeActions(episodeActionList.mid(startIndex, maxAmountEpisodeUploads)); + connect(this, &SyncJob::aborting, upEpRequest, &UploadEpisodeActionRequest::abort); + connect(upEpRequest, &UploadEpisodeActionRequest::finished, this, [=]() { + qCDebug(kastsSync) << "Finished uploading batch of episode actions to server"; + if (upEpRequest->error() || upEpRequest->aborted()) { + if (upEpRequest->aborted()) { + Q_EMIT infoMessage(this, getProgressMessage(Aborted)); + } else if (upEpRequest->error()) { + setError(SyncJobError::EpisodeUploadError); + setErrorText(upEpRequest->errorString()); + Q_EMIT infoMessage(this, getProgressMessage(Error)); + } + emitResult(); + return; + } + + if (episodeActionList.count() > startIndex + maxAmountEpisodeUploads) { + // Still episodeActions remaining to be uploaded + QTimer::singleShot(0, this, [this, &episodeActionList, startIndex]() { + uploadEpisodeActionsPartial(episodeActionList, startIndex + maxAmountEpisodeUploads); + }); + } else { + // All episodeActions have been uploaded + + updateDBTimestamp(upEpRequest->timestamp(), (m_syncStatus == SyncStatus::UploadOnlySync) ? uploadEpisodeTimestampLabel : episodeTimestampLabel); + + removeAppliedEpisodeActionsFromDB(); + + setProcessedAmount(KJob::Unit::Items, processedAmount(KJob::Unit::Items) + 1); + Q_EMIT infoMessage(this, getProgressMessage(Finished)); + + // This is the final exit point for the Job unless an error or abort occured + qCDebug(kastsSync) << "Syncing finished"; + emitResult(); + } + }); +} + +void SyncJob::updateDBTimestamp(const qulonglong ×tamp, const QString ×tampLabel) +{ + if (timestamp > 1) { // only accept timestamp if it's larger than zero + bool timestampExists = false; + QSqlQuery query; + query.prepare(QStringLiteral("SELECT timestamp FROM SyncTimeStamps WHERE syncservice=:syncservice;")); + query.bindValue(QStringLiteral(":syncservice"), timestampLabel); + Database::instance().execute(query); + if (query.next()) { + timestampExists = true; + } + + if (timestampExists) { + query.prepare(QStringLiteral("UPDATE SyncTimeStamps SET timestamp=:timestamp WHERE syncservice=:syncservice;")); + } else { + query.prepare(QStringLiteral("INSERT INTO SyncTimeStamps VALUES (:syncservice, :timestamp);")); + } + query.bindValue(QStringLiteral(":syncservice"), timestampLabel); + query.bindValue(QStringLiteral(":timestamp"), timestamp + 1); // add 1 second to avoid fetching our own previously sent updates next time + Database::instance().execute(query); + } +} + +void SyncJob::removeAppliedSubscriptionChangesFromDB() +{ + Database::instance().transaction(); + QSqlQuery query; + query.prepare(QStringLiteral("DELETE FROM FeedActions WHERE url=:url AND action=:action;")); + + for (const QString &url : m_localSubscriptionChanges.first) { + query.bindValue(QStringLiteral(":url"), url); + query.bindValue(QStringLiteral(":action"), QStringLiteral("add")); + Database::instance().execute(query); + } + + for (const QString &url : m_localSubscriptionChanges.second) { + query.bindValue(QStringLiteral(":url"), url); + query.bindValue(QStringLiteral(":action"), QStringLiteral("remove")); + Database::instance().execute(query); + } + Database::instance().commit(); +} + +void SyncJob::removeAppliedEpisodeActionsFromDB() +{ + Database::instance().transaction(); + QSqlQuery query; + query.prepare( + QStringLiteral("DELETE FROM EpisodeActions WHERE podcast=:podcast AND url=:url AND id=:id AND action=:action AND started=:started AND " + "position=:position AND total=:total AND timestamp=:timestamp;")); + for (const EpisodeAction &epAction : m_localEpisodeActions) { + qCDebug(kastsSync) << "Removing episode action from DB" << epAction.id; + query.bindValue(QStringLiteral(":podcast"), epAction.podcast); + query.bindValue(QStringLiteral(":url"), epAction.url); + query.bindValue(QStringLiteral(":id"), epAction.id); + query.bindValue(QStringLiteral(":action"), epAction.action); + query.bindValue(QStringLiteral(":started"), epAction.started); + query.bindValue(QStringLiteral(":position"), epAction.position); + query.bindValue(QStringLiteral(":total"), epAction.total); + query.bindValue(QStringLiteral(":timestamp"), epAction.timestamp); + Database::instance().execute(query); + } + Database::instance().commit(); +} + +void SyncJob::removeSubscriptionChangeConflicts(QStringList &addList, QStringList &removeList) +{ + // Do some sanity checks and cleaning-up + addList.removeDuplicates(); + removeList.removeDuplicates(); + for (const QString &addUrl : addList) { + if (removeList.contains(addUrl)) { + addList.removeAt(addList.indexOf(addUrl)); + removeList.removeAt(removeList.indexOf(addUrl)); + } + } + for (const QString &removeUrl : removeList) { + if (addList.contains(removeUrl)) { + removeList.removeAt(removeList.indexOf(removeUrl)); + addList.removeAt(addList.indexOf(removeUrl)); + } + } +} + +QVector SyncJob::createListFromHash(const QHash> &episodeActionHash) +{ + QVector episodeActionList; + + for (const QHash &actions : episodeActionHash) { + for (const EpisodeAction &action : actions) { + if (action.action == QStringLiteral("play")) { + episodeActionList << action; + } + } + } + + return episodeActionList; +} + +QPair SyncJob::getLocalSubscriptionChanges() const +{ + QPair localChanges; + QSqlQuery query; + query.prepare(QStringLiteral("SELECT * FROM FeedActions;")); + Database::instance().execute(query); + while (query.next()) { + QString url = query.value(QStringLiteral("url")).toString(); + QString action = query.value(QStringLiteral("action")).toString(); + // qulonglong timestamp = query.value(QStringLiteral("timestamp")).toULongLong(); + if (action == QStringLiteral("add")) { + localChanges.first << url; + } else if (action == QStringLiteral("remove")) { + localChanges.second << url; + } + } + + return localChanges; +} + +QVector SyncJob::getLocalEpisodeActions() const +{ + QVector localEpisodeActions; + QSqlQuery query; + query.prepare(QStringLiteral("SELECT * FROM EpisodeActions;")); + Database::instance().execute(query); + while (query.next()) { + QString podcast = query.value(QStringLiteral("podcast")).toString(); + QString url = query.value(QStringLiteral("url")).toString(); + QString id = query.value(QStringLiteral("id")).toString(); + QString action = query.value(QStringLiteral("action")).toString(); + qulonglong started = query.value(QStringLiteral("started")).toULongLong(); + qulonglong position = query.value(QStringLiteral("position")).toULongLong(); + qulonglong total = query.value(QStringLiteral("total")).toULongLong(); + qulonglong timestamp = query.value(QStringLiteral("timestamp")).toULongLong(); + EpisodeAction episodeAction = {podcast, url, id, m_device, action, started, position, total, timestamp}; + localEpisodeActions += episodeAction; + } + + return localEpisodeActions; +} + +void SyncJob::addToHashIfNewer(QHash> &episodeActionHash, const EpisodeAction &episodeAction) +{ + if (episodeAction.action == QStringLiteral("play")) { + if (episodeActionHash.contains(episodeAction.id) && episodeActionHash[episodeAction.id].contains(QStringLiteral("play"))) { + if (episodeActionHash[episodeAction.id][QStringLiteral("play")].timestamp <= episodeAction.timestamp) { + episodeActionHash[episodeAction.id][QStringLiteral("play")] = episodeAction; + } + } else { + episodeActionHash[episodeAction.id][QStringLiteral("play")] = episodeAction; + } + } + + if (episodeAction.action == QStringLiteral("download") || episodeAction.action == QStringLiteral("delete")) { + if (episodeActionHash.contains(episodeAction.id)) { + if (episodeActionHash[episodeAction.id].contains(QStringLiteral("download"))) { + if (episodeActionHash[episodeAction.id][QStringLiteral("download")].timestamp <= episodeAction.timestamp) { + episodeActionHash[episodeAction.id][QStringLiteral("download-delete")] = episodeAction; + } + } else if (episodeActionHash[episodeAction.id].contains(QStringLiteral("delete"))) { + if (episodeActionHash[episodeAction.id][QStringLiteral("delete")].timestamp <= episodeAction.timestamp) { + episodeActionHash[episodeAction.id][QStringLiteral("download-delete")] = episodeAction; + } + } else { + episodeActionHash[episodeAction.id][QStringLiteral("download-delete")] = episodeAction; + } + } else { + episodeActionHash[episodeAction.id][QStringLiteral("download-delete")] = episodeAction; + } + } + + if (episodeAction.action == QStringLiteral("new")) { + if (episodeActionHash.contains(episodeAction.id) && episodeActionHash[episodeAction.id].contains(QStringLiteral("new"))) { + if (episodeActionHash[episodeAction.id][QStringLiteral("new")].timestamp <= episodeAction.timestamp) { + episodeActionHash[episodeAction.id][QStringLiteral("new")] = episodeAction; + } + } else { + episodeActionHash[episodeAction.id][QStringLiteral("new")] = episodeAction; + } + } +} + +void SyncJob::removeEpisodeActionConflicts(QHash> &local, QHash> &remote) +{ + QStringList actions; + actions << QStringLiteral("play") << QStringLiteral("download-delete") << QStringLiteral("new"); + + // We first remove the conflicts from the hash with local changes + for (const QHash &hashItem : remote) { + for (const QString &action : actions) { + QString id = hashItem[action].id; + if (local.contains(id) && local.value(id).contains(action)) { + if (local[id][action].timestamp < remote[id][action].timestamp) { + local[id].remove(action); + } + } + } + } + + // And now the same for the remote + for (const QHash &hashItem : local) { + for (const QString &action : actions) { + QString id = hashItem[action].id; + if (remote.contains(id) && remote.value(id).contains(action)) { + if (remote[id][action].timestamp < local[id][action].timestamp) { + remote[id].remove(action); + } + } + } + } +} + +QStringList SyncJob::getFeedsFromHash(const QHash> &hash) +{ + QStringList feedUrls; + for (const QHash &actionList : hash) { + for (const EpisodeAction &action : actionList) { + feedUrls += action.podcast; + } + } + return feedUrls; +} + +void SyncJob::debugEpisodeActionHash(const QHash> &hash) +{ + for (const QHash &hashItem : hash) { + for (const EpisodeAction &action : hashItem) { + qCDebug(kastsSync) << action.podcast << action.url << action.id << action.device << action.action << action.started << action.position + << action.total << action.timestamp; + } + } +} + +QString SyncJob::getProgressMessage(SyncJobStatus status) const +{ + int processed = processedAmount(KJob::Unit::Items); + int total = totalAmount(KJob::Unit::Items); + + switch (status) { + case Started: + return i18nc("Step in Subscription and Episode Syncing Progress", "(Step %1 of %2) Start Syncing", processed, total); + break; + case SubscriptionDownload: + return i18nc("Step in Subscription and Episode Syncing Progress", "(Step %1 of %2) Requesting Remote Subscription Updates", processed, total); + break; + case SubscriptionUpload: + return i18nc("Step in Subscription and Episode Syncing Progress", "(Step %1 of %2) Uploading Local Subscription Updates", processed, total); + break; + case SubscriptionFetch: + return i18ncp("Step in Subscription and Episode Syncing Progress", + "(Step %3 of %4) Updated %2 of %1 Podcast", + "(Step %3 of %4) Updated %2 of %1 Podcasts", + m_feedUpdateTotal, + m_feedUpdateProgress, + processed, + total); + break; + case EpisodeDownload: + return i18nc("Step in Subscription and Episode Syncing Progress", "(Step %1 of %2) Requesting Remote Episode Updates", processed, total); + break; + case EpisodeUpload: + return i18nc("Step in Subscription and Episode Syncing Progress", "(Step %1 of %2) Uploading Local Episode Updates", processed, total); + break; + case Finished: + return i18nc("Step in Subscription and Episode Syncing Progress", "(Step %1 of %2) Finished Syncing", processed, total); + break; + case Aborted: + return i18nc("Step in Subscription and Episode Syncing Progress", "Sync Aborted"); + break; + case Error: + default: + return i18nc("Step in Subscription and Episode Syncing Progress", "Sync finished with Error"); + break; + } +} diff --git a/src/sync/syncjob.h b/src/sync/syncjob.h new file mode 100644 index 00000000..45b3d458 --- /dev/null +++ b/src/sync/syncjob.h @@ -0,0 +1,99 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include + +#include + +#include "error.h" +#include "sync/syncutils.h" + +class GPodder; + +class SyncJob : public KJob +{ + Q_OBJECT + +public: + enum SyncJobError { + SubscriptionDownloadError = KJob::UserDefinedError, + SubscriptionUploadError, + EpisodeDownloadError, + EpisodeUploadError, + InternalDataError, + }; + + enum SyncJobStatus { + Started = 0, + SubscriptionDownload, + SubscriptionUpload, + EpisodeDownload, + EpisodeUpload, + SubscriptionFetch, + Finished, + Aborted, + Error, + }; + + SyncJob(SyncUtils::SyncStatus syncStatus, GPodder *gpodder, const QString &device, bool forceFetchAll, QObject *parent); + + void start() override; + void abort(); + + bool aborted(); + QString errorString() const override; + +Q_SIGNALS: + void aborting(); + +private: + void doSync(); + + void doRegularSync(); // regular sync; can be forced to update all feeds + void doForceSync(); // force a full re-sync with the server; discarding local eposide acions + void doQuickSync(); // only upload pending local episode actions; intended to be run directly after an episode action has been created + + void syncSubscriptions(); + void uploadSubscriptions(const QStringList &localAddFeedUrlList, const QStringList &localRemoveFeedUrlList); + void fetchModifiedSubscriptions(); + void syncEpisodeStates(); + void uploadEpisodeActions(const QVector &episodeActions); + void uploadEpisodeActionsPartial(const QVector &episodeActionList, const int startIndex); + void updateDBTimestamp(const qulonglong ×tamp, const QString ×tampLabel); + + void removeAppliedSubscriptionChangesFromDB(); + void removeAppliedEpisodeActionsFromDB(); + + QPair getLocalSubscriptionChanges() const; // First list are additions, second are removals + QVector getLocalEpisodeActions() const; + + void removeSubscriptionChangeConflicts(QStringList &addList, QStringList &removeList); + QVector createListFromHash(const QHash> &episodeActionHash); + void addToHashIfNewer(QHash> &episodeActionHash, const SyncUtils::EpisodeAction &episodeAction); + void removeEpisodeActionConflicts(QHash> &local, + QHash> &remote); + QStringList getFeedsFromHash(const QHash> &hash); + void debugEpisodeActionHash(const QHash> &hash); + + SyncUtils::SyncStatus m_syncStatus = SyncUtils::SyncStatus::NoSync; + GPodder *m_gpodder = nullptr; + QString m_device; + bool m_forceFetchAll = false; + bool m_abort = false; + + // internal variables used while syncing + QStringList m_feedsToBeUpdatedSubs; + QStringList m_feedsToBeUpdatedEps; + int m_feedUpdateProgress = 0; + int m_feedUpdateTotal = 0; + QPair m_localSubscriptionChanges; + QVector m_localEpisodeActions; + + // needed for UI notifications + QString getProgressMessage(SyncJobStatus status) const; +}; diff --git a/src/sync/syncutils.cpp b/src/sync/syncutils.cpp new file mode 100644 index 00000000..ce782b94 --- /dev/null +++ b/src/sync/syncutils.cpp @@ -0,0 +1,7 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "sync/syncutils.h" diff --git a/src/sync/syncutils.h b/src/sync/syncutils.h new file mode 100644 index 00000000..7783738a --- /dev/null +++ b/src/sync/syncutils.h @@ -0,0 +1,64 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include +#include + +namespace SyncUtils +{ +Q_NAMESPACE + +// constants +const QString subscriptionTimestampLabel = QStringLiteral("syncsubscriptions"); +const QString episodeTimestampLabel = QStringLiteral("syncepisodes"); +const QString uploadSubscriptionTimestampLabel = QStringLiteral("uploadsyncsubscriptions"); +const QString uploadEpisodeTimestampLabel = QStringLiteral("uploadsyncepisodes"); +const int maxAmountEpisodeUploads = 30; + +// enums +enum Provider { + GPodderNet = 0, + GPodderNextcloud, +}; +Q_ENUM_NS(Provider) + +enum SyncStatus { + NoSync = 0, + RegularSync, + ForceSync, + UploadOnlySync, +}; + +Q_ENUM_NS(SyncStatus) + +// structs +struct EpisodeAction { + QString podcast; + QString url; + QString id; + QString device; + QString action; + qulonglong started; + qulonglong position; + qulonglong total; + qulonglong timestamp; +}; + +struct Device { + Q_GADGET +public: + QString id; + QString caption; + QString type; + int subscriptions; + Q_PROPERTY(QString caption MEMBER caption) + Q_PROPERTY(QString id MEMBER id) + Q_PROPERTY(QString type MEMBER type) + Q_PROPERTY(int subscriptions MEMBER subscriptions) +}; +}; diff --git a/src/updatefeedjob.cpp b/src/updatefeedjob.cpp index 14e91bf7..2fb56fbc 100644 --- a/src/updatefeedjob.cpp +++ b/src/updatefeedjob.cpp @@ -54,10 +54,12 @@ void UpdateFeedJob::retrieveFeed() connect(m_reply, &QNetworkReply::finished, this, [this]() { qCDebug(kastsFetcher) << "got networkreply for" << m_reply; if (m_reply->error()) { - qWarning() << "Error fetching feed"; - qWarning() << m_reply->errorString(); - setError(m_reply->error()); - setErrorText(m_reply->errorString()); + if (!m_abort) { + qWarning() << "Error fetching feed"; + qWarning() << m_reply->errorString(); + setError(m_reply->error()); + setErrorText(m_reply->errorString()); + } } else { QByteArray data = m_reply->readAll(); Syndication::DocumentSource document(data, m_url);