Update podcasts on a schedule and on demand. Update them immediately after subscribing, and make episodes appear in the tree.

This commit is contained in:
David Sansome 2012-03-09 12:15:24 +00:00
parent 80b95a357d
commit b9e08bbfe6
18 changed files with 491 additions and 60 deletions

View File

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

View File

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

View File

@ -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<void()> callback);
@ -74,6 +75,7 @@ class Closure : public QObject, boost::noncopyable {
boost::scoped_ptr<const ClosureArgumentWrapper> val0_;
boost::scoped_ptr<const ClosureArgumentWrapper> val1_;
boost::scoped_ptr<const ClosureArgumentWrapper> val2_;
boost::scoped_ptr<const ClosureArgumentWrapper> val3_;
};
#define C_ARG(type, data) new ClosureArgument<type>(data)
@ -123,4 +125,19 @@ Closure* NewClosure(
C_ARG(T0, val0), C_ARG(T1, val1), C_ARG(T2, val2));
}
template <typename T0, typename T1, typename T2, typename T3>
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

View File

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

View File

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

View File

@ -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<QObject*> objects_in_threads_;
QList<QThread*> threads_;

View File

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

View File

@ -19,13 +19,14 @@
#include "core/utilities.h"
#include <QDataStream>
#include <QDateTime>
#include <Podcast.h>
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);

View File

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

View File

@ -18,6 +18,7 @@
#include "podcastbackend.h"
#include "core/application.h"
#include "core/database.h"
#include "core/logging.h"
#include "core/scopedtransaction.h"
#include <QMutexLocker>
@ -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;

View File

@ -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<PodcastEpisode>& episodes);
private:
// Adds each episode to the database, setting their IDs after inserting each
// one.

View File

@ -22,7 +22,7 @@
#include <QDateTime>
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_);

View File

@ -17,6 +17,7 @@
#include "opmlcontainer.h"
#include "podcastparser.h"
#include "core/logging.h"
#include "core/utilities.h"
#include <QDateTime>
@ -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 {

View File

@ -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<PodcastEpisode>)), SLOT(EpisodesAdded(QList<PodcastEpisode>)));
}
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<PodcastEpisode>();
const PodcastEpisode right_episode = right.data(PodcastService::Type_Episode).value<PodcastEpisode>();
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<Podcast>();
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<Podcast>());
}
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 ; i<model_->rowCount() ; ++i) {
Podcast item_podcast(model_->item(i)->data(Role_Podcast).value<Podcast>());
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<PodcastEpisode>& episodes) {
QSet<int> 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);
}
}
}

View File

@ -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<PodcastEpisode>& 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<int, QStandardItem*> podcasts_by_database_id_;
QScopedPointer<AddPodcastDialog> add_podcast_dialog_;
};

View File

@ -0,0 +1,143 @@
/* This file is part of Clementine.
Copyright 2012, David Sansome <me@davidsansome.com>
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 <http://www.gnu.org/licenses/>.
*/
#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 <QSettings>
#include <QTimer>
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<QUrl> 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();
}

View File

@ -0,0 +1,65 @@
/* This file is part of Clementine.
Copyright 2012, David Sansome <me@davidsansome.com>
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 <http://www.gnu.org/licenses/>.
*/
#ifndef PODCASTUPDATER_H
#define PODCASTUPDATER_H
#include <QDateTime>
#include <QObject>
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

View File

@ -758,6 +758,7 @@ void MainWindow::ReloadAllSettings() {
ReloadSettings();
// Other settings
app_->ReloadSettings();
app_->global_search()->ReloadSettings();
ui_->global_search->ReloadSettings();
app_->library()->ReloadSettings();