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);