1 /****************************************************************************************
2  * Copyright (c) 2007-2009 Bart Cerneels <bart.cerneels@kde.org>                        *
3  * Copyright (c) 2009 Frank Meerkoetter <frank@meerkoetter.org>                         *
4  *                                                                                      *
5  * This program is free software; you can redistribute it and/or modify it under        *
6  * the terms of the GNU General Public License as published by the Free Software        *
7  * Foundation; either version 2 of the License, or (at your option) any later           *
8  * version.                                                                             *
9  *                                                                                      *
10  * This program is distributed in the hope that it will be useful, but WITHOUT ANY      *
11  * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A      *
12  * PARTICULAR PURPOSE. See the GNU General Public License for more details.             *
13  *                                                                                      *
14  * You should have received a copy of the GNU General Public License along with         *
15  * this program.  If not, see <http://www.gnu.org/licenses/>.                           *
16  ****************************************************************************************/
17 
18 #include "SqlPodcastProvider.h"
19 
20 #include "MainWindow.h"
21 #include "OpmlWriter.h"
22 #include "SvgHandler.h"
23 #include "QStringx.h"
24 #include "browsers/playlistbrowser/PodcastModel.h"
25 // #include "context/popupdropper/libpud/PopupDropper.h"
26 // #include "context/popupdropper/libpud/PopupDropperItem.h"
27 #include <core/storage/SqlStorage.h>
28 #include "core/logger/Logger.h"
29 #include "core/podcasts/PodcastImageFetcher.h"
30 #include "core/podcasts/PodcastReader.h"
31 #include "core/support/Amarok.h"
32 #include "core/support/Components.h"
33 #include "core/support/Debug.h"
34 #include "core-impl/storage/StorageManager.h"
35 #include "core-impl/podcasts/sql/PodcastSettingsDialog.h"
36 #include "playlistmanager/sql/SqlPlaylistGroup.h"
37 
38 #include "ui_SqlPodcastProviderSettingsWidget.h"
39 
40 #include <KCodecs>
41 #include <KFileWidget>
42 #include <KIO/CopyJob>
43 #include <KIO/DeleteJob>
44 #include <KIO/Job>
45 #include <KLocalizedString>
46 
47 #include <QAction>
48 #include <QCheckBox>
49 #include <QCryptographicHash>
50 #include <QDialog>
51 #include <QDir>
52 #include <QFile>
53 #include <QMap>
54 #include <QMessageBox>
55 #include <QNetworkConfigurationManager>
56 #include <QProgressDialog>
57 #include <QStandardPaths>
58 #include <QTimer>
59 #include <QUrl>
60 
61 using namespace Podcasts;
62 
63 static const int PODCAST_DB_VERSION = 6;
64 static const QString key( QStringLiteral("AMAROK_PODCAST") );
65 static const QString PODCAST_TMP_POSTFIX( QStringLiteral(".tmp") );
66 
SqlPodcastProvider()67 SqlPodcastProvider::SqlPodcastProvider()
68         : m_updateTimer( new QTimer( this ) )
69         , m_updatingChannels( 0 )
70         , m_completedDownloads( 0 )
71         , m_providerSettingsDialog( 0 )
72         , m_providerSettingsWidget( 0 )
73         , m_configureChannelAction( 0 )
74         , m_deleteAction( 0 )
75         , m_downloadAction( 0 )
76         , m_keepAction( 0 )
77         , m_removeAction( 0 )
78         , m_updateAction( 0 )
79         , m_writeTagsAction( 0 )
80         , m_podcastImageFetcher( 0 )
81 {
82     connect( m_updateTimer, &QTimer::timeout, this, &SqlPodcastProvider::autoUpdate );
83 
84     auto sqlStorage = StorageManager::instance()->sqlStorage();
85 
86     if( !sqlStorage )
87     {
88         error() << "Could not get a SqlStorage instance";
89         return;
90     }
91 
92     m_autoUpdateInterval = Amarok::config( QStringLiteral("Podcasts") )
93                            .readEntry( "AutoUpdate Interval", 30 );
94     m_maxConcurrentDownloads = Amarok::config( QStringLiteral("Podcasts") )
95                                .readEntry( "Maximum Simultaneous Downloads", 4 );
96     m_maxConcurrentUpdates = Amarok::config( QStringLiteral("Podcasts") )
97                              .readEntry( "Maximum Simultaneous Updates", 4 );
98     m_baseDownloadDir = QUrl::fromUserInput( Amarok::config( QStringLiteral("Podcasts") ).readEntry( "Base Download Directory",
99                                                            Amarok::saveLocation( QStringLiteral("podcasts") ) ) );
100 
101     QStringList values;
102 
103     values = sqlStorage->query(
104                  QStringLiteral( "SELECT version FROM admin WHERE component = '%1';" )
105                     .arg( sqlStorage->escape( key ) )
106              );
107     if( values.isEmpty() )
108     {
109         debug() << "creating Podcast Tables";
110         createTables();
111         sqlStorage->query( "INSERT INTO admin(component,version) "
112                            "VALUES('" + key + "',"
113                            + QString::number( PODCAST_DB_VERSION ) + ");" );
114     }
115     else
116     {
117         int version = values.first().toInt();
118         if( version == PODCAST_DB_VERSION )
119             loadPodcasts();
120         else
121             updateDatabase( version /*from*/, PODCAST_DB_VERSION /*to*/ );
122 
123         startTimer();
124     }
125 }
126 
127 void
startTimer()128 SqlPodcastProvider::startTimer()
129 {
130     if( !m_autoUpdateInterval )
131         return; //timer is disabled
132 
133     if( m_updateTimer->isActive() &&
134         m_updateTimer->interval() == ( m_autoUpdateInterval * 1000 * 60 ) )
135         return; //already started with correct interval
136 
137     //and only start if at least one channel has autoscan enabled
138     foreach( Podcasts::SqlPodcastChannelPtr channel, m_channels )
139     {
140         if( channel->autoScan() )
141         {
142             m_updateTimer->start( 1000 * 60 * m_autoUpdateInterval );
143             return;
144         }
145     }
146 }
147 
~SqlPodcastProvider()148 SqlPodcastProvider::~SqlPodcastProvider()
149 {
150     foreach( Podcasts::SqlPodcastChannelPtr channel, m_channels )
151     {
152         channel->updateInDb();
153         foreach( Podcasts::SqlPodcastEpisodePtr episode, channel->sqlEpisodes() )
154             episode->updateInDb();
155     }
156     m_channels.clear();
157 
158     Amarok::config( QStringLiteral("Podcasts") )
159         .writeEntry( "AutoUpdate Interval", m_autoUpdateInterval );
160     Amarok::config( QStringLiteral("Podcasts") )
161         .writeEntry( "Maximum Simultaneous Downloads", m_maxConcurrentDownloads );
162     Amarok::config( QStringLiteral("Podcasts") )
163         .writeEntry( "Maximum Simultaneous Updates", m_maxConcurrentUpdates );
164 }
165 
166 void
loadPodcasts()167 SqlPodcastProvider::loadPodcasts()
168 {
169     m_channels.clear();
170     auto sqlStorage = StorageManager::instance()->sqlStorage();
171     if( !sqlStorage )
172         return;
173 
174     QStringList results = sqlStorage->query( "SELECT id, url, title, weblink, image"
175         ", description, copyright, directory, labels, subscribedate, autoscan, fetchtype"
176         ", haspurge, purgecount, writetags, filenamelayout FROM podcastchannels;" );
177 
178     int rowLength = 16;
179     for( int i = 0; i < results.size(); i += rowLength )
180     {
181         QStringList channelResult = results.mid( i, rowLength );
182         SqlPodcastChannelPtr channel =
183                 SqlPodcastChannelPtr( new SqlPodcastChannel( this, channelResult ) );
184         if( channel->image().isNull() )
185             fetchImage( channel );
186 
187         m_channels << channel;
188     }
189     if( m_podcastImageFetcher )
190         m_podcastImageFetcher->run();
191     Q_EMIT updated();
192 }
193 
194 SqlPodcastEpisodePtr
sqlEpisodeForString(const QString & string)195 SqlPodcastProvider::sqlEpisodeForString( const QString &string )
196 {
197     if( string.isEmpty() )
198         return SqlPodcastEpisodePtr();
199 
200     auto sqlStorage = StorageManager::instance()->sqlStorage();
201     if( !sqlStorage )
202         return SqlPodcastEpisodePtr();
203 
204     QString command = "SELECT id, url, channel, localurl, guid, "
205             "title, subtitle, sequencenumber, description, mimetype, pubdate, "
206             "duration, filesize, isnew, iskeep FROM podcastepisodes "
207             "WHERE guid='%1' OR url='%1' OR localurl='%1' ORDER BY id DESC;";
208     command = command.arg( sqlStorage->escape( string ) );
209     QStringList dbResult = sqlStorage->query( command );
210 
211     if( dbResult.isEmpty() )
212         return SqlPodcastEpisodePtr();
213 
214     int episodeId = dbResult[0].toInt();
215     int channelId = dbResult[2].toInt();
216     bool found = false;
217     Podcasts::SqlPodcastChannelPtr channel;
218     foreach( channel, m_channels )
219     {
220         if( channel->dbId() == channelId )
221         {
222             found = true;
223             break;
224         }
225     }
226 
227     if( !found )
228     {
229         error() << QString( "There is a track in the database with url/guid=%1 (%2) "
230                             "but there is no channel with dbId=%3 in our list!" )
231                 .arg( string ).arg( episodeId ).arg( channelId );
232         return SqlPodcastEpisodePtr();
233     }
234 
235     Podcasts::SqlPodcastEpisodePtr episode;
236     foreach( episode, channel->sqlEpisodes() )
237         if( episode->dbId() == episodeId )
238             return episode;
239 
240     //The episode was found in the database but it's channel didn't have it in it's list.
241     //That probably is because it's beyond the purgecount limit or the tracks were not loaded yet.
242     return SqlPodcastEpisodePtr( new SqlPodcastEpisode( dbResult.mid( 0, 15 ), channel ) );
243 }
244 
245 bool
possiblyContainsTrack(const QUrl & url) const246 SqlPodcastProvider::possiblyContainsTrack( const QUrl &url ) const
247 {
248     auto sqlStorage = StorageManager::instance()->sqlStorage();
249     if( !sqlStorage )
250         return false;
251 
252     QString command = "SELECT id FROM podcastepisodes WHERE guid='%1' OR url='%1' "
253                       "OR localurl='%1';";
254     command = command.arg( sqlStorage->escape( url.url() ) );
255 
256     QStringList dbResult = sqlStorage->query( command );
257     return !dbResult.isEmpty();
258 }
259 
260 Meta::TrackPtr
trackForUrl(const QUrl & url)261 SqlPodcastProvider::trackForUrl( const QUrl &url )
262 {
263     if( url.isEmpty() )
264         return Meta::TrackPtr();
265 
266     SqlPodcastEpisodePtr episode = sqlEpisodeForString( url.url() );
267 
268     return Meta::TrackPtr::dynamicCast( episode );
269 }
270 
271 Playlists::PlaylistList
playlists()272 SqlPodcastProvider::playlists()
273 {
274     Playlists::PlaylistList playlistList;
275 
276     QListIterator<Podcasts::SqlPodcastChannelPtr> i( m_channels );
277     while( i.hasNext() )
278     {
279         playlistList << Playlists::PlaylistPtr::staticCast( i.next() );
280     }
281     return playlistList;
282 }
283 
284 QActionList
providerActions()285 SqlPodcastProvider::providerActions()
286 {
287     if( m_providerActions.isEmpty() )
288     {
289         QAction *updateAllAction = new QAction( QIcon::fromTheme( QStringLiteral("view-refresh-amarok") ),
290                 i18n( "&Update All Channels" ), this );
291         updateAllAction->setProperty( "popupdropper_svg_id", "update" );
292         connect( updateAllAction, &QAction::triggered, this, &SqlPodcastProvider::updateAll );
293         m_providerActions << updateAllAction;
294 
295         QAction *configureAction = new QAction( QIcon::fromTheme( QStringLiteral("configure") ),
296                 i18n( "&Configure General Settings" ), this );
297         configureAction->setProperty( "popupdropper_svg_id", "configure" );
298         connect( configureAction, &QAction::triggered, this, &SqlPodcastProvider::slotConfigureProvider );
299         m_providerActions << configureAction;
300 
301         QAction *exportOpmlAction = new QAction( QIcon::fromTheme( QStringLiteral("document-export") ),
302                 i18n( "&Export subscriptions to OPML file" ), this );
303         connect( exportOpmlAction, &QAction::triggered, this, &SqlPodcastProvider::slotExportOpml );
304         m_providerActions << exportOpmlAction;
305     }
306 
307     return m_providerActions;
308 }
309 
310 QActionList
playlistActions(const Playlists::PlaylistList & playlists)311 SqlPodcastProvider::playlistActions( const Playlists::PlaylistList &playlists )
312 {
313     QActionList actions;
314     SqlPodcastChannelList sqlChannels;
315     foreach( const Playlists::PlaylistPtr &playlist, playlists )
316     {
317         SqlPodcastChannelPtr sqlChannel = SqlPodcastChannel::fromPlaylistPtr( playlist );
318         if( sqlChannel )
319             sqlChannels << sqlChannel;
320     }
321 
322     if( sqlChannels.isEmpty() )
323         return actions;
324 
325     //TODO: add export OPML action for selected playlists only. Use the QAction::data() trick.
326     if( m_configureChannelAction == 0 )
327     {
328         m_configureChannelAction = new QAction( QIcon::fromTheme( QStringLiteral("configure") ), i18n( "&Configure" ), this );
329         m_configureChannelAction->setProperty( "popupdropper_svg_id", "configure" );
330         connect( m_configureChannelAction, &QAction::triggered, this, &SqlPodcastProvider::slotConfigureChannel );
331     }
332     //only one channel can be configured at a time.
333     if( sqlChannels.count() == 1 )
334     {
335         m_configureChannelAction->setData( QVariant::fromValue( sqlChannels.first() ) );
336         actions << m_configureChannelAction;
337     }
338 
339     if( m_removeAction == 0 )
340     {
341         m_removeAction = new QAction( QIcon::fromTheme( QStringLiteral("news-unsubscribe") ), i18n( "&Remove Subscription" ), this );
342         m_removeAction->setProperty( "popupdropper_svg_id", "remove" );
343         connect( m_removeAction, &QAction::triggered, this, &SqlPodcastProvider::slotRemoveChannels );
344     }
345     m_removeAction->setData( QVariant::fromValue( sqlChannels ) );
346     actions << m_removeAction;
347 
348     if( m_updateAction == 0 )
349     {
350         m_updateAction = new QAction( QIcon::fromTheme( QStringLiteral("view-refresh-amarok") ), i18n( "&Update Channel" ), this );
351         m_updateAction->setProperty( "popupdropper_svg_id", "update" );
352         connect( m_updateAction, &QAction::triggered, this, &SqlPodcastProvider::slotUpdateChannels );
353     }
354     m_updateAction->setData( QVariant::fromValue( sqlChannels ) );
355     actions << m_updateAction;
356 
357     return actions;
358 }
359 
360 QActionList
trackActions(const QMultiHash<Playlists::PlaylistPtr,int> & playlistTracks)361 SqlPodcastProvider::trackActions( const QMultiHash<Playlists::PlaylistPtr, int> &playlistTracks )
362 {
363     SqlPodcastEpisodeList episodes;
364     foreach( const Playlists::PlaylistPtr &playlist, playlistTracks.uniqueKeys() )
365     {
366         SqlPodcastChannelPtr sqlChannel = SqlPodcastChannel::fromPlaylistPtr( playlist );
367         if( !sqlChannel )
368             continue;
369 
370         SqlPodcastEpisodeList channelEpisodes = sqlChannel->sqlEpisodes();
371         QList<int> trackPositions = playlistTracks.values( playlist );
372         qSort( trackPositions );
373         foreach( int trackPosition, trackPositions )
374         {
375             if( trackPosition >= 0 && trackPosition < channelEpisodes.count() )
376                 episodes << channelEpisodes.at( trackPosition );
377         }
378     }
379 
380     QActionList actions;
381     if( episodes.isEmpty() )
382         return actions;
383 
384     if( m_downloadAction == 0 )
385     {
386         m_downloadAction = new QAction( QIcon::fromTheme( QStringLiteral("go-down") ), i18n( "&Download Episode" ), this );
387         m_downloadAction->setProperty( "popupdropper_svg_id", "download" );
388         connect( m_downloadAction, &QAction::triggered, this, &SqlPodcastProvider::slotDownloadEpisodes );
389     }
390 
391     if( m_deleteAction == 0 )
392     {
393         m_deleteAction = new QAction( QIcon::fromTheme( QStringLiteral("edit-delete") ),
394             i18n( "&Delete Downloaded Episode" ), this );
395         m_deleteAction->setProperty( "popupdropper_svg_id", "delete" );
396         m_deleteAction->setObjectName( QStringLiteral("deleteAction") );
397         connect( m_deleteAction, &QAction::triggered, this, &SqlPodcastProvider::slotDeleteDownloadedEpisodes );
398     }
399 
400     if( m_writeTagsAction == 0 )
401     {
402         m_writeTagsAction = new QAction( QIcon::fromTheme( QStringLiteral("media-track-edit-amarok") ),
403             i18n( "&Write Feed Information to File" ), this );
404         m_writeTagsAction->setProperty( "popupdropper_svg_id", "edit" );
405         connect( m_writeTagsAction, &QAction::triggered, this, &SqlPodcastProvider::slotWriteTagsToFiles );
406     }
407 
408     if( m_keepAction == 0 )
409     {
410         m_keepAction = new QAction( QIcon::fromTheme( QStringLiteral("podcast-amarok") ),
411                 i18n( "&Keep downloaded file" ), this );
412         m_keepAction->setToolTip( i18n( "Toggle the \"keep\" downloaded file status of "
413                 "this podcast episode. Downloaded files with this status wouldn't be "
414                 "deleted even if we apply a purge." ) );
415         m_keepAction->setProperty( "popupdropper_svg_id", "keep" );
416         m_keepAction->setCheckable( true );
417         connect( m_keepAction, &QAction::triggered, this, &SqlPodcastProvider::slotSetKeep );
418     }
419 
420     SqlPodcastEpisodeList remoteEpisodes;
421     SqlPodcastEpisodeList keptDownloadedEpisodes, unkeptDownloadedEpisodes;
422     foreach( const SqlPodcastEpisodePtr &episode, episodes )
423     {
424         if( episode->localUrl().isEmpty() )
425             remoteEpisodes << episode;
426         else
427         {
428             if( episode->isKeep() )
429                 keptDownloadedEpisodes << episode;
430             else
431                 unkeptDownloadedEpisodes << episode;
432         }
433     }
434 
435     if( !remoteEpisodes.isEmpty() )
436     {
437         m_downloadAction->setData( QVariant::fromValue( remoteEpisodes ) );
438         actions << m_downloadAction;
439     }
440     if( !( keptDownloadedEpisodes + unkeptDownloadedEpisodes ).isEmpty() )
441     {
442         m_deleteAction->setData( QVariant::fromValue( keptDownloadedEpisodes + unkeptDownloadedEpisodes ) );
443         actions << m_deleteAction;
444 
445         m_keepAction->setChecked( unkeptDownloadedEpisodes.isEmpty() );
446         m_keepAction->setData( QVariant::fromValue( keptDownloadedEpisodes + unkeptDownloadedEpisodes ) );
447         actions << m_keepAction;
448     }
449 
450     return actions;
451 }
452 
453 Podcasts::PodcastEpisodePtr
episodeForGuid(const QString & guid)454 SqlPodcastProvider::episodeForGuid( const QString &guid )
455 {
456     return PodcastEpisodePtr::dynamicCast( sqlEpisodeForString( guid ) );
457 }
458 
459 void
addPodcast(const QUrl & url)460 SqlPodcastProvider::addPodcast( const QUrl &url )
461 {
462     QUrl kurl = QUrl( url );
463     debug() << "importing " << kurl.url();
464 
465     auto sqlStorage = StorageManager::instance()->sqlStorage();
466     if( !sqlStorage )
467         return;
468 
469     QString command = QStringLiteral("SELECT title FROM podcastchannels WHERE url='%1';");
470     command = command.arg( sqlStorage->escape( kurl.url() ) );
471 
472     QStringList dbResult = sqlStorage->query( command );
473     if( !dbResult.isEmpty() )
474     {
475         //Already subscribed to this Channel
476         //notify the user.
477         Amarok::Logger::longMessage(
478                     i18n( "Already subscribed to %1.", dbResult.first() ), Amarok::Logger::Error );
479     }
480     else
481     {
482         subscribe( kurl );
483     }
484 }
485 
486 void
updateAll()487 SqlPodcastProvider::updateAll()
488 {
489     foreach( Podcasts::SqlPodcastChannelPtr channel, m_channels )
490         updateSqlChannel( channel );
491 }
492 
493 void
subscribe(const QUrl & url)494 SqlPodcastProvider::subscribe( const QUrl &url )
495 {
496     if( !url.isValid() )
497         return;
498 
499     if( m_updatingChannels >= m_maxConcurrentUpdates )
500     {
501         debug() << QString( "Maximum concurrent updates (%1) reached. "
502                             "Queueing \"%2\" for subscribing." )
503                         .arg( m_maxConcurrentUpdates )
504                         .arg( url.url() );
505         m_subscribeQueue << url;
506         return;
507     }
508 
509     PodcastReader *podcastReader = new PodcastReader( this );
510     connect( podcastReader, &PodcastReader::finished,
511              this, &SqlPodcastProvider::slotReadResult );
512     connect( podcastReader, &PodcastReader::statusBarSorryMessage,
513              this, &SqlPodcastProvider::slotStatusBarSorryMessage );
514     connect( podcastReader, &PodcastReader::statusBarNewProgressOperation,
515              this, &SqlPodcastProvider::slotStatusBarNewProgressOperation );
516 
517     m_updatingChannels++;
518     podcastReader->read( url );
519 }
520 
521 Podcasts::PodcastChannelPtr
addChannel(const PodcastChannelPtr & channel)522 SqlPodcastProvider::addChannel(const PodcastChannelPtr &channel )
523 {
524     Podcasts::SqlPodcastChannelPtr sqlChannel =
525             SqlPodcastChannelPtr( new Podcasts::SqlPodcastChannel( this, channel ) );
526     m_channels << sqlChannel;
527 
528     if( sqlChannel->episodes().isEmpty() )
529         updateSqlChannel( sqlChannel );
530 
531     Q_EMIT playlistAdded( Playlists::PlaylistPtr( sqlChannel.data() ) );
532     return PodcastChannelPtr( sqlChannel.data() );
533 }
534 
535 Podcasts::PodcastEpisodePtr
addEpisode(Podcasts::PodcastEpisodePtr episode)536 SqlPodcastProvider::addEpisode( Podcasts::PodcastEpisodePtr episode )
537 {
538     Podcasts::SqlPodcastEpisodePtr sqlEpisode =
539             Podcasts::SqlPodcastEpisodePtr::dynamicCast( episode );
540     if( sqlEpisode.isNull() )
541         return Podcasts::PodcastEpisodePtr();
542 
543     if( sqlEpisode->channel().isNull() )
544     {
545         debug() << "channel is null";
546         return Podcasts::PodcastEpisodePtr();
547     }
548 
549     if( sqlEpisode->channel()->fetchType() == Podcasts::PodcastChannel::DownloadWhenAvailable )
550         downloadEpisode( sqlEpisode );
551     return Podcasts::PodcastEpisodePtr::dynamicCast( sqlEpisode );
552 }
553 
554 Podcasts::PodcastChannelList
channels()555 SqlPodcastProvider::channels()
556 {
557     PodcastChannelList list;
558     QListIterator<SqlPodcastChannelPtr> i( m_channels );
559     while( i.hasNext() )
560     {
561         list << PodcastChannelPtr::dynamicCast( i.next() );
562     }
563     return list;
564 }
565 
566 void
removeSubscription(Podcasts::SqlPodcastChannelPtr sqlChannel)567 SqlPodcastProvider::removeSubscription( Podcasts::SqlPodcastChannelPtr sqlChannel )
568 {
569     debug() << "Deleting channel " << sqlChannel->title();
570     sqlChannel->deleteFromDb();
571 
572     m_channels.removeOne( sqlChannel );
573 
574     //HACK: because of a database "leak" in the past we have orphan data in the tables.
575     //Remove it when we know it's supposed to be empty.
576     if( m_channels.isEmpty() )
577     {
578         auto sqlStorage = StorageManager::instance()->sqlStorage();
579         if( !sqlStorage )
580             return;
581         debug() << "Unsubscribed from last channel, cleaning out the podcastepisodes table.";
582         sqlStorage->query( QStringLiteral("DELETE FROM podcastepisodes WHERE 1;") );
583     }
584 
585     Q_EMIT playlistRemoved( Playlists::PlaylistPtr::dynamicCast( sqlChannel ) );
586 }
587 
588 void
configureProvider()589 SqlPodcastProvider::configureProvider()
590 {
591     m_providerSettingsDialog = new QDialog( The::mainWindow() );
592     QWidget *settingsWidget = new QWidget( m_providerSettingsDialog );
593     m_providerSettingsDialog->setObjectName( QStringLiteral("SqlPodcastProviderSettings") );
594     Ui::SqlPodcastProviderSettingsWidget settings;
595     m_providerSettingsWidget = &settings;
596     settings.setupUi( settingsWidget );
597 
598     settings.m_baseDirUrl->setMode( KFile::Directory );
599     settings.m_baseDirUrl->setUrl( m_baseDownloadDir );
600 
601     settings.m_autoUpdateInterval->setValue( m_autoUpdateInterval );
602     settings.m_autoUpdateInterval->setPrefix(
603             i18nc( "prefix to 'x minutes'", "every " ) );
604     settings.m_autoUpdateInterval->setSuffix( i18np( " minute", " minutes", settings.m_autoUpdateInterval->value() ) );
605 
606     auto buttonBox = new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel | QDialogButtonBox::Apply, m_providerSettingsDialog );
607 
608     connect( settings.m_baseDirUrl, &KUrlRequester::textChanged, this, &SqlPodcastProvider::slotConfigChanged );
609     connect( settings.m_autoUpdateInterval, QOverload<int>::of(&QSpinBox::valueChanged),
610              this, &SqlPodcastProvider::slotConfigChanged );
611 
612     m_providerSettingsDialog->setWindowTitle( i18n( "Configure Local Podcasts" ) );
613     buttonBox->button( QDialogButtonBox::Apply )->setEnabled( false );
614 
615     if( m_providerSettingsDialog->exec() == QDialog::Accepted )
616     {
617         m_autoUpdateInterval = settings.m_autoUpdateInterval->value();
618         if( m_autoUpdateInterval )
619             startTimer();
620         else
621             m_updateTimer->stop();
622         QUrl adjustedNewPath = settings.m_baseDirUrl->url();
623         adjustedNewPath = adjustedNewPath.adjusted(QUrl::StripTrailingSlash);
624 
625         if( adjustedNewPath != m_baseDownloadDir )
626         {
627             m_baseDownloadDir = adjustedNewPath;
628             Amarok::config( QStringLiteral("Podcasts") ).writeEntry( "Base Download Directory", m_baseDownloadDir );
629             if( !m_channels.isEmpty() )
630             {
631                 //TODO: check if there actually are downloaded episodes
632                 auto button = QMessageBox::question( The::mainWindow(),
633                                                      i18n( "Move Podcasts" ),
634                                                      i18n( "Do you want to move all downloaded episodes to the new location?") );
635 
636                 if( button == QMessageBox::Yes )
637                 {
638                     foreach( SqlPodcastChannelPtr sqlChannel, m_channels )
639                     {
640                         QUrl oldSaveLocation = sqlChannel->saveLocation();
641                         QUrl newSaveLocation = m_baseDownloadDir;
642                         newSaveLocation = newSaveLocation.adjusted(QUrl::StripTrailingSlash);
643                         newSaveLocation.setPath(newSaveLocation.path() + QLatin1Char('/') + ( oldSaveLocation.fileName() ));
644                         sqlChannel->setSaveLocation( newSaveLocation );
645                         debug() << newSaveLocation.path();
646                         moveDownloadedEpisodes( sqlChannel );
647 
648                         if( !QDir().rmdir( oldSaveLocation.toLocalFile() ) )
649                                 debug() << "Could not remove old directory "
650                                         << oldSaveLocation.toLocalFile();
651                     }
652                 }
653             }
654         }
655     }
656 
657     delete m_providerSettingsDialog;
658     m_providerSettingsDialog = 0;
659     m_providerSettingsWidget = 0;
660 }
661 
662 void
slotConfigChanged()663 SqlPodcastProvider::slotConfigChanged()
664 {
665     if( !m_providerSettingsWidget )
666         return;
667 
668     if( m_providerSettingsWidget->m_autoUpdateInterval->value() != m_autoUpdateInterval
669         || m_providerSettingsWidget->m_baseDirUrl->url() != m_baseDownloadDir )
670     {
671         auto buttonBox = m_providerSettingsDialog->findChild<QDialogButtonBox*>();
672         buttonBox->button( QDialogButtonBox::Apply )->setEnabled( true );
673     }
674 }
675 
676 void
slotExportOpml()677 SqlPodcastProvider::slotExportOpml()
678 {
679     QList<OpmlOutline *> rootOutlines;
680     QMap<QString,QString> headerData;
681     //TODO: set header data such as date
682 
683     //TODO: folder outline support
684     foreach( SqlPodcastChannelPtr channel, m_channels )
685     {
686         OpmlOutline *channelOutline = new OpmlOutline();
687         #define addAttr( k, v ) channelOutline->addAttribute( k, v )
688         addAttr( "text", channel->title() );
689         addAttr( "type", "rss" );
690         addAttr( "xmlUrl", channel->url().url() );
691         rootOutlines << channelOutline;
692     }
693 
694     //TODO: add checkbox as widget to filedialog to include podcast settings.
695     QFileDialog fileDialog;
696     fileDialog.restoreState( Amarok::config( QStringLiteral("amarok-podcast-export-dialog") ).readEntry( "state", QByteArray() ) );
697 
698     fileDialog.setMimeTypeFilters( QStringList( QStringLiteral( "*.opml" ) ) );
699     fileDialog.setAcceptMode( QFileDialog::AcceptSave );
700     fileDialog.setFileMode( QFileDialog::AnyFile );
701     fileDialog.setWindowTitle( i18n( "Select file for OPML export") );
702 
703     if( fileDialog.exec() != QDialog::Accepted )
704         return;
705 
706     QString filePath = fileDialog.selectedFiles().value( 0 );
707 
708     QFile *opmlFile = new QFile( filePath, this );
709     if( !opmlFile->open( QIODevice::WriteOnly | QIODevice::Truncate ) )
710     {
711         error() << "could not open OPML file " << filePath;
712         return;
713     }
714     OpmlWriter *opmlWriter = new OpmlWriter( rootOutlines, headerData, opmlFile );
715     connect( opmlWriter, &OpmlWriter::result, this, &SqlPodcastProvider::slotOpmlWriterDone );
716     opmlWriter->run();
717 }
718 
719 void
slotOpmlWriterDone(int result)720 SqlPodcastProvider::slotOpmlWriterDone( int result )
721 {
722     Q_UNUSED( result )
723 
724     OpmlWriter *writer = qobject_cast<OpmlWriter *>( QObject::sender() );
725     Q_ASSERT( writer );
726     writer->device()->close();
727     delete writer;
728 }
729 
730 void
configureChannel(Podcasts::SqlPodcastChannelPtr sqlChannel)731 SqlPodcastProvider::configureChannel( Podcasts::SqlPodcastChannelPtr sqlChannel )
732 {
733     if( !sqlChannel )
734         return;
735 
736     QUrl oldUrl = sqlChannel->url();
737     QUrl oldSaveLocation = sqlChannel->saveLocation();
738     bool oldHasPurge = sqlChannel->hasPurge();
739     int oldPurgeCount = sqlChannel->purgeCount();
740     bool oldAutoScan = sqlChannel->autoScan();
741 
742     PodcastSettingsDialog dialog( sqlChannel, The::mainWindow() );
743     dialog.configure();
744 
745     sqlChannel->updateInDb();
746 
747     if( ( oldHasPurge && !sqlChannel->hasPurge() )
748         || ( oldPurgeCount < sqlChannel->purgeCount() ) )
749     {
750         /* changed from purge to no-purge or increase purge count:
751         we need to reload all episodes from the database. */
752         sqlChannel->loadEpisodes();
753     }
754     else
755         sqlChannel->applyPurge();
756 
757     Q_EMIT updated();
758 
759     if( oldSaveLocation != sqlChannel->saveLocation() )
760     {
761         moveDownloadedEpisodes( sqlChannel );
762         if( !QDir().rmdir( oldSaveLocation.toLocalFile() ) )
763             debug() << "Could not remove old directory " << oldSaveLocation.toLocalFile();
764     }
765 
766     //if the url changed force an update.
767     if( oldUrl != sqlChannel->url() )
768         updateSqlChannel( sqlChannel );
769 
770     //start autoscan in case it wasn't already
771     if( sqlChannel->autoScan() && !oldAutoScan )
772         startTimer();
773 }
774 
775 void
deleteDownloadedEpisodes(Podcasts::SqlPodcastEpisodeList & episodes)776 SqlPodcastProvider::deleteDownloadedEpisodes( Podcasts::SqlPodcastEpisodeList &episodes )
777 {
778     foreach( Podcasts::SqlPodcastEpisodePtr episode, episodes )
779         deleteDownloadedEpisode( episode );
780 }
781 
782 void
moveDownloadedEpisodes(Podcasts::SqlPodcastChannelPtr sqlChannel)783 SqlPodcastProvider::moveDownloadedEpisodes( Podcasts::SqlPodcastChannelPtr sqlChannel )
784 {
785     debug() << QStringLiteral( "We need to move downloaded episodes of \"%1\" to %2" )
786             .arg( sqlChannel->title(),
787                   sqlChannel->saveLocation().toDisplayString() );
788 
789     foreach( Podcasts::SqlPodcastEpisodePtr episode, sqlChannel->sqlEpisodes() )
790     {
791         if( !episode->localUrl().isEmpty() )
792         {
793             QUrl newLocation = sqlChannel->saveLocation();
794             QDir dir( newLocation.toLocalFile() );
795             dir.mkpath( QStringLiteral(".") );
796 
797             newLocation = newLocation.adjusted(QUrl::StripTrailingSlash);
798             newLocation.setPath(newLocation.path() + QLatin1Char('/') + ( episode->localUrl().fileName() ));
799             debug() << "Moving from " << episode->localUrl() << " to " << newLocation;
800             KIO::Job *moveJob = KIO::move( episode->localUrl(), newLocation,
801                                            KIO::HideProgressInfo );
802             //wait until job is finished.
803             if( moveJob->exec() )
804                 episode->setLocalUrl( newLocation );
805         }
806     }
807 }
808 
809 void
slotDeleteDownloadedEpisodes()810 SqlPodcastProvider::slotDeleteDownloadedEpisodes()
811 {
812     QAction *action = qobject_cast<QAction *>( QObject::sender() );
813     if( action == 0 )
814         return;
815     Podcasts::SqlPodcastEpisodeList episodes = action->data().value<Podcasts::SqlPodcastEpisodeList>();
816     deleteDownloadedEpisodes( episodes );
817 }
818 
819 void
slotDownloadEpisodes()820 SqlPodcastProvider::slotDownloadEpisodes()
821 {
822     QAction *action = qobject_cast<QAction *>( QObject::sender() );
823     if( action == 0 )
824         return;
825     Podcasts::SqlPodcastEpisodeList episodes = action->data().value<Podcasts::SqlPodcastEpisodeList>();
826 
827     foreach( Podcasts::SqlPodcastEpisodePtr episode, episodes )
828         downloadEpisode( episode );
829 }
830 
831 void
slotSetKeep()832 SqlPodcastProvider::slotSetKeep()
833 {
834     QAction *action = qobject_cast<QAction *>( QObject::sender() );
835     if( action == 0 )
836         return;
837 
838     Podcasts::SqlPodcastEpisodeList episodes = action->data().value<Podcasts::SqlPodcastEpisodeList>();
839 
840     foreach( Podcasts::SqlPodcastEpisodePtr episode, episodes )
841         episode->setKeep( action->isChecked() );
842 }
843 
844 QPair<bool, bool>
confirmUnsubscribe(Podcasts::SqlPodcastChannelPtr channel)845 SqlPodcastProvider::confirmUnsubscribe( Podcasts::SqlPodcastChannelPtr channel )
846 {
847     QMessageBox unsubscribeDialog;
848     unsubscribeDialog.setText( i18n( "Do you really want to unsubscribe from \"%1\"?", channel->title() ) );
849     unsubscribeDialog.setStandardButtons( QMessageBox::Ok | QMessageBox::Cancel );
850 
851     QCheckBox *deleteMediaCheckBox = new QCheckBox( i18n( "Delete downloaded episodes" ), nullptr );
852     unsubscribeDialog.setCheckBox( deleteMediaCheckBox );
853 
854     QPair<bool, bool> result;
855     result.first = unsubscribeDialog.exec() == QMessageBox::Ok;
856     result.second = deleteMediaCheckBox->isChecked();
857     return result;
858 }
859 
860 void
slotRemoveChannels()861 SqlPodcastProvider::slotRemoveChannels()
862 {
863     QAction *action = qobject_cast<QAction *>( QObject::sender() );
864     if( action == 0 )
865         return;
866 
867     Podcasts::SqlPodcastChannelList channels = action->data().value<Podcasts::SqlPodcastChannelList>();
868 
869     foreach( Podcasts::SqlPodcastChannelPtr channel, channels )
870     {
871         QPair<bool, bool> result = confirmUnsubscribe( channel );
872         if( result.first )
873         {
874             debug() << "unsubscribing " << channel->title();
875             if( result.second )
876             {
877                 debug() << "removing all episodes";
878                 Podcasts::SqlPodcastEpisodeList sqlEpisodes = channel->sqlEpisodes();
879                 deleteDownloadedEpisodes( sqlEpisodes );
880             }
881             removeSubscription( channel );
882         }
883     }
884 }
885 
886 void
slotUpdateChannels()887 SqlPodcastProvider::slotUpdateChannels()
888 {
889     QAction *action = qobject_cast<QAction *>( QObject::sender() );
890         if( action == 0 )
891             return;
892     Podcasts::SqlPodcastChannelList channels = action->data().value<Podcasts::SqlPodcastChannelList>();
893 
894     foreach( Podcasts::SqlPodcastChannelPtr channel, channels )
895             updateSqlChannel( channel );
896 }
897 
898 void
slotDownloadProgress(KJob * job,unsigned long percent)899 SqlPodcastProvider::slotDownloadProgress( KJob *job, unsigned long percent )
900 {
901     Q_UNUSED( job );
902     Q_UNUSED( percent );
903 
904     unsigned int totalDownloadPercentage = 0;
905     foreach( const KJob *jobKey, m_downloadJobMap.keys() )
906         totalDownloadPercentage += jobKey->percent();
907 
908     //keep the completed jobs in mind as well.
909     totalDownloadPercentage += m_completedDownloads * 100;
910 
911     Q_EMIT totalPodcastDownloadProgress(
912         totalDownloadPercentage / ( m_downloadJobMap.count() + m_completedDownloads ) );
913 }
914 
915 void
slotWriteTagsToFiles()916 SqlPodcastProvider::slotWriteTagsToFiles()
917 {
918     QAction *action = qobject_cast<QAction *>( QObject::sender() );
919     if( action == 0 )
920         return;
921 
922     Podcasts::SqlPodcastEpisodeList episodes = action->data().value<Podcasts::SqlPodcastEpisodeList>();
923     foreach( Podcasts::SqlPodcastEpisodePtr episode, episodes )
924         episode->writeTagsToFile();
925 }
926 
927 void
slotConfigureChannel()928 SqlPodcastProvider::slotConfigureChannel()
929 {
930     QAction *action = qobject_cast<QAction *>( QObject::sender() );
931     if( action == 0 )
932         return;
933 
934     Podcasts::SqlPodcastChannelPtr podcastChannel = action->data().value<Podcasts::SqlPodcastChannelPtr>();
935     if( !podcastChannel.isNull() )
936         configureChannel( podcastChannel );
937 }
938 
939 void
deleteDownloadedEpisode(Podcasts::SqlPodcastEpisodePtr episode)940 SqlPodcastProvider::deleteDownloadedEpisode( Podcasts::SqlPodcastEpisodePtr episode )
941 {
942     if( !episode || episode->localUrl().isEmpty() )
943         return;
944 
945     debug() << "deleting " << episode->title();
946     KIO::del( episode->localUrl(), KIO::HideProgressInfo );
947 
948     episode->setLocalUrl( QUrl() );
949 
950     Q_EMIT episodeDeleted( Podcasts::PodcastEpisodePtr::dynamicCast( episode ) );
951 }
952 
953 Podcasts::SqlPodcastChannelPtr
podcastChannelForId(int podcastChannelId)954 SqlPodcastProvider::podcastChannelForId( int podcastChannelId )
955 {
956     QListIterator<Podcasts::SqlPodcastChannelPtr> i( m_channels );
957     while( i.hasNext() )
958     {
959         int id = i.next()->dbId();
960         if( id == podcastChannelId )
961             return i.previous();
962     }
963     return Podcasts::SqlPodcastChannelPtr();
964 }
965 
966 void
completePodcastDownloads()967 SqlPodcastProvider::completePodcastDownloads()
968 {
969     //check to see if there are still downloads in progress
970     if( !m_downloadJobMap.isEmpty() )
971     {
972         debug() << QStringLiteral( "There are still %1 podcast download jobs running!" )
973                 .arg( m_downloadJobMap.count() );
974         QProgressDialog progressDialog( i18np( "There is still a podcast download in progress",
975                                         "There are still %1 podcast downloads in progress",
976                                         m_downloadJobMap.count() ),
977                                         i18n("Cancel Download and Quit."),
978                                         0, m_downloadJobMap.size(), The::mainWindow()
979                                       );
980         progressDialog.setValue( 0 );
981         m_completedDownloads = 0;
982         foreach( KJob *job, m_downloadJobMap.keys() )
983         {
984             connect( job, SIGNAL(percent(KJob*,ulong)),
985                      this, SLOT(slotDownloadProgress(KJob*,ulong))
986                    );
987         }
988         connect( this, &SqlPodcastProvider::totalPodcastDownloadProgress,
989                  &progressDialog, &QProgressDialog::setValue );
990         int result = progressDialog.exec();
991         if( result == QDialog::Rejected )
992         {
993             foreach( KJob *job, m_downloadJobMap.keys() )
994             {
995                 job->kill();
996             }
997         }
998     }
999 }
1000 
1001 void
autoUpdate()1002 SqlPodcastProvider::autoUpdate()
1003 {
1004     QNetworkConfigurationManager mgr;
1005     if( !mgr.isOnline() )
1006     {
1007         debug() << "Solid reports we are not online, canceling podcast auto-update";
1008         return;
1009     }
1010 
1011     foreach( Podcasts::SqlPodcastChannelPtr channel, m_channels )
1012     {
1013         if( channel->autoScan() )
1014             updateSqlChannel( channel );
1015     }
1016 }
1017 
1018 void
updateSqlChannel(Podcasts::SqlPodcastChannelPtr channel)1019 SqlPodcastProvider::updateSqlChannel( Podcasts::SqlPodcastChannelPtr channel )
1020 {
1021     if( channel.isNull() )
1022         return;
1023     if( m_updatingChannels >= m_maxConcurrentUpdates )
1024     {
1025         debug() << QString( "Maximum concurrent updates (%1) reached. "
1026                             "Queueing \"%2\" for download." )
1027                 .arg( m_maxConcurrentUpdates )
1028                 .arg( channel->title() );
1029         m_updateQueue << channel;
1030         return;
1031     }
1032 
1033     PodcastReader *podcastReader = new PodcastReader( this );
1034 
1035     connect( podcastReader, &PodcastReader::finished,
1036              this, &SqlPodcastProvider::slotReadResult );
1037     connect( podcastReader, &PodcastReader::statusBarSorryMessage,
1038              this, &SqlPodcastProvider::slotStatusBarSorryMessage );
1039     connect( podcastReader, &PodcastReader::statusBarNewProgressOperation,
1040              this, &SqlPodcastProvider::slotStatusBarNewProgressOperation );
1041 
1042     m_updatingChannels++;
1043     podcastReader->update( Podcasts::PodcastChannelPtr::dynamicCast( channel ) );
1044 }
1045 
1046 void
slotReadResult(Podcasts::PodcastReader * podcastReader)1047 SqlPodcastProvider::slotReadResult( Podcasts::PodcastReader *podcastReader )
1048 {
1049     if( podcastReader->error() != QXmlStreamReader::NoError )
1050     {
1051         debug() << podcastReader->errorString();
1052         Amarok::Logger::longMessage( podcastReader->errorString(),
1053                                                    Amarok::Logger::Error );
1054     }
1055     debug() << "Finished updating: " << podcastReader->url();
1056     --m_updatingChannels;
1057     debug() << "Updating counter reached " << m_updatingChannels;
1058 
1059     Podcasts::SqlPodcastChannelPtr channel =
1060             Podcasts::SqlPodcastChannelPtr::dynamicCast( podcastReader->channel() );
1061 
1062     if( channel.isNull() )
1063     {
1064         error() << "Could not cast to SqlPodcastChannel " << __FILE__ << ":" << __LINE__;
1065         return;
1066     }
1067 
1068     if( channel->image().isNull() )
1069     {
1070         fetchImage( channel );
1071     }
1072 
1073     channel->updateInDb();
1074 
1075     podcastReader->deleteLater();
1076 
1077     //first we work through the list of new subscriptions
1078     if( !m_subscribeQueue.isEmpty() )
1079     {
1080         subscribe( m_subscribeQueue.takeFirst() );
1081     }
1082     else if( !m_updateQueue.isEmpty() )
1083     {
1084         updateSqlChannel( m_updateQueue.takeFirst() );
1085     }
1086     else if( m_updatingChannels == 0 )
1087     {
1088         //TODO: start downloading episodes here.
1089         if( m_podcastImageFetcher )
1090             m_podcastImageFetcher->run();
1091     }
1092 }
1093 
1094 void
slotStatusBarNewProgressOperation(KIO::TransferJob * job,const QString & description,Podcasts::PodcastReader * reader)1095 SqlPodcastProvider::slotStatusBarNewProgressOperation( KIO::TransferJob * job,
1096                                                        const QString &description,
1097                                                        Podcasts::PodcastReader* reader )
1098 {
1099     Amarok::Logger::newProgressOperation( job, description, reader, &Podcasts::PodcastReader::slotAbort );
1100 }
1101 
1102 void
downloadEpisode(Podcasts::SqlPodcastEpisodePtr sqlEpisode)1103 SqlPodcastProvider::downloadEpisode( Podcasts::SqlPodcastEpisodePtr sqlEpisode )
1104 {
1105     if( sqlEpisode.isNull() )
1106     {
1107         error() << "SqlPodcastProvider::downloadEpisode(  Podcasts::SqlPodcastEpisodePtr sqlEpisode ) was called for a non-SqlPodcastEpisode";
1108         return;
1109     }
1110 
1111     foreach( struct PodcastEpisodeDownload download, m_downloadJobMap )
1112     {
1113         if( download.episode == sqlEpisode )
1114         {
1115             debug() << "already downloading " << sqlEpisode->uidUrl();
1116             return;
1117         }
1118     }
1119 
1120     if( m_downloadJobMap.size() >= m_maxConcurrentDownloads )
1121     {
1122         debug() << QString( "Maximum concurrent downloads (%1) reached. "
1123                             "Queueing \"%2\" for download." )
1124                 .arg( m_maxConcurrentDownloads )
1125                 .arg( sqlEpisode->title() );
1126         //put into a FIFO which is used in downloadResult() to start a new download
1127         m_downloadQueue << sqlEpisode;
1128         return;
1129     }
1130 
1131     KIO::TransferJob *transferJob =
1132             KIO::get( QUrl::fromUserInput(sqlEpisode->uidUrl()), KIO::Reload, KIO::HideProgressInfo );
1133 
1134 
1135     QFile *tmpFile = createTmpFile( sqlEpisode );
1136     struct PodcastEpisodeDownload download = { sqlEpisode,
1137                                                tmpFile,
1138     /* Unless a redirect happens the filename from the enclosure is used. This is a potential source
1139        of filename conflicts in downloadResult() */
1140                                                QUrl( sqlEpisode->uidUrl() ).fileName(),
1141                                                false
1142                                              };
1143     m_downloadJobMap.insert( transferJob, download );
1144 
1145     if( tmpFile->exists() )
1146     {
1147         qint64 offset = tmpFile->size();
1148         debug() << "temporary file exists, resume download from offset " << offset;
1149         QMap<QString, QString> resumeData;
1150         resumeData.insert( QStringLiteral("resume"), QString::number( offset ) );
1151         transferJob->addMetaData( resumeData );
1152     }
1153 
1154     if( !tmpFile->open( QIODevice::WriteOnly | QIODevice::Append ) )
1155     {
1156         Amarok::Logger::longMessage( i18n( "Unable to save podcast episode file to %1",
1157                                              tmpFile->fileName() ) );
1158         delete tmpFile;
1159         return;
1160     }
1161 
1162     debug() << "starting download for " << sqlEpisode->title()
1163             << " url: " << sqlEpisode->prettyUrl();
1164     Amarok::Logger::newProgressOperation( transferJob
1165                                                         , sqlEpisode->title().isEmpty()
1166                                                         ? i18n( "Downloading Podcast Media" )
1167                                                         : i18n( "Downloading Podcast \"%1\""
1168                                                                 , sqlEpisode->title() ),
1169                                                         transferJob,
1170                                                         &KIO::TransferJob::kill,
1171                                                         Qt::AutoConnection,
1172                                                         KJob::Quietly
1173                                                       );
1174 
1175     connect( transferJob, &KIO::TransferJob::data,
1176              this, &SqlPodcastProvider::addData );
1177     //need to connect to finished instead of result because it's always emitted.
1178     //We need to cleanup after a download is canceled regardless of the argument in
1179     //KJob::kill()
1180     connect( transferJob, &KIO::TransferJob::finished,
1181              this, &SqlPodcastProvider::downloadResult );
1182     connect( transferJob, &KIO::TransferJob::redirection,
1183              this, &SqlPodcastProvider::redirected );
1184 }
1185 
1186 void
downloadEpisode(const Podcasts::PodcastEpisodePtr & episode)1187 SqlPodcastProvider::downloadEpisode( const Podcasts::PodcastEpisodePtr &episode )
1188 {
1189     downloadEpisode( SqlPodcastEpisodePtr::dynamicCast( episode ) );
1190 }
1191 
1192 void
cleanupDownload(KJob * job,bool downloadFailed)1193 SqlPodcastProvider::cleanupDownload( KJob *job, bool downloadFailed )
1194 {
1195     struct PodcastEpisodeDownload download = m_downloadJobMap.value( job );
1196     QFile *tmpFile = download.tmpFile;
1197 
1198     if( downloadFailed && tmpFile )
1199     {
1200         debug() << "deleting temporary podcast file: " << tmpFile->fileName();
1201         tmpFile->remove();
1202     }
1203     m_downloadJobMap.remove( job );
1204 
1205     delete tmpFile;
1206 }
1207 
1208 QFile *
createTmpFile(Podcasts::SqlPodcastEpisodePtr sqlEpisode)1209 SqlPodcastProvider::createTmpFile( Podcasts::SqlPodcastEpisodePtr sqlEpisode )
1210 {
1211     if( sqlEpisode.isNull() )
1212     {
1213         error() << "sqlEpisodePtr is NULL after download";
1214         return 0;
1215     }
1216     Podcasts::SqlPodcastChannelPtr sqlChannel =
1217             Podcasts::SqlPodcastChannelPtr::dynamicCast( sqlEpisode->channel() );
1218     if( sqlChannel.isNull() )
1219     {
1220         error() << "sqlChannelPtr is NULL after download";
1221         return 0;
1222     }
1223 
1224     QDir dir( sqlChannel->saveLocation().toLocalFile() );
1225     dir.mkpath( QStringLiteral(".") );  // ensure that the path is there
1226     //TODO: what if result is false?
1227 
1228     QUrl localUrl = QUrl::fromLocalFile( dir.absolutePath() );
1229     QString tempName;
1230     if( !sqlEpisode->guid().isEmpty() )
1231         tempName = QUrl::toPercentEncoding( sqlEpisode->guid() );
1232     else
1233         tempName = QUrl::toPercentEncoding( sqlEpisode->uidUrl() );
1234 
1235     QString tempNameMd5( QCryptographicHash::hash( tempName.toUtf8(), QCryptographicHash::Md5 ).toHex() );
1236 
1237     localUrl = localUrl.adjusted(QUrl::StripTrailingSlash);
1238     localUrl.setPath(localUrl.path() + QLatin1Char('/') + ( tempNameMd5 + PODCAST_TMP_POSTFIX ));
1239 
1240     return new QFile( localUrl.toLocalFile() );
1241 }
1242 
1243 bool
checkEnclosureLocallyAvailable(KIO::Job * job)1244 SqlPodcastProvider::checkEnclosureLocallyAvailable( KIO::Job *job )
1245 {
1246     struct PodcastEpisodeDownload download = m_downloadJobMap.value( job );
1247     Podcasts::SqlPodcastEpisodePtr sqlEpisode = download.episode;
1248     if( sqlEpisode.isNull() )
1249     {
1250         error() << "sqlEpisodePtr is NULL after download";
1251         return false;
1252     }
1253     Podcasts::SqlPodcastChannelPtr sqlChannel =
1254             Podcasts::SqlPodcastChannelPtr::dynamicCast( sqlEpisode->channel() );
1255     if( sqlChannel.isNull() )
1256     {
1257         error() << "sqlChannelPtr is NULL after download";
1258         return false;
1259     }
1260 
1261     QString fileName = sqlChannel->saveLocation().adjusted(QUrl::StripTrailingSlash).toLocalFile()
1262                        + QLatin1Char('/')
1263                        + download.fileName;
1264     debug() << "checking " << fileName;
1265     QFileInfo fileInfo( fileName );
1266     if( !fileInfo.exists() )
1267         return false;
1268 
1269     debug() << fileName << " already exists, no need to redownload";
1270     // NOTE: we need to Q_EMIT because the KJobProgressBar relies on it to clean up
1271     job->kill( KJob::EmitResult );
1272     sqlEpisode->setLocalUrl( QUrl::fromLocalFile(fileName) );
1273     //TODO: repaint icons, probably with signal metadataUpdate()
1274     return true;
1275 }
1276 
1277 void
addData(KIO::Job * job,const QByteArray & data)1278 SqlPodcastProvider::addData( KIO::Job *job, const QByteArray &data )
1279 {
1280     if( !data.size() )
1281     {
1282         return; // EOF
1283     }
1284 
1285     struct PodcastEpisodeDownload &download = m_downloadJobMap[job];
1286 
1287     // NOTE: if there is a tmpfile we are already downloading, no need to
1288     // checkEnclosureLocallyAvailable() on every data chunk. performance optimization.
1289     if( !download.finalNameReady )
1290     {
1291         download.finalNameReady = true;
1292         if( checkEnclosureLocallyAvailable( job ) )
1293             return;
1294     }
1295 
1296     if( download.tmpFile->write( data ) == -1 )
1297     {
1298         error() << "write error for " << download.tmpFile->fileName() << ": "
1299                 << download.tmpFile->errorString();
1300         job->kill();
1301     }
1302 }
1303 
1304 void
deleteDownloadedEpisode(const Podcasts::PodcastEpisodePtr & episode)1305 SqlPodcastProvider::deleteDownloadedEpisode( const Podcasts::PodcastEpisodePtr &episode )
1306 {
1307     deleteDownloadedEpisode( SqlPodcastEpisodePtr::dynamicCast( episode ) );
1308 }
1309 
1310 void
slotStatusBarSorryMessage(const QString & message)1311 SqlPodcastProvider::slotStatusBarSorryMessage( const QString &message )
1312 {
1313     Amarok::Logger::longMessage( message, Amarok::Logger::Error );
1314 }
1315 
1316 void
downloadResult(KJob * job)1317 SqlPodcastProvider::downloadResult( KJob *job )
1318 {
1319     struct PodcastEpisodeDownload download = m_downloadJobMap.value( job );
1320     QFile *tmpFile = download.tmpFile;
1321     bool downloadFailed = false;
1322 
1323     if( job->error() )
1324     {
1325         // NOTE: prevents empty error notifications from popping up
1326         // in the statusbar when the user cancels a download
1327         if( job->error() != KJob::KilledJobError )
1328         {
1329             Amarok::Logger::longMessage( job->errorText() );
1330         }
1331         error() << "Unable to retrieve podcast media. KIO Error: " << job->errorText();
1332         error() << "keeping temporary file for download restart";
1333         downloadFailed = false;
1334     }
1335     else
1336     {
1337         Podcasts::SqlPodcastEpisodePtr sqlEpisode = download.episode;
1338         if( sqlEpisode.isNull() )
1339         {
1340             error() << "sqlEpisodePtr is NULL after download";
1341             cleanupDownload( job, true );
1342             return;
1343         }
1344         Podcasts::SqlPodcastChannelPtr sqlChannel =
1345             Podcasts::SqlPodcastChannelPtr::dynamicCast( sqlEpisode->channel() );
1346         if( sqlChannel.isNull() )
1347         {
1348             error() << "sqlChannelPtr is NULL after download";
1349             cleanupDownload( job, true );
1350             return;
1351         }
1352 
1353         Amarok::QStringx filenameLayout = Amarok::QStringx( sqlChannel->filenameLayout() );
1354         QMap<QString,QString> layoutmap;
1355         QString sequenceNumber;
1356 
1357         if( sqlEpisode->artist() )
1358             layoutmap.insert( QStringLiteral("artist"), sqlEpisode->artist()->prettyName() );
1359 
1360         layoutmap.insert( QStringLiteral("title"), sqlEpisode->title() );
1361 
1362         if( sqlEpisode->genre() )
1363             layoutmap.insert( QStringLiteral("genre"), sqlEpisode->genre()->prettyName() );
1364 
1365         if( sqlEpisode->year() )
1366             layoutmap.insert( QStringLiteral("year"), sqlEpisode->year()->prettyName() );
1367 
1368         if( sqlEpisode->composer() )
1369             layoutmap.insert( QStringLiteral("composer"), sqlEpisode->composer()->prettyName() );
1370 
1371         layoutmap.insert( QStringLiteral("pubdate"), sqlEpisode->pubDate().toString() );
1372 
1373         sequenceNumber.sprintf( "%.6d", sqlEpisode->sequenceNumber() );
1374         layoutmap.insert( QStringLiteral("number"), sequenceNumber );
1375 
1376         if( sqlEpisode->album() )
1377             layoutmap.insert( QStringLiteral("album"), sqlEpisode->album()->prettyName() );
1378 
1379         if( !filenameLayout.isEmpty() &&
1380                 Amarok::QStringx::compare( filenameLayout, QStringLiteral("%default%"), Qt::CaseInsensitive ) )
1381         {
1382             filenameLayout = Amarok::QStringx(filenameLayout.namedArgs( layoutmap ));
1383             //add the file extension to the filename
1384             filenameLayout.append( QStringLiteral( "." ) );
1385             filenameLayout.append( sqlEpisode->type() );
1386             download.fileName = QString( filenameLayout );
1387         }
1388 
1389         QString finalName = sqlChannel->saveLocation().adjusted(QUrl::StripTrailingSlash).toLocalFile()
1390                             + QLatin1Char('/')
1391                             + download.fileName;
1392         if( tmpFile->rename( finalName ) )
1393         {
1394             debug() << "successfully written Podcast Episode " << sqlEpisode->title()
1395                     << " to " << finalName;
1396             sqlEpisode->setLocalUrl( QUrl::fromLocalFile(finalName) );
1397 
1398             if( sqlChannel->writeTags() )
1399                 sqlEpisode->writeTagsToFile();
1400             //TODO: force a redraw of the view so the icon can be updated in the PlaylistBrowser
1401 
1402             Q_EMIT episodeDownloaded( Podcasts::PodcastEpisodePtr::dynamicCast( sqlEpisode ) );
1403         }
1404         else
1405         {
1406             Amarok::Logger::longMessage( i18n( "Unable to save podcast episode file to %1",
1407                                                  finalName ) );
1408             downloadFailed = true;
1409         }
1410     }
1411 
1412     //remove it from the jobmap
1413     m_completedDownloads++;
1414     cleanupDownload( job, downloadFailed );
1415 
1416     //start a new download. We just finished one so there is at least one slot free.
1417     if( !m_downloadQueue.isEmpty() )
1418         downloadEpisode( m_downloadQueue.takeFirst() );
1419 }
1420 
1421 void
redirected(KIO::Job * job,const QUrl & redirectedUrl)1422 SqlPodcastProvider::redirected( KIO::Job *job, const QUrl &redirectedUrl )
1423 {
1424     debug() << "redirecting to " << redirectedUrl << ". filename: "
1425             << redirectedUrl.fileName();
1426     m_downloadJobMap[job].fileName = redirectedUrl.fileName();
1427 }
1428 
1429 void
createTables() const1430 SqlPodcastProvider::createTables() const
1431 {
1432     auto sqlStorage = StorageManager::instance()->sqlStorage();
1433     if( !sqlStorage )
1434         return;
1435 
1436     sqlStorage->query( QString( "CREATE TABLE podcastchannels ("
1437                                 "id " + sqlStorage->idType() +
1438                                 ",url " + sqlStorage->longTextColumnType() +
1439                                 ",title " + sqlStorage->longTextColumnType() +
1440                                 ",weblink " + sqlStorage->longTextColumnType() +
1441                                 ",image " + sqlStorage->longTextColumnType() +
1442                                 ",description " + sqlStorage->longTextColumnType() +
1443                                 ",copyright "  + sqlStorage->textColumnType() +
1444                                 ",directory "  + sqlStorage->textColumnType() +
1445                                 ",labels " + sqlStorage->textColumnType() +
1446                                 ",subscribedate " + sqlStorage->textColumnType() +
1447                                 ",autoscan BOOL, fetchtype INTEGER"
1448                                 ",haspurge BOOL, purgecount INTEGER"
1449                                 ",writetags BOOL, filenamelayout VARCHAR(1024) ) ENGINE = MyISAM;" ) );
1450 
1451     sqlStorage->query( QString( "CREATE TABLE podcastepisodes ("
1452                                 "id " + sqlStorage->idType() +
1453                                 ",url " + sqlStorage->longTextColumnType() +
1454                                 ",channel INTEGER"
1455                                 ",localurl " + sqlStorage->longTextColumnType() +
1456                                 ",guid " + sqlStorage->exactTextColumnType() +
1457                                 ",title " + sqlStorage->longTextColumnType() +
1458                                 ",subtitle " + sqlStorage->longTextColumnType() +
1459                                 ",sequencenumber INTEGER" +
1460                                 ",description " + sqlStorage->longTextColumnType() +
1461                                 ",mimetype "  + sqlStorage->textColumnType() +
1462                                 ",pubdate "  + sqlStorage->textColumnType() +
1463                                 ",duration INTEGER"
1464                                 ",filesize INTEGER"
1465                                 ",isnew BOOL"
1466                                 ",iskeep BOOL) ENGINE = MyISAM;" ) );
1467 
1468     sqlStorage->query( QStringLiteral("CREATE FULLTEXT INDEX url_podchannel ON podcastchannels( url );") );
1469     sqlStorage->query( QStringLiteral("CREATE FULLTEXT INDEX url_podepisode ON podcastepisodes( url );") );
1470     sqlStorage->query(
1471             QStringLiteral("CREATE FULLTEXT INDEX localurl_podepisode ON podcastepisodes( localurl );") );
1472 }
1473 
1474 void
updateDatabase(int fromVersion,int toVersion)1475 SqlPodcastProvider::updateDatabase( int fromVersion, int toVersion )
1476 {
1477     debug() << QStringLiteral( "Updating Podcast tables from version %1 to version %2" )
1478             .arg( fromVersion ).arg( toVersion );
1479 
1480     auto sqlStorage = StorageManager::instance()->sqlStorage();
1481     if( !sqlStorage )
1482         return;
1483 #define escape(x) sqlStorage->escape(x)
1484 
1485     if( fromVersion == 1 && toVersion == 2 )
1486     {
1487         QString updateChannelQuery = QString( "ALTER TABLE podcastchannels"
1488                                               " ADD subscribedate " + sqlStorage->textColumnType() + ';' );
1489 
1490         sqlStorage->query( updateChannelQuery );
1491 
1492         QString setDateQuery = QStringLiteral(
1493                 "UPDATE podcastchannels SET subscribedate='%1' WHERE subscribedate='';" )
1494                 .arg( escape( QDate::currentDate().toString() ) );
1495         sqlStorage->query( setDateQuery );
1496     }
1497     else if( fromVersion < 3 && toVersion == 3 )
1498     {
1499         sqlStorage->query( QString( "CREATE TABLE podcastchannels_temp ("
1500                                     "id " + sqlStorage->idType() +
1501                                     ",url " + sqlStorage->exactTextColumnType() + " UNIQUE"
1502                                     ",title " + sqlStorage->textColumnType() +
1503                                     ",weblink " + sqlStorage->exactTextColumnType() +
1504                                     ",image " + sqlStorage->exactTextColumnType() +
1505                                     ",description " + sqlStorage->longTextColumnType() +
1506                                     ",copyright "  + sqlStorage->textColumnType() +
1507                                     ",directory "  + sqlStorage->textColumnType() +
1508                                     ",labels " + sqlStorage->textColumnType() +
1509                                     ",subscribedate " + sqlStorage->textColumnType() +
1510                                     ",autoscan BOOL, fetchtype INTEGER"
1511                                     ",haspurge BOOL, purgecount INTEGER ) ENGINE = MyISAM;" ) );
1512 
1513         sqlStorage->query( QString( "CREATE TABLE podcastepisodes_temp ("
1514                                     "id " + sqlStorage->idType() +
1515                                     ",url " + sqlStorage->exactTextColumnType() + " UNIQUE"
1516                                     ",channel INTEGER"
1517                                     ",localurl " + sqlStorage->exactTextColumnType() +
1518                                     ",guid " + sqlStorage->exactTextColumnType() +
1519                                     ",title " + sqlStorage->textColumnType() +
1520                                     ",subtitle " + sqlStorage->textColumnType() +
1521                                     ",sequencenumber INTEGER" +
1522                                     ",description " + sqlStorage->longTextColumnType() +
1523                                     ",mimetype "  + sqlStorage->textColumnType() +
1524                                     ",pubdate "  + sqlStorage->textColumnType() +
1525                                     ",duration INTEGER"
1526                                     ",filesize INTEGER"
1527                                     ",isnew BOOL"
1528                                     ",iskeep BOOL) ENGINE = MyISAM;" ) );
1529 
1530         sqlStorage->query( QStringLiteral("INSERT INTO podcastchannels_temp SELECT * FROM podcastchannels;") );
1531         sqlStorage->query( QStringLiteral("INSERT INTO podcastepisodes_temp SELECT * FROM podcastepisodes;") );
1532 
1533         sqlStorage->query( QStringLiteral("DROP TABLE podcastchannels;") );
1534         sqlStorage->query( QStringLiteral("DROP TABLE podcastepisodes;") );
1535 
1536         createTables();
1537 
1538         sqlStorage->query( QStringLiteral("INSERT INTO podcastchannels SELECT * FROM podcastchannels_temp;") );
1539         sqlStorage->query( QStringLiteral("INSERT INTO podcastepisodes SELECT * FROM podcastepisodes_temp;") );
1540 
1541         sqlStorage->query( QStringLiteral("DROP TABLE podcastchannels_temp;") );
1542         sqlStorage->query( QStringLiteral("DROP TABLE podcastepisodes_temp;") );
1543     }
1544 
1545     if( fromVersion < 4 && toVersion == 4 )
1546     {
1547         QString updateChannelQuery = QString( "ALTER TABLE podcastchannels"
1548                                               " ADD writetags BOOL;" );
1549         sqlStorage->query( updateChannelQuery );
1550         QString setWriteTagsQuery = QString( "UPDATE podcastchannels SET writetags=" +
1551                                              sqlStorage->boolTrue() +
1552                                              " WHERE 1;" );
1553         sqlStorage->query( setWriteTagsQuery );
1554     }
1555 
1556     if( fromVersion < 5 && toVersion == 5 )
1557     {
1558         QString updateChannelQuery = QString ( "ALTER TABLE podcastchannels"
1559                                                " ADD filenamelayout VARCHAR(1024);" );
1560         sqlStorage->query( updateChannelQuery );
1561         QString setWriteTagsQuery = QStringLiteral( "UPDATE podcastchannels SET filenamelayout='%default%'" );
1562         sqlStorage->query( setWriteTagsQuery );
1563     }
1564 
1565     if( fromVersion < 6 && toVersion == 6 )
1566     {
1567         QString updateEpisodeQuery = QString ( "ALTER TABLE podcastepisodes"
1568                                                " ADD iskeep BOOL;" );
1569         sqlStorage->query( updateEpisodeQuery );
1570         QString setIsKeepQuery = QStringLiteral( "UPDATE podcastepisodes SET iskeep=FALSE;" );
1571         sqlStorage->query( setIsKeepQuery );
1572     }
1573 
1574     QString updateAdmin = QStringLiteral( "UPDATE admin SET version=%1 WHERE component='%2';" );
1575     sqlStorage->query( updateAdmin.arg( toVersion ).arg( escape( key ) ) );
1576 
1577     loadPodcasts();
1578 }
1579 
1580 void
fetchImage(const SqlPodcastChannelPtr & channel)1581 SqlPodcastProvider::fetchImage( const SqlPodcastChannelPtr &channel )
1582 {
1583     if( m_podcastImageFetcher == 0 )
1584     {
1585         m_podcastImageFetcher = new PodcastImageFetcher();
1586         connect( m_podcastImageFetcher, &PodcastImageFetcher::channelImageReady,
1587                  this, &SqlPodcastProvider::channelImageReady );
1588                  connect( m_podcastImageFetcher,&PodcastImageFetcher::done,
1589                  this, &SqlPodcastProvider::podcastImageFetcherDone );
1590     }
1591 
1592     m_podcastImageFetcher->addChannel( PodcastChannelPtr::dynamicCast( channel ) );
1593 }
1594 
1595 void
channelImageReady(Podcasts::PodcastChannelPtr channel,const QImage & image)1596 SqlPodcastProvider::channelImageReady( Podcasts::PodcastChannelPtr channel, const QImage &image )
1597 {
1598     if( image.isNull() )
1599         return;
1600 
1601     channel->setImage( image );
1602 }
1603 
1604 void
podcastImageFetcherDone(PodcastImageFetcher * fetcher)1605 SqlPodcastProvider::podcastImageFetcherDone( PodcastImageFetcher *fetcher )
1606 {
1607     fetcher->deleteLater();
1608     m_podcastImageFetcher = 0;
1609 }
1610 
1611 void
slotConfigureProvider()1612 SqlPodcastProvider::slotConfigureProvider()
1613 {
1614     configureProvider();
1615 }
1616 
1617