1
0
mirror of https://github.com/clementine-player/Clementine synced 2025-01-30 11:04:57 +01:00

Parse OPML documents

This commit is contained in:
David Sansome 2012-03-07 15:11:56 +00:00
parent 8a2e282676
commit d48177d630
12 changed files with 251 additions and 54 deletions

View File

@ -60,7 +60,15 @@ void AddPodcastByUrl::RequestFinished(PodcastUrlLoaderReply* reply) {
return; return;
} }
foreach (const Podcast& podcast, reply->results()) { switch (reply->result_type()) {
model()->appendRow(model()->CreatePodcastItem(podcast)); case PodcastUrlLoaderReply::Type_Podcast:
foreach (const Podcast& podcast, reply->podcast_results()) {
model()->appendRow(model()->CreatePodcastItem(podcast));
}
break;
case PodcastUrlLoaderReply::Type_Opml:
model()->CreateOpmlContainerItems(reply->opml_results(), model()->invisibleRootItem());
break;
} }
} }

View File

@ -84,7 +84,6 @@ void AddPodcastDialog::ChangePage(int index) {
ui_->stack->setCurrentIndex(index); ui_->stack->setCurrentIndex(index);
ui_->stack->setVisible(page->has_visible_widget()); ui_->stack->setVisible(page->has_visible_widget());
ui_->results->setModel(page->model()); ui_->results->setModel(page->model());
ui_->results->setRootIsDecorated(page->model()->is_tree());
ui_->results_stack->setCurrentWidget( ui_->results_stack->setCurrentWidget(
page_is_busy_[index] ? ui_->busy_page : ui_->results_page); page_is_busy_[index] ? ui_->busy_page : ui_->results_page);
@ -98,25 +97,17 @@ void AddPodcastDialog::ChangePage(int index) {
} }
void AddPodcastDialog::ChangePodcast(const QModelIndex& current) { void AddPodcastDialog::ChangePodcast(const QModelIndex& current) {
QVariant podcast_variant = current.data(PodcastDiscoveryModel::Role_Podcast);
// If the selected item is invalid or not a podcast, hide the details pane. // If the selected item is invalid or not a podcast, hide the details pane.
if (!current.isValid() || if (podcast_variant.isNull()) {
current.data(PodcastDiscoveryModel::Role_Type).toInt() !=
PodcastDiscoveryModel::Type_Podcast) {
ui_->details_scroll_area->hide(); ui_->details_scroll_area->hide();
add_button_->setEnabled(false); add_button_->setEnabled(false);
remove_button_->setEnabled(false); remove_button_->setEnabled(false);
return; return;
} }
current_podcast_ = current.data(PodcastDiscoveryModel::Role_Podcast).value<Podcast>(); current_podcast_ = podcast_variant.value<Podcast>();
// Also hide the details pane if this podcast isn't valid.
if (!current_podcast_.url().isValid()) {
ui_->details_scroll_area->hide();
add_button_->setEnabled(false);
remove_button_->setEnabled(false);
return;
}
// Start the blur+fade if there's already a podcast in the details pane. // Start the blur+fade if there's already a podcast in the details pane.
if (ui_->details_scroll_area->isVisible()) { if (ui_->details_scroll_area->isVisible()) {

View File

@ -53,7 +53,7 @@
<item> <item>
<widget class="QStackedWidget" name="results_stack"> <widget class="QStackedWidget" name="results_stack">
<property name="currentIndex"> <property name="currentIndex">
<number>1</number> <number>0</number>
</property> </property>
<widget class="QWidget" name="results_page"> <widget class="QWidget" name="results_page">
<layout class="QVBoxLayout" name="verticalLayout_2"> <layout class="QVBoxLayout" name="verticalLayout_2">
@ -229,8 +229,8 @@
<slot>accept()</slot> <slot>accept()</slot>
<hints> <hints>
<hint type="sourcelabel"> <hint type="sourcelabel">
<x>827</x> <x>836</x>
<y>436</y> <y>463</y>
</hint> </hint>
<hint type="destinationlabel"> <hint type="destinationlabel">
<x>157</x> <x>157</x>
@ -245,8 +245,8 @@
<slot>reject()</slot> <slot>reject()</slot>
<hints> <hints>
<hint type="sourcelabel"> <hint type="sourcelabel">
<x>876</x> <x>885</x>
<y>436</y> <y>463</y>
</hint> </hint>
<hint type="destinationlabel"> <hint type="destinationlabel">
<x>286</x> <x>286</x>

View File

@ -29,7 +29,6 @@ GPodderTopTagsModel::GPodderTopTagsModel(mygpo::ApiRequest* api, Application* ap
: PodcastDiscoveryModel(app, parent), : PodcastDiscoveryModel(app, parent),
api_(api) api_(api)
{ {
set_is_tree(true);
} }
bool GPodderTopTagsModel::hasChildren(const QModelIndex& parent) const { bool GPodderTopTagsModel::hasChildren(const QModelIndex& parent) const {

View File

@ -47,5 +47,22 @@
<resources> <resources>
<include location="../../data/data.qrc"/> <include location="../../data/data.qrc"/>
</resources> </resources>
<connections/> <connections>
<connection>
<sender>query</sender>
<signal>returnPressed()</signal>
<receiver>search</receiver>
<slot>click()</slot>
<hints>
<hint type="sourcelabel">
<x>237</x>
<y>52</y>
</hint>
<hint type="destinationlabel">
<x>461</x>
<y>55</y>
</hint>
</hints>
</connection>
</connections>
</ui> </ui>

View File

@ -0,0 +1,31 @@
/* 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 OPMLCONTAINER_H
#define OPMLCONTAINER_H
#include "podcast.h"
struct OpmlContainer {
QString name;
QList<OpmlContainer> containers;
PodcastList feeds;
};
Q_DECLARE_METATYPE(OpmlContainer)
#endif // OPMLCONTAINER_H

View File

@ -15,6 +15,7 @@
along with Clementine. If not, see <http://www.gnu.org/licenses/>. along with Clementine. If not, see <http://www.gnu.org/licenses/>.
*/ */
#include "opmlcontainer.h"
#include "podcast.h" #include "podcast.h"
#include "podcastdiscoverymodel.h" #include "podcastdiscoverymodel.h"
#include "core/application.h" #include "core/application.h"
@ -28,18 +29,18 @@ PodcastDiscoveryModel::PodcastDiscoveryModel(Application* app, QObject* parent)
: QStandardItemModel(parent), : QStandardItemModel(parent),
app_(app), app_(app),
icon_loader_(new StandardItemIconLoader(app->album_cover_loader(), this)), icon_loader_(new StandardItemIconLoader(app->album_cover_loader(), this)),
is_tree_(false),
default_icon_(":providers/podcast16.png") default_icon_(":providers/podcast16.png")
{ {
icon_loader_->SetModel(this); icon_loader_->SetModel(this);
} }
QVariant PodcastDiscoveryModel::data(const QModelIndex& index, int role) const { QVariant PodcastDiscoveryModel::data(const QModelIndex& index, int role) const {
if (index.isValid() && if (index.isValid() && role == Qt::DecorationRole &&
role == Qt::DecorationRole &&
QStandardItemModel::data(index, Role_Type).toInt() == Type_Podcast &&
QStandardItemModel::data(index, Role_StartedLoadingImage).toBool() == false) { QStandardItemModel::data(index, Role_StartedLoadingImage).toBool() == false) {
const_cast<PodcastDiscoveryModel*>(this)->LazyLoadImage(index); const QUrl image_url = QStandardItemModel::data(index, Role_ImageUrl).toUrl();
if (image_url.isValid()) {
const_cast<PodcastDiscoveryModel*>(this)->LazyLoadImage(image_url, index);
}
} }
return QStandardItemModel::data(index, role); return QStandardItemModel::data(index, role);
@ -51,6 +52,7 @@ QStandardItem* PodcastDiscoveryModel::CreatePodcastItem(const Podcast& podcast)
item->setText(podcast.title()); item->setText(podcast.title());
item->setData(QVariant::fromValue(podcast), Role_Podcast); item->setData(QVariant::fromValue(podcast), Role_Podcast);
item->setData(Type_Podcast, Role_Type); item->setData(Type_Podcast, Role_Type);
item->setData(podcast.ImageUrlSmall(), Role_ImageUrl);
return item; return item;
} }
@ -66,15 +68,28 @@ QStandardItem* PodcastDiscoveryModel::CreateFolder(const QString& name) {
return item; return item;
} }
void PodcastDiscoveryModel::LazyLoadImage(const QModelIndex& index) { QStandardItem* PodcastDiscoveryModel::CreateOpmlContainerItem(const OpmlContainer& container) {
QStandardItem* item = CreateFolder(container.name);
CreateOpmlContainerItems(container, item);
return item;
}
void PodcastDiscoveryModel::CreateOpmlContainerItems(const OpmlContainer& container, QStandardItem* parent) {
foreach (const OpmlContainer& child, container.containers) {
QStandardItem* child_item = CreateOpmlContainerItem(child);
parent->appendRow(child_item);
}
foreach (const Podcast& child, container.feeds) {
QStandardItem* child_item = CreatePodcastItem(child);
parent->appendRow(child_item);
}
}
void PodcastDiscoveryModel::LazyLoadImage(const QUrl& url, const QModelIndex& index) {
QStandardItem* item = itemFromIndex(index); QStandardItem* item = itemFromIndex(index);
item->setData(true, Role_StartedLoadingImage); item->setData(true, Role_StartedLoadingImage);
icon_loader_->LoadIcon(url.toString(), QString(), item);
Podcast podcast = index.data(Role_Podcast).value<Podcast>();
if (podcast.ImageUrlSmall().isValid()) {
icon_loader_->LoadIcon(podcast.ImageUrlSmall().toString(), QString(), item);
}
} }
QStandardItem* PodcastDiscoveryModel::CreateLoadingIndicator() { QStandardItem* PodcastDiscoveryModel::CreateLoadingIndicator() {

View File

@ -23,6 +23,8 @@
#include <QStandardItemModel> #include <QStandardItemModel>
class Application; class Application;
class OpmlContainer;
class OpmlFeed;
class Podcast; class Podcast;
class StandardItemIconLoader; class StandardItemIconLoader;
@ -41,14 +43,14 @@ public:
enum Role { enum Role {
Role_Podcast = Qt::UserRole, Role_Podcast = Qt::UserRole,
Role_Type, Role_Type,
Role_ImageUrl,
Role_StartedLoadingImage, Role_StartedLoadingImage,
RoleCount RoleCount
}; };
bool is_tree() const { return is_tree_; } void CreateOpmlContainerItems(const OpmlContainer& container, QStandardItem* parent);
void set_is_tree(bool v) { is_tree_ = v; } QStandardItem* CreateOpmlContainerItem(const OpmlContainer& container);
QStandardItem* CreatePodcastItem(const Podcast& podcast); QStandardItem* CreatePodcastItem(const Podcast& podcast);
QStandardItem* CreateFolder(const QString& name); QStandardItem* CreateFolder(const QString& name);
QStandardItem* CreateLoadingIndicator(); QStandardItem* CreateLoadingIndicator();
@ -56,14 +58,12 @@ public:
QVariant data(const QModelIndex& index, int role) const; QVariant data(const QModelIndex& index, int role) const;
private: private:
void LazyLoadImage(const QModelIndex& index); void LazyLoadImage(const QUrl& url, const QModelIndex& index);
private: private:
Application* app_; Application* app_;
StandardItemIconLoader* icon_loader_; StandardItemIconLoader* icon_loader_;
bool is_tree_;
QIcon default_icon_; QIcon default_icon_;
QIcon folder_icon_; QIcon folder_icon_;
}; };

View File

@ -15,6 +15,7 @@
along with Clementine. If not, see <http://www.gnu.org/licenses/>. along with Clementine. If not, see <http://www.gnu.org/licenses/>.
*/ */
#include "opmlcontainer.h"
#include "podcastparser.h" #include "podcastparser.h"
#include "core/utilities.h" #include "core/utilities.h"
@ -27,6 +28,7 @@ const char* PodcastParser::kItunesNamespace = "http://www.itunes.com/dtds/podcas
PodcastParser::PodcastParser() { PodcastParser::PodcastParser() {
supported_mime_types_ << "application/rss+xml" supported_mime_types_ << "application/rss+xml"
<< "application/xml" << "application/xml"
<< "text/x-opml"
<< "text/xml"; << "text/xml";
} }
@ -39,16 +41,47 @@ bool PodcastParser::SupportsContentType(const QString& content_type) const {
return false; return false;
} }
bool PodcastParser::Load(QIODevice* device, const QUrl& url, Podcast* ret) const { QVariant PodcastParser::Load(QIODevice* device, const QUrl& url) const {
ret->set_url(url);
QXmlStreamReader reader(device); QXmlStreamReader reader(device);
if (!Utilities::ParseUntilElement(&reader, "rss") ||
!Utilities::ParseUntilElement(&reader, "channel")) { while (!reader.atEnd()) {
switch (reader.readNext()) {
case QXmlStreamReader::StartElement: {
const QStringRef name = reader.name();
if (name == "rss") {
Podcast podcast;
if (!ParseRss(&reader, &podcast)) {
return QVariant();
} else {
podcast.set_url(url);
return QVariant::fromValue(podcast);
}
} else if (name == "opml") {
OpmlContainer container;
if (!ParseOpml(&reader, &container)) {
return QVariant();
} else {
return QVariant::fromValue(container);
}
}
return QVariant();
}
default:
break;
}
}
return QVariant();
}
bool PodcastParser::ParseRss(QXmlStreamReader* reader, Podcast* ret) const {
if (!Utilities::ParseUntilElement(reader, "channel")) {
return false; return false;
} }
ParseChannel(&reader, ret); ParseChannel(reader, ret);
return true; return true;
} }
@ -185,3 +218,70 @@ void PodcastParser::ParseItem(QXmlStreamReader* reader, Podcast* ret) const {
} }
} }
} }
bool PodcastParser::ParseOpml(QXmlStreamReader* reader, OpmlContainer* ret) const {
if (!Utilities::ParseUntilElement(reader, "body")) {
return false;
}
ParseOutline(reader, ret);
// OPML files sometimes consist of a single top level container.
while (ret->feeds.count() == 0 &&
ret->containers.count() == 1) {
*ret = ret->containers[0];
}
return true;
}
void PodcastParser::ParseOutline(QXmlStreamReader* reader, OpmlContainer* ret) const {
while (!reader->atEnd()) {
QXmlStreamReader::TokenType type = reader->readNext();
switch (type) {
case QXmlStreamReader::StartElement: {
const QStringRef name = reader->name();
if (name != "outline") {
Utilities::ConsumeCurrentElement(reader);
continue;
}
QXmlStreamAttributes attributes = reader->attributes();
if (attributes.value("type").toString() == "rss") {
// Parse the feed and add it to this container
Podcast podcast;
podcast.set_description(attributes.value("description").toString());
podcast.set_title(attributes.value("text").toString());
podcast.set_image_url_large(QUrl(attributes.value("imageHref").toString()));
podcast.set_url(QUrl(attributes.value("xmlUrl").toString()));
ret->feeds.append(podcast);
// Consume any children and the EndElement.
Utilities::ConsumeCurrentElement(reader);
} else {
// Create a new child container
OpmlContainer child;
// Take the name from the fullname attribute first if it exists.
child.name = attributes.value("fullname").toString();
if (child.name.isEmpty()) {
child.name = attributes.value("text").toString();
}
// Parse its contents and add it to this container
ParseOutline(reader, &child);
ret->containers.append(child);
}
break;
}
case QXmlStreamReader::EndElement:
return;
default:
break;
}
}
}

View File

@ -22,9 +22,13 @@
#include "podcast.h" #include "podcast.h"
class OpmlContainer;
class QXmlStreamReader; class QXmlStreamReader;
// Reads XML data from a QIODevice and returns a Podcast. // Reads XML data from a QIODevice.
// Returns either a Podcast or an OpmlContainer depending on what was inside
// the XML document.
class PodcastParser { class PodcastParser {
public: public:
PodcastParser(); PodcastParser();
@ -35,14 +39,21 @@ public:
const QStringList& supported_mime_types() const { return supported_mime_types_; } const QStringList& supported_mime_types() const { return supported_mime_types_; }
bool SupportsContentType(const QString& content_type) const; bool SupportsContentType(const QString& content_type) const;
bool Load(QIODevice* device, const QUrl& url, Podcast* ret) const; // You should check the type of the returned QVariant to see whether it
// contains a Podcast or an OpmlContainer. If the QVariant isNull then an
// error occurred parsing the XML.
QVariant Load(QIODevice* device, const QUrl& url) const;
private: private:
bool ParseRss(QXmlStreamReader* reader, Podcast* ret) const;
void ParseChannel(QXmlStreamReader* reader, Podcast* ret) const; void ParseChannel(QXmlStreamReader* reader, Podcast* ret) const;
void ParseImage(QXmlStreamReader* reader, Podcast* ret) const; void ParseImage(QXmlStreamReader* reader, Podcast* ret) const;
void ParseItunesOwner(QXmlStreamReader* reader, Podcast* ret) const; void ParseItunesOwner(QXmlStreamReader* reader, Podcast* ret) const;
void ParseItem(QXmlStreamReader* reader, Podcast* ret) const; void ParseItem(QXmlStreamReader* reader, Podcast* ret) const;
bool ParseOpml(QXmlStreamReader* reader, OpmlContainer* ret) const;
void ParseOutline(QXmlStreamReader* reader, OpmlContainer* ret) const;
private: private:
QStringList supported_mime_types_; QStringList supported_mime_types_;
}; };

View File

@ -145,13 +145,17 @@ void PodcastUrlLoader::RequestFinished(RequestState* state, QNetworkReply* reply
// Check the mime type. // Check the mime type.
const QString content_type = reply->header(QNetworkRequest::ContentTypeHeader).toString(); const QString content_type = reply->header(QNetworkRequest::ContentTypeHeader).toString();
if (parser_->SupportsContentType(content_type)) { if (parser_->SupportsContentType(content_type)) {
Podcast podcast; const QVariant ret = parser_->Load(reply, reply->url());
if (!parser_->Load(reply, reply->url(), &podcast)) {
if (ret.canConvert<Podcast>()) {
state->reply_->SetFinished(PodcastList() << ret.value<Podcast>());
} else if (ret.canConvert<OpmlContainer>()) {
state->reply_->SetFinished(ret.value<OpmlContainer>());
} else {
SendErrorAndDelete(tr("Failed to parse the XML for this RSS feed"), state); SendErrorAndDelete(tr("Failed to parse the XML for this RSS feed"), state);
return; return;
} }
state->reply_->SetFinished(PodcastList() << podcast);
delete state; delete state;
return; return;
} else if (content_type.contains("text/html")) { } else if (content_type.contains("text/html")) {
@ -192,7 +196,15 @@ PodcastUrlLoaderReply::PodcastUrlLoaderReply(const QUrl& url, QObject* parent)
} }
void PodcastUrlLoaderReply::SetFinished(const PodcastList& results) { void PodcastUrlLoaderReply::SetFinished(const PodcastList& results) {
results_ = results; result_type_ = Type_Podcast;
podcast_results_ = results;
finished_ = true;
emit Finished(true);
}
void PodcastUrlLoaderReply::SetFinished(const OpmlContainer& results) {
result_type_ = Type_Opml;
opml_results_ = results;
finished_ = true; finished_ = true;
emit Finished(true); emit Finished(true);
} }

View File

@ -21,6 +21,7 @@
#include <QObject> #include <QObject>
#include <QRegExp> #include <QRegExp>
#include "opmlcontainer.h"
#include "podcast.h" #include "podcast.h"
class PodcastParser; class PodcastParser;
@ -34,14 +35,23 @@ class PodcastUrlLoaderReply : public QObject {
public: public:
PodcastUrlLoaderReply(const QUrl& url, QObject* parent); PodcastUrlLoaderReply(const QUrl& url, QObject* parent);
enum ResultType {
Type_Podcast,
Type_Opml
};
const QUrl& url() const { return url_; } const QUrl& url() const { return url_; }
bool is_finished() const { return finished_; } bool is_finished() const { return finished_; }
bool is_success() const { return error_text_.isEmpty(); } bool is_success() const { return error_text_.isEmpty(); }
const QString& error_text() const { return error_text_; } const QString& error_text() const { return error_text_; }
const PodcastList& results() const { return results_; }
ResultType result_type() const { return result_type_; }
const PodcastList& podcast_results() const { return podcast_results_; }
const OpmlContainer& opml_results() const { return opml_results_; }
void SetFinished(const QString& error_text); void SetFinished(const QString& error_text);
void SetFinished(const PodcastList& results); void SetFinished(const PodcastList& results);
void SetFinished(const OpmlContainer& results);
signals: signals:
void Finished(bool success); void Finished(bool success);
@ -50,7 +60,10 @@ private:
QUrl url_; QUrl url_;
bool finished_; bool finished_;
QString error_text_; QString error_text_;
PodcastList results_;
ResultType result_type_;
PodcastList podcast_results_;
OpmlContainer opml_results_;
}; };