Add podcast subscription and episode play state synchronization

This implements the gpodder API from scratch.  It turned out that
libmygpo-qt has several critical bugs, and there's no response to pull
requests upstream.  So using that library was not an option.

The implementation into kasts consists of the following:
- Can sync with gpodder.net or with a nextcloud server that has the
  nextcloud-gpodder app installed.  (This app is mostly API compatible
  with gpodder.)
- Passwords are stored using qtkeychain.  If the keychain is
  unavailable it will fallback to file.
- It syncs podcast subscriptions and episode play positions, including
  marking episodes as played. Episodes that have a non-zero play
  position will be added to the queue automatically.
- It will check for a metered connection before syncing.  This is
  coupled to the allowMeteredFeedUpdates setting.
- Full synchronization can be performed either manually (from the
  settings page) or through automatic triggers: on startup and/or on
  feed refresh.
- There is an additional possibility to trigger quick upload-only syncs
  to make sure that the local changes are immediately uploaded to the
  server (if the connection allows).  This will trigger when
  subscriptions are added or removed, when the pause/play button is
  toggled or an episode is marked as played.
- This implements a few safeguards to avoid having multiple feed URLS
  pointing to the same underlying feed (e.g. http vs https).  This
  solves part of #17

Solves #13
This commit is contained in:
Bart De Vries 2021-10-29 17:00:52 +02:00
parent 6ad1818734
commit f861f4e802
65 changed files with 4397 additions and 158 deletions

View File

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

View File

@ -19,6 +19,7 @@ Note: When using versions of kasts built from git-master, it's possible that the
- Kirigami
- Syndication
- TagLib
- QtKeychain
## Linux

View File

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

View File

@ -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();
}
}

View File

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

View File

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

View File

@ -37,5 +37,6 @@ private:
bool migrateTo3();
bool migrateTo4();
bool migrateTo5();
bool migrateTo6();
void cleanup();
};

View File

@ -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<Feed *> 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<Feed *> feeds;
for (QString feedurl : feedurls) {
feeds << getFeed(feedurl);
}
removeFeeds(feeds);
}
void DataManager::removeFeeds(const QVariantList feedVariantList)
{
QList<Feed *> feeds;
for (QVariant feedVariant : feedVariantList) {
if (feedVariant.canConvert<Feed *>()) {
feeds << feedVariant.value<Feed *>();
}
}
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<Feed *> &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)

View File

@ -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<Feed *> &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;

View File

@ -24,6 +24,7 @@
#include "models/errorlogmodel.h"
#include "settingsmanager.h"
#include "storagemanager.h"
#include "sync/sync.h"
#include <solidextras/networkstatus.h>
@ -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();

View File

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

View File

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

View File

@ -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;
}

View File

@ -23,6 +23,7 @@ public:
InvalidMedia,
DiscoverError,
StorageMoveError,
SyncError,
};
Q_ENUM(Type)

View File

@ -29,6 +29,7 @@
#include "models/errorlogmodel.h"
#include "settingsmanager.h"
#include "storagemanager.h"
#include "sync/sync.h"
#include <solidextras/networkstatus.h>
@ -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);

View File

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

View File

@ -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();
}

View File

@ -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<UpdateFeedJob *> m_feedjobs;
bool m_abort = false;
};

View File

@ -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<Entry *>("const Entry*"); // "hack" to make qml understand Entry*
qRegisterMetaType<Feed *>("const Feed*"); // "hack" to make qml understand Feed*
qRegisterMetaType<QVector<SyncUtils::Device>>("QVector<SyncUtils::Device>"); // "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);

View File

@ -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)
}

View File

@ -33,7 +33,6 @@ Kirigami.OverlaySheet {
}
}
Kirigami.PlaceholderMessage {
id: placeholder
visible: errorList.count == 0

View File

@ -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

View File

@ -39,7 +39,7 @@ Kirigami.ScrollablePage {
onClicked: {
DataManager.addFeed(feed.url)
}
enabled: !DataManager.isFeedExists(feed.url)
enabled: !DataManager.feedExists(feed.url)
}
}

View File

@ -240,7 +240,7 @@ Controls.ItemDelegate {
Kirigami.OverlaySheet {
id: actionOverlay
parent: applicationWindow().overlay
// parent: applicationWindow().overlay
showCloseButton: true
header: Kirigami.Heading {

View File

@ -262,8 +262,8 @@ Kirigami.ScrollablePage {
pageStack.pop();
}
}
DataManager.removeFeed(feeds[i]);
}
DataManager.removeFeeds(feeds);
}
}

View File

@ -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"

View File

@ -0,0 +1,463 @@
/**
* SPDX-FileCopyrightText: 2020 Tobias Fella <fella@posteo.de>
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* 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.<br/>It is advised to manually create an app password for Kasts through the web interface and use those credentials." , "<a href=\"https://apps.nextcloud.com/apps/gpoddersync\">https://apps.nextcloud.com/apps/gpoddersync</a>") :
i18nc("argument is a weblink", "If you don't already have an account, you should first create one at %1", "<a href=\"https://gpodder.net\">https://gpodder.net</a>")
}
}
}
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();
}
}
}
}

View File

@ -0,0 +1,73 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* 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();
}
}

View File

@ -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

View File

@ -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"

View File

@ -28,10 +28,12 @@
<file alias="ErrorNotification.qml">qml/ErrorNotification.qml</file>
<file alias="ConnectionCheckAction.qml">qml/ConnectionCheckAction.qml</file>
<file alias="ChapterListDelegate.qml">qml/ChapterListDelegate.qml</file>
<file alias="SyncPasswordOverlay.qml">qml/SyncPasswordOverlay.qml</file>
<file alias="SettingsPage.qml">qml/Settings/SettingsPage.qml</file>
<file alias="GeneralSettingsPage.qml">qml/Settings/GeneralSettingsPage.qml</file>
<file alias="NetworkSettingsPage.qml">qml/Settings/NetworkSettingsPage.qml</file>
<file alias="StorageSettingsPage.qml">qml/Settings/StorageSettingsPage.qml</file>
<file alias="SynchronizationSettingsPage.qml">qml/Settings/SynchronizationSettingsPage.qml</file>
<file>qtquickcontrols2.conf</file>
<file alias="logo.svg">../kasts.svg</file>
</qresource>

View File

@ -81,4 +81,38 @@
<default>FeedListPage</default>
</entry>
</group>
<group name="Synchronization">
<entry name="syncEnabled" type="Bool">
<label>Whether or not sync is active</label>
<default>false</default>
</entry>
<entry name="syncWhenUpdatingFeeds" type="Bool">
<label>Whether to sync when a feed update is triggered</label>
<default>true</default>
</entry>
<entry name="syncWhenPlayerstateChanges" type="Bool">
<label>Whether to do a quick sync when the audio player state changes</label>
<default>true</default>
</entry>
<entry name="syncProvider" type="Int">
<label>Which service to sync to; see enum Sync::Provider</label>
<default>0</default>
</entry>
<entry name="syncHostname" type="String">
<label>Hostname for sync service</label>
<default></default>
</entry>
<entry name="syncUsername" type="String">
<label>Username to log into sync service</label>
<default></default>
</entry>
<entry name="syncDevice" type="String">
<label>Device id with which to log into sync service</label>
<default></default>
</entry>
<entry name="syncDeviceName" type="String">
<label>Device name (caption) with which to log into sync service</label>
<default></default>
</entry>
</group>
</kcfg>

View File

@ -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;
}

View File

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

View File

@ -0,0 +1,54 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "devicerequest.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include "synclogging.h"
DeviceRequest::DeviceRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent)
: GenericRequest(provider, reply, parent)
{
}
QVector<SyncUtils::Device> 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();
}

View File

@ -0,0 +1,29 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QNetworkReply>
#include <QObject>
#include <QVector>
#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<SyncUtils::Device> devices() const;
private:
void processResults() override;
QVector<SyncUtils::Device> m_devices;
};

View File

@ -0,0 +1,111 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "episodeactionrequest.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QSqlQuery>
#include <QString>
#include <QVector>
#include "database.h"
#include "synclogging.h"
EpisodeActionRequest::EpisodeActionRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent)
: GenericRequest(provider, reply, parent)
{
}
QVector<SyncUtils::EpisodeAction> 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<qulonglong>(
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();
}

View File

@ -0,0 +1,31 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QNetworkReply>
#include <QObject>
#include <QVector>
#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<SyncUtils::EpisodeAction> episodeActions() const;
qulonglong timestamp() const;
private:
void processResults() override;
QVector<SyncUtils::EpisodeAction> m_episodeActions;
qulonglong m_timestamp = 0;
};

View File

@ -0,0 +1,49 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "genericrequest.h"
#include <QNetworkReply>
#include <QObject>
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;
}
}

View File

@ -0,0 +1,40 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QNetworkReply>
#include <QObject>
#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;
};

View File

@ -0,0 +1,276 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "gpodder.h"
#include <QDateTime>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSqlQuery>
#include <QUrl>
#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<QStringList> &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 &timestamp, 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<SyncUtils::EpisodeAction> &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<qint64>(episodeAction.started));
actionObject.insert(QStringLiteral("position"), static_cast<qint64>(episodeAction.position));
actionObject.insert(QStringLiteral("total"), static_cast<qint64>(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);
}

View File

@ -0,0 +1,55 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QNetworkRequest>
#include <QObject>
#include <QString>
#include <QVector>
#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<QStringList> &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 &timestamp, bool aggregated = false);
UploadEpisodeActionRequest *uploadEpisodeActions(const QVector<SyncUtils::EpisodeAction> &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;
};

View File

@ -0,0 +1,35 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "loginrequest.h"
#include <QNetworkReply>
#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();
}

View File

@ -0,0 +1,28 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QNetworkReply>
#include <QObject>
#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;
};

View File

@ -0,0 +1,35 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "logoutrequest.h"
#include <QNetworkReply>
#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();
}

View File

@ -0,0 +1,28 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QNetworkReply>
#include <QObject>
#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;
};

View File

@ -0,0 +1,65 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "subscriptionrequest.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QString>
#include <QStringList>
#include <QUrl>
#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();
}

View File

@ -0,0 +1,33 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QNetworkReply>
#include <QObject>
#include <QStringList>
#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;
};

View File

@ -0,0 +1,62 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "syncrequest.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QStringList>
#include <QVector>
#include "synclogging.h"
SyncRequest::SyncRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent)
: GenericRequest(provider, reply, parent)
{
}
QVector<QStringList> 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();
}

View File

@ -0,0 +1,32 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QNetworkReply>
#include <QObject>
#include <QStringList>
#include <QVector>
#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<QStringList> syncedDevices() const;
QStringList unsyncedDevices() const;
private:
void processResults() override;
QVector<QStringList> m_syncedDevices;
QStringList m_unsyncedDevices;
};

View File

@ -0,0 +1,36 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "updatedevicerequest.h"
#include <QNetworkReply>
#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();
}

View File

@ -0,0 +1,28 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QNetworkReply>
#include <QObject>
#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;
};

View File

@ -0,0 +1,68 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "updatesyncrequest.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QStringList>
#include <QVector>
#include "synclogging.h"
UpdateSyncRequest::UpdateSyncRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent)
: GenericRequest(provider, reply, parent)
{
}
bool UpdateSyncRequest::success() const
{
return m_success;
}
QVector<QStringList> 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();
}

View File

@ -0,0 +1,34 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QNetworkReply>
#include <QObject>
#include <QStringList>
#include <QVector>
#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<QStringList> syncedDevices() const;
QStringList unsyncedDevices() const;
private:
void processResults() override;
QVector<QStringList> m_syncedDevices;
QStringList m_unsyncedDevices;
bool m_success = false;
};

View File

@ -0,0 +1,58 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "uploadepisodeactionrequest.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QPair>
#include <QString>
#include <QUrl>
#include <QVector>
#include "synclogging.h"
UploadEpisodeActionRequest::UploadEpisodeActionRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent)
: GenericRequest(provider, reply, parent)
{
}
QVector<QPair<QString, QString>> 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();
}

View File

@ -0,0 +1,33 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QNetworkReply>
#include <QObject>
#include <QPair>
#include <QString>
#include <QVector>
#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<QPair<QString, QString>> updateUrls() const;
qulonglong timestamp() const;
private:
void processResults() override;
QVector<QPair<QString, QString>> m_updateUrls;
qulonglong m_timestamp = 0;
};

View File

@ -0,0 +1,58 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "uploadsubscriptionrequest.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QPair>
#include <QString>
#include <QUrl>
#include <QVector>
#include "synclogging.h"
UploadSubscriptionRequest::UploadSubscriptionRequest(SyncUtils::Provider provider, QNetworkReply *reply, QObject *parent)
: GenericRequest(provider, reply, parent)
{
}
QVector<QPair<QString, QString>> 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();
}

View File

@ -0,0 +1,33 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QNetworkReply>
#include <QObject>
#include <QPair>
#include <QString>
#include <QVector>
#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<QPair<QString, QString>> updateUrls() const;
qulonglong timestamp() const;
private:
void processResults() override;
QVector<QPair<QString, QString>> m_updateUrls;
qulonglong m_timestamp = 0;
};

945
src/sync/sync.cpp Normal file
View File

@ -0,0 +1,945 @@
/**
* SPDX-FileCopyrightText: 2021 Tobias Fella <fella@posteo.de>
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "sync.h"
#include "synclogging.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSqlQuery>
#include <QString>
#include <QSysInfo>
#include <QTimer>
#include <KFormat>
#include <KLocalizedString>
#include <qt5keychain/keychain.h>
#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 <solidextras/networkstatus.h>
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<Provider>(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<Device> 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<int>(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<QString> syncDevices;
for (const QStringList &group : syncRequest->syncedDevices()) {
syncDevices += group.toSet();
}
syncDevices += syncRequest->unsyncedDevices().toSet();
QVector<QStringList> 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<QString, QHash<QString, EpisodeAction>> &episodeActionHash)
{
m_allowSyncActionLogging = false;
for (const QHash<QString, EpisodeAction> &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<qint64>(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<qint64>(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);
}
}
}

146
src/sync/sync.h Normal file
View File

@ -0,0 +1,146 @@
/**
* SPDX-FileCopyrightText: 2021 Tobias Fella <fella@posteo.de>
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QObject>
#include <KFormat>
#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<SyncUtils::Device> 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<SyncUtils::Device> 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<QString, QHash<QString, SyncUtils::EpisodeAction>> &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<SyncUtils::Device> 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("");
};

873
src/sync/syncjob.cpp Normal file
View File

@ -0,0 +1,873 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "syncjob.h"
#include "synclogging.h"
#include <QDateTime>
#include <QDir>
#include <QSqlQuery>
#include <QString>
#include <QTimer>
#include <KLocalizedString>
#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 <solidextras/networkstatus.h>
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<QStringList, QStringList> 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<QString, QHash<QString, EpisodeAction>> 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<QStringList, QStringList> 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<QPair<QString, QString>> 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<EpisodeAction> remoteEpisodeActionList;
QHash<QString, QHash<QString, EpisodeAction>> 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<EpisodeAction> 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<EpisodeAction> &episodeActions)
{
// We have to upload episode actions in batches because otherwise the server
// will reject them.
uploadEpisodeActionsPartial(episodeActions, 0);
}
void SyncJob::uploadEpisodeActionsPartial(const QVector<EpisodeAction> &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 &timestamp, const QString &timestampLabel)
{
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<EpisodeAction> SyncJob::createListFromHash(const QHash<QString, QHash<QString, EpisodeAction>> &episodeActionHash)
{
QVector<EpisodeAction> episodeActionList;
for (const QHash<QString, EpisodeAction> &actions : episodeActionHash) {
for (const EpisodeAction &action : actions) {
if (action.action == QStringLiteral("play")) {
episodeActionList << action;
}
}
}
return episodeActionList;
}
QPair<QStringList, QStringList> SyncJob::getLocalSubscriptionChanges() const
{
QPair<QStringList, QStringList> 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<EpisodeAction> SyncJob::getLocalEpisodeActions() const
{
QVector<EpisodeAction> 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<QString, QHash<QString, EpisodeAction>> &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<QString, QHash<QString, EpisodeAction>> &local, QHash<QString, QHash<QString, EpisodeAction>> &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<QString, EpisodeAction> &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<QString, EpisodeAction> &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<QString, QHash<QString, EpisodeAction>> &hash)
{
QStringList feedUrls;
for (const QHash<QString, EpisodeAction> &actionList : hash) {
for (const EpisodeAction &action : actionList) {
feedUrls += action.podcast;
}
}
return feedUrls;
}
void SyncJob::debugEpisodeActionHash(const QHash<QString, QHash<QString, EpisodeAction>> &hash)
{
for (const QHash<QString, EpisodeAction> &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;
}
}

99
src/sync/syncjob.h Normal file
View File

@ -0,0 +1,99 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QObject>
#include <KJob>
#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<SyncUtils::EpisodeAction> &episodeActions);
void uploadEpisodeActionsPartial(const QVector<SyncUtils::EpisodeAction> &episodeActionList, const int startIndex);
void updateDBTimestamp(const qulonglong &timestamp, const QString &timestampLabel);
void removeAppliedSubscriptionChangesFromDB();
void removeAppliedEpisodeActionsFromDB();
QPair<QStringList, QStringList> getLocalSubscriptionChanges() const; // First list are additions, second are removals
QVector<SyncUtils::EpisodeAction> getLocalEpisodeActions() const;
void removeSubscriptionChangeConflicts(QStringList &addList, QStringList &removeList);
QVector<SyncUtils::EpisodeAction> createListFromHash(const QHash<QString, QHash<QString, SyncUtils::EpisodeAction>> &episodeActionHash);
void addToHashIfNewer(QHash<QString, QHash<QString, SyncUtils::EpisodeAction>> &episodeActionHash, const SyncUtils::EpisodeAction &episodeAction);
void removeEpisodeActionConflicts(QHash<QString, QHash<QString, SyncUtils::EpisodeAction>> &local,
QHash<QString, QHash<QString, SyncUtils::EpisodeAction>> &remote);
QStringList getFeedsFromHash(const QHash<QString, QHash<QString, SyncUtils::EpisodeAction>> &hash);
void debugEpisodeActionHash(const QHash<QString, QHash<QString, SyncUtils::EpisodeAction>> &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<QStringList, QStringList> m_localSubscriptionChanges;
QVector<SyncUtils::EpisodeAction> m_localEpisodeActions;
// needed for UI notifications
QString getProgressMessage(SyncJobStatus status) const;
};

7
src/sync/syncutils.cpp Normal file
View File

@ -0,0 +1,7 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "sync/syncutils.h"

64
src/sync/syncutils.h Normal file
View File

@ -0,0 +1,64 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QMetaType>
#include <QString>
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)
};
};

View File

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