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