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:
parent
8a2e282676
commit
d48177d630
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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()) {
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
31
src/podcasts/opmlcontainer.h
Normal file
31
src/podcasts/opmlcontainer.h
Normal 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
|
@ -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() {
|
||||
|
@ -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_;
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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_;
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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_;
|
||||
};
|
||||
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user