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:
parent
80b95a357d
commit
b9e08bbfe6
@ -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;
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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_;
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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.
|
||||
|
@ -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_);
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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_;
|
||||
};
|
||||
|
||||
|
143
src/podcasts/podcastupdater.cpp
Normal file
143
src/podcasts/podcastupdater.cpp
Normal 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();
|
||||
}
|
65
src/podcasts/podcastupdater.h
Normal file
65
src/podcasts/podcastupdater.h
Normal 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
|
@ -758,6 +758,7 @@ void MainWindow::ReloadAllSettings() {
|
||||
ReloadSettings();
|
||||
|
||||
// Other settings
|
||||
app_->ReloadSettings();
|
||||
app_->global_search()->ReloadSettings();
|
||||
ui_->global_search->ReloadSettings();
|
||||
app_->library()->ReloadSettings();
|
||||
|
Loading…
x
Reference in New Issue
Block a user