Files
cantata/streams/webstreams.cpp
2013-02-12 16:38:08 +00:00

473 lines
16 KiB
C++

/*
* Cantata
*
* Copyright (c) 2011-2013 Craig Drummond <craig.p.drummond@gmail.com>
*
* ----
*
* This program 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 2 of the License, or
* (at your option) any later version.
*
* This program 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 this program; see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
* Boston, MA 02110-1301, USA.
*/
#include "webstreams.h"
#include "networkaccessmanager.h"
#include "streamsmodel.h"
#include "localize.h"
#include "song.h"
#include <QXmlStreamReader>
#include <QFile>
enum Type {
WS_IceCast,
WS_SomaFm,
WS_Radio,
WS_Count
};
static QList<WebStream *> providers;
QList<WebStream *> WebStream::getAll()
{
if (providers.isEmpty()) {
QFile f(":streams.xml");
if (f.open(QIODevice::ReadOnly)) {
QXmlStreamReader doc(&f);
while (!doc.atEnd()) {
doc.readNext();
if (doc.isStartElement() && QLatin1String("stream")==doc.name()) {
QString name=doc.attributes().value("name").toString();
unsigned int type=doc.attributes().value("type").toString().toUInt();
QUrl url=QUrl(doc.attributes().value("url").toString());
switch (type) {
case WS_IceCast: providers.append(new IceCastWebStream(name, doc.attributes().value("region").toString(), url)); break;
case WS_SomaFm: providers.append(new SomaFmWebStream(name, doc.attributes().value("region").toString(), url)); break;
case WS_Radio: providers.append(new RadioWebStream(name, doc.attributes().value("region").toString(), url)); break;
default: break;
}
}
}
}
}
return providers;
}
WebStream * WebStream::get(const QUrl &url)
{
foreach (WebStream *p, providers) {
if (p->url==url) {
return p;
}
}
return 0;
}
void WebStream::download()
{
if (job) {
return;
}
job=NetworkAccessManager::self()->get(QNetworkRequest(url));
connect(job, SIGNAL(finished()), this, SLOT(downloadFinished()));
}
void WebStream::cancelDownload()
{
if (job) {
disconnect(job, SIGNAL(finished()), this, SLOT(downloadFinished()));
job->deleteLater();
job=0;
}
}
void WebStream::downloadFinished()
{
QNetworkReply *reply=qobject_cast<QNetworkReply *>(sender());
if (!reply) {
return;
}
if(QNetworkReply::NoError==reply->error()) {
QList<StreamsModel::StreamItem *> streams=parse(reply);
if (streams.isEmpty()) {
emit error(i18nc("message \n url", "No streams downloaded from %1\n(%2)").arg(name).arg(url.toString()));
} else {
StreamsModel::self()->add(name, streams);
}
} else {
emit error(i18nc("message \n url", "Failed to download streams from %1\n(%2)").arg(name).arg(url.toString()));
}
job=0;
reply->deleteLater();
emit finished();
}
static QString fixSingleGenre(const QString &g)
{
if (g.length()) {
QString genre=Song::capitalize(g);
genre[0]=genre[0].toUpper();
genre=genre.trimmed();
genre=genre.replace(QLatin1String("Afrocaribbean"), QLatin1String("Afro-Caribbean"));
genre=genre.replace(QLatin1String("Afro Caribbean"), QLatin1String("Afro-Caribbean"));
if (genre.length() < 3 ||
QLatin1String("The")==genre || QLatin1String("All")==genre ||
QLatin1String("Various")==genre || QLatin1String("Unknown")==genre ||
QLatin1String("Misc")==genre || QLatin1String("Mix")==genre || QLatin1String("100%")==genre ||
genre.contains("ÃÂ") || // Broken unicode.
genre.contains(QRegExp("^#x[0-9a-f][0-9a-f]"))) { // Broken XML entities.
return QString();
}
if (genre==QLatin1String("R&B") || genre==QLatin1String("R B") || genre==QLatin1String("Rnb") || genre==QLatin1String("RnB")) {
return QLatin1String("R&B");
}
if (genre==QLatin1String("Classic") || genre==QLatin1String("Classical")) {
return QLatin1String("Classical");
}
if (genre==QLatin1String("Christian") || genre.startsWith(QLatin1String("Christian "))) {
return QLatin1String("Christian");
}
if (genre==QLatin1String("Rock") || genre.startsWith(QLatin1String("Rock "))) {
return QLatin1String("Rock");
}
if (genre==QLatin1String("Electronic") || genre==QLatin1String("Electronica") || genre==QLatin1String("Electric")) {
return QLatin1String("Electronic");
}
if (genre==QLatin1String("Easy") || genre==QLatin1String("Easy Listening")) {
return QLatin1String("Easy Listening");
}
if (genre==QLatin1String("Hit") || genre==QLatin1String("Hits") || genre==QLatin1String("Easy listening")) {
return QLatin1String("Hits");
}
if (genre==QLatin1String("Hip") || genre==QLatin1String("Hiphop") || genre==QLatin1String("Hip Hop") || genre==QLatin1String("Hop Hip")) {
return QLatin1String("Hip Hop");
}
if (genre==QLatin1String("News") || genre==QLatin1String("News talk")) {
return QLatin1String("News");
}
if (genre==QLatin1String("Top40") || genre==QLatin1String("Top 40") || genre==QLatin1String("40Top") || genre==QLatin1String("40 Top")) {
return QLatin1String("Top 40");
}
QStringList small=QStringList() << QLatin1String("Adult Contemporary") << QLatin1String("Alternative")
<< QLatin1String("Community Radio") << QLatin1String("Local Service")
<< QLatin1String("Multiultural") << QLatin1String("News")
<< QLatin1String("Student") << QLatin1String("Urban");
foreach (const QString &s, small) {
if (genre==s || genre.startsWith(s+" ") || genre.endsWith(" "+s)) {
return s;
}
}
// Convert XX's to XXs
if (genre.contains(QRegExp("^[0-9]0's$"))) {
genre=genre.remove('\'');
}
if (genre.length()>25 && (0==genre.indexOf(QRegExp("^[0-9]0s ")) || 0==genre.indexOf(QRegExp("^[0-9]0 ")))) {
int pos=genre.indexOf(' ');
if (pos>1) {
genre=genre.left(pos);
}
}
// Convert 80 -> 80s.
return genre.contains(QRegExp("^[0-9]0$")) ? genre + 's' : genre;
}
return g;
}
static QString fixGenres(const QString &genre)
{
QString g(genre);
int pos=g.indexOf("<br");
if (pos>3) {
g=g.left(pos);
}
pos=g.indexOf("(");
if (pos>3) {
g=g.left(pos);
}
g=Song::capitalize(g);
QStringList genres=g.split('|', QString::SkipEmptyParts);
QStringList allGenres;
foreach (const QString &genre, genres) {
allGenres+=genre.split('/', QString::SkipEmptyParts);
}
QStringList fixed;
foreach (const QString &genre, allGenres) {
fixed.append(fixSingleGenre(genre));
}
return fixed.join(StreamsModel::constGenreSeparator);
}
static void trimGenres(QMultiHash<QString, StreamsModel::StreamItem *> &genres)
{
QSet<QString> genreSet = genres.keys().toSet();
foreach (const QString &genre, genreSet) {
if (genres.count(genre) < 3) {
const QList<StreamsModel::StreamItem *> &smallGnre = genres.values(genre);
foreach (StreamsModel::StreamItem* s, smallGnre) {
s->genres.remove(genre);
}
}
}
}
QList<StreamsModel::StreamItem *> IceCastWebStream::parse(QIODevice *dev)
{
QList<StreamsModel::StreamItem *> streams;
QString name;
QUrl url;
QString genre;
QSet<QString> names;
QMultiHash<QString, StreamsModel::StreamItem *> genres;
int level=0;
QXmlStreamReader doc(dev);
while (!doc.atEnd()) {
doc.readNext();
if (doc.isStartElement()) {
++level;
if (2==level && QLatin1String("entry")==doc.name()) {
name=QString();
url=QUrl();
genre=QString();
} else if (3==level) {
if (QLatin1String("server_name")==doc.name()) {
name=doc.readElementText();
--level;
} else if (QLatin1String("genre")==doc.name()) {
genre=fixGenres(doc.readElementText());
--level;
} else if (QLatin1String("listen_url")==doc.name()) {
url=QUrl(doc.readElementText());
--level;
}
}
} else if (doc.isEndElement()) {
if (2==level && QLatin1String("entry")==doc.name() && !name.isEmpty() && url.isValid() && !names.contains(name)) {
StreamsModel::StreamItem *item=new StreamsModel::StreamItem(name, genre, QString(), url);
streams.append(item);
foreach (const QString &genre, item->genres) {
genres.insert(genre, item);
}
names.insert(item->name);
}
--level;
}
}
trimGenres(genres);
return streams;
}
QList<StreamsModel::StreamItem *> SomaFmWebStream::parse(QIODevice *dev)
{
QList<StreamsModel::StreamItem *> streams;
QSet<QString> names;
QString streamFormat;
QMultiHash<QString, StreamsModel::StreamItem *> genres;
QString name;
QUrl url;
QString genre;
int level=0;
QXmlStreamReader doc(dev);
while (!doc.atEnd()) {
doc.readNext();
if (doc.isStartElement()) {
++level;
if (2==level && QLatin1String("channel")==doc.name()) {
name=QString();
url=QUrl();
genre=QString();
streamFormat=QString();
} else if (3==level) {
if (QLatin1String("title")==doc.name()) {
name=doc.readElementText();
--level;
} else if (QLatin1String("genre")==doc.name()) {
genre=fixGenres(doc.readElementText());
--level;
} else if (QLatin1String("fastpls")==doc.name()) {
if (streamFormat.isEmpty() || QLatin1String("mp3")!=streamFormat) {
streamFormat=doc.attributes().value("format").toString();
url=QUrl(doc.readElementText());
--level;
}
}
}
} else if (doc.isEndElement()) {
if (2==level && QLatin1String("channel")==doc.name() && !name.isEmpty() && url.isValid() && !names.contains(name)) {
StreamsModel::StreamItem *item=new StreamsModel::StreamItem(name, genre, QString(), url);
streams.append(item);
foreach (const QString &genre, item->genres) {
genres.insert(genre, item);
}
names.insert(item->name);
}
--level;
}
}
trimGenres(genres);
return streams;
}
struct Stream {
enum Format {
AAC,
MP3,
OGG,
WMA,
Unknown
};
Stream() : format(Unknown), bitrate(0) { }
bool operator<(const Stream &o) const {
return weight()>o.weight();
}
int weight() const {
return ((format&0x0f)<<8)+(bitrate&0xff);
}
void setFormat(const QString &f) {
if (QLatin1String("mp3")==f.toLower()) {
format=MP3;
} else if (QLatin1String("aacplus")==f.toLower()) {
format=AAC;
} else if (QLatin1String("ogg vorbis")==f.toLower()) {
format=OGG;
} else if (QLatin1String("windows media")==f.toLower()) {
format=WMA;
} else {
format=Unknown;
}
}
QUrl url;
Format format;
unsigned int bitrate;
};
struct StationEntry {
StationEntry() { clear(); }
void clear() { name=location=comment=QString(); streams.clear(); }
QString name;
QString location;
QString comment;
QList<Stream> streams;
};
static QString getString(QString &str, const QString &start, const QString &end)
{
QString rv;
int b=str.indexOf(start);
int e=-1==b ? -1 : str.indexOf(end, b+start.length());
if (-1!=e) {
rv=str.mid(b+start.length(), e-(b+start.length())).trimmed();
str=str.mid(e+end.length());
}
return rv;
}
QList<StreamsModel::StreamItem *> RadioWebStream::parse(QIODevice *dev)
{
QList<StreamsModel::StreamItem *> streams;
// QMultiHash<QString, StreamsModel::StreamItem *> genres;
QSet<QString> names;
if (dev) {
StationEntry entry;
while (!dev->atEnd()) {
QString line=dev->readLine().trimmed().replace("> <", "><").replace("<td><b><a href", "<td><a href").replace("</b></a></b>", "</b></a>");
if ("<tr>"==line) {
entry.clear();
} else if (line.startsWith("<td><a href=")) {
if (entry.name.isEmpty()) {
entry.name=getString(line, "<b>", "</b>");
QString extra=getString(line, "</a>", "</td>");
if (!extra.isEmpty()) {
entry.name+=" "+extra;
}
} else {
// Station URLs...
QString url;
QString bitrate;
int idx=0;
do {
url=getString(line, "href=\"", "\"");
bitrate=getString(line, ">", " Kbps");
if (!url.isEmpty() && !bitrate.isEmpty() && !url.startsWith(QLatin1String("javascript"))) {
if (idx<entry.streams.count()) {
entry.streams[idx].url=url;
entry.streams[idx].bitrate=bitrate.toUInt();
idx++;
}
}
} while (!url.isEmpty() && !bitrate.isEmpty());
}
} else if (line.startsWith("<td><img src")) {
// Station formats...
QString format;
do {
format=getString(line, "alt=\"", "\"");
if (!format.isEmpty()) {
Stream stream;
stream.setFormat(format);
entry.streams.append(stream);
}
} while (!format.isEmpty());
} else if (line.startsWith("<td>")) {
if (entry.location.isEmpty()) {
entry.location=getString(line, "<td>", "</td>");
} else {
entry.comment=getString(line, "<td>", "</td>");
}
} else if ("</tr>"==line) {
if (entry.streams.count()) {
QString name=QLatin1String("National")==entry.location ? entry.name : (entry.name+" ("+entry.location+")");
QUrl url=entry.streams.at(0).url;
if (!names.contains(name) && !name.isEmpty() && url.isValid()) {
qSort(entry.streams);
QString genre=fixGenres(entry.comment);
StreamsModel::StreamItem *item=new StreamsModel::StreamItem(name, genre, QString(), url);
streams.append(item);
// foreach (const QString &genre, item->genres) {
// genres.insert(genre, item);
// }
names.insert(item->name);
}
}
}
}
}
// trimGenres(genres);
return streams;
}