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