/* * Cantata * * Copyright (c) 2011-2013 Craig Drummond * * ---- * * 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 #include #include #include #include #if QT_VERSION >= 0x050000 #include #endif #include 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() 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::ConstIterator it(prefixMap.constBegin()); QMap::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())) { 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->abort(); j->deleteLater(); } rssJobs.clear(); setBusy(!rssJobs.isEmpty() || !downloadJobs.isEmpty()); } void PodcastService::rssJobFinished() { NetworkJob *j=dynamic_cast(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(i); downloadEpisode(podcast, QUrl(song->file())); } } endInsertRows(); // emitNeedToSort(); } else { MusicLibraryItemPodcast *orig = getPodcast(j->url()); if (!orig) { delete podcast; return; } QSet origSongs; QSet newSongs; foreach (MusicLibraryItem *i, orig->childItems()) { MusicLibraryItemPodcastEpisode *episode=static_cast(i); origSongs.insert(episode->file()); } foreach (MusicLibraryItem *i, podcast->childItems()) { MusicLibraryItemSong *song=static_cast(i); newSongs.insert(song->file()); } QSet added=newSongs-origSongs; QSet 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 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(i)->rssUrl()==url) { return static_cast(i); } } return 0; } MusicLibraryItemPodcastEpisode * PodcastService::getEpisode(const MusicLibraryItemPodcast *podcast, const QUrl &episode) { if (podcast) { foreach (MusicLibraryItem *i, podcast->childItems()) { MusicLibraryItemPodcastEpisode *song=static_cast(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(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(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(QUrl(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 &episodes) { foreach (MusicLibraryItemPodcastEpisode *ep, episodes) { QUrl url(ep->file()); if (!downloadingEpisode(url)) { downloadEpisode(pod, url); } } } void PodcastService::deleteDownloadedPodcasts(MusicLibraryItemPodcast *pod, const QList &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(QUrl(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) { disconnect(job, SIGNAL(finished()), this, SLOT(downloadJobFinished())); disconnect(job, SIGNAL(readyRead()), this, SLOT(downloadReadyRead())); disconnect(job, SIGNAL(downloadPercent(int)), this, SLOT(downloadPercent(int))); job->abort(); job->deleteLater(); 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(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(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(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(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(p); foreach (MusicLibraryItem *i, podcast->childItems()) { MusicLibraryItemSong *song=static_cast(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; } } } } }