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