1
0
mirror of https://github.com/clementine-player/Clementine synced 2025-01-29 02:29:56 +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;
}
foreach (const Podcast& podcast, reply->results()) {
model()->appendRow(model()->CreatePodcastItem(podcast));
switch (reply->result_type()) {
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->setVisible(page->has_visible_widget());
ui_->results->setModel(page->model());
ui_->results->setRootIsDecorated(page->model()->is_tree());
ui_->results_stack->setCurrentWidget(
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) {
QVariant podcast_variant = current.data(PodcastDiscoveryModel::Role_Podcast);
// If the selected item is invalid or not a podcast, hide the details pane.
if (!current.isValid() ||
current.data(PodcastDiscoveryModel::Role_Type).toInt() !=
PodcastDiscoveryModel::Type_Podcast) {
if (podcast_variant.isNull()) {
ui_->details_scroll_area->hide();
add_button_->setEnabled(false);
remove_button_->setEnabled(false);
return;
}
current_podcast_ = current.data(PodcastDiscoveryModel::Role_Podcast).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;
}
current_podcast_ = podcast_variant.value<Podcast>();
// Start the blur+fade if there's already a podcast in the details pane.
if (ui_->details_scroll_area->isVisible()) {

View File

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

View File

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

View File

@ -47,5 +47,22 @@
<resources>
<include location="../../data/data.qrc"/>
</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>

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/>.
*/
#include "opmlcontainer.h"
#include "podcast.h"
#include "podcastdiscoverymodel.h"
#include "core/application.h"
@ -28,18 +29,18 @@ PodcastDiscoveryModel::PodcastDiscoveryModel(Application* app, QObject* parent)
: QStandardItemModel(parent),
app_(app),
icon_loader_(new StandardItemIconLoader(app->album_cover_loader(), this)),
is_tree_(false),
default_icon_(":providers/podcast16.png")
{
icon_loader_->SetModel(this);
}
QVariant PodcastDiscoveryModel::data(const QModelIndex& index, int role) const {
if (index.isValid() &&
role == Qt::DecorationRole &&
QStandardItemModel::data(index, Role_Type).toInt() == Type_Podcast &&
if (index.isValid() && role == Qt::DecorationRole &&
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);
@ -51,6 +52,7 @@ QStandardItem* PodcastDiscoveryModel::CreatePodcastItem(const Podcast& podcast)
item->setText(podcast.title());
item->setData(QVariant::fromValue(podcast), Role_Podcast);
item->setData(Type_Podcast, Role_Type);
item->setData(podcast.ImageUrlSmall(), Role_ImageUrl);
return item;
}
@ -66,15 +68,28 @@ QStandardItem* PodcastDiscoveryModel::CreateFolder(const QString& name) {
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);
item->setData(true, Role_StartedLoadingImage);
Podcast podcast = index.data(Role_Podcast).value<Podcast>();
if (podcast.ImageUrlSmall().isValid()) {
icon_loader_->LoadIcon(podcast.ImageUrlSmall().toString(), QString(), item);
}
icon_loader_->LoadIcon(url.toString(), QString(), item);
}
QStandardItem* PodcastDiscoveryModel::CreateLoadingIndicator() {

View File

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

View File

@ -15,6 +15,7 @@
along with Clementine. If not, see <http://www.gnu.org/licenses/>.
*/
#include "opmlcontainer.h"
#include "podcastparser.h"
#include "core/utilities.h"
@ -27,6 +28,7 @@ const char* PodcastParser::kItunesNamespace = "http://www.itunes.com/dtds/podcas
PodcastParser::PodcastParser() {
supported_mime_types_ << "application/rss+xml"
<< "application/xml"
<< "text/x-opml"
<< "text/xml";
}
@ -39,16 +41,47 @@ bool PodcastParser::SupportsContentType(const QString& content_type) const {
return false;
}
bool PodcastParser::Load(QIODevice* device, const QUrl& url, Podcast* ret) const {
ret->set_url(url);
QVariant PodcastParser::Load(QIODevice* device, const QUrl& url) const {
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;
}
ParseChannel(&reader, ret);
ParseChannel(reader, ret);
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"
class OpmlContainer;
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 {
public:
PodcastParser();
@ -35,14 +39,21 @@ public:
const QStringList& supported_mime_types() const { return supported_mime_types_; }
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:
bool ParseRss(QXmlStreamReader* reader, Podcast* ret) const;
void ParseChannel(QXmlStreamReader* reader, Podcast* ret) const;
void ParseImage(QXmlStreamReader* reader, Podcast* ret) const;
void ParseItunesOwner(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:
QStringList supported_mime_types_;
};

View File

@ -145,13 +145,17 @@ void PodcastUrlLoader::RequestFinished(RequestState* state, QNetworkReply* reply
// Check the mime type.
const QString content_type = reply->header(QNetworkRequest::ContentTypeHeader).toString();
if (parser_->SupportsContentType(content_type)) {
Podcast podcast;
if (!parser_->Load(reply, reply->url(), &podcast)) {
const QVariant ret = parser_->Load(reply, reply->url());
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);
return;
}
state->reply_->SetFinished(PodcastList() << podcast);
delete state;
return;
} else if (content_type.contains("text/html")) {
@ -192,7 +196,15 @@ PodcastUrlLoaderReply::PodcastUrlLoaderReply(const QUrl& url, QObject* parent)
}
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;
emit Finished(true);
}

View File

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