diff --git a/data/schema/schema-37.sql b/data/schema/schema-37.sql index 0bb53957c..c8d13e890 100644 --- a/data/schema/schema-37.sql +++ b/data/schema/schema-37.sql @@ -10,6 +10,9 @@ CREATE TABLE podcasts ( owner_name TEXT, owner_email TEXT, + last_updated INTEGER, + last_update_error TEXT, + extra BLOB ); @@ -32,6 +35,6 @@ CREATE TABLE podcast_episodes ( CREATE INDEX podcast_idx_url ON podcasts(url); -CREATE INDEX podcast_episodes_idx_aggregate ON podcast_episodes(podcast_id, listened); +CREATE INDEX podcast_episodes_idx_podcast_id ON podcast_episodes(podcast_id); UPDATE schema_version SET version=37; diff --git a/ext/libclementine-common/core/closure.cpp b/ext/libclementine-common/core/closure.cpp index 7d5d77fff..0688903de 100644 --- a/ext/libclementine-common/core/closure.cpp +++ b/ext/libclementine-common/core/closure.cpp @@ -25,12 +25,14 @@ Closure::Closure(QObject* sender, const char* slot, const ClosureArgumentWrapper* val0, const ClosureArgumentWrapper* val1, - const ClosureArgumentWrapper* val2) + const ClosureArgumentWrapper* val2, + const ClosureArgumentWrapper* val3) : QObject(receiver), callback_(NULL), val0_(val0), val1_(val1), - val2_(val2) { + val2_(val2), + val3_(val3) { const QMetaObject* meta_receiver = receiver->metaObject(); QByteArray normalised_slot = QMetaObject::normalizedSignature(slot + 1); @@ -64,7 +66,8 @@ void Closure::Invoked() { parent(), val0_ ? val0_->arg() : QGenericArgument(), val1_ ? val1_->arg() : QGenericArgument(), - val2_ ? val2_->arg() : QGenericArgument()); + val2_ ? val2_->arg() : QGenericArgument(), + val3_ ? val3_->arg() : QGenericArgument()); } deleteLater(); } diff --git a/ext/libclementine-common/core/closure.h b/ext/libclementine-common/core/closure.h index 0453c0824..6f9c3626f 100644 --- a/ext/libclementine-common/core/closure.h +++ b/ext/libclementine-common/core/closure.h @@ -56,7 +56,8 @@ class Closure : public QObject, boost::noncopyable { QObject* receiver, const char* slot, const ClosureArgumentWrapper* val0 = 0, const ClosureArgumentWrapper* val1 = 0, - const ClosureArgumentWrapper* val2 = 0); + const ClosureArgumentWrapper* val2 = 0, + const ClosureArgumentWrapper* val3 = 0); Closure(QObject* sender, const char* signal, std::tr1::function callback); @@ -74,6 +75,7 @@ class Closure : public QObject, boost::noncopyable { boost::scoped_ptr val0_; boost::scoped_ptr val1_; boost::scoped_ptr val2_; + boost::scoped_ptr val3_; }; #define C_ARG(type, data) new ClosureArgument(data) @@ -123,4 +125,19 @@ Closure* NewClosure( C_ARG(T0, val0), C_ARG(T1, val1), C_ARG(T2, val2)); } +template +Closure* NewClosure( + QObject* sender, + const char* signal, + QObject* receiver, + const char* slot, + const T0& val0, + const T1& val1, + const T2& val2, + const T3& val3) { + return new Closure( + sender, signal, receiver, slot, + C_ARG(T0, val0), C_ARG(T1, val1), C_ARG(T2, val2), C_ARG(T3, val3)); +} + #endif // CLOSURE_H diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5aa51425f..aa92b61f8 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -243,6 +243,7 @@ set(SOURCES podcasts/podcastinfowidget.cpp podcasts/podcastservice.cpp podcasts/podcastparser.cpp + podcasts/podcastupdater.cpp podcasts/podcasturlloader.cpp smartplaylists/generator.cpp @@ -493,6 +494,7 @@ set(HEADERS podcasts/podcastdiscoverymodel.h podcasts/podcastinfowidget.h podcasts/podcastservice.h + podcasts/podcastupdater.h podcasts/podcasturlloader.h smartplaylists/generator.h diff --git a/src/core/application.cpp b/src/core/application.cpp index 8929a926c..8e1506529 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -32,6 +32,7 @@ #include "playlist/playlistbackend.h" #include "playlist/playlistmanager.h" #include "podcasts/podcastbackend.h" +#include "podcasts/podcastupdater.h" Application::Application(QObject* parent) : QObject(parent), @@ -49,7 +50,8 @@ Application::Application(QObject* parent) global_search_(NULL), internet_model_(NULL), library_(NULL), - device_manager_(NULL) + device_manager_(NULL), + podcast_updater_(NULL) { tag_reader_client_ = new TagReaderClient(this); MoveToNewThread(tag_reader_client_); @@ -75,10 +77,9 @@ Application::Application(QObject* parent) current_art_loader_ = new CurrentArtLoader(this, this); global_search_ = new GlobalSearch(this, this); internet_model_ = new InternetModel(this, this); - library_ = new Library(this, this); - device_manager_ = new DeviceManager(this, this); + podcast_updater_ = new PodcastUpdater(this, this); library_->Init(); @@ -130,3 +131,7 @@ LibraryBackend* Application::library_backend() const { LibraryModel* Application::library_model() const { return library()->model(); } + +void Application::ReloadSettings() { + emit SettingsChanged(); +} diff --git a/src/core/application.h b/src/core/application.h index a775c2530..5ff1fbd17 100644 --- a/src/core/application.h +++ b/src/core/application.h @@ -35,6 +35,7 @@ class Player; class PlaylistBackend; class PlaylistManager; class PodcastBackend; +class PodcastUpdater; class TagReaderClient; class TaskManager; @@ -61,6 +62,7 @@ public: InternetModel* internet_model() const { return internet_model_; } Library* library() const { return library_; } DeviceManager* device_manager() const { return device_manager_; } + PodcastUpdater* podcast_updater() const { return podcast_updater_; } LibraryBackend* library_backend() const; LibraryModel* library_model() const; @@ -70,9 +72,11 @@ public: public slots: void AddError(const QString& message); + void ReloadSettings(); signals: void ErrorAdded(const QString& message); + void SettingsChanged(); private: TagReaderClient* tag_reader_client_; @@ -90,6 +94,7 @@ private: InternetModel* internet_model_; Library* library_; DeviceManager* device_manager_; + PodcastUpdater* podcast_updater_; QList objects_in_threads_; QList threads_; diff --git a/src/podcasts/addpodcastdialog.cpp b/src/podcasts/addpodcastdialog.cpp index 17fb7fd16..ee72f501e 100644 --- a/src/podcasts/addpodcastdialog.cpp +++ b/src/podcasts/addpodcastdialog.cpp @@ -163,6 +163,7 @@ void AddPodcastDialog::AddPodcast() { void AddPodcastDialog::RemovePodcast() { app_->podcast_backend()->Unsubscribe(current_podcast_); + current_podcast_.set_database_id(-1); add_button_->setEnabled(true); remove_button_->setEnabled(false); } diff --git a/src/podcasts/podcast.cpp b/src/podcasts/podcast.cpp index a095cf353..e666a499d 100644 --- a/src/podcasts/podcast.cpp +++ b/src/podcasts/podcast.cpp @@ -19,13 +19,14 @@ #include "core/utilities.h" #include +#include #include const QStringList Podcast::kColumns = QStringList() << "url" << "title" << "description" << "copyright" << "link" << "image_url_large" << "image_url_small" << "author" << "owner_name" - << "owner_email" << "extra"; + << "owner_email" << "last_updated" << "last_update_error" << "extra"; const QString Podcast::kColumnSpec = Podcast::kColumns.join(", "); const QString Podcast::kJoinSpec = Utilities::Prepend("p.", Podcast::kColumns).join(", "); @@ -51,6 +52,9 @@ struct Podcast::Private : public QSharedData { QString owner_name_; QString owner_email_; + QDateTime last_updated_; + QString last_update_error_; + QVariantMap extra_; // These are stored in a different table @@ -92,6 +96,8 @@ const QUrl& Podcast::image_url_small() const { return d->image_url_small_; } const QString& Podcast::author() const { return d->author_; } const QString& Podcast::owner_name() const { return d->owner_name_; } const QString& Podcast::owner_email() const { return d->owner_email_; } +const QDateTime& Podcast::last_updated() const { return d->last_updated_; } +const QString& Podcast::last_update_error() const { return d->last_update_error_; } const QVariantMap& Podcast::extra() const { return d->extra_; } QVariant Podcast::extra(const QString& key) const { return d->extra_[key]; } @@ -106,6 +112,8 @@ void Podcast::set_image_url_small(const QUrl& v) { d->image_url_small_ = v; } void Podcast::set_author(const QString& v) { d->author_ = v; } void Podcast::set_owner_name(const QString& v) { d->owner_name_ = v; } void Podcast::set_owner_email(const QString& v) { d->owner_email_ = v; } +void Podcast::set_last_updated(const QDateTime& v) { d->last_updated_ = v; } +void Podcast::set_last_update_error(const QString& v) { d->last_update_error_ = v; } void Podcast::set_extra(const QVariantMap& v) { d->extra_ = v; } void Podcast::set_extra(const QString& key, const QVariant& value) { d->extra_[key] = value; } @@ -126,8 +134,10 @@ void Podcast::InitFromQuery(const QSqlQuery& query) { d->author_ = query.value(8).toString(); d->owner_name_ = query.value(9).toString(); d->owner_email_ = query.value(10).toString(); + d->last_updated_ = QDateTime::fromTime_t(query.value(11).toUInt()); + d->last_update_error_ = query.value(12).toString(); - QDataStream extra_stream(query.value(11).toByteArray()); + QDataStream extra_stream(query.value(13).toByteArray()); extra_stream >> d->extra_; } @@ -142,6 +152,8 @@ void Podcast::BindToQuery(QSqlQuery* query) const { query->bindValue(":author", d->author_); query->bindValue(":owner_name", d->owner_name_); query->bindValue(":owner_email", d->owner_email_); + query->bindValue(":last_updated", d->last_updated_.toTime_t()); + query->bindValue(":last_update_error", d->last_update_error_); QByteArray extra; QDataStream extra_stream(&extra, QIODevice::WriteOnly); diff --git a/src/podcasts/podcast.h b/src/podcasts/podcast.h index 47a2f5f96..e793af409 100644 --- a/src/podcasts/podcast.h +++ b/src/podcasts/podcast.h @@ -59,6 +59,8 @@ public: const QString& author() const; const QString& owner_name() const; const QString& owner_email() const; + const QDateTime& last_updated() const; + const QString& last_update_error() const; const QVariantMap& extra() const; QVariant extra(const QString& key) const; @@ -73,6 +75,8 @@ public: void set_author(const QString& v); void set_owner_name(const QString& v); void set_owner_email(const QString& v); + void set_last_updated(const QDateTime& v); + void set_last_update_error(const QString& v); void set_extra(const QVariantMap& v); void set_extra(const QString& key, const QVariant& value); diff --git a/src/podcasts/podcastbackend.cpp b/src/podcasts/podcastbackend.cpp index be7953d5d..3192f1720 100644 --- a/src/podcasts/podcastbackend.cpp +++ b/src/podcasts/podcastbackend.cpp @@ -18,6 +18,7 @@ #include "podcastbackend.h" #include "core/application.h" #include "core/database.h" +#include "core/logging.h" #include "core/scopedtransaction.h" #include @@ -117,23 +118,15 @@ void PodcastBackend::AddEpisodes(PodcastEpisodeList* episodes, QSqlDatabase* db) } } -#define SELECT_PODCAST_QUERY(where_clauses) \ - "SELECT p.ROWID, " + Podcast::kJoinSpec + "," \ - " COUNT(e.ROWID), SUM(e.listened)" \ - " FROM podcasts AS p" \ - " LEFT JOIN podcast_episodes AS e" \ - " ON p.ROWID = e.podcast_id" \ - " " where_clauses \ - " GROUP BY p.ROWID" +void PodcastBackend::AddEpisodes(PodcastEpisodeList* episodes) { + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + ScopedTransaction t(&db); -namespace { - void AddAggregatePodcastFields(const QSqlQuery& q, int column_count, Podcast* podcast) { - const int episode_count = q.value(column_count + 1).toInt(); - const int listened_count = q.value(column_count + 2).toInt(); + AddEpisodes(episodes, &db); + t.Commit(); - podcast->set_extra("db:episode_count", episode_count); - podcast->set_extra("db:unlistened_count", episode_count - listened_count); - } + emit EpisodesAdded(*episodes); } PodcastList PodcastBackend::GetAllSubscriptions() { @@ -142,20 +135,14 @@ PodcastList PodcastBackend::GetAllSubscriptions() { QMutexLocker l(db_->Mutex()); QSqlDatabase db(db_->Connect()); - QSqlQuery q(SELECT_PODCAST_QUERY(""), db); - + QSqlQuery q("SELECT ROWID, " + Podcast::kColumnSpec + " FROM podcasts", db); q.exec(); if (db_->CheckErrors(q)) return ret; - static const int kPodcastColumnCount = Podcast::kColumns.count(); - while (q.next()) { Podcast podcast; podcast.InitFromQuery(q); - - AddAggregatePodcastFields(q, kPodcastColumnCount, &podcast); - ret << podcast; } @@ -168,12 +155,13 @@ Podcast PodcastBackend::GetSubscriptionById(int id) { QMutexLocker l(db_->Mutex()); QSqlDatabase db(db_->Connect()); - QSqlQuery q(SELECT_PODCAST_QUERY("WHERE ROWID = :id"), db); + QSqlQuery q("SELECT ROWID, " + Podcast::kColumnSpec + + " FROM podcasts" + " WHERE ROWID = :id", db); q.bindValue(":id", id); q.exec(); if (!db_->CheckErrors(q) && q.next()) { ret.InitFromQuery(q); - AddAggregatePodcastFields(q, Podcast::kColumns.count(), &ret); } return ret; @@ -185,12 +173,36 @@ Podcast PodcastBackend::GetSubscriptionByUrl(const QUrl& url) { QMutexLocker l(db_->Mutex()); QSqlDatabase db(db_->Connect()); - QSqlQuery q(SELECT_PODCAST_QUERY("WHERE p.url = :url"), db); + QSqlQuery q("SELECT ROWID, " + Podcast::kColumnSpec + + " FROM podcasts" + " WHERE url = :url", db); q.bindValue(":url", url.toEncoded()); q.exec(); if (!db_->CheckErrors(q) && q.next()) { ret.InitFromQuery(q); - AddAggregatePodcastFields(q, Podcast::kColumns.count(), &ret); + } + + return ret; +} + +PodcastEpisodeList PodcastBackend::GetEpisodes(int podcast_id) { + PodcastEpisodeList ret; + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + + QSqlQuery q("SELECT ROWID, " + PodcastEpisode::kColumnSpec + + " FROM podcast_episodes" + " WHERE podcast_id = :id", db); + q.bindValue(":db", podcast_id); + q.exec(); + if (db_->CheckErrors(q)) + return ret; + + while (q.next()) { + PodcastEpisode episode; + episode.InitFromQuery(q); + ret << episode; } return ret; diff --git a/src/podcasts/podcastbackend.h b/src/podcasts/podcastbackend.h index d8d64a2af..ea5fa225a 100644 --- a/src/podcasts/podcastbackend.h +++ b/src/podcasts/podcastbackend.h @@ -42,18 +42,26 @@ public: void Unsubscribe(const Podcast& podcast); // Returns a list of all the subscribed podcasts. For efficiency the Podcast - // objects returned won't contain any PodcastEpisode objects, but they will - // contain aggregate information about total number of episodes and number of - // unlistened episodes, in the extra fields "db:episode_count" and - // "db:unlistened_count". + // objects returned won't contain any PodcastEpisode objects - get them + // separately if you want them. PodcastList GetAllSubscriptions(); Podcast GetSubscriptionById(int id); Podcast GetSubscriptionByUrl(const QUrl& url); + + // Returns a list of the episodes in the podcast with the given ID. + PodcastEpisodeList GetEpisodes(int podcast_id); + + // Adds episodes to the database. Every episode must have a valid + // podcast_database_id set already. + void AddEpisodes(PodcastEpisodeList* episodes); signals: void SubscriptionAdded(const Podcast& podcast); void SubscriptionRemoved(const Podcast& podcast); + // Emitted when episodes are added to a subscription that *already exists*. + void EpisodesAdded(const QList& episodes); + private: // Adds each episode to the database, setting their IDs after inserting each // one. diff --git a/src/podcasts/podcastepisode.cpp b/src/podcasts/podcastepisode.cpp index 3e88297ec..35acd6de9 100644 --- a/src/podcasts/podcastepisode.cpp +++ b/src/podcasts/podcastepisode.cpp @@ -22,7 +22,7 @@ #include const QStringList PodcastEpisode::kColumns = QStringList() - << "podcast_database_id" << "title" << "description" << "author" + << "podcast_id" << "title" << "description" << "author" << "publication_date" << "duration_secs" << "url" << "listened" << "downloaded" << "local_url" << "extra"; @@ -125,7 +125,7 @@ void PodcastEpisode::InitFromQuery(const QSqlQuery& query) { } void PodcastEpisode::BindToQuery(QSqlQuery* query) const { - query->bindValue(":podcast_database_id", d->podcast_database_id_); + query->bindValue(":podcast_id", d->podcast_database_id_); query->bindValue(":title", d->title_); query->bindValue(":description", d->description_); query->bindValue(":author", d->author_); diff --git a/src/podcasts/podcastparser.cpp b/src/podcasts/podcastparser.cpp index 9efca855d..c86b76c35 100644 --- a/src/podcasts/podcastparser.cpp +++ b/src/podcasts/podcastparser.cpp @@ -17,6 +17,7 @@ #include "opmlcontainer.h" #include "podcastparser.h" +#include "core/logging.h" #include "core/utilities.h" #include @@ -107,6 +108,8 @@ void PodcastParser::ParseChannel(QXmlStreamReader* reader, Podcast* ret) const { if (ret->url().isEmpty() && reader->attributes().value("rel") == "self") { ret->set_url(QUrl(reader->readElementText())); } + } else if (name == "item") { + ParseItem(reader, ret); } else { Utilities::ConsumeCurrentElement(reader); } @@ -197,8 +200,9 @@ void PodcastParser::ParseItem(QXmlStreamReader* reader, Podcast* ret) const { } } else if (name == "enclosure") { if (reader->attributes().value("type").toString().startsWith("audio/")) { - episode.set_url(QUrl(reader->attributes().value("href").toString())); + episode.set_url(QUrl(reader->attributes().value("url").toString())); } + Utilities::ConsumeCurrentElement(reader); } else if (name == "author" && reader->namespaceUri() == kItunesNamespace) { episode.set_author(reader->readElementText()); } else { diff --git a/src/podcasts/podcastservice.cpp b/src/podcasts/podcastservice.cpp index 61a0e33e8..579dba436 100644 --- a/src/podcasts/podcastservice.cpp +++ b/src/podcasts/podcastservice.cpp @@ -18,6 +18,7 @@ #include "addpodcastdialog.h" #include "podcastbackend.h" #include "podcastservice.h" +#include "podcastupdater.h" #include "core/application.h" #include "core/logging.h" #include "core/mergedproxymodel.h" @@ -32,13 +33,23 @@ const char* PodcastService::kServiceName = "Podcasts"; const char* PodcastService::kSettingsGroup = "Podcasts"; + +class PodcastSortProxyModel : public QSortFilterProxyModel { +public: + PodcastSortProxyModel(QObject* parent = NULL); + +protected: + bool lessThan(const QModelIndex& left, const QModelIndex& right) const; +}; + + PodcastService::PodcastService(Application* app, InternetModel* parent) : InternetService(kServiceName, app, parent, parent), use_pretty_covers_(true), icon_loader_(new StandardItemIconLoader(app->album_cover_loader(), this)), backend_(app->podcast_backend()), model_(new QStandardItemModel(this)), - proxy_(new QSortFilterProxyModel(this)), + proxy_(new PodcastSortProxyModel(this)), context_menu_(NULL), root_(NULL) { @@ -49,11 +60,46 @@ PodcastService::PodcastService(Application* app, InternetModel* parent) connect(backend_, SIGNAL(SubscriptionAdded(Podcast)), SLOT(SubscriptionAdded(Podcast))); connect(backend_, SIGNAL(SubscriptionRemoved(Podcast)), SLOT(SubscriptionRemoved(Podcast))); + connect(backend_, SIGNAL(EpisodesAdded(QList)), SLOT(EpisodesAdded(QList))); } PodcastService::~PodcastService() { } +PodcastSortProxyModel::PodcastSortProxyModel(QObject* parent) + : QSortFilterProxyModel(parent) { +} + +bool PodcastSortProxyModel::lessThan(const QModelIndex& left, const QModelIndex& right) const { + const int left_type = left.data(InternetModel::Role_Type).toInt(); + const int right_type = right.data(InternetModel::Role_Type).toInt(); + + // The special Add Podcast item comes first + if (left_type == PodcastService::Type_AddPodcast) + return true; + else if (right_type == PodcastService::Type_AddPodcast) + return false; + + // Otherwise we only compare identical typed items. + if (left_type != right_type) + return QSortFilterProxyModel::lessThan(left, right); + + switch (left_type) { + case PodcastService::Type_Podcast: + return left.data().toString().localeAwareCompare(right.data().toString()) < 0; + + case PodcastService::Type_Episode: { + const PodcastEpisode left_episode = left.data(PodcastService::Type_Episode).value(); + const PodcastEpisode right_episode = right.data(PodcastService::Type_Episode).value(); + + return left_episode.publication_date() > right_episode.publication_date(); + } + + default: + return QSortFilterProxyModel::lessThan(left, right); + } +} + QStandardItem* PodcastService::CreateRootItem() { root_ = new QStandardItem(QIcon(":providers/podcast16.png"), tr("Podcasts")); root_->setData(true, InternetModel::Role_CanLazyLoad); @@ -79,31 +125,64 @@ void PodcastService::PopulatePodcastList(QStandardItem* parent) { } } -QStandardItem* PodcastService::CreatePodcastItem(const Podcast& podcast) { - const int unlistened_count = podcast.extra("db:unlistened_count").toInt(); - QString title = podcast.title(); +void PodcastService::UpdatePodcastText(QStandardItem* item, int unlistened_count) const { + const Podcast podcast = item->data(Role_Podcast).value(); - QStandardItem* item = new QStandardItem; + QString title = podcast.title(); + QFont font; if (unlistened_count > 0) { // Add the number of new episodes after the title. title.append(QString(" (%1)").arg(unlistened_count)); // Set a bold font - QFont font(item->font()); - font.setBold(true); - item->setFont(font); + font.setWeight(QFont::DemiBold); + } + + item->setFont(font); + item->setText(title); +} + +QStandardItem* PodcastService::CreatePodcastItem(const Podcast& podcast) { + QStandardItem* item = new QStandardItem; + + // Add the episodes in this podcast and gather aggregate stats. + int unlistened_count = 0; + foreach (const PodcastEpisode& episode, backend_->GetEpisodes(podcast.database_id())) { + if (!episode.listened()) { + unlistened_count ++; + } + + item->appendRow(CreatePodcastEpisodeItem(episode)); } - item->setText(podcast.title()); item->setIcon(default_icon_); + item->setData(Type_Podcast, InternetModel::Role_Type); item->setData(QVariant::fromValue(podcast), Role_Podcast); + UpdatePodcastText(item, unlistened_count); // Load the podcast's image if it has one if (podcast.ImageUrlSmall().isValid()) { icon_loader_->LoadIcon(podcast.ImageUrlSmall().toString(), QString(), item); } + podcasts_by_database_id_[podcast.database_id()] = item; + + return item; +} + +QStandardItem* PodcastService::CreatePodcastEpisodeItem(const PodcastEpisode& episode) { + QStandardItem* item = new QStandardItem; + item->setText(episode.title()); + item->setData(Type_Episode, InternetModel::Role_Type); + item->setData(QVariant::fromValue(episode), Role_Episode); + + if (!episode.listened()) { + QFont font(item->font()); + font.setBold(true); + item->setFont(font); + } + return item; } @@ -113,11 +192,42 @@ void PodcastService::ShowContextMenu(const QModelIndex& index, context_menu_ = new QMenu; context_menu_->addAction(IconLoader::Load("list-add"), tr("Add podcast..."), this, SLOT(AddPodcast())); + context_menu_->addSeparator(); + context_menu_->addAction(IconLoader::Load("view-refresh"), tr("Update all podcasts"), + app_->podcast_updater(), SLOT(UpdateAllPodcastsNow())); + update_selected_action_ = context_menu_->addAction(IconLoader::Load("view-refresh"), + tr("Update this podcast"), + this, SLOT(UpdateSelectedPodcast())); } + current_index_ = index; + + switch (index.data(InternetModel::Role_Type).toInt()) { + case Type_Podcast: + current_podcast_index_ = index; + break; + + case Type_Episode: + current_podcast_index_ = index.parent(); + break; + + default: + current_podcast_index_ = QModelIndex(); + break; + } + + update_selected_action_->setVisible(current_podcast_index_.isValid()); context_menu_->popup(global_pos); } +void PodcastService::UpdateSelectedPodcast() { + if (!current_podcast_index_.isValid()) + return; + + app_->podcast_updater()->UpdatePodcastNow( + current_podcast_index_.data(Role_Podcast).value()); +} + void PodcastService::ReloadSettings() { QSettings s; s.beginGroup(LibraryView::kSettingsGroup); @@ -127,7 +237,7 @@ void PodcastService::ReloadSettings() { } QModelIndex PodcastService::GetCurrentIndex() { - return QModelIndex(); + return current_index_; } void PodcastService::AddPodcast() { @@ -148,12 +258,34 @@ void PodcastService::SubscriptionAdded(const Podcast& podcast) { } void PodcastService::SubscriptionRemoved(const Podcast& podcast) { - // Find the item in the model that matches this podcast. - for (int i=0 ; irowCount() ; ++i) { - Podcast item_podcast(model_->item(i)->data(Role_Podcast).value()); - if (podcast.database_id() == item_podcast.database_id()) { - model_->removeRow(i); - return; + QStandardItem* item = podcasts_by_database_id_.take(podcast.database_id()); + if (item) { + item->parent()->removeRow(item->row()); + } +} + +void PodcastService::EpisodesAdded(const QList& episodes) { + QSet seen_podcast_ids; + + foreach (const PodcastEpisode& episode, episodes) { + const int database_id = episode.podcast_database_id(); + QStandardItem* parent = podcasts_by_database_id_[database_id]; + if (!parent) + continue; + + parent->appendRow(CreatePodcastEpisodeItem(episode)); + + if (!seen_podcast_ids.contains(database_id)) { + // Update the unlistened count text once for each podcast + int unlistened_count = 0; + foreach (const PodcastEpisode& episode, backend_->GetEpisodes(database_id)) { + if (!episode.listened()) { + unlistened_count ++; + } + } + + UpdatePodcastText(parent, unlistened_count); + seen_podcast_ids.insert(database_id); } } } diff --git a/src/podcasts/podcastservice.h b/src/podcasts/podcastservice.h index 2fc2f40d7..2c2daca10 100644 --- a/src/podcasts/podcastservice.h +++ b/src/podcasts/podcastservice.h @@ -26,6 +26,7 @@ class AddPodcastDialog; class Podcast; class PodcastBackend; +class PodcastEpisode; class StandardItemIconLoader; class QSortFilterProxyModel; @@ -47,7 +48,8 @@ public: }; enum Role { - Role_Podcast = InternetModel::RoleCount + Role_Podcast = InternetModel::RoleCount, + Role_Episode }; QStandardItem* CreateRootItem(); @@ -61,12 +63,18 @@ protected: private slots: void AddPodcast(); + void UpdateSelectedPodcast(); + void SubscriptionAdded(const Podcast& podcast); void SubscriptionRemoved(const Podcast& podcast); + void EpisodesAdded(const QList& episodes); private: void PopulatePodcastList(QStandardItem* parent); + void UpdatePodcastText(QStandardItem* item, int unlistened_count) const; + QStandardItem* CreatePodcastItem(const Podcast& podcast); + QStandardItem* CreatePodcastEpisodeItem(const PodcastEpisode& episode); private: bool use_pretty_covers_; @@ -78,8 +86,14 @@ private: QSortFilterProxyModel* proxy_; QMenu* context_menu_; + QAction* update_selected_action_; QStandardItem* root_; + QModelIndex current_index_; + QModelIndex current_podcast_index_; + + QMap podcasts_by_database_id_; + QScopedPointer add_podcast_dialog_; }; diff --git a/src/podcasts/podcastupdater.cpp b/src/podcasts/podcastupdater.cpp new file mode 100644 index 000000000..43d80c3ad --- /dev/null +++ b/src/podcasts/podcastupdater.cpp @@ -0,0 +1,143 @@ +/* This file is part of Clementine. + Copyright 2012, David Sansome + + Clementine is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Clementine is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Clementine. If not, see . +*/ + +#include "podcastbackend.h" +#include "podcastupdater.h" +#include "podcasturlloader.h" +#include "core/application.h" +#include "core/closure.h" +#include "core/logging.h" +#include "core/timeconstants.h" + +#include +#include + +const char* PodcastUpdater::kSettingsGroup = "Podcasts"; + +PodcastUpdater::PodcastUpdater(Application* app, QObject* parent) + : QObject(parent), + app_(app), + update_interval_secs_(0), + update_timer_(new QTimer(this)), + loader_(new PodcastUrlLoader(this)), + pending_replies_(0) +{ + connect(app_, SIGNAL(SettingsChanged()), SLOT(ReloadSettings())); + connect(update_timer_, SIGNAL(timeout()), SLOT(UpdateAllPodcastsNow())); + connect(app_->podcast_backend(), SIGNAL(SubscriptionAdded(Podcast)), + SLOT(UpdatePodcastNow(Podcast))); + + update_timer_->setSingleShot(true); + + ReloadSettings(); +} + +void PodcastUpdater::ReloadSettings() { + QSettings s; + s.beginGroup(kSettingsGroup); + + last_full_update_ = s.value("last_full_update").toDateTime(); + update_interval_secs_ = s.value("update_interval_secs").toInt(); + + RestartTimer(); +} + +void PodcastUpdater::RestartTimer() { + // Stop any existing timer + update_timer_->stop(); + + if (update_interval_secs_ > 0) { + if (!last_full_update_.isValid()) { + // Updates are enabled and we've never updated before. Do it now. + qLog(Info) << "Updating podcasts for the first time"; + UpdateAllPodcastsNow(); + } else { + const QDateTime next_update = last_full_update_.addSecs(update_interval_secs_); + const int secs_until_next_update = QDateTime::currentDateTime().secsTo(next_update); + + if (secs_until_next_update < 0) { + qLog(Info) << "Updating podcasts" << (-secs_until_next_update) << "seconds late"; + UpdateAllPodcastsNow(); + } else { + qLog(Info) << "Updating podcasts at" << next_update << "(in" << secs_until_next_update << "seconds)"; + update_timer_->start(secs_until_next_update * kMsecPerSec); + } + } + } +} + +void PodcastUpdater::UpdatePodcastNow(const Podcast& podcast) { + PodcastUrlLoaderReply* reply = loader_->Load(podcast.url()); + NewClosure(reply, SIGNAL(Finished(bool)), + this, SLOT(PodcastLoaded(PodcastUrlLoaderReply*,Podcast,bool)), + reply, podcast, false); +} + +void PodcastUpdater::UpdateAllPodcastsNow() { + foreach (const Podcast& podcast, app_->podcast_backend()->GetAllSubscriptions()) { + PodcastUrlLoaderReply* reply = loader_->Load(podcast.url()); + NewClosure(reply, SIGNAL(Finished(bool)), + this, SLOT(PodcastLoaded(PodcastUrlLoaderReply*,Podcast,bool)), + reply, podcast, true); + + pending_replies_ ++; + } +} + +void PodcastUpdater::PodcastLoaded(PodcastUrlLoaderReply* reply, const Podcast& podcast, + bool one_of_many) { + reply->deleteLater(); + + if (one_of_many) { + if (--pending_replies_ == 0) { + RestartTimer(); + } + } + + if (!reply->is_success()) { + qLog(Warning) << "Error fetching podcast at" << podcast.url() << ":" + << reply->error_text(); + return; + } + + if (reply->result_type() != PodcastUrlLoaderReply::Type_Podcast) { + qLog(Warning) << "The URL" << podcast.url() << "no longer contains a podcast"; + return; + } + + // Get the episode URLs we had for this podcast already. + QSet existing_urls; + foreach (const PodcastEpisode& episode, + app_->podcast_backend()->GetEpisodes(podcast.database_id())) { + existing_urls.insert(episode.url()); + } + + // Add any new episodes + PodcastEpisodeList new_episodes; + foreach (const Podcast& reply_podcast, reply->podcast_results()) { + foreach (const PodcastEpisode& episode, reply_podcast.episodes()) { + if (!existing_urls.contains(episode.url())) { + PodcastEpisode episode_copy(episode); + episode_copy.set_podcast_database_id(podcast.database_id()); + new_episodes.append(episode_copy); + } + } + } + + app_->podcast_backend()->AddEpisodes(&new_episodes); + qLog(Info) << "Added" << new_episodes.count() << "new episodes for" << podcast.url(); +} diff --git a/src/podcasts/podcastupdater.h b/src/podcasts/podcastupdater.h new file mode 100644 index 000000000..add78a69d --- /dev/null +++ b/src/podcasts/podcastupdater.h @@ -0,0 +1,65 @@ +/* This file is part of Clementine. + Copyright 2012, David Sansome + + Clementine is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Clementine is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Clementine. If not, see . +*/ + +#ifndef PODCASTUPDATER_H +#define PODCASTUPDATER_H + +#include +#include + +class Application; +class Podcast; +class PodcastUrlLoader; +class PodcastUrlLoaderReply; + +class QTimer; + +// Responsible for updating podcasts when they're first subscribed to, and +// then updating them at regular intervals afterwards. +class PodcastUpdater : public QObject { + Q_OBJECT + +public: + PodcastUpdater(Application* app, QObject* parent = 0); + + static const char* kSettingsGroup; + +public slots: + void UpdateAllPodcastsNow(); + void UpdatePodcastNow(const Podcast& podcast); + +private slots: + void ReloadSettings(); + + void PodcastLoaded(PodcastUrlLoaderReply* reply, const Podcast& podcast, + bool one_of_many); + +private: + void RestartTimer(); + +private: + Application* app_; + + QDateTime last_full_update_; + int update_interval_secs_; + + QTimer* update_timer_; + PodcastUrlLoader* loader_; + int pending_replies_; +}; + +#endif // PODCASTUPDATER_H diff --git a/src/ui/mainwindow.cpp b/src/ui/mainwindow.cpp index 84a7a50ca..d01d845ed 100644 --- a/src/ui/mainwindow.cpp +++ b/src/ui/mainwindow.cpp @@ -758,6 +758,7 @@ void MainWindow::ReloadAllSettings() { ReloadSettings(); // Other settings + app_->ReloadSettings(); app_->global_search()->ReloadSettings(); ui_->global_search->ReloadSettings(); app_->library()->ReloadSettings();