Files
cantata/online/podcastservice.cpp
2014-03-17 19:48:11 +00:00

651 lines
21 KiB
C++

/*
* Cantata
*
* Copyright (c) 2011-2014 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 "podcastservice.h"
#include "podcastsettingsdialog.h"
#include "networkaccessmanager.h"
#include "onlineservicesmodel.h"
#include "musiclibraryitempodcast.h"
#include "musiclibraryitemsong.h"
#include "utils.h"
#include "settings.h"
#include "mpdconnection.h"
#include "config.h"
#include "httpserver.h"
#include <QCoreApplication>
#include <QDir>
#include <QUrl>
#include <QSet>
#include <QTimer>
#if QT_VERSION >= 0x050000
#include <QUrlQuery>
#endif
#include <stdio.h>
const QString PodcastService::constName=QLatin1String("Podcasts");
QString PodcastService::iconFile;
static const char * constNewFeedProperty="new-feed";
static const char * constRssUrlProperty="rss-url";
static const char * constDestProperty="dest";
static const QLatin1String constPartialExt(".partial");
// Move files from previous ~/.config/cantata to ~/.local/share/cantata
static void moveToNewLocation()
{
#if !defined Q_OS_WIN && !defined Q_OS_MAC // Not required for windows - as already stored in data location!
if (Settings::self()->version()<CANTATA_MAKE_VERSION(1, 51, 0)) {
Utils::moveDir(Utils::configDir(MusicLibraryItemPodcast::constDir), Utils::dataDir(MusicLibraryItemPodcast::constDir, true));
}
#endif
}
QUrl PodcastService::fixUrl(const QString &url)
{
QString trimmed(url.trimmed());
// Thanks gpodder!
static QMap<QString, QString> prefixMap;
if (prefixMap.isEmpty()) {
prefixMap.insert(QLatin1String("fb:"), QLatin1String("http://feeds.feedburner.com/%1"));
prefixMap.insert(QLatin1String("yt:"), QLatin1String("http://www.youtube.com/rss/user/%1/videos.rss"));
prefixMap.insert(QLatin1String("sc:"), QLatin1String("http://soundcloud.com/%1"));
prefixMap.insert(QLatin1String("fm4od:"), QLatin1String("http://onapp1.orf.at/webcam/fm4/fod/%1.xspf"));
prefixMap.insert(QLatin1String("ytpl:"), QLatin1String("http://gdata.youtube.com/feeds/api/playlists/%1"));
}
QMap<QString, QString>::ConstIterator it(prefixMap.constBegin());
QMap<QString, QString>::ConstIterator end(prefixMap.constEnd());
for (; it!=end; ++it) {
if (trimmed.startsWith(it.key())) {
trimmed=it.value().arg(trimmed.mid(it.key().length()));
}
}
if (!trimmed.contains(QLatin1String("://"))) {
trimmed.prepend(QLatin1String("http://"));
}
return fixUrl(QUrl(trimmed));
}
QUrl PodcastService::fixUrl(const QUrl &orig)
{
QUrl u=orig;
if (u.scheme().isEmpty() || QLatin1String("itpc")==u.scheme() || QLatin1String("pcast")==u.scheme() ||
QLatin1String("feed")==u.scheme() || QLatin1String("itms")==u.scheme()) {
u.setScheme(QLatin1String("http"));
}
return u;
}
PodcastService::PodcastService(MusicModel *m)
: OnlineService(m, i18n("Podcasts"))
, rssUpdateTimer(0)
{
moveToNewLocation();
loaded=true;
setUseArtistImages(false);
setUseAlbumImages(false);
QMetaObject::invokeMethod(this, "loadAll", Qt::QueuedConnection);
connect(MPDConnection::self(), SIGNAL(currentSongUpdated(const Song &)), this, SLOT(currentMpdSong(const Song &)));
if (iconFile.isEmpty()) {
#ifdef Q_OS_WIN
iconFile=QCoreApplication::applicationDirPath()+"/icons/podcasts.png";
#else
iconFile=QString(INSTALL_PREFIX"/share/")+QCoreApplication::applicationName()+"/icons/podcasts.png";
#endif
}
}
Song PodcastService::fixPath(const Song &orig, bool) const
{
Song song=orig;
song.setPodcastLocalPath(QString());
song.setIsFromOnlineService(constName);
if (!orig.podcastLocalPath().isEmpty() && QFile::exists(orig.podcastLocalPath())) {
if (!HttpServer::self()->forceUsage() && MPDConnection::self()->getDetails().isLocal()) {
song.file=QLatin1String("file://")+orig.podcastLocalPath();
} else if (HttpServer::self()->isAlive()) {
song.file=orig.podcastLocalPath();
song.file=HttpServer::self()->encodeUrl(song);
}
return song;
}
return encode(song);
}
void PodcastService::clear()
{
cancelAllJobs();
::OnlineService::clear();
}
void PodcastService::loadAll()
{
QString dir=Utils::dataDir(MusicLibraryItemPodcast::constDir);
if (!dir.isEmpty()) {
QDir d(dir);
QStringList entries=d.entryList(QStringList() << "*"+MusicLibraryItemPodcast::constExt, QDir::Files|QDir::Readable|QDir::NoDot|QDir::NoDotDot);
foreach (const QString &e, entries) {
if (!update) {
update=new OnlineServiceMusicRoot();
}
MusicLibraryItemPodcast *podcast=new MusicLibraryItemPodcast(dir+e, update);
if (podcast->load()) {
update->append(podcast);
} else {
delete podcast;
}
}
if (update) {
if (update->childItems().isEmpty()) {
delete update;
} else {
applyUpdate();
}
}
startRssUpdateTimer();
}
}
void PodcastService::cancelAll()
{
foreach (NetworkJob *j, rssJobs) {
disconnect(j, SIGNAL(finished()), this, SLOT(rssJobFinished()));
j->cancelAndDelete();
}
rssJobs.clear();
setBusy(!rssJobs.isEmpty() || !downloadJobs.isEmpty());
}
void PodcastService::rssJobFinished()
{
NetworkJob *j=dynamic_cast<NetworkJob *>(sender());
if (!j || !rssJobs.contains(j)) {
return;
}
j->deleteLater();
rssJobs.removeAll(j);
bool isNew=j->property(constNewFeedProperty).toBool();
if (j->ok()) {
if (updateUrls.contains(j->url())){
updateUrls.remove(j->url());
if (updateUrls.isEmpty()) {
lastRssUpdate=QDateTime::currentDateTime();
Settings::self()->saveLastRssUpdate(lastRssUpdate);
startRssUpdateTimer();
}
}
MusicLibraryItemPodcast *podcast=new MusicLibraryItemPodcast(QString(), this);
MusicLibraryItemPodcast::RssStatus loadStatus=podcast->loadRss(j->actualJob());
if (MusicLibraryItemPodcast::Loaded==loadStatus) {
bool autoDownload=Settings::self()->podcastAutoDownload();
if (isNew) {
podcast->save();
beginInsertRows(index(), childCount(), childCount());
m_childItems.append(podcast);
if (autoDownload) {
foreach (MusicLibraryItem *i, podcast->childItems()) {
MusicLibraryItemSong *song=static_cast<MusicLibraryItemSong *>(i);
downloadEpisode(podcast, QUrl(song->file()));
}
}
endInsertRows();
// emitNeedToSort();
} else {
MusicLibraryItemPodcast *orig = getPodcast(j->url());
if (!orig) {
delete podcast;
return;
}
QSet<QString> origSongs;
QSet<QString> newSongs;
foreach (MusicLibraryItem *i, orig->childItems()) {
MusicLibraryItemPodcastEpisode *episode=static_cast<MusicLibraryItemPodcastEpisode *>(i);
origSongs.insert(episode->file());
}
foreach (MusicLibraryItem *i, podcast->childItems()) {
MusicLibraryItemSong *song=static_cast<MusicLibraryItemSong *>(i);
newSongs.insert(song->file());
}
QSet<QString> added=newSongs-origSongs;
QSet<QString> removed=origSongs-newSongs;
if (added.count() || removed.count()) {
QModelIndex origIndex=createIndex(orig);
if (removed.count()) {
foreach (const QString &s, removed) {
MusicLibraryItemPodcastEpisode *episode=orig->getEpisode(s);
if (episode->localPath().isEmpty() || !QFile::exists(episode->localPath())) {
int idx=orig->indexOf(episode);
if (-1!=idx) {
beginRemoveRows(origIndex, idx, idx);
orig->remove(idx);
endRemoveRows();
}
}
}
}
if (added.count()) {
QList<MusicLibraryItemPodcastEpisode *> newSongs;
foreach (const QString &s, added) {
MusicLibraryItemPodcastEpisode *episode=podcast->getEpisode(s);
if (episode) {
newSongs.append(episode);
if (autoDownload) {
downloadEpisode(orig, QUrl(episode->file()));
}
}
}
beginInsertRows(origIndex, orig->childCount(), (orig->childCount()+newSongs.count())-1);
orig->addAll(newSongs);
endInsertRows();
}
orig->setUnplayedCount();
orig->save();
// emitNeedToSort();
}
delete podcast;
}
} else if (isNew) {
delete podcast;
if (MusicLibraryItemPodcast::VideoPodcast==loadStatus) {
emitError(i18n("Cantata only supports audio podcasts! %1 contains only video podcasts.", j->url().toString()), isNew);
} else {
emitError(i18n("Failed to parse %1", j->url().toString()), isNew);
}
}
} else {
emitError(i18n("Failed to download %1", j->url().toString()), isNew);
}
setBusy(!rssJobs.isEmpty() || !downloadJobs.isEmpty());
}
void PodcastService::configure(QWidget *p)
{
PodcastSettingsDialog dlg(p);
if (QDialog::Accepted==dlg.exec()) {
int changes=dlg.changes();
if (changes&PodcastSettingsDialog::RssUpdate) {
startRssUpdateTimer();
}
}
}
MusicLibraryItemPodcast * PodcastService::getPodcast(const QUrl &url) const
{
foreach (MusicLibraryItem *i, m_childItems) {
if (static_cast<MusicLibraryItemPodcast *>(i)->rssUrl()==url) {
return static_cast<MusicLibraryItemPodcast *>(i);
}
}
return 0;
}
MusicLibraryItemPodcastEpisode * PodcastService::getEpisode(const MusicLibraryItemPodcast *podcast, const QUrl &episode)
{
if (podcast) {
foreach (MusicLibraryItem *i, podcast->childItems()) {
MusicLibraryItemPodcastEpisode *song=static_cast<MusicLibraryItemPodcastEpisode *>(i);
if (QUrl(song->file())==episode) {
return song;
}
}
}
return 0;
}
void PodcastService::unSubscribe(MusicLibraryItem *item)
{
int row=m_childItems.indexOf(item);
if (row>=0) {
beginRemoveRows(index(), row, row);
static_cast<MusicLibraryItemPodcast *>(item)->removeFiles();
delete m_childItems.takeAt(row);
resetRows();
endRemoveRows();
if (m_childItems.isEmpty()) {
stopRssUpdateTimer();
}
}
}
void PodcastService::refreshSubscription(MusicLibraryItem *item)
{
if (item) {
QUrl url=static_cast<MusicLibraryItemPodcast *>(item)->rssUrl();
if (processingUrl(url)) {
return;
}
addUrl(url, false);
} else {
updateRss();
}
}
bool PodcastService::processingUrl(const QUrl &url) const
{
foreach (NetworkJob *j, rssJobs) {
if (j->url()==url) {
return true;
}
}
return false;
}
void PodcastService::addUrl(const QUrl &url, bool isNew)
{
setBusy(true);
NetworkJob *job=NetworkAccessManager::self()->get(url);
connect(job, SIGNAL(finished()), this, SLOT(rssJobFinished()));
job->setProperty(constNewFeedProperty, isNew);
rssJobs.append(job);
}
bool PodcastService::downloadingEpisode(const QUrl &url) const
{
foreach (NetworkJob *j, downloadJobs) {
if (j->url()==url) {
return true;
}
}
return false;
}
void PodcastService::cancelAllDownloads()
{
foreach (NetworkJob *j, downloadJobs) {
cancelDownload(j);
}
downloadJobs.clear();
setBusy(!rssJobs.isEmpty() || !downloadJobs.isEmpty());
}
void PodcastService::downloadPodcasts(MusicLibraryItemPodcast *pod, const QList<MusicLibraryItemPodcastEpisode *> &episodes)
{
foreach (MusicLibraryItemPodcastEpisode *ep, episodes) {
QUrl url(ep->file());
if (!downloadingEpisode(url)) {
downloadEpisode(pod, url);
}
}
}
void PodcastService::deleteDownloadedPodcasts(MusicLibraryItemPodcast *pod, const QList<MusicLibraryItemPodcastEpisode *> &episodes)
{
foreach (MusicLibraryItemPodcastEpisode *ep, episodes) {
QUrl url(ep->file());
if (downloadingEpisode(url)) {
cancelDownload(url);
}
QString fileName=ep->localPath();
if (!fileName.isEmpty()) {
if (QFile::exists(fileName)) {
QFile::remove(fileName);
}
QString dirName=fileName.isEmpty() ? QString() : Utils::getDir(fileName);
if (!dirName.isEmpty()) {
QDir dir(dirName);
if (dir.exists()) {
dir.rmdir(dirName);
}
}
ep->setLocalPath(QString());
ep->setDownloadProgress(-1);
emitDataChanged(createIndex(ep));
}
}
pod->save();
}
static QString encodeName(const QString &name)
{
QString n=name;
n=n.replace("/", "_");
n=n.replace("\\", "_");
n=n.replace(":", "_");
return n;
}
void PodcastService::downloadEpisode(const MusicLibraryItemPodcast *podcast, const QUrl &episode)
{
QString dest=Settings::self()->podcastDownloadPath();
if (dest.isEmpty()) {
return;
}
if (downloadingEpisode(episode)) {
return;
}
dest=Utils::fixPath(dest)+Utils::fixPath(encodeName(podcast->data()))+Utils::getFile(episode.toString());
setBusy(true);
NetworkJob *job=NetworkAccessManager::self()->get(episode);
connect(job, SIGNAL(finished()), this, SLOT(downloadJobFinished()));
connect(job, SIGNAL(readyRead()), this, SLOT(downloadReadyRead()));
connect(job, SIGNAL(downloadPercent(int)), this, SLOT(downloadPercent(int)));
job->setProperty(constRssUrlProperty, podcast->rssUrl());
job->setProperty(constDestProperty, dest);
downloadJobs.append(job);
QString partial=dest+constPartialExt;
if (QFile::exists(partial)) {
QFile::remove(partial);
}
}
void PodcastService::cancelDownload(const QUrl &url)
{
foreach (NetworkJob *j, downloadJobs) {
if (j->url()==url) {
cancelDownload(j);
downloadJobs.removeAll(j);
break;
}
}
setBusy(!rssJobs.isEmpty() || !downloadJobs.isEmpty());
}
void PodcastService::cancelDownload(NetworkJob *job)
{
job->cancelAndDelete();
QString dest=job->property(constDestProperty).toString();
QString partial=dest.isEmpty() ? QString() : QString(dest+constPartialExt);
if (!partial.isEmpty() && QFile::exists(partial)) {
QFile::remove(partial);
}
}
void PodcastService::downloadJobFinished()
{
NetworkJob *job=dynamic_cast<NetworkJob *>(sender());
if (!job || !downloadJobs.contains(job)) {
return;
}
job->deleteLater();
QString dest=job->property(constDestProperty).toString();
QString partial=dest.isEmpty() ? QString() : QString(dest+constPartialExt);
if (job->ok()) {
QString dest=job->property(constDestProperty).toString();
if (dest.isEmpty()) {
return;
}
QString partial=dest+constPartialExt;
if (QFile::exists(partial)) {
if (QFile::exists(dest)) {
QFile::remove(dest);
}
if (QFile::rename(partial, dest)) {
MusicLibraryItemPodcast *pod=getPodcast(job->property(constRssUrlProperty).toUrl());
if (pod) {
MusicLibraryItemPodcastEpisode *song=getEpisode(pod, job->url());
if (song) {
song->setLocalPath(dest);
song->setDownloadProgress(-1);
pod->save();
emitDataChanged(createIndex(song));
}
}
}
}
} else if (!partial.isEmpty() && QFile::exists(partial)) {
QFile::remove(partial);
}
downloadJobs.removeAll(job);
setBusy(!rssJobs.isEmpty() || !downloadJobs.isEmpty());
}
void PodcastService::downloadReadyRead()
{
NetworkJob *job=dynamic_cast<NetworkJob *>(sender());
if (!job || !downloadJobs.contains(job)) {
return;
}
QString dest=job->property(constDestProperty).toString();
QString partial=dest.isEmpty() ? QString() : QString(dest+constPartialExt);
if (!partial.isEmpty()) {
QString dir=Utils::getDir(partial);
if (!QDir(dir).exists()) {
QDir(dir).mkpath(dir);
}
if (!QDir(dir).exists()) {
return;
}
QFile f(partial);
while (true) {
const qint64 bytes = job->bytesAvailable();
if (bytes <= 0) {
break;
}
if (!f.isOpen()) {
if (!f.open(QIODevice::Append)) {
return;
}
}
f.write(job->read(bytes));
}
}
}
void PodcastService::downloadPercent(int pc)
{
NetworkJob *job=dynamic_cast<NetworkJob *>(sender());
if (!job || !downloadJobs.contains(job)) {
return;
}
MusicLibraryItemPodcast *pod=getPodcast(job->property(constRssUrlProperty).toUrl());
if (pod) {
MusicLibraryItemPodcastEpisode *song=getEpisode(pod, job->url());
if (song) {
song->setDownloadProgress(pc);
emitDataChanged(createIndex(song));
}
}
}
void PodcastService::startRssUpdateTimer()
{
if (0==Settings::self()->rssUpdate() || m_childItems.isEmpty()) {
stopRssUpdateTimer();
return;
}
if (!rssUpdateTimer) {
rssUpdateTimer=new QTimer(this);
rssUpdateTimer->setSingleShot(true);
connect(rssUpdateTimer, SIGNAL(timeout()), this, SLOT(updateRss()));
}
if (!lastRssUpdate.isValid()) {
lastRssUpdate=Settings::self()->lastRssUpdate();
}
if (!lastRssUpdate.isValid()) {
updateRss();
} else {
QDateTime nextUpdate = lastRssUpdate.addSecs(Settings::self()->rssUpdate()*60);
int secsUntilNextUpdate = QDateTime::currentDateTime().secsTo(nextUpdate);
if (secsUntilNextUpdate<0) {
// Oops, missed update time!!!
updateRss();
} else {
rssUpdateTimer->start(secsUntilNextUpdate*1000ll);
}
}
}
void PodcastService::stopRssUpdateTimer()
{
if (rssUpdateTimer) {
rssUpdateTimer->stop();
}
}
void PodcastService::updateRss()
{
foreach (MusicLibraryItem *i, m_childItems) {
QUrl url=static_cast<MusicLibraryItemPodcast *>(i)->rssUrl();
updateUrls.insert(url);
if (!processingUrl(url)) {
addUrl(url, false);
}
}
}
void PodcastService::currentMpdSong(const Song &s)
{
if (s.isFromOnlineService() && s.album==constName) {
foreach (MusicLibraryItem *p, m_childItems) {
MusicLibraryItemPodcast *podcast=static_cast<MusicLibraryItemPodcast *>(p);
foreach (MusicLibraryItem *i, podcast->childItems()) {
MusicLibraryItemSong *song=static_cast<MusicLibraryItemSong *>(i);
if (song->file()==s.file || song->song().podcastLocalPath()==s.file) {
if (!song->song().hasBeenPlayed()) {
podcast->setPlayed(song);
emitDataChanged(createIndex(song));
emitDataChanged(createIndex(podcast));
podcast->save();
}
return;
}
}
}
}
}