1 /* This file is part of Clementine.
2    Copyright 2012, David Sansome <me@davidsansome.com>
3    Copyright 2014, John Maguire <john.maguire@gmail.com>
4    Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
5 
6    Clementine is free software: you can redistribute it and/or modify
7    it under the terms of the GNU General Public License as published by
8    the Free Software Foundation, either version 3 of the License, or
9    (at your option) any later version.
10 
11    Clementine is distributed in the hope that it will be useful,
12    but WITHOUT ANY WARRANTY; without even the implied warranty of
13    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14    GNU General Public License for more details.
15 
16    You should have received a copy of the GNU General Public License
17    along with Clementine.  If not, see <http://www.gnu.org/licenses/>.
18 */
19 
20 #include "gpoddersync.h"
21 
22 #include <QCoreApplication>
23 #include <QHostInfo>
24 #include <QNetworkAccessManager>
25 #include <QNetworkCookieJar>
26 #include <QSettings>
27 #include <QTimer>
28 
29 #include "podcastbackend.h"
30 #include "podcasturlloader.h"
31 #include "core/application.h"
32 #include "core/closure.h"
33 #include "core/logging.h"
34 #include "core/network.h"
35 #include "core/timeconstants.h"
36 #include "core/utilities.h"
37 
38 const char* GPodderSync::kSettingsGroup = "Podcasts";
39 const int GPodderSync::kFlushUpdateQueueDelay = 30 * kMsecPerSec;  // 30 seconds
40 const int GPodderSync::kGetUpdatesInterval =
41     30 * 60 * kMsecPerSec;  // 30 minutes
42 const int GPodderSync::kRequestTimeout = 30 * kMsecPerSec;  // 30 seconds
43 
GPodderSync(Application * app,QObject * parent)44 GPodderSync::GPodderSync(Application* app, QObject* parent)
45     : QObject(parent),
46       app_(app),
47       network_(new NetworkAccessManager(kRequestTimeout, this)),
48       backend_(app_->podcast_backend()),
49       loader_(new PodcastUrlLoader(this)),
50       get_updates_timer_(new QTimer(this)),
51       flush_queue_timer_(new QTimer(this)),
52       flushing_queue_(false) {
53   ReloadSettings();
54   LoadQueue();
55 
56   connect(app_, SIGNAL(SettingsChanged()), SLOT(ReloadSettings()));
57   connect(backend_, SIGNAL(SubscriptionAdded(Podcast)),
58           SLOT(SubscriptionAdded(Podcast)));
59   connect(backend_, SIGNAL(SubscriptionRemoved(Podcast)),
60           SLOT(SubscriptionRemoved(Podcast)));
61 
62   get_updates_timer_->setInterval(kGetUpdatesInterval);
63   connect(get_updates_timer_, SIGNAL(timeout()), SLOT(GetUpdatesNow()));
64 
65   flush_queue_timer_->setInterval(kFlushUpdateQueueDelay);
66   flush_queue_timer_->setSingleShot(true);
67   connect(flush_queue_timer_, SIGNAL(timeout()), SLOT(FlushUpdateQueue()));
68 
69   if (is_logged_in()) {
70     GetUpdatesNow();
71     flush_queue_timer_->start();
72     get_updates_timer_->start();
73   }
74 }
75 
~GPodderSync()76 GPodderSync::~GPodderSync() {}
77 
DeviceId()78 QString GPodderSync::DeviceId() {
79   return QString("%1-%2")
80       .arg(qApp->applicationName(), QHostInfo::localHostName())
81       .toLower();
82 }
83 
DefaultDeviceName()84 QString GPodderSync::DefaultDeviceName() {
85   return tr("%1 on %2")
86       .arg(qApp->applicationName(), QHostInfo::localHostName());
87 }
88 
is_logged_in() const89 bool GPodderSync::is_logged_in() const {
90   return !username_.isEmpty() && !password_.isEmpty() && api_;
91 }
92 
ReloadSettings()93 void GPodderSync::ReloadSettings() {
94   QSettings s;
95   s.beginGroup(kSettingsGroup);
96 
97   username_ = s.value("gpodder_username").toString();
98   password_ = s.value("gpodder_password").toString();
99   last_successful_get_ = s.value("gpodder_last_get").toDateTime();
100 
101   if (!username_.isEmpty() && !password_.isEmpty()) {
102     api_.reset(new mygpo::ApiRequest(username_, password_, network_));
103   }
104 }
105 
Login(const QString & username,const QString & password,const QString & device_name)106 void GPodderSync::Login(const QString& username, const QString& password,
107                         const QString& device_name) {
108   api_.reset(new mygpo::ApiRequest(username, password, network_));
109 
110   QNetworkReply* reply = api_->renameDevice(
111       username, DeviceId(), device_name,
112       Utilities::IsLaptop() ? mygpo::Device::LAPTOP : mygpo::Device::DESKTOP);
113   NewClosure(reply, SIGNAL(finished()), this,
114              SLOT(LoginFinished(QNetworkReply*, QString, QString)), reply,
115              username, password);
116 }
117 
LoginFinished(QNetworkReply * reply,const QString & username,const QString & password)118 void GPodderSync::LoginFinished(QNetworkReply* reply, const QString& username,
119                                 const QString& password) {
120   reply->deleteLater();
121 
122   if (reply->error() == QNetworkReply::NoError) {
123     username_ = username;
124     password_ = password;
125 
126     QSettings s;
127     s.beginGroup(kSettingsGroup);
128     s.setValue("gpodder_username", username);
129     s.setValue("gpodder_password", password);
130 
131     DoInitialSync();
132     emit LoginSuccess();
133   } else {
134     api_.reset();
135     emit LoginFailure(reply->errorString());
136   }
137 }
138 
Logout()139 void GPodderSync::Logout() {
140   QSettings s;
141   s.beginGroup(kSettingsGroup);
142   s.remove("gpodder_username");
143   s.remove("gpodder_password");
144   s.remove("gpodder_last_get");
145 
146   api_.reset();
147 
148   // Remove session cookies. QNetworkAccessManager takes ownership of the new
149   // object and frees the previous.
150   network_->setCookieJar(new QNetworkCookieJar());
151 }
152 
GetUpdatesNow()153 void GPodderSync::GetUpdatesNow() {
154   if (!is_logged_in()) return;
155 
156   qlonglong timestamp = 0;
157   if (last_successful_get_.isValid()) {
158     timestamp = last_successful_get_.toTime_t();
159   }
160 
161   mygpo::DeviceUpdatesPtr reply(
162       api_->deviceUpdates(username_, DeviceId(), timestamp));
163   NewClosure(reply, SIGNAL(finished()), this,
164              SLOT(DeviceUpdatesFinished(mygpo::DeviceUpdatesPtr)), reply);
165   connect(reply.data(), SIGNAL(parseError()), SLOT(DeviceUpdatesParseError()));
166   connect(reply.data(), SIGNAL(requestError(QNetworkReply::NetworkError)),
167           SLOT(DeviceUpdatesRequestError(QNetworkReply::NetworkError)));
168 }
169 
DeviceUpdatesParseError()170 void GPodderSync::DeviceUpdatesParseError() {
171   qLog(Warning) << "Failed to get gpodder device updates: parse error";
172 }
173 
DeviceUpdatesRequestError(QNetworkReply::NetworkError error)174 void GPodderSync::DeviceUpdatesRequestError(QNetworkReply::NetworkError error) {
175   qLog(Warning) << "Failed to get gpodder device updates:" << error;
176 }
177 
DeviceUpdatesFinished(mygpo::DeviceUpdatesPtr reply)178 void GPodderSync::DeviceUpdatesFinished(mygpo::DeviceUpdatesPtr reply) {
179   // Remember episode actions for each podcast, so when we add a new podcast
180   // we can apply the actions immediately.
181   QMap<QUrl, QList<mygpo::EpisodePtr>> episodes_by_podcast;
182   for (mygpo::EpisodePtr episode : reply->updateList()) {
183     episodes_by_podcast[episode->podcastUrl()].append(episode);
184   }
185 
186   for (mygpo::PodcastPtr podcast : reply->addList()) {
187     const QUrl url(podcast->url());
188 
189     // Are we subscribed to this podcast already?
190     Podcast existing_podcast = backend_->GetSubscriptionByUrl(url);
191     if (existing_podcast.is_valid()) {
192       // Just apply actions to this existing podcast
193       ApplyActions(episodes_by_podcast[url],
194                    existing_podcast.mutable_episodes());
195       backend_->UpdateEpisodes(existing_podcast.episodes());
196       continue;
197     }
198 
199     // Start loading the podcast.  Remember actions and apply them after we
200     // have a list of the episodes.
201     PodcastUrlLoaderReply* loader_reply = loader_->Load(url);
202     NewClosure(loader_reply, SIGNAL(Finished(bool)), this,
203                SLOT(NewPodcastLoaded(PodcastUrlLoaderReply*, QUrl,
204                                      QList<mygpo::EpisodePtr>)),
205                loader_reply, url, episodes_by_podcast[url]);
206   }
207 
208   // Unsubscribe from podcasts that were removed.
209   for (const QUrl& url : reply->removeList()) {
210     backend_->Unsubscribe(backend_->GetSubscriptionByUrl(url));
211   }
212 
213   last_successful_get_ = QDateTime::currentDateTime();
214 
215   QSettings s;
216   s.beginGroup(kSettingsGroup);
217   s.setValue("gpodder_last_get", last_successful_get_);
218 }
219 
NewPodcastLoaded(PodcastUrlLoaderReply * reply,const QUrl & url,const QList<mygpo::EpisodePtr> & actions)220 void GPodderSync::NewPodcastLoaded(PodcastUrlLoaderReply* reply,
221                                    const QUrl& url,
222                                    const QList<mygpo::EpisodePtr>& actions) {
223   reply->deleteLater();
224 
225   if (!reply->is_success()) {
226     qLog(Warning) << "Error fetching podcast at" << url << ":"
227                   << reply->error_text();
228     return;
229   }
230 
231   if (reply->result_type() != PodcastUrlLoaderReply::Type_Podcast) {
232     qLog(Warning) << "The URL" << url << "no longer contains a podcast";
233     return;
234   }
235 
236   // Apply the actions to the episodes in the podcast.
237   for (Podcast podcast : reply->podcast_results()) {
238     ApplyActions(actions, podcast.mutable_episodes());
239 
240     // Add the subscription
241     backend_->Subscribe(&podcast);
242   }
243 }
244 
ApplyActions(const QList<QSharedPointer<mygpo::Episode>> & actions,PodcastEpisodeList * episodes)245 void GPodderSync::ApplyActions(
246     const QList<QSharedPointer<mygpo::Episode>>& actions,
247     PodcastEpisodeList* episodes) {
248   for (PodcastEpisodeList::iterator it = episodes->begin();
249        it != episodes->end(); ++it) {
250     // Find an action for this episode
251     for (mygpo::EpisodePtr action : actions) {
252       if (action->url() != it->url()) continue;
253 
254       switch (action->status()) {
255         case mygpo::Episode::PLAY:
256         case mygpo::Episode::DOWNLOAD:
257           it->set_listened(true);
258           break;
259 
260         default:
261           break;
262       }
263       break;
264     }
265   }
266 }
267 
SubscriptionAdded(const Podcast & podcast)268 void GPodderSync::SubscriptionAdded(const Podcast& podcast) {
269   if (!is_logged_in()) return;
270 
271   const QUrl& url = podcast.url();
272 
273   queued_remove_subscriptions_.remove(url);
274   queued_add_subscriptions_.insert(url);
275 
276   SaveQueue();
277   flush_queue_timer_->start();
278 }
279 
SubscriptionRemoved(const Podcast & podcast)280 void GPodderSync::SubscriptionRemoved(const Podcast& podcast) {
281   if (!is_logged_in()) return;
282 
283   const QUrl& url = podcast.url();
284 
285   queued_remove_subscriptions_.insert(url);
286   queued_add_subscriptions_.remove(url);
287 
288   SaveQueue();
289   flush_queue_timer_->start();
290 }
291 
292 namespace {
293 template <typename T>
WriteContainer(const T & container,QSettings * s,const char * array_name,const char * item_name)294 void WriteContainer(const T& container, QSettings* s, const char* array_name,
295                     const char* item_name) {
296   s->beginWriteArray(array_name, container.count());
297   int index = 0;
298   for (const auto& item : container) {
299     s->setArrayIndex(index++);
300     s->setValue(item_name, item);
301   }
302   s->endArray();
303 }
304 
305 template <typename T>
ReadContainer(T * container,QSettings * s,const char * array_name,const char * item_name)306 void ReadContainer(T* container, QSettings* s, const char* array_name,
307                    const char* item_name) {
308   container->clear();
309   const int count = s->beginReadArray(array_name);
310   for (int i = 0; i < count; ++i) {
311     s->setArrayIndex(i);
312     *container << s->value(item_name).value<typename T::value_type>();
313   }
314   s->endArray();
315 }
316 }  // namespace
317 
SaveQueue()318 void GPodderSync::SaveQueue() {
319   QSettings s;
320   s.beginGroup(kSettingsGroup);
321 
322   WriteContainer(queued_add_subscriptions_, &s,
323                  "gpodder_queued_add_subscriptions", "url");
324   WriteContainer(queued_remove_subscriptions_, &s,
325                  "gpodder_queued_remove_subscriptions", "url");
326 }
327 
LoadQueue()328 void GPodderSync::LoadQueue() {
329   QSettings s;
330   s.beginGroup(kSettingsGroup);
331 
332   ReadContainer(&queued_add_subscriptions_, &s,
333                 "gpodder_queued_add_subscriptions", "url");
334   ReadContainer(&queued_remove_subscriptions_, &s,
335                 "gpodder_queued_remove_subscriptions", "url");
336 }
337 
FlushUpdateQueue()338 void GPodderSync::FlushUpdateQueue() {
339   if (!is_logged_in() || flushing_queue_) return;
340 
341   QSet<QUrl> all_urls =
342       queued_add_subscriptions_ + queued_remove_subscriptions_;
343   if (all_urls.isEmpty()) return;
344 
345   flushing_queue_ = true;
346   mygpo::AddRemoveResultPtr reply(api_->addRemoveSubscriptions(
347       username_, DeviceId(), queued_add_subscriptions_.toList(),
348       queued_remove_subscriptions_.toList()));
349 
350   qLog(Info) << "Sending" << all_urls.count() << "changes to gpodder.net";
351 
352   NewClosure(reply, SIGNAL(finished()), this,
353              SLOT(AddRemoveFinished(mygpo::AddRemoveResultPtr, QList<QUrl>)),
354              reply, all_urls.toList());
355   connect(reply.data(), SIGNAL(parseError()), SLOT(AddRemoveParseError()));
356   connect(reply.data(), SIGNAL(requestError(QNetworkReply::NetworkError)),
357           SLOT(AddRemoveRequestError(QNetworkReply::NetworkError)));
358 }
359 
AddRemoveParseError()360 void GPodderSync::AddRemoveParseError() {
361   flushing_queue_ = false;
362   qLog(Warning) << "Failed to update gpodder subscriptions: parse error";
363 }
364 
AddRemoveRequestError(QNetworkReply::NetworkError err)365 void GPodderSync::AddRemoveRequestError(QNetworkReply::NetworkError err) {
366   flushing_queue_ = false;
367   qLog(Warning) << "Failed to update gpodder subscriptions:" << err;
368 }
369 
AddRemoveFinished(mygpo::AddRemoveResultPtr reply,const QList<QUrl> & affected_urls)370 void GPodderSync::AddRemoveFinished(mygpo::AddRemoveResultPtr reply,
371                                     const QList<QUrl>& affected_urls) {
372   flushing_queue_ = false;
373 
374   // Remove the URLs from the queue.
375   for (const QUrl& url : affected_urls) {
376     queued_add_subscriptions_.remove(url);
377     queued_remove_subscriptions_.remove(url);
378   }
379 
380   SaveQueue();
381 
382   // Did more change in the mean time?
383   if (!queued_add_subscriptions_.isEmpty() ||
384       !queued_remove_subscriptions_.isEmpty()) {
385     flush_queue_timer_->start();
386   }
387 }
388 
DoInitialSync()389 void GPodderSync::DoInitialSync() {
390   // Get updates from the server
391   GetUpdatesNow();
392   get_updates_timer_->start();
393 
394   // Send our complete list of subscriptions
395   queued_remove_subscriptions_.clear();
396   queued_add_subscriptions_.clear();
397   for (const Podcast& podcast : backend_->GetAllSubscriptions()) {
398     queued_add_subscriptions_.insert(podcast.url());
399   }
400 
401   SaveQueue();
402   FlushUpdateQueue();
403 }
404