1 /*
2  * Cantata
3  *
4  * Copyright (c) 2011-2020 Craig Drummond <craig.p.drummond@gmail.com>
5  *
6  * ----
7  *
8  * This program is free software; you can redistribute it and/or modify
9  * it under the terms of the GNU General Public License as published by
10  * the Free Software Foundation; either version 2 of the License, or
11  * (at your option) any later version.
12  *
13  * This program is distributed in the hope that it will be useful,
14  * but WITHOUT ANY WARRANTY; without even the implied warranty of
15  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
16  * General Public License for more details.
17  *
18  * You should have received a copy of the GNU General Public License
19  * along with this program; see the file COPYING.  If not, write to
20  * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
21  * Boston, MA 02110-1301, USA.
22  */
23 
24 #include "podcastservice.h"
25 #include "podcastsettingsdialog.h"
26 #include "rssparser.h"
27 #include "support/globalstatic.h"
28 #include "support/utils.h"
29 #include "support/monoicon.h"
30 #include "gui/settings.h"
31 #include "widgets/icons.h"
32 #include "mpd-interface/mpdconnection.h"
33 #include "config.h"
34 #include "http/httpserver.h"
35 #include "qtiocompressor/qtiocompressor.h"
36 #include "network/networkaccessmanager.h"
37 #include "models/roles.h"
38 #include "models/playqueuemodel.h"
39 #include <QCoreApplication>
40 #include <QDir>
41 #include <QFile>
42 #include <QSet>
43 #include <QTimer>
44 #include <QXmlStreamReader>
45 #include <QXmlStreamWriter>
46 #include <QCryptographicHash>
47 #include <QMimeData>
48 #include <QDateTime>
49 #include <QTextDocument>
50 #include <stdio.h>
51 
GLOBAL_STATIC(PodcastService,instance)52 GLOBAL_STATIC(PodcastService, instance)
53 
54 static QString encodeName(const QString &name)
55 {
56     QString n=name;
57     n=n.replace("/", "_");
58     n=n.replace("\\", "_");
59     n=n.replace(":", "_");
60     return n;
61 }
62 
episodeFileName(const QUrl & url)63 static inline QString episodeFileName(const QUrl &url)
64 {
65     return url.path().split('/', QString::SkipEmptyParts).join('_').replace('~', '_');
66 }
67 
Proxy(QObject * parent)68 PodcastService::Proxy::Proxy(QObject *parent)
69     : ProxyModel(parent)
70     , unplayedOnly(false)
71 {
72     setDynamicSortFilter(true);
73     setFilterCaseSensitivity(Qt::CaseInsensitive);
74     setSortCaseSensitivity(Qt::CaseInsensitive);
75     setSortLocaleAware(true);
76 }
77 
showUnplayedOnly(bool on)78 void PodcastService::Proxy::showUnplayedOnly(bool on)
79 {
80     if (on!=unplayedOnly) {
81         unplayedOnly=on;
82         invalidateFilter();
83     }
84 }
85 
lessThan(const QModelIndex & left,const QModelIndex & right) const86 bool PodcastService::Proxy::lessThan(const QModelIndex &left, const QModelIndex &right) const
87 {
88     if (left.row()<0 || right.row()<0) {
89         return left.row()<0;
90     }
91 
92     if (!static_cast<Item *>(left.internalPointer())->isPodcast()) {
93         Episode *l=static_cast<Episode *>(left.internalPointer());
94         Episode *r=static_cast<Episode *>(right.internalPointer());
95 
96         if (l->publishedDate!=r->publishedDate) {
97             return l->publishedDate>r->publishedDate;
98         }
99     }
100 
101     return QSortFilterProxyModel::lessThan(left, right);
102 }
103 
filterAcceptsPodcast(const Podcast * pod) const104 bool PodcastService::Proxy::filterAcceptsPodcast(const Podcast *pod) const
105 {
106     for (const Episode *ep: pod->episodes) {
107         if (filterAcceptsEpisode(ep)) {
108             return true;
109         }
110     }
111 
112     return false;
113 }
114 
filterAcceptsEpisode(const Episode * item) const115 bool PodcastService::Proxy::filterAcceptsEpisode(const Episode *item) const
116 {
117     return (!unplayedOnly || (unplayedOnly && !item->played)) &&
118            matchesFilter(QStringList() << item->name << item->parent->name);
119 }
120 
filterAcceptsRow(int sourceRow,const QModelIndex & sourceParent) const121 bool PodcastService::Proxy::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
122 {
123     if (!filterEnabled && !unplayedOnly) {
124         return true;
125     }
126 
127     const QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent);
128     const PodcastService::Item *item = static_cast<const PodcastService::Item *>(index.internalPointer());
129 
130     if (filterStrings.isEmpty() && !unplayedOnly) {
131         return true;
132     }
133 
134     if (item->isPodcast()) {
135         return filterAcceptsPodcast(static_cast<const Podcast *>(item));
136     }
137     return filterAcceptsEpisode(static_cast<const Episode *>(item));
138 }
139 
140 const QLatin1String PodcastService::constName("podcasts");
141 static const QLatin1String constExt(".xml.gz");
142 static const char * constNewFeedProperty="new-feed";
143 static const char * constRssUrlProperty="rss-url";
144 static const char * constDestProperty="dest";
145 static const QLatin1String constPartialExt(".partial");
146 
generateFileName(const QUrl & url,bool creatingNew)147 static QString generateFileName(const QUrl &url, bool creatingNew)
148 {
149     QString hash=QCryptographicHash::hash(url.toString().toUtf8(), QCryptographicHash::Md5).toHex();
150     QString dir=Utils::dataDir(PodcastService::constName, true);
151     QString fileName=dir+hash+constExt;
152 
153     if (creatingNew) {
154         int i=0;
155         while (QFile::exists(fileName) && i<100) {
156             fileName=dir+hash+QChar('_')+QString::number(i)+constExt;
157             i++;
158         }
159     }
160 
161     return fileName;
162 }
163 
isPodcastFile(const QString & file)164 bool PodcastService::isPodcastFile(const QString &file)
165 {
166     if (file.startsWith(Utils::constDirSep) && MPDConnection::self()->getDetails().isLocal()) {
167         QString downloadPath=Settings::self()->podcastDownloadPath();
168         if (downloadPath.isEmpty()) {
169             return false;
170         }
171         return file.startsWith(downloadPath);
172     }
173     return false;
174 }
175 
fixUrl(const QString & url)176 QUrl PodcastService::fixUrl(const QString &url)
177 {
178     QString trimmed(url.trimmed());
179 
180     // Thanks gpodder!
181     static QMap<QString, QString> prefixMap;
182     if (prefixMap.isEmpty()) {
183         prefixMap.insert(QLatin1String("fb:"),    QLatin1String("http://feeds.feedburner.com/%1"));
184         prefixMap.insert(QLatin1String("yt:"),    QLatin1String("http://www.youtube.com/rss/user/%1/videos.rss"));
185         prefixMap.insert(QLatin1String("sc:"),    QLatin1String("http://soundcloud.com/%1"));
186         prefixMap.insert(QLatin1String("fm4od:"), QLatin1String("http://onapp1.orf.at/webcam/fm4/fod/%1.xspf"));
187         prefixMap.insert(QLatin1String("ytpl:"),  QLatin1String("http://gdata.youtube.com/feeds/api/playlists/%1"));
188     }
189 
190     QMap<QString, QString>::ConstIterator it(prefixMap.constBegin());
191     QMap<QString, QString>::ConstIterator end(prefixMap.constEnd());
192     for (; it!=end; ++it) {
193         if (trimmed.startsWith(it.key())) {
194             trimmed=it.value().arg(trimmed.mid(it.key().length()));
195         }
196     }
197 
198     if (!trimmed.contains(QLatin1String("://"))) {
199         trimmed.prepend(QLatin1String("http://"));
200     }
201 
202     return fixUrl(QUrl(trimmed));
203 }
204 
fixUrl(const QUrl & orig)205 QUrl PodcastService::fixUrl(const QUrl &orig)
206 {
207     QUrl u=orig;
208     if (u.scheme().isEmpty() || QLatin1String("itpc")==u.scheme() || QLatin1String("pcast")==u.scheme() ||
209         QLatin1String("feed")==u.scheme() || QLatin1String("itms")==u.scheme()) {
210         u.setScheme(QLatin1String("http"));
211     }
212     return u;
213 }
214 
toSong() const215 Song PodcastService::Episode::toSong() const
216 {
217     Song song;
218     song.title=name;
219     song.file=url.toString();
220     song.album=parent->name;
221     song.time=duration;
222     song.setExtraField(Song::OnlineImageUrl, parent->imageUrl.toString());
223     song.setExtraField(Song::OnlineImageCacheName, parent->imageFile);
224     if (!localFile.isEmpty()) {
225         song.setLocalPath(localFile);
226     }
227     return song;
228 }
229 
Podcast(const QString & f)230 PodcastService::Podcast::Podcast(const QString &f)
231     : unplayedCount(0)
232     , fileName(f)
233     , imageFile(f)
234 {
235     imageFile=imageFile.replace(constExt, ".jpg");
236 }
237 
238 static QLatin1String constTopTag("podcast");
239 static QLatin1String constImageAttribute("img");
240 static QLatin1String constRssAttribute("rss");
241 static QLatin1String constEpisodeTag("episode");
242 static QLatin1String constNameAttribute("name");
243 static QLatin1String constDescrAttribute("descr");
244 static QLatin1String constDateAttribute("date");
245 static QLatin1String constUrlAttribute("url");
246 static QLatin1String constTimeAttribute("time");
247 static QLatin1String constPlayedAttribute("played");
248 static QLatin1String constLocalAttribute("local");
249 static QLatin1String constTrue("true");
250 
load()251 bool PodcastService::Podcast::load()
252 {
253     if (fileName.isEmpty()) {
254         return false;
255     }
256 
257     QFile file(fileName);
258     QtIOCompressor compressor(&file);
259     compressor.setStreamFormat(QtIOCompressor::GzipFormat);
260     if (!compressor.open(QIODevice::ReadOnly)) {
261         return false;
262     }
263 
264     QXmlStreamReader reader(&compressor);
265     unplayedCount=0;
266 
267     QString podPath=Settings::self()->podcastDownloadPath();
268     while (!reader.atEnd()) {
269         reader.readNext();
270         if (!reader.error() && reader.isStartElement()) {
271             QString element = reader.name().toString();
272             QXmlStreamAttributes attributes=reader.attributes();
273 
274             if (constTopTag == element) {
275                 imageUrl=attributes.value(constImageAttribute).toString();
276                 url=attributes.value(constRssAttribute).toString();
277                 name=attributes.value(constNameAttribute).toString();
278                 descr=attributes.value(constDescrAttribute).toString();
279                 if (url.isEmpty() || name.isEmpty()) {
280                     return false;
281                 }
282                 if (!podPath.isEmpty()) {
283                     podPath=Utils::fixPath(podPath)+Utils::fixPath(encodeName(name));
284                 }
285             } else if (constEpisodeTag == element) {
286                 QString epName=attributes.value(constNameAttribute).toString();
287                 QString epUrl=attributes.value(constUrlAttribute).toString();
288                 if (!epName.isEmpty() && !epUrl.isEmpty()) {
289                     Episode *ep=new Episode(QDateTime::fromString(attributes.value(constDateAttribute).toString(), Qt::ISODate), epName, epUrl, this);
290                     QString localFile=attributes.value(constLocalAttribute).toString();
291                     QString time=attributes.value(constTimeAttribute).toString();
292 
293                     ep->duration=time.isEmpty() ? 0 : time.toUInt();
294                     ep->played=constTrue==attributes.value(constPlayedAttribute).toString();
295                     ep->descr=attributes.value(constDescrAttribute).toString();
296                     if (!localFile.isEmpty() && QFile::exists(localFile)) {
297                         ep->localFile=localFile;
298                     } else if (!podPath.isEmpty()) {
299                         QString localPath=podPath+episodeFileName(ep->url);
300                         if (QFile::exists(localPath)) {
301                             ep->localFile = localPath;
302                         }
303                     }
304 
305                     episodes.append(ep);
306                     if (!ep->played) {
307                         unplayedCount++;
308                     }
309                 }
310             }
311         }
312     }
313 
314     return true;
315 }
316 
save() const317 bool PodcastService::Podcast::save() const
318 {
319     if (fileName.isEmpty()) {
320         return false;
321     }
322 
323     QFile file(fileName);
324     QtIOCompressor compressor(&file);
325     compressor.setStreamFormat(QtIOCompressor::GzipFormat);
326     if (!compressor.open(QIODevice::WriteOnly)) {
327         return false;
328     }
329 
330     QXmlStreamWriter writer(&compressor);
331     writer.writeStartElement(constTopTag);
332     writer.writeAttribute(constImageAttribute, imageUrl.toString()); // ??
333     writer.writeAttribute(constRssAttribute, url.toString()); // ??
334     writer.writeAttribute(constNameAttribute, name);
335     writer.writeAttribute(constDescrAttribute, descr);
336     for (Episode *ep: episodes) {
337         writer.writeStartElement(constEpisodeTag);
338         writer.writeAttribute(constNameAttribute, ep->name);
339         writer.writeAttribute(constDescrAttribute, ep->descr);
340         writer.writeAttribute(constUrlAttribute, ep->url.toString()); // ??
341         if (ep->duration) {
342             writer.writeAttribute(constTimeAttribute, QString::number(ep->duration));
343         }
344         if (ep->played) {
345             writer.writeAttribute(constPlayedAttribute, constTrue);
346         }
347         if (ep->publishedDate.isValid()) {
348             writer.writeAttribute(constDateAttribute, ep->publishedDate.toString(Qt::ISODate));
349         }
350         if (!ep->localFile.isEmpty()) {
351             writer.writeAttribute(constLocalAttribute, ep->localFile);
352         }
353         writer.writeEndElement();
354     }
355     writer.writeEndElement();
356     compressor.close();
357     return true;
358 }
359 
add(Episode * ep)360 void PodcastService::Podcast::add(Episode *ep)
361 {
362     ep->parent=this;
363     episodes.append(ep);
364 }
365 
add(QList<Episode * > & eps)366 void PodcastService::Podcast::add(QList<Episode *> &eps)
367 {
368     for (Episode *ep: eps) {
369         add(ep);
370     }
371     setUnplayedCount();
372 }
373 
getEpisode(const QUrl & epUrl) const374 PodcastService::Episode * PodcastService::Podcast::getEpisode(const QUrl &epUrl) const
375 {
376     for (Episode *episode: episodes) {
377         if (episode->url==epUrl) {
378             return episode;
379         }
380     }
381     return nullptr;
382 }
383 
setUnplayedCount()384 void PodcastService::Podcast::setUnplayedCount()
385 {
386     unplayedCount=episodes.count();
387     for (Episode *episode: episodes) {
388         if (episode->played) {
389             unplayedCount--;
390         }
391     }
392 }
393 
removeFiles()394 void PodcastService::Podcast::removeFiles()
395 {
396     if (!fileName.isEmpty() && QFile::exists(fileName)) {
397         QFile::remove(fileName);
398     }
399     if (!imageFile.isEmpty() && QFile::exists(imageFile)) {
400         QFile::remove(imageFile);
401     }
402 }
403 
coverSong()404 const Song & PodcastService::Podcast::coverSong()
405 {
406     if (song.isEmpty()) {
407         song.artist=constName;
408         song.album=name;
409         song.title=name;
410         song.type=Song::OnlineSvrTrack;
411         song.setIsFromOnlineService(constName);
412         song.setExtraField(Song::OnlineImageUrl, imageUrl.toString());
413         song.setExtraField(Song::OnlineImageCacheName, imageFile);
414         song.file=url.toString();
415     }
416     return song;
417 }
418 
PodcastService()419 PodcastService::PodcastService()
420     : ActionModel(nullptr)
421     , downloadJob(nullptr)
422     , rssUpdateTimer(nullptr)
423 {
424     QMetaObject::invokeMethod(this, "loadAll", Qt::QueuedConnection);
425     icn=MonoIcon::icon(FontAwesome::rsssquare, Utils::monoIconColor());
426     useCovers(name(), true);
427     clearPartialDownloads();
428     connect(MPDConnection::self(), SIGNAL(currentSongUpdated(const Song &)), this, SLOT(currentMpdSong(const Song &)));
429 }
430 
name() const431 QString PodcastService::name() const
432 {
433     return constName;
434 }
435 
title() const436 QString PodcastService::title() const
437 {
438     return QLatin1String("Podcasts");
439 }
440 
descr() const441 QString PodcastService::descr() const
442 {
443     return tr("Subscribe to RSS feeds");
444 }
445 
episodeCover(const Song & s,QImage & img,QString & imgFilename) const446 bool PodcastService::episodeCover(const Song &s, QImage &img, QString &imgFilename) const
447 {
448     if ((s.isFromOnlineService() && s.onlineService()==constName) || isPodcastFile(s.file)) {
449         QString path=s.decodedPath();
450         if (path.isEmpty()) {
451             path=s.file;
452         }
453         for (Podcast *podcast: podcasts) {
454             for (Episode *episode: podcast->episodes) {
455                 if (episode->url==path || episode->localFile==path) {
456                     imgFilename = podcast->imageFile;
457                     img = QImage(imgFilename);
458                     return true;
459                 }
460             }
461         }
462     }
463     return false;
464 }
465 
episodeDescr(const Song & s) const466 QString PodcastService::episodeDescr(const Song &s) const
467 {
468     if ((s.isFromOnlineService() && s.onlineService()==constName) || isPodcastFile(s.file)) {
469         QString path=s.decodedPath();
470         if (path.isEmpty()) {
471             path=s.file;
472         }
473         for (Podcast *podcast: podcasts) {
474             for (Episode *episode: podcast->episodes) {
475                 if (episode->url==path || episode->localFile==path) {
476                     return episode->descr.replace("<p><br></p>", "");
477                 }
478             }
479         }
480     }
481     return QString();
482 }
483 
rowCount(const QModelIndex & index) const484 int PodcastService::rowCount(const QModelIndex &index) const
485 {
486     if (index.column()>0) {
487         return 0;
488     }
489 
490     if (!index.isValid()) {
491         return podcasts.size();
492     }
493 
494     Item *item=static_cast<Item *>(index.internalPointer());
495     if (item->isPodcast()) {
496         return static_cast<Podcast *>(index.internalPointer())->episodes.count();
497     }
498     return 0;
499 }
500 
hasChildren(const QModelIndex & parent) const501 bool PodcastService::hasChildren(const QModelIndex &parent) const
502 {
503     return !parent.isValid() || static_cast<Item *>(parent.internalPointer())->isPodcast();
504 }
505 
parent(const QModelIndex & index) const506 QModelIndex PodcastService::parent(const QModelIndex &index) const
507 {
508     if (!index.isValid()) {
509         return QModelIndex();
510     }
511 
512     Item *item=static_cast<Item *>(index.internalPointer());
513 
514     if (item->isPodcast()) {
515         return QModelIndex();
516     } else {
517         Episode *episode=static_cast<Episode *>(item);
518 
519         if (episode->parent) {
520             return createIndex(podcasts.indexOf(episode->parent), 0, episode->parent);
521         }
522     }
523 
524     return QModelIndex();
525 }
526 
index(int row,int col,const QModelIndex & parent) const527 QModelIndex PodcastService::index(int row, int col, const QModelIndex &parent) const
528 {
529     if (!hasIndex(row, col, parent)) {
530         return QModelIndex();
531     }
532 
533     if (parent.isValid()) {
534         Item *p=static_cast<Item *>(parent.internalPointer());
535 
536         if (p->isPodcast()) {
537             Podcast *podcast=static_cast<Podcast *>(p);
538             return row<podcast->episodes.count() ? createIndex(row, col, podcast->episodes.at(row)) : QModelIndex();
539         }
540     }
541 
542     return row<podcasts.count() ? createIndex(row, col, podcasts.at(row)) : QModelIndex();
543 }
544 
trimDescr(QString descr,int limit=1000)545 static QString trimDescr(QString descr, int limit=1000)
546 {
547     if (!descr.isEmpty()) {
548         QTextDocument doc;
549         doc.setHtml(descr);
550         descr=doc.toPlainText().simplified();
551         if (descr.length()>limit) {
552             descr=descr.left(limit)+QLatin1String("...");
553         }
554         descr=QLatin1String("<br/>")+descr+QLatin1String("<br/><br/>");
555     }
556     return descr;
557 }
558 
data(const QModelIndex & index,int role) const559 QVariant PodcastService::data(const QModelIndex &index, int role) const
560 {
561     if (!index.isValid()) {
562         switch (role) {
563         case Cantata::Role_TitleText:
564             return title();
565         case Cantata::Role_SubText:
566             return tr("%n Podcast(s)", "", podcasts.count());
567         case Qt::DecorationRole:
568             return icon();
569         }
570         return QVariant();
571     }
572 
573     Item *item=static_cast<Item *>(index.internalPointer());
574 
575     if (item->isPodcast()) {
576         Podcast *podcast=static_cast<Podcast *>(item);
577 
578         switch(role) {
579         case Cantata::Role_ListImage:
580             return true;
581         case Cantata::Role_CoverSong: {
582             QVariant v;
583             v.setValue<Song>(podcast->coverSong());
584             return v;
585         }
586         case Qt::DecorationRole:
587             return Icons::self()->podcastIcon;
588         case Cantata::Role_LoadCoverInUIThread:
589             return true;
590         case Cantata::Role_MainText:
591         case Qt::DisplayRole:
592             return tr("%1 (%2)", "podcast name (num unplayed episodes)").arg(podcast->name).arg(podcast->unplayedCount);
593         case Cantata::Role_SubText:
594             return tr("%n Episode(s)", "", podcast->episodes.count());
595         case Qt::ToolTipRole:
596             if (Settings::self()->infoTooltips()) {
597                 return podcast->name+QLatin1String("<br/>")+
598                        trimDescr(podcast->descr)+
599                        tr("%n Episode(s)", "", podcast->episodes.count());
600             }
601             break;
602         case Qt::FontRole:
603             if (podcast->unplayedCount>0) {
604                 QFont f;
605                 f.setBold(true);
606                 return f;
607             }
608         default:
609             break;
610         }
611     } else {
612         Episode *episode=static_cast<Episode *>(item);
613 
614         switch (role) {
615         case Qt::DecorationRole:
616             if (!episode->localFile.isEmpty()) {
617                 return Icons::self()->savedRssListIcon;
618             } else if (episode->downloadProg>=0) {
619                 return Icons::self()->downloadIcon;
620             } else if (Episode::QueuedForDownload==episode->downloadProg) {
621                 return Icons::self()->clockIcon;
622             } else {
623                 return Icons::self()->rssListIcon;
624             }
625         case Cantata::Role_MainText:
626         case Qt::DisplayRole:
627             return episode->name;
628         case Cantata::Role_SubText:
629             if (episode->downloadProg>=0) {
630                 return Utils::formatTime(episode->duration, true)+QLatin1Char(' ')+
631                        tr("(Downloading: %1%)").arg(episode->downloadProg);
632             }
633             return episode->publishedDate.toString(Qt::LocalDate)+
634                         (0==episode->duration
635                             ? QString()
636                             : (QLatin1String(" (")+Utils::formatTime(episode->duration, true)+QLatin1Char(')')));
637         case Qt::ToolTipRole:
638             if (Settings::self()->infoTooltips()) {
639                 return QLatin1String("<b>")+episode->parent->name+QLatin1String("</b><br/>")+
640                         episode->name+QLatin1String("<br/>")+
641                         trimDescr(episode->descr)+
642                         Utils::formatTime(episode->duration, true)+QLatin1String("<br/>")+
643                         episode->publishedDate.toString(Qt::LocalDate);
644             }
645             break;
646         case Qt::FontRole:
647             if (!episode->played) {
648                 QFont f;
649                 f.setBold(true);
650                 return f;
651             }
652         default:
653             break;
654         }
655     }
656 
657     return ActionModel::data(index, role);
658 }
659 
flags(const QModelIndex & index) const660 Qt::ItemFlags PodcastService::flags(const QModelIndex &index) const
661 {
662     if (index.isValid()) {
663         return Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsDragEnabled;
664     }
665     return Qt::NoItemFlags;
666 }
667 
mimeData(const QModelIndexList & indexes) const668 QMimeData * PodcastService::mimeData(const QModelIndexList &indexes) const
669 {
670     QMimeData *mimeData=nullptr;
671     QStringList paths=filenames(indexes);
672 
673     if (!paths.isEmpty()) {
674         mimeData=new QMimeData();
675         PlayQueueModel::encode(*mimeData, PlayQueueModel::constUriMimeType, paths);
676     }
677     return mimeData;
678 }
679 
filenames(const QModelIndexList & indexes,bool allowPlaylists) const680 QStringList PodcastService::filenames(const QModelIndexList &indexes, bool allowPlaylists) const
681 {
682     Q_UNUSED(allowPlaylists)
683     QList<Song> songList=songs(indexes);
684     QStringList files;
685     for (const Song &song: songList) {
686         files.append(song.file);
687     }
688     return files;
689 }
690 
songs(const QModelIndexList & indexes,bool allowPlaylists) const691 QList<Song> PodcastService::songs(const QModelIndexList &indexes, bool allowPlaylists) const
692 {
693     Q_UNUSED(allowPlaylists)
694     QList<Song> songs;
695     QSet<Item *> selectedPodcasts;
696 
697     for (const QModelIndex &idx: indexes) {
698         Item *item=static_cast<Item *>(idx.internalPointer());
699         if (item->isPodcast()) {
700             Podcast *podcast=static_cast<Podcast *>(item);
701             for (const Episode *episode: podcast->episodes) {
702                 selectedPodcasts.insert(item);
703                 Song s=episode->toSong();
704                 songs.append(fixPath(s));
705             }
706         }
707     }
708     for (const QModelIndex &idx: indexes) {
709         Item *item=static_cast<Item *>(idx.internalPointer());
710         if (!item->isPodcast()) {
711             Episode *episode=static_cast<Episode *>(item);
712             if (!selectedPodcasts.contains(episode->parent)) {
713                 Song s=episode->toSong();
714                 songs.append(fixPath(s));
715             }
716         }
717     }
718     return songs;
719 }
720 
fixPath(Song & song) const721 Song & PodcastService::fixPath(Song &song) const
722 {
723     song.setIsFromOnlineService(constName);
724     song.artist=title();
725     if (!song.localPath().isEmpty() && QFile::exists(song.localPath())) {
726         if (HttpServer::self()->isAlive()) {
727             song.file=song.localPath();
728             song.file=HttpServer::self()->encodeUrl(song);
729         } else if (MPDConnection::self()->localFilePlaybackSupported()) {
730             song.file=QLatin1String("file://")+song.localPath();
731         }
732         return song;
733     }
734     return encode(song);
735 }
736 
clear()737 void PodcastService::clear()
738 {
739     cancelAllJobs();
740     beginResetModel();
741     qDeleteAll(podcasts);
742     podcasts.clear();
743     endResetModel();
744     emit dataChanged(QModelIndex(), QModelIndex());
745 }
746 
loadAll()747 void PodcastService::loadAll()
748 {
749     beginResetModel();
750     QString dir=Utils::dataDir(constName);
751 
752     if (!dir.isEmpty()) {
753         QDir d(dir);
754         QStringList entries=d.entryList(QStringList() << "*"+constExt, QDir::Files|QDir::Readable|QDir::NoDot|QDir::NoDotDot);
755         for (const QString &e: entries) {
756             Podcast *podcast=new Podcast(dir+e);
757             if (podcast->load()) {
758                 podcasts.append(podcast);
759             } else {
760                 delete podcast;
761             }
762         }
763         startRssUpdateTimer();
764     }
765     endResetModel();
766     emit dataChanged(QModelIndex(), QModelIndex());
767 }
768 
cancelAll()769 void PodcastService::cancelAll()
770 {
771     for (NetworkJob *j: rssJobs) {
772         disconnect(j, SIGNAL(finished()), this, SLOT(rssJobFinished()));
773         j->cancelAndDelete();
774     }
775     rssJobs.clear();
776     cancelAllDownloads();
777 }
778 
rssJobFinished()779 void PodcastService::rssJobFinished()
780 {
781     NetworkJob *j=dynamic_cast<NetworkJob *>(sender());
782     if (!j || !rssJobs.contains(j)) {
783         return;
784     }
785 
786     j->deleteLater();
787     rssJobs.removeAll(j);
788     bool isNew=j->property(constNewFeedProperty).toBool();
789 
790     if (j->ok()) {
791         if (updateUrls.contains(j->origUrl())){
792             updateUrls.remove(j->origUrl());
793             if (updateUrls.isEmpty()) {
794                 lastRssUpdate=QDateTime::currentDateTime();
795                 Settings::self()->saveLastRssUpdate(lastRssUpdate);
796                 startRssUpdateTimer();
797             }
798         }
799 
800         RssParser::Channel ch=RssParser::parse(j->actualJob());
801 
802         if (!ch.isValid()) {
803             if (isNew) {
804                 emit newError(tr("Failed to parse %1").arg(j->origUrl().toString()));
805             } else {
806                 emit error(tr("Failed to parse %1").arg(j->origUrl().toString()));
807             }
808             return;
809         }
810 
811         if (ch.video) {
812             if (isNew) {
813                 emit newError(tr("Cantata only supports audio podcasts! %1 contains only video podcasts.").arg(j->origUrl().toString()));
814             } else {
815                 emit error(tr("Cantata only supports audio podcasts! %1 contains only video podcasts.").arg(j->origUrl().toString()));
816             }
817             return;
818         }
819 
820         int autoDownload=Settings::self()->podcastAutoDownloadLimit();
821 
822         if (isNew) {
823             Podcast *podcast=new Podcast();
824             podcast->url=j->origUrl();
825             podcast->fileName=podcast->imageFile=generateFileName(podcast->url, true);
826             podcast->imageFile=podcast->imageFile.replace(constExt, ".jpg");
827             podcast->imageUrl=ch.image.toString();
828             podcast->name=ch.name;
829             podcast->descr=ch.description;
830             podcast->unplayedCount=ch.episodes.count();
831 
832             QString podPath=Settings::self()->podcastDownloadPath();
833             if (!podPath.isEmpty()) {
834                 podPath=Utils::fixPath(podPath)+Utils::fixPath(encodeName(podcast->name));
835             }
836 
837             for (const RssParser::Episode &ep: ch.episodes) {
838                 Episode *episode=new Episode(ep.publicationDate, ep.name, ep.url, podcast);
839                 episode->duration=ep.duration;
840                 episode->descr=ep.description;
841                 if (!podPath.isEmpty()) {
842                     // Check if we had subscribed to this before, and downloaded episodes...
843                     QString localPath=podPath+episodeFileName(episode->url);
844                     if (QFile::exists(localPath)) {
845                         episode->localFile = localPath;
846                     }
847                 }
848                 podcast->add(episode);
849             }
850             podcast->save();
851             beginInsertRows(QModelIndex(), podcasts.count(), podcasts.count());
852             podcasts.append(podcast);
853             emit dataChanged(QModelIndex(), QModelIndex());
854             if (autoDownload) {
855                 int ep=0;
856                 for (Episode *episode: podcast->episodes) {
857                     downloadEpisode(podcast, QUrl(episode->url));
858                     if (autoDownload<1000 && ++ep>=autoDownload) {
859                         break;
860                     }
861                 }
862             }
863             endInsertRows();
864         } else {
865             Podcast *podcast = getPodcast(j->origUrl());
866             if (!podcast) {
867                 return;
868             }
869             QSet<QUrl> newEpisodes;
870             QSet<QUrl> oldEpisodes;
871             for (Episode *episode: podcast->episodes) {
872                 newEpisodes.insert(episode->url);
873             }
874             for (const RssParser::Episode &ep: ch.episodes) {
875                 oldEpisodes.insert(ep.url);
876             }
877 
878             QSet<QUrl> added=oldEpisodes-newEpisodes;
879             QSet<QUrl> removed=newEpisodes-oldEpisodes;
880             if (added.count() || removed.count()) {
881                 QModelIndex podcastIndex=createIndex(podcasts.indexOf(podcast), 0, (void *)podcast);
882                 if (removed.count()) {
883                     for (const QUrl &s: removed) {
884                         Episode *episode=podcast->getEpisode(s);
885                         if (episode->localFile.isEmpty() || !QFile::exists(episode->localFile)) {
886                             int idx=podcast->episodes.indexOf(episode);
887                             if (-1!=idx) {
888                                 beginRemoveRows(podcastIndex, idx, idx);
889                                 podcast->episodes.removeAt(idx);
890                                 delete episode;
891                                 endRemoveRows();
892                             }
893                         }
894                     }
895                 }
896                 if (added.count()) {
897                     beginInsertRows(podcastIndex, podcast->episodes.count(), (podcast->episodes.count()+added.count())-1);
898 
899                     for (const RssParser::Episode &ep: ch.episodes) {
900                         QString epUrl=ep.url.toString();
901                         if (added.contains(epUrl)) {
902                             Episode *episode=new Episode(ep.publicationDate, ep.name, ep.url, podcast);
903                             episode->duration=ep.duration;
904                             episode->descr=ep.description;
905                             podcast->add(episode);
906                         }
907                     }
908                     endInsertRows();
909                 }
910 
911                 podcast->setUnplayedCount();
912                 podcast->save();
913                 emit dataChanged(podcastIndex, podcastIndex);
914             }
915         }
916     } else {
917         if (isNew) {
918             emit newError(tr("Failed to download %1").arg(j->origUrl().toString()));
919         } else {
920             emit error(tr("Failed to download %1").arg(j->origUrl().toString()));
921         }
922     }
923 }
924 
configure(QWidget * p)925 void PodcastService::configure(QWidget *p)
926 {
927     PodcastSettingsDialog dlg(p);
928     if (QDialog::Accepted==dlg.exec()) {
929         int changes=dlg.changes();
930         if (changes&PodcastSettingsDialog::RssUpdate) {
931             startRssUpdateTimer();
932         }
933     }
934 }
935 
getPodcast(const QUrl & url) const936 PodcastService::Podcast * PodcastService::getPodcast(const QUrl &url) const
937 {
938     for (Podcast *podcast: podcasts) {
939         if (podcast->url==url) {
940             return podcast;
941         }
942     }
943     return nullptr;
944 }
945 
unSubscribe(Podcast * podcast)946 void PodcastService::unSubscribe(Podcast *podcast)
947 {
948     int row=podcasts.indexOf(podcast);
949     if (row>=0) {
950         QList<Episode *> episodes;
951         for (Episode *e: podcast->episodes) {
952             episodes.append(e);
953         }
954         cancelDownloads(episodes);
955         beginRemoveRows(QModelIndex(), row, row);
956         podcast->removeFiles();
957         delete podcasts.takeAt(row);
958         endRemoveRows();
959         emit dataChanged(QModelIndex(), QModelIndex());
960         if (podcasts.isEmpty()) {
961             stopRssUpdateTimer();
962         }
963     }
964 }
965 
refresh(const QModelIndexList & list)966 void PodcastService::refresh(const QModelIndexList &list)
967 {
968     for (const QModelIndex &idx: list) {
969         Item *itm=static_cast<Item *>(idx.internalPointer());
970         if (itm->isPodcast()) {
971             refreshSubscription(static_cast<Podcast *>(itm));
972         }
973     }
974 }
975 
refreshAll()976 void PodcastService::refreshAll()
977 {
978     for (Podcast *pod: podcasts) {
979         refreshSubscription(pod);
980     }
981 }
982 
refreshSubscription(Podcast * item)983 void PodcastService::refreshSubscription(Podcast *item)
984 {
985     if (item) {
986         QUrl url=item->url;
987         if (processingUrl(url)) {
988             return;
989         }
990         addUrl(url, false);
991     } else {
992         updateRss();
993     }
994 }
995 
processingUrl(const QUrl & url) const996 bool PodcastService::processingUrl(const QUrl &url) const
997 {
998     for (NetworkJob *j: rssJobs) {
999         if (j->origUrl()==url) {
1000             return true;
1001         }
1002     }
1003     return false;
1004 }
1005 
addUrl(const QUrl & url,bool isNew)1006 void PodcastService::addUrl(const QUrl &url, bool isNew)
1007 {
1008     NetworkJob *job=NetworkAccessManager::self()->get(url);
1009     connect(job, SIGNAL(finished()), this, SLOT(rssJobFinished()));
1010     job->setProperty(constNewFeedProperty, isNew);
1011     rssJobs.append(job);
1012 }
1013 
downloadingEpisode(const QUrl & url) const1014 bool PodcastService::downloadingEpisode(const QUrl &url) const
1015 {
1016     if (downloadJob && downloadJob->origUrl()==url) {
1017         return true;
1018     }
1019     return toDownload.contains(url);
1020 }
1021 
cancelAllDownloads()1022 void PodcastService::cancelAllDownloads()
1023 {
1024     for (const DownloadEntry &e: toDownload) {
1025         updateEpisode(e.rssUrl, e.url, Episode::NotDownloading);
1026     }
1027 
1028     toDownload.clear();
1029     cancelDownload();
1030 }
1031 
downloadPodcasts(Podcast * pod,const QList<Episode * > & episodes)1032 void PodcastService::downloadPodcasts(Podcast *pod, const QList<Episode *> &episodes)
1033 {
1034     for (Episode *ep: episodes) {
1035         downloadEpisode(pod, QUrl(ep->url));
1036     }
1037 }
1038 
deleteDownloadedPodcasts(Podcast * pod,const QList<Episode * > & episodes)1039 void PodcastService::deleteDownloadedPodcasts(Podcast *pod, const QList<Episode *> &episodes)
1040 {
1041     cancelDownloads(episodes);
1042     bool modified=false;
1043     for (Episode *ep: episodes) {
1044         QString fileName=ep->localFile;
1045         if (!fileName.isEmpty()) {
1046             if (QFile::exists(fileName)) {
1047                 QFile::remove(fileName);
1048             }
1049             QString dirName=fileName.isEmpty() ? QString() : Utils::getDir(fileName);
1050             if (!dirName.isEmpty()) {
1051                 QDir dir(dirName);
1052                 if (dir.exists()) {
1053                     dir.rmdir(dirName);
1054                 }
1055             }
1056             ep->localFile=QString();
1057             ep->downloadProg=Episode::NotDownloading;
1058             QModelIndex idx=createIndex(pod->episodes.indexOf(ep), 0, (void *)ep);
1059             emit dataChanged(idx, idx);
1060             modified=true;
1061         }
1062     }
1063     if (modified) {
1064         QModelIndex idx=createIndex(podcasts.indexOf(pod), 0, (void *)pod);
1065         emit dataChanged(idx, idx);
1066         pod->save();
1067     }
1068 }
1069 
setPodcastsAsListened(Podcast * pod,const QList<Episode * > & episodes,bool listened)1070 void PodcastService::setPodcastsAsListened(Podcast *pod, const QList<Episode *> &episodes, bool listened)
1071 {
1072     bool modified=false;
1073     for (Episode *ep: episodes) {
1074         if (listened!=ep->played) {
1075             ep->played=listened;
1076             QModelIndex idx=createIndex(pod->episodes.indexOf(ep), 0, (void *)ep);
1077             emit dataChanged(idx, idx);
1078             modified=true;
1079             if (listened) {
1080                 pod->unplayedCount--;
1081             } else {
1082                 pod->unplayedCount++;
1083             }
1084         }
1085     }
1086     if (modified) {
1087         QModelIndex idx=createIndex(podcasts.indexOf(pod), 0, (void *)pod);
1088         emit dataChanged(idx, idx);
1089         pod->save();
1090     }
1091 }
1092 
downloadEpisode(const Podcast * podcast,const QUrl & episode)1093 void PodcastService::downloadEpisode(const Podcast *podcast, const QUrl &episode)
1094 {
1095     QString dest=Settings::self()->podcastDownloadPath();
1096     if (dest.isEmpty()) {
1097         return;
1098     }
1099     if (downloadingEpisode(episode)) {
1100         return;
1101     }
1102     dest=Utils::fixPath(dest)+Utils::fixPath(encodeName(podcast->name))+episodeFileName(episode);
1103     toDownload.append(DownloadEntry(episode, podcast->url, dest));
1104     updateEpisode(podcast->url, episode, Episode::QueuedForDownload);
1105     doNextDownload();
1106 }
1107 
cancelDownloads(const QList<Episode * > episodes)1108 void PodcastService::cancelDownloads(const QList<Episode *> episodes)
1109 {
1110     bool cancelDl=false;
1111     for (Episode *e: episodes) {
1112         toDownload.removeAll(e->url);
1113         e->downloadProg=Episode::NotDownloading;
1114         QModelIndex idx=createIndex(e->parent->episodes.indexOf(e), 0, (void *)e);
1115         emit dataChanged(idx, idx);
1116         if (!cancelDl && downloadJob && downloadJob->url()==e->url) {
1117             cancelDl=true;
1118         }
1119     }
1120     if (cancelDl) {
1121         cancelDownload();
1122     }
1123 }
1124 
cancelDownload(const QUrl & url)1125 void PodcastService::cancelDownload(const QUrl &url)
1126 {
1127     if (downloadJob && downloadJob->origUrl()==url) {
1128         cancelDownload();
1129         doNextDownload();
1130     }
1131 }
1132 
cancelDownload()1133 void PodcastService::cancelDownload()
1134 {
1135     if (downloadJob) {
1136         downloadJob->cancelAndDelete();
1137         disconnect(downloadJob, SIGNAL(finished()), this, SLOT(downloadJobFinished()));
1138         disconnect(downloadJob, SIGNAL(readyRead()), this, SLOT(downloadReadyRead()));
1139         disconnect(downloadJob, SIGNAL(downloadPercent(int)), this, SLOT(downloadPercent(int)));
1140 
1141         QString dest=downloadJob->property(constDestProperty).toString();
1142         QString partial=dest.isEmpty() ? QString() : QString(dest+constPartialExt);
1143         if (!partial.isEmpty() && QFile::exists(partial)) {
1144             QFile::remove(partial);
1145         }
1146         updateEpisode(downloadJob->property(constRssUrlProperty).toUrl(), downloadJob->origUrl(), Episode::NotDownloading);
1147         downloadJob=nullptr;
1148     }
1149 }
1150 
doNextDownload()1151 void PodcastService::doNextDownload()
1152 {
1153     if (downloadJob) {
1154         return;
1155     }
1156 
1157     if (toDownload.isEmpty()) {
1158         return;
1159     }
1160 
1161     DownloadEntry entry=toDownload.takeFirst();
1162     downloadJob=NetworkAccessManager::self()->get(entry.url);
1163     connect(downloadJob, SIGNAL(finished()), this, SLOT(downloadJobFinished()));
1164     connect(downloadJob, SIGNAL(readyRead()), this, SLOT(downloadReadyRead()));
1165     connect(downloadJob, SIGNAL(downloadPercent(int)), this, SLOT(downloadPercent(int)));
1166     downloadJob->setProperty(constRssUrlProperty, entry.rssUrl);
1167     downloadJob->setProperty(constDestProperty, entry.dest);
1168     updateEpisode(entry.rssUrl, entry.url, 0);
1169 
1170     QString partial=entry.dest+constPartialExt;
1171     if (QFile::exists(partial)) {
1172         QFile::remove(partial);
1173     }
1174 }
1175 
updateEpisode(const QUrl & rssUrl,const QUrl & url,int pc)1176 void PodcastService::updateEpisode(const QUrl &rssUrl, const QUrl &url, int pc)
1177 {
1178     Podcast *pod=getPodcast(rssUrl);
1179     if (pod) {
1180         Episode *episode=pod->getEpisode(url);
1181         if (episode && episode->downloadProg!=pc) {
1182             episode->downloadProg=pc;
1183             QModelIndex idx=createIndex(pod->episodes.indexOf(episode), 0, (void *)episode);
1184             emit dataChanged(idx, idx);
1185         }
1186     }
1187 }
1188 
clearPartialDownloads()1189 void PodcastService::clearPartialDownloads()
1190 {
1191     QString dest=Settings::self()->podcastDownloadPath();
1192     if (dest.isEmpty()) {
1193         return;
1194     }
1195 
1196     dest=Utils::fixPath(dest);
1197     QStringList sub=QDir(dest).entryList(QDir::Dirs|QDir::NoDotAndDotDot);
1198     for (const QString &d: sub) {
1199         QStringList partials=QDir(dest+d).entryList(QStringList() << QLatin1Char('*')+constPartialExt, QDir::Files);
1200         for (const QString &p: partials) {
1201             QFile::remove(dest+d+Utils::constDirSep+p);
1202         }
1203     }
1204 }
1205 
downloadJobFinished()1206 void PodcastService::downloadJobFinished()
1207 {
1208     NetworkJob *job=dynamic_cast<NetworkJob *>(sender());
1209     if (!job || job!=downloadJob) {
1210         return;
1211     }
1212     job->deleteLater();
1213 
1214     QString dest=job->property(constDestProperty).toString();
1215     QString partial=dest.isEmpty() ? QString() : QString(dest+constPartialExt);
1216 
1217     if (job->ok()) {
1218         QString dest=job->property(constDestProperty).toString();
1219         if (dest.isEmpty()) {
1220             return;
1221         }
1222 
1223         QString partial=dest+constPartialExt;
1224         if (QFile::exists(partial)) {
1225             if (QFile::exists(dest)) {
1226                 QFile::remove(dest);
1227             }
1228             if (QFile::rename(partial, dest)) {
1229                 Podcast *pod=getPodcast(job->property(constRssUrlProperty).toUrl());
1230                 if (pod) {
1231                     Episode *episode=pod->getEpisode(job->origUrl());
1232                     if (episode) {
1233                         episode->localFile=dest;
1234                         pod->save();
1235                         QModelIndex idx=createIndex(pod->episodes.indexOf(episode), 0, (void *)episode);
1236                         emit dataChanged(idx, idx);
1237                     }
1238                 }
1239             }
1240         }
1241     } else if (!partial.isEmpty() && QFile::exists(partial)) {
1242         QFile::remove(partial);
1243     }
1244     updateEpisode(job->property(constRssUrlProperty).toUrl(), job->origUrl(), Episode::NotDownloading);
1245     downloadJob=nullptr;
1246     doNextDownload();
1247 }
1248 
downloadReadyRead()1249 void PodcastService::downloadReadyRead()
1250 {
1251     NetworkJob *job=dynamic_cast<NetworkJob *>(sender());
1252     if (!job || job!=downloadJob) {
1253         return;
1254     }
1255     QString dest=job->property(constDestProperty).toString();
1256     QString partial=dest.isEmpty() ? QString() : QString(dest+constPartialExt);
1257     if (!partial.isEmpty()) {
1258         QString dir=Utils::getDir(partial);
1259         if (!QDir(dir).exists()) {
1260             QDir(dir).mkpath(dir);
1261         }
1262         if (!QDir(dir).exists()) {
1263             return;
1264         }
1265         QFile f(partial);
1266         while (true) {
1267             const qint64 bytes = job->bytesAvailable();
1268             if (bytes <= 0) {
1269                 break;
1270             }
1271             if (!f.isOpen()) {
1272                 if (!f.open(QIODevice::Append)) {
1273                     return;
1274                 }
1275             }
1276             f.write(job->read(bytes));
1277         }
1278     }
1279 }
1280 
downloadPercent(int pc)1281 void PodcastService::downloadPercent(int pc)
1282 {
1283     NetworkJob *job=dynamic_cast<NetworkJob *>(sender());
1284     if (!job || job!=downloadJob) {
1285         return;
1286     }
1287     updateEpisode(job->property(constRssUrlProperty).toUrl(), job->origUrl(), pc);
1288 }
1289 
startRssUpdateTimer()1290 void PodcastService::startRssUpdateTimer()
1291 {
1292     if (0==Settings::self()->rssUpdate() || podcasts.isEmpty()) {
1293         stopRssUpdateTimer();
1294         return;
1295     }
1296     if (!rssUpdateTimer) {
1297         rssUpdateTimer=new QTimer(this);
1298         rssUpdateTimer->setSingleShot(true);
1299         connect(rssUpdateTimer, SIGNAL(timeout()), this, SLOT(updateRss()));
1300     }
1301     if (!lastRssUpdate.isValid()) {
1302         lastRssUpdate=Settings::self()->lastRssUpdate();
1303     }
1304     if (!lastRssUpdate.isValid()) {
1305         updateRss();
1306     } else {
1307         QDateTime nextUpdate = lastRssUpdate.addSecs(Settings::self()->rssUpdate()*60);
1308         int secsUntilNextUpdate = QDateTime::currentDateTime().secsTo(nextUpdate);
1309         if (secsUntilNextUpdate<0) {
1310             // Oops, missed update time!!!
1311             updateRss();
1312         } else {
1313             rssUpdateTimer->start(secsUntilNextUpdate*1000ll);
1314         }
1315     }
1316 }
1317 
stopRssUpdateTimer()1318 void PodcastService::stopRssUpdateTimer()
1319 {
1320     if (rssUpdateTimer) {
1321         rssUpdateTimer->stop();
1322     }
1323 }
1324 
exportSubscriptions(const QString & name)1325 bool PodcastService::exportSubscriptions(const QString &name)
1326 {
1327     QFile f(name);
1328     if (!f.open(QIODevice::Text|QIODevice::WriteOnly)) {
1329         return false;
1330     }
1331     QString date = QDateTime::currentDateTime().toString(Qt::RFC2822Date);
1332     QXmlStreamWriter writer(&f);
1333     writer.writeStartDocument();
1334     writer.writeStartElement(QLatin1String("opml"));
1335     writer.writeAttribute(QLatin1String("version"), QLatin1String("1.1"));
1336     writer.writeStartElement(QLatin1String("head"));
1337     writer.writeTextElement(QLatin1String("title"), QLatin1String("Cantata Podcasts"));
1338     writer.writeTextElement(QLatin1String("dateCreated"), date);
1339     writer.writeTextElement(QLatin1String("dateModified"), date);
1340 
1341     writer.writeEndElement(); // head
1342     writer.writeStartElement(QLatin1String("body"));
1343     for (Podcast *podcast: podcasts) {
1344         writer.writeStartElement(QLatin1String("outline"));
1345         writer.writeAttribute(QLatin1String("text"), podcast->name);
1346         writer.writeAttribute(QLatin1String("description"), podcast->descr);
1347         writer.writeAttribute(QLatin1String("type"), QLatin1String("rss"));
1348         writer.writeAttribute(QLatin1String("xmlUrl"), podcast->url.toEncoded());
1349         writer.writeAttribute(QLatin1String("imageUrl"), podcast->imageUrl.toEncoded());
1350         writer.writeEndElement(); // outline
1351     }
1352     writer.writeEndElement(); // body
1353     writer.writeEndElement(); // opml
1354     writer.writeEndDocument();
1355     f.close();
1356     return true;
1357 }
1358 
updateRss()1359 void PodcastService::updateRss()
1360 {
1361     for (Podcast *podcast: podcasts) {
1362         const QUrl &url=podcast->url;
1363         updateUrls.insert(url);
1364         if (!processingUrl(url)) {
1365             addUrl(url, false);
1366         }
1367     }
1368 }
1369 
currentMpdSong(const Song & s)1370 void PodcastService::currentMpdSong(const Song &s)
1371 {
1372     if ((s.isFromOnlineService() && s.onlineService()==constName) || isPodcastFile(s.file)) {
1373         QString path=s.decodedPath();
1374         if (path.isEmpty()) {
1375             path=s.file;
1376         }
1377         for (Podcast *podcast: podcasts) {
1378             for (Episode *episode: podcast->episodes) {
1379                 if (episode->url==path || episode->localFile==path) {
1380                     if (!episode->played) {
1381                         episode->played=true;
1382                         QModelIndex idx=createIndex(podcast->episodes.indexOf(episode), 0, (void *)episode);
1383                         emit dataChanged(idx, idx);
1384                         podcast->unplayedCount--;
1385                         podcast->save();
1386                         idx=createIndex(podcasts.indexOf(podcast), 0, (void *)podcast);
1387                         emit dataChanged(idx, idx);
1388                     }
1389 
1390                     return;
1391                 }
1392             }
1393         }
1394     }
1395 }
1396 
1397 #include "moc_podcastservice.cpp"
1398