1 /* This file is part of Clementine.
2    Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
3    Copyright 2012-2013, David Sansome <me@davidsansome.com>
4    Copyright 2013-2014, Krzysztof A. Sobiecki <sobkas@gmail.com>
5    Copyright 2014, Simeon Bird <sbird@andrew.cmu.edu>
6 
7    Clementine is free software: you can redistribute it and/or modify
8    it under the terms of the GNU General Public License as published by
9    the Free Software Foundation, either version 3 of the License, or
10    (at your option) any later version.
11 
12    Clementine is distributed in the hope that it will be useful,
13    but WITHOUT ANY WARRANTY; without even the implied warranty of
14    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15    GNU General Public License for more details.
16 
17    You should have received a copy of the GNU General Public License
18    along with Clementine.  If not, see <http://www.gnu.org/licenses/>.
19 */
20 
21 #include "internet/podcasts/podcastservice.h"
22 
23 #include <QMap>
24 #include <QMenu>
25 #include <QSortFilterProxyModel>
26 #include <QtConcurrentRun>
27 
28 #include "addpodcastdialog.h"
29 #include "podcastinfodialog.h"
30 #include "core/application.h"
31 #include "core/logging.h"
32 #include "core/mergedproxymodel.h"
33 #include "devices/devicemanager.h"
34 #include "devices/devicestatefiltermodel.h"
35 #include "devices/deviceview.h"
36 #include "internet/core/internetmodel.h"
37 #include "library/libraryview.h"
38 #include "opmlcontainer.h"
39 #include "podcastbackend.h"
40 #include "podcastdeleter.h"
41 #include "podcastdownloader.h"
42 #include "internet/podcasts/podcastservicemodel.h"
43 #include "podcastupdater.h"
44 #include "ui/iconloader.h"
45 #include "ui/organisedialog.h"
46 #include "ui/organiseerrordialog.h"
47 #include "ui/standarditemiconloader.h"
48 
49 const char* PodcastService::kServiceName = "Podcasts";
50 const char* PodcastService::kSettingsGroup = "Podcasts";
51 
52 class PodcastSortProxyModel : public QSortFilterProxyModel {
53  public:
54   explicit PodcastSortProxyModel(QObject* parent = nullptr);
55 
56  protected:
57   bool lessThan(const QModelIndex& left, const QModelIndex& right) const;
58 };
59 
PodcastService(Application * app,InternetModel * parent)60 PodcastService::PodcastService(Application* app, InternetModel* parent)
61     : InternetService(kServiceName, app, parent, parent),
62       use_pretty_covers_(true),
63       hide_listened_(false),
64       show_episodes_(0),
65       icon_loader_(new StandardItemIconLoader(app->album_cover_loader(), this)),
66       backend_(app->podcast_backend()),
67       model_(new PodcastServiceModel(this)),
68       proxy_(new PodcastSortProxyModel(this)),
69       context_menu_(nullptr),
70       root_(nullptr),
71       organise_dialog_(new OrganiseDialog(app_->task_manager())) {
72   icon_loader_->SetModel(model_);
73   proxy_->setSourceModel(model_);
74   proxy_->setDynamicSortFilter(true);
75   proxy_->sort(0);
76 
77   connect(backend_, SIGNAL(SubscriptionAdded(Podcast)),
78           SLOT(SubscriptionAdded(Podcast)));
79   connect(backend_, SIGNAL(SubscriptionRemoved(Podcast)),
80           SLOT(SubscriptionRemoved(Podcast)));
81   connect(backend_, SIGNAL(EpisodesAdded(PodcastEpisodeList)),
82           SLOT(EpisodesAdded(PodcastEpisodeList)));
83   connect(backend_, SIGNAL(EpisodesUpdated(PodcastEpisodeList)),
84           SLOT(EpisodesUpdated(PodcastEpisodeList)));
85 
86   connect(app_->playlist_manager(), SIGNAL(CurrentSongChanged(Song)),
87           SLOT(CurrentSongChanged(Song)));
88   connect(organise_dialog_.get(), SIGNAL(FileCopied(int)), this,
89           SLOT(FileCopied(int)));
90 }
91 
~PodcastService()92 PodcastService::~PodcastService() {}
93 
PodcastSortProxyModel(QObject * parent)94 PodcastSortProxyModel::PodcastSortProxyModel(QObject* parent)
95     : QSortFilterProxyModel(parent) {}
96 
lessThan(const QModelIndex & left,const QModelIndex & right) const97 bool PodcastSortProxyModel::lessThan(const QModelIndex& left,
98                                      const QModelIndex& right) const {
99   const int left_type = left.data(InternetModel::Role_Type).toInt();
100   const int right_type = right.data(InternetModel::Role_Type).toInt();
101 
102   // The special Add Podcast item comes first
103   if (left_type == PodcastService::Type_AddPodcast)
104     return true;
105   else if (right_type == PodcastService::Type_AddPodcast)
106     return false;
107 
108   // Otherwise we only compare identical typed items.
109   if (left_type != right_type)
110     return QSortFilterProxyModel::lessThan(left, right);
111 
112   switch (left_type) {
113     case PodcastService::Type_Podcast:
114       return left.data().toString().localeAwareCompare(
115                  right.data().toString()) < 0;
116 
117     case PodcastService::Type_Episode: {
118       const PodcastEpisode left_episode =
119           left.data(PodcastService::Role_Episode).value<PodcastEpisode>();
120       const PodcastEpisode right_episode =
121           right.data(PodcastService::Role_Episode).value<PodcastEpisode>();
122 
123       return left_episode.publication_date() > right_episode.publication_date();
124     }
125 
126     default:
127       return QSortFilterProxyModel::lessThan(left, right);
128   }
129 }
130 
CreateRootItem()131 QStandardItem* PodcastService::CreateRootItem() {
132   root_ = new QStandardItem(IconLoader::Load("podcast", IconLoader::Provider),
133                             tr("Podcasts"));
134   root_->setData(true, InternetModel::Role_CanLazyLoad);
135   return root_;
136 }
137 
CopyToDevice()138 void PodcastService::CopyToDevice() {
139   if (selected_episodes_.isEmpty() && explicitly_selected_podcasts_.isEmpty()) {
140     CopyToDevice(backend_->GetNewDownloadedEpisodes());
141   } else {
142     CopyToDevice(selected_episodes_, explicitly_selected_podcasts_);
143   }
144 }
145 
CopyToDevice(const PodcastEpisodeList & episodes_list)146 void PodcastService::CopyToDevice(const PodcastEpisodeList& episodes_list) {
147   SongList songs;
148   Podcast podcast;
149   for (const PodcastEpisode& episode : episodes_list) {
150     podcast = backend_->GetSubscriptionById(episode.podcast_database_id());
151     songs.append(episode.ToSong(podcast));
152   }
153 
154   organise_dialog_->SetDestinationModel(
155       app_->device_manager()->connected_devices_model(), true);
156   organise_dialog_->SetCopy(true);
157   if (organise_dialog_->SetSongs(songs)) organise_dialog_->show();
158 }
159 
CopyToDevice(const QModelIndexList & episode_indexes,const QModelIndexList & podcast_indexes)160 void PodcastService::CopyToDevice(const QModelIndexList& episode_indexes,
161                                   const QModelIndexList& podcast_indexes) {
162   PodcastEpisode episode_tmp;
163   SongList songs;
164   PodcastEpisodeList episodes;
165   Podcast podcast;
166   for (const QModelIndex& index : episode_indexes) {
167     episode_tmp = index.data(Role_Episode).value<PodcastEpisode>();
168     if (episode_tmp.downloaded()) episodes << episode_tmp;
169   }
170 
171   for (const QModelIndex& podcast : podcast_indexes) {
172     for (int i = 0; i < podcast.model()->rowCount(podcast); ++i) {
173       const QModelIndex& index = podcast.child(i, 0);
174       episode_tmp = index.data(Role_Episode).value<PodcastEpisode>();
175       if (episode_tmp.downloaded() && !episode_tmp.listened())
176         episodes << episode_tmp;
177     }
178   }
179   for (const PodcastEpisode& episode : episodes) {
180     podcast = backend_->GetSubscriptionById(episode.podcast_database_id());
181     songs.append(episode.ToSong(podcast));
182   }
183 
184   organise_dialog_->SetDestinationModel(
185       app_->device_manager()->connected_devices_model(), true);
186   organise_dialog_->SetCopy(true);
187   if (organise_dialog_->SetSongs(songs)) organise_dialog_->show();
188 }
189 
CancelDownload()190 void PodcastService::CancelDownload() {
191   CancelDownload(selected_episodes_, explicitly_selected_podcasts_);
192 }
193 
CancelDownload(const QModelIndexList & episode_indexes,const QModelIndexList & podcast_indexes)194 void PodcastService::CancelDownload(const QModelIndexList& episode_indexes,
195                                     const QModelIndexList& podcast_indexes) {
196   PodcastEpisode episode_tmp;
197   SongList songs;
198   PodcastEpisodeList episodes;
199   Podcast podcast;
200   for (const QModelIndex& index : episode_indexes) {
201     episode_tmp = index.data(Role_Episode).value<PodcastEpisode>();
202     episodes << episode_tmp;
203   }
204 
205   for (const QModelIndex& podcast : podcast_indexes) {
206     for (int i = 0; i < podcast.model()->rowCount(podcast); ++i) {
207       const QModelIndex& index = podcast.child(i, 0);
208       episode_tmp = index.data(Role_Episode).value<PodcastEpisode>();
209       episodes << episode_tmp;
210     }
211   }
212   episodes = app_->podcast_downloader()->EpisodesDownloading(episodes);
213   app_->podcast_downloader()->cancelDownload(episodes);
214 }
215 
LazyPopulate(QStandardItem * parent)216 void PodcastService::LazyPopulate(QStandardItem* parent) {
217   switch (parent->data(InternetModel::Role_Type).toInt()) {
218     case InternetModel::Type_Service:
219       PopulatePodcastList(model_->invisibleRootItem());
220       model()->merged_model()->AddSubModel(parent->index(), proxy_);
221       break;
222   }
223 }
224 
PopulatePodcastList(QStandardItem * parent)225 void PodcastService::PopulatePodcastList(QStandardItem* parent) {
226   // Do this here since the downloader won't be created yet in the ctor.
227   connect(app_->podcast_downloader(),
228           SIGNAL(ProgressChanged(PodcastEpisode, PodcastDownload::State, int)),
229           SLOT(DownloadProgressChanged(PodcastEpisode, PodcastDownload::State, int)));
230 
231   if (default_icon_.isNull()) {
232     default_icon_ = IconLoader::Load("podcast", IconLoader::Provider);
233   }
234 
235   for (const Podcast& podcast : backend_->GetAllSubscriptions()) {
236     parent->appendRow(CreatePodcastItem(podcast));
237   }
238 }
239 
ClearPodcastList(QStandardItem * parent)240 void PodcastService::ClearPodcastList(QStandardItem* parent) {
241   parent->removeRows(0, parent->rowCount());
242 }
243 
UpdatePodcastText(QStandardItem * item,int unlistened_count) const244 void PodcastService::UpdatePodcastText(QStandardItem* item,
245                                        int unlistened_count) const {
246   const Podcast podcast = item->data(Role_Podcast).value<Podcast>();
247 
248   QString title = podcast.title().simplified();
249   QFont font;
250 
251   if (unlistened_count > 0) {
252     // Add the number of new episodes after the title.
253     title.append(QString(" (%1)").arg(unlistened_count));
254 
255     // Set a bold font
256     font.setBold(true);
257   }
258 
259   item->setFont(font);
260   item->setText(title);
261 }
262 
UpdateEpisodeText(QStandardItem * item,PodcastDownload::State state,int percent)263 void PodcastService::UpdateEpisodeText(QStandardItem* item,
264                                        PodcastDownload::State state,
265                                        int percent) {
266   const PodcastEpisode episode =
267       item->data(Role_Episode).value<PodcastEpisode>();
268 
269   QString title = episode.title().simplified();
270   QString tooltip;
271   QFont font;
272   QIcon icon;
273 
274   // Unlistened episodes are bold
275   if (!episode.listened()) {
276     font.setBold(true);
277   }
278 
279   // Downloaded episodes get an icon
280   if (episode.downloaded()) {
281     if (downloaded_icon_.isNull()) {
282       downloaded_icon_ = IconLoader::Load("document-save", IconLoader::Base);
283     }
284     icon = downloaded_icon_;
285   }
286 
287   // Queued or downloading episodes get icons, tooltips, and maybe a title.
288   switch (state) {
289     case PodcastDownload::Queued:
290       if (queued_icon_.isNull()) {
291         queued_icon_ = IconLoader::Load("user-away", IconLoader::Base);
292       }
293       icon = queued_icon_;
294       tooltip = tr("Download queued");
295       break;
296 
297     case PodcastDownload::Downloading:
298       if (downloading_icon_.isNull()) {
299         downloading_icon_ = IconLoader::Load("go-down", IconLoader::Base);
300       }
301       icon = downloading_icon_;
302       tooltip = tr("Downloading (%1%)...").arg(percent);
303       title =
304           QString("[ %1% ] %2").arg(QString::number(percent), episode.title());
305       break;
306 
307     case PodcastDownload::Finished:
308     case PodcastDownload::NotDownloading:
309       break;
310   }
311 
312   item->setFont(font);
313   item->setText(title);
314   item->setIcon(icon);
315 }
316 
UpdatePodcastText(QStandardItem * item,PodcastDownload::State state,int percent)317 void PodcastService::UpdatePodcastText(QStandardItem* item,
318                                        PodcastDownload::State state,
319                                        int percent) {
320   const Podcast podcast = item->data(Role_Podcast).value<Podcast>();
321 
322   QString tooltip;
323   QIcon icon;
324 
325   // Queued or downloading podcasts get icons, tooltips, and maybe a title.
326   switch (state) {
327     case PodcastDownload::Queued:
328       if (queued_icon_.isNull()) {
329         queued_icon_ = IconLoader::Load("user-away", IconLoader::Base);
330       }
331       icon = queued_icon_;
332       item->setIcon(icon);
333       tooltip = tr("Download queued");
334       break;
335 
336     case PodcastDownload::Downloading:
337       if (downloading_icon_.isNull()) {
338         downloading_icon_ = IconLoader::Load("go-down", IconLoader::Base);
339       }
340       icon = downloading_icon_;
341       item->setIcon(icon);
342       tooltip = tr("Downloading (%1%)...").arg(percent);
343       break;
344 
345     case PodcastDownload::Finished:
346     case PodcastDownload::NotDownloading:
347       if (podcast.ImageUrlSmall().isValid()) {
348         icon_loader_->LoadIcon(podcast.ImageUrlSmall().toString(), QString(), item);
349       } else {
350         item->setIcon(default_icon_);
351       }
352       break;
353   }
354 }
355 
CreatePodcastItem(const Podcast & podcast)356 QStandardItem* PodcastService::CreatePodcastItem(const Podcast& podcast) {
357   QStandardItem* item = new QStandardItem;
358 
359   // Add the episodes in this podcast and gather aggregate stats.
360   int unlistened_count = 0;
361   qint64 number = 0;
362   for (const PodcastEpisode& episode :
363        backend_->GetEpisodes(podcast.database_id())) {
364     if (!episode.listened()) {
365       unlistened_count++;
366     }
367 
368     if (episode.listened() && hide_listened_) {
369       continue;
370     } else {
371       item->appendRow(CreatePodcastEpisodeItem(episode));
372       ++number;
373     }
374 
375     if ((number >= show_episodes_) && (show_episodes_ != 0)) {
376       break;
377     }
378   }
379 
380   item->setIcon(default_icon_);
381   item->setData(Type_Podcast, InternetModel::Role_Type);
382   item->setData(QVariant::fromValue(podcast), Role_Podcast);
383   item->setFlags(Qt::ItemIsEnabled | Qt::ItemIsDragEnabled |
384                  Qt::ItemIsSelectable);
385   UpdatePodcastText(item, unlistened_count);
386 
387   // Load the podcast's image if it has one
388   if (podcast.ImageUrlSmall().isValid()) {
389     icon_loader_->LoadIcon(podcast.ImageUrlSmall().toString(), QString(), item);
390   }
391 
392   podcasts_by_database_id_[podcast.database_id()] = item;
393 
394   return item;
395 }
396 
CreatePodcastEpisodeItem(const PodcastEpisode & episode)397 QStandardItem* PodcastService::CreatePodcastEpisodeItem(
398     const PodcastEpisode& episode) {
399   QStandardItem* item = new QStandardItem;
400   item->setText(episode.title().simplified());
401   item->setData(Type_Episode, InternetModel::Role_Type);
402   item->setData(QVariant::fromValue(episode), Role_Episode);
403   item->setData(InternetModel::PlayBehaviour_UseSongLoader,
404                 InternetModel::Role_PlayBehaviour);
405   item->setFlags(Qt::ItemIsEnabled | Qt::ItemIsDragEnabled |
406                  Qt::ItemIsSelectable);
407 
408   UpdateEpisodeText(item);
409 
410   episodes_by_database_id_[episode.database_id()] = item;
411 
412   return item;
413 }
414 
ShowContextMenu(const QPoint & global_pos)415 void PodcastService::ShowContextMenu(const QPoint& global_pos) {
416   if (!context_menu_) {
417     context_menu_ = new QMenu;
418     context_menu_->addAction(IconLoader::Load("list-add", IconLoader::Base),
419                              tr("Add podcast..."), this, SLOT(AddPodcast()));
420     context_menu_->addAction(IconLoader::Load("view-refresh", IconLoader::Base),
421                              tr("Update all podcasts"), app_->podcast_updater(),
422                              SLOT(UpdateAllPodcastsNow()));
423 
424     context_menu_->addSeparator();
425     context_menu_->addActions(GetPlaylistActions());
426 
427     context_menu_->addSeparator();
428     update_selected_action_ = context_menu_->addAction(
429         IconLoader::Load("view-refresh", IconLoader::Base),
430         tr("Update this podcast"), this, SLOT(UpdateSelectedPodcast()));
431     download_selected_action_ =
432         context_menu_->addAction(IconLoader::Load("download", IconLoader::Base),
433                                  "", this, SLOT(DownloadSelectedEpisode()));
434     info_selected_action_ =
435         context_menu_->addAction(IconLoader::Load("about-info",
436                                                   IconLoader::Base),
437                                  tr("Podcast information"), this,
438                                  SLOT(PodcastInfo()));
439     delete_downloaded_action_ = context_menu_->addAction(
440         IconLoader::Load("edit-delete", IconLoader::Base),
441         tr("Delete downloaded data"), this, SLOT(DeleteDownloadedData()));
442     copy_to_device_ = context_menu_->addAction(
443         IconLoader::Load("multimedia-player-ipod-mini-blue", IconLoader::Base),
444         tr("Copy to device..."), this, SLOT(CopyToDevice()));
445     cancel_download_ = context_menu_->addAction(IconLoader::Load("cancel",
446                                                 IconLoader::Base),
447                                                 tr("Cancel download"), this,
448                                                 SLOT(CancelDownload()));
449     remove_selected_action_ = context_menu_->addAction(
450         IconLoader::Load("list-remove", IconLoader::Base), tr("Unsubscribe"),
451         this, SLOT(RemoveSelectedPodcast()));
452 
453     context_menu_->addSeparator();
454     set_new_action_ =
455         context_menu_->addAction(tr("Mark as new"), this, SLOT(SetNew()));
456     set_listened_action_ = context_menu_->addAction(tr("Mark as listened"),
457                                                     this, SLOT(SetListened()));
458 
459     context_menu_->addSeparator();
460     context_menu_->addAction(IconLoader::Load("configure", IconLoader::Base),
461                              tr("Configure podcasts..."), this,
462                              SLOT(ShowConfig()));
463 
464     copy_to_device_->setDisabled(
465         app_->device_manager()->connected_devices_model()->rowCount() == 0);
466     connect(app_->device_manager()->connected_devices_model(),
467             SIGNAL(IsEmptyChanged(bool)), copy_to_device_,
468             SLOT(setDisabled(bool)));
469   }
470 
471   selected_episodes_.clear();
472   selected_podcasts_.clear();
473   explicitly_selected_podcasts_.clear();
474   QSet<int> podcast_ids;
475 
476   for (const QModelIndex& index : model()->selected_indexes()) {
477     switch (index.data(InternetModel::Role_Type).toInt()) {
478       case Type_Podcast: {
479         const int id = index.data(Role_Podcast).value<Podcast>().database_id();
480         if (!podcast_ids.contains(id)) {
481           selected_podcasts_.append(index);
482           explicitly_selected_podcasts_.append(index);
483           podcast_ids.insert(id);
484         }
485         break;
486       }
487 
488       case Type_Episode: {
489         selected_episodes_.append(index);
490 
491         // Add the parent podcast as well.
492         const QModelIndex parent = index.parent();
493         const int id = parent.data(Role_Podcast).value<Podcast>().database_id();
494         if (!podcast_ids.contains(id)) {
495           selected_podcasts_.append(parent);
496           podcast_ids.insert(id);
497         }
498         break;
499       }
500     }
501   }
502 
503   const bool episodes = !selected_episodes_.isEmpty();
504   const bool podcasts = !selected_podcasts_.isEmpty();
505 
506   update_selected_action_->setEnabled(podcasts);
507   remove_selected_action_->setEnabled(podcasts);
508   set_new_action_->setEnabled(episodes || podcasts);
509   set_listened_action_->setEnabled(episodes || podcasts);
510   cancel_download_->setEnabled(episodes || podcasts);
511 
512   if (selected_episodes_.count() == 1) {
513     const PodcastEpisode episode =
514         selected_episodes_[0].data(Role_Episode).value<PodcastEpisode>();
515     const bool downloaded = episode.downloaded();
516     const bool listened = episode.listened();
517 
518     download_selected_action_->setEnabled(!downloaded);
519     delete_downloaded_action_->setEnabled(downloaded);
520 
521     if (explicitly_selected_podcasts_.isEmpty()) {
522       set_new_action_->setEnabled(listened);
523       set_listened_action_->setEnabled(!listened || !episode.listened_date().isValid());
524     }
525   } else {
526     download_selected_action_->setEnabled(episodes);
527     delete_downloaded_action_->setEnabled(episodes);
528   }
529 
530   if (selected_podcasts_.count() == 1) {
531     if (selected_episodes_.count() == 1) {
532       info_selected_action_->setText(tr("Episode information"));
533       info_selected_action_->setEnabled(true);
534     } else {
535       info_selected_action_->setText(tr("Podcast information"));
536       info_selected_action_->setEnabled(true);
537     }
538   } else {
539     info_selected_action_->setText(tr("Podcast information"));
540     info_selected_action_->setEnabled(false);
541   }
542 
543   if (explicitly_selected_podcasts_.isEmpty() && selected_episodes_.isEmpty()) {
544     PodcastEpisodeList epis = backend_->GetNewDownloadedEpisodes();
545     set_listened_action_->setEnabled(!epis.isEmpty());
546   }
547 
548   if (selected_episodes_.count() > 1) {
549     download_selected_action_->setText(
550         tr("Download %n episodes", "", selected_episodes_.count()));
551   } else {
552     download_selected_action_->setText(tr("Download this episode"));
553   }
554 
555   GetAppendToPlaylistAction()->setEnabled(episodes || podcasts);
556   GetReplacePlaylistAction()->setEnabled(episodes || podcasts);
557   GetOpenInNewPlaylistAction()->setEnabled(episodes || podcasts);
558 
559   context_menu_->popup(global_pos);
560 }
561 
UpdateSelectedPodcast()562 void PodcastService::UpdateSelectedPodcast() {
563   for (const QModelIndex& index : selected_podcasts_) {
564     app_->podcast_updater()->UpdatePodcastNow(
565         index.data(Role_Podcast).value<Podcast>());
566   }
567 }
568 
RemoveSelectedPodcast()569 void PodcastService::RemoveSelectedPodcast() {
570   for (const QModelIndex& index : selected_podcasts_) {
571     backend_->Unsubscribe(index.data(Role_Podcast).value<Podcast>());
572   }
573 }
574 
ReloadSettings()575 void PodcastService::ReloadSettings() {
576   InitialLoadSettings();
577   ClearPodcastList(model_->invisibleRootItem());
578   PopulatePodcastList(model_->invisibleRootItem());
579 }
580 
InitialLoadSettings()581 void PodcastService::InitialLoadSettings() {
582   QSettings s;
583   s.beginGroup(LibraryView::kSettingsGroup);
584   use_pretty_covers_ = s.value("pretty_covers", true).toBool();
585   s.endGroup();
586   s.beginGroup(kSettingsGroup);
587   hide_listened_ = s.value("hide_listened", false).toBool();
588   show_episodes_ = s.value("show_episodes", 0).toInt();
589   s.endGroup();
590   // TODO(notme): reload the podcast icons that are already loaded?
591 }
592 
EnsureAddPodcastDialogCreated()593 void PodcastService::EnsureAddPodcastDialogCreated() {
594   add_podcast_dialog_.reset(new AddPodcastDialog(app_));
595 }
596 
AddPodcast()597 void PodcastService::AddPodcast() {
598   EnsureAddPodcastDialogCreated();
599   add_podcast_dialog_->show();
600 }
601 
FileCopied(int database_id)602 void PodcastService::FileCopied(int database_id) {
603   SetListened(PodcastEpisodeList() << backend_->GetEpisodeById(database_id),
604               true);
605 }
606 
SubscriptionAdded(const Podcast & podcast)607 void PodcastService::SubscriptionAdded(const Podcast& podcast) {
608   // Ensure the root item is lazy loaded already
609   LazyLoadRoot();
610 
611   // The podcast might already be in the list - maybe the LazyLoadRoot() above
612   // added it.
613   QStandardItem* item = podcasts_by_database_id_[podcast.database_id()];
614   if (!item) {
615     item = CreatePodcastItem(podcast);
616     model_->appendRow(item);
617   }
618 
619   emit ScrollToIndex(MapToMergedModel(item->index()));
620 }
621 
SubscriptionRemoved(const Podcast & podcast)622 void PodcastService::SubscriptionRemoved(const Podcast& podcast) {
623   QStandardItem* item = podcasts_by_database_id_.take(podcast.database_id());
624   if (item) {
625     // Remove any episode ID -> item mappings for the episodes in this podcast.
626     for (int i = 0; i < item->rowCount(); ++i) {
627       QStandardItem* episode_item = item->child(i);
628       const int episode_id = episode_item->data(Role_Episode)
629                                  .value<PodcastEpisode>()
630                                  .database_id();
631 
632       episodes_by_database_id_.remove(episode_id);
633     }
634 
635     // Remove this episode's row
636     model_->removeRow(item->row());
637   }
638 }
639 
EpisodesAdded(const PodcastEpisodeList & episodes)640 void PodcastService::EpisodesAdded(const PodcastEpisodeList& episodes) {
641   QSet<int> seen_podcast_ids;
642 
643   for (const PodcastEpisode& episode : episodes) {
644     const int database_id = episode.podcast_database_id();
645     QStandardItem* parent = podcasts_by_database_id_[database_id];
646     if (!parent) continue;
647 
648     parent->appendRow(CreatePodcastEpisodeItem(episode));
649     if (!seen_podcast_ids.contains(database_id)) {
650       // Update the unlistened count text once for each podcast
651       int unlistened_count = 0;
652       for (const PodcastEpisode& episode : backend_->GetEpisodes(database_id)) {
653         if (!episode.listened()) {
654           unlistened_count++;
655         }
656       }
657 
658       UpdatePodcastText(parent, unlistened_count);
659       seen_podcast_ids.insert(database_id);
660     }
661     const Podcast podcast = parent->data(Role_Podcast).value<Podcast>();
662     ReloadPodcast(podcast);
663   }
664 }
665 
EpisodesUpdated(const PodcastEpisodeList & episodes)666 void PodcastService::EpisodesUpdated(const PodcastEpisodeList& episodes) {
667   QSet<int> seen_podcast_ids;
668   QMap<int, Podcast> podcasts_map;
669 
670   for (const PodcastEpisode& episode : episodes) {
671     const int podcast_database_id = episode.podcast_database_id();
672     QStandardItem* item = episodes_by_database_id_[episode.database_id()];
673     QStandardItem* parent = podcasts_by_database_id_[podcast_database_id];
674     if (!item || !parent) continue;
675     // Update the episode data on the item, and update the item's text.
676     item->setData(QVariant::fromValue(episode), Role_Episode);
677     UpdateEpisodeText(item);
678 
679     // Update the parent podcast's text too.
680     if (!seen_podcast_ids.contains(podcast_database_id)) {
681       // Update the unlistened count text once for each podcast
682       int unlistened_count = 0;
683       for (const PodcastEpisode& episode :
684            backend_->GetEpisodes(podcast_database_id)) {
685         if (!episode.listened()) {
686           unlistened_count++;
687         }
688       }
689 
690       UpdatePodcastText(parent, unlistened_count);
691       seen_podcast_ids.insert(podcast_database_id);
692     }
693     const Podcast podcast = parent->data(Role_Podcast).value<Podcast>();
694     podcasts_map[podcast.database_id()] = podcast;
695   }
696   for (const Podcast& podcast_tmp : podcasts_map.values()) {
697     ReloadPodcast(podcast_tmp);
698   }
699 }
700 
DownloadSelectedEpisode()701 void PodcastService::DownloadSelectedEpisode() {
702   for (const QModelIndex& index : selected_episodes_) {
703     app_->podcast_downloader()->DownloadEpisode(
704         index.data(Role_Episode).value<PodcastEpisode>());
705   }
706 }
707 
PodcastInfo()708 void PodcastService::PodcastInfo() {
709   if (selected_podcasts_.isEmpty()) {
710     // Should never happen.
711     return;
712   }
713   const Podcast podcast =
714       selected_podcasts_[0].data(Role_Podcast).value<Podcast>();
715   podcast_info_dialog_.reset(new PodcastInfoDialog(app_));
716 
717   if (selected_episodes_.count() == 1) {
718     const PodcastEpisode episode =
719         selected_episodes_[0].data(Role_Episode).value<PodcastEpisode>();
720     podcast_info_dialog_->ShowEpisode(episode, podcast);
721   } else {
722     podcast_info_dialog_->ShowPodcast(podcast);
723   }
724 }
725 
DeleteDownloadedData()726 void PodcastService::DeleteDownloadedData() {
727   for (const QModelIndex& index : selected_episodes_) {
728     app_->podcast_deleter()->DeleteEpisode(
729         index.data(Role_Episode).value<PodcastEpisode>());
730   }
731 }
732 
DownloadProgressChanged(const PodcastEpisode & episode,PodcastDownload::State state,int percent)733 void PodcastService::DownloadProgressChanged(const PodcastEpisode& episode,
734                                              PodcastDownload::State state,
735                                              int percent) {
736   QStandardItem* item = episodes_by_database_id_[episode.database_id()];
737   QStandardItem* item2 = podcasts_by_database_id_[episode.podcast_database_id()];
738   if (!item || !item2) return;
739 
740   UpdateEpisodeText(item, state, percent);
741   UpdatePodcastText(item2, state, percent);
742 }
743 
ShowConfig()744 void PodcastService::ShowConfig() {
745   app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Podcasts);
746 }
747 
CurrentSongChanged(const Song & metadata)748 void PodcastService::CurrentSongChanged(const Song& metadata) {
749   // This does two db queries, and we are called on every song change, so run
750   // this off the main thread.
751   QtConcurrent::run(this, &PodcastService::UpdatePodcastListenedStateAsync,
752                     metadata);
753 }
754 
UpdatePodcastListenedStateAsync(const Song & metadata)755 void PodcastService::UpdatePodcastListenedStateAsync(const Song& metadata) {
756   // Check whether this song is one of our podcast episodes.
757   PodcastEpisode episode = backend_->GetEpisodeByUrlOrLocalUrl(metadata.url());
758   if (!episode.is_valid()) return;
759 
760   // Mark it as listened if it's not already
761   if (!episode.listened() || !episode.listened_date().isValid()) {
762     episode.set_listened(true);
763     episode.set_listened_date(QDateTime::currentDateTime());
764     backend_->UpdateEpisodes(PodcastEpisodeList() << episode);
765   }
766 }
767 
SetNew()768 void PodcastService::SetNew() {
769   SetListened(selected_episodes_, explicitly_selected_podcasts_, false);
770 }
771 
SetListened()772 void PodcastService::SetListened() {
773   if (selected_episodes_.isEmpty() && explicitly_selected_podcasts_.isEmpty())
774     SetListened(backend_->GetNewDownloadedEpisodes(), true);
775   else
776     SetListened(selected_episodes_, explicitly_selected_podcasts_, true);
777 }
778 
SetListened(const PodcastEpisodeList & episodes_list,bool listened)779 void PodcastService::SetListened(const PodcastEpisodeList& episodes_list,
780                                  bool listened) {
781   PodcastEpisodeList episodes;
782   QDateTime current_date_time = QDateTime::currentDateTime();
783   for (PodcastEpisode episode : episodes_list) {
784     episode.set_listened(listened);
785     if (listened) {
786       episode.set_listened_date(current_date_time);
787     }
788     episodes << episode;
789   }
790 
791   backend_->UpdateEpisodes(episodes);
792 }
793 
SetListened(const QModelIndexList & episode_indexes,const QModelIndexList & podcast_indexes,bool listened)794 void PodcastService::SetListened(const QModelIndexList& episode_indexes,
795                                  const QModelIndexList& podcast_indexes,
796                                  bool listened) {
797   PodcastEpisodeList episodes;
798 
799   // Get all the episodes from the indexes.
800   for (const QModelIndex& index : episode_indexes) {
801     episodes << index.data(Role_Episode).value<PodcastEpisode>();
802   }
803 
804   for (const QModelIndex& podcast : podcast_indexes) {
805     for (int i = 0; i < podcast.model()->rowCount(podcast); ++i) {
806       const QModelIndex& index = podcast.child(i, 0);
807       episodes << index.data(Role_Episode).value<PodcastEpisode>();
808     }
809   }
810 
811   // Update each one with the new state and maybe the listened time.
812   QDateTime current_date_time = QDateTime::currentDateTime();
813   for (int i = 0; i < episodes.count(); ++i) {
814     PodcastEpisode* episode = &episodes[i];
815     episode->set_listened(listened);
816     if (listened) {
817       episode->set_listened_date(current_date_time);
818     }
819   }
820 
821   backend_->UpdateEpisodes(episodes);
822 }
823 
MapToMergedModel(const QModelIndex & index) const824 QModelIndex PodcastService::MapToMergedModel(const QModelIndex& index) const {
825   return model()->merged_model()->mapFromSource(proxy_->mapFromSource(index));
826 }
827 
LazyLoadRoot()828 void PodcastService::LazyLoadRoot() {
829   if (root_->data(InternetModel::Role_CanLazyLoad).toBool()) {
830     root_->setData(false, InternetModel::Role_CanLazyLoad);
831     LazyPopulate(root_);
832   }
833 }
834 
SubscribeAndShow(const QVariant & podcast_or_opml)835 void PodcastService::SubscribeAndShow(const QVariant& podcast_or_opml) {
836   if (podcast_or_opml.canConvert<Podcast>()) {
837     Podcast podcast(podcast_or_opml.value<Podcast>());
838     backend_->Subscribe(&podcast);
839 
840     // Lazy load the root item if it hasn't been already
841     LazyLoadRoot();
842 
843     QStandardItem* item = podcasts_by_database_id_[podcast.database_id()];
844     if (item) {
845       // There will be an item already if this podcast was already there,
846       // otherwise it'll be scrolled to when the item is created.
847       emit ScrollToIndex(MapToMergedModel(item->index()));
848     }
849   } else if (podcast_or_opml.canConvert<OpmlContainer>()) {
850     EnsureAddPodcastDialogCreated();
851 
852     add_podcast_dialog_->ShowWithOpml(podcast_or_opml.value<OpmlContainer>());
853   }
854 }
855 
ReloadPodcast(const Podcast & podcast)856 void PodcastService::ReloadPodcast(const Podcast& podcast) {
857   if (!(hide_listened_ || (show_episodes_ > 0))) {
858     return;
859   }
860   QStandardItem* item = podcasts_by_database_id_[podcast.database_id()];
861 
862   model_->invisibleRootItem()->removeRow(item->row());
863   model_->invisibleRootItem()->appendRow(CreatePodcastItem(podcast));
864 }
865