From b0dd1bd2845d5a8b9bd79d605f3be882ab142fed Mon Sep 17 00:00:00 2001 From: David Sansome Date: Sat, 10 Mar 2012 15:32:36 +0000 Subject: [PATCH] Add a podcast downloader --- src/CMakeLists.txt | 2 + src/core/application.cpp | 3 + src/core/application.h | 3 + src/core/network.cpp | 39 +++++ src/core/network.h | 40 +++++ src/podcasts/podcastdownloader.cpp | 211 +++++++++++++++++++++++++++ src/podcasts/podcastdownloader.h | 89 +++++++++++ src/podcasts/podcastservice.cpp | 12 ++ src/podcasts/podcastservice.h | 3 + src/podcasts/podcastsettingspage.cpp | 21 +++ src/podcasts/podcastsettingspage.h | 2 + src/podcasts/podcastsettingspage.ui | 25 +++- 12 files changed, 449 insertions(+), 1 deletion(-) create mode 100644 src/podcasts/podcastdownloader.cpp create mode 100644 src/podcasts/podcastdownloader.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6be6f8d76..1321552c9 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -240,6 +240,7 @@ set(SOURCES podcasts/podcast.cpp podcasts/podcastbackend.cpp podcasts/podcastdiscoverymodel.cpp + podcasts/podcastdownloader.cpp podcasts/podcastepisode.cpp podcasts/podcastinfowidget.cpp podcasts/podcastservice.cpp @@ -494,6 +495,7 @@ set(HEADERS podcasts/itunessearchpage.h podcasts/podcastbackend.h podcasts/podcastdiscoverymodel.h + podcasts/podcastdownloader.h podcasts/podcastinfowidget.h podcasts/podcastservice.h podcasts/podcastsettingspage.h diff --git a/src/core/application.cpp b/src/core/application.cpp index d0d675166..6c38826a3 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -33,6 +33,7 @@ #include "playlist/playlistmanager.h" #include "podcasts/gpoddersync.h" #include "podcasts/podcastbackend.h" +#include "podcasts/podcastdownloader.h" #include "podcasts/podcastupdater.h" Application::Application(QObject* parent) @@ -53,6 +54,7 @@ Application::Application(QObject* parent) library_(NULL), device_manager_(NULL), podcast_updater_(NULL), + podcast_downloader_(NULL), gpodder_sync_(NULL) { tag_reader_client_ = new TagReaderClient(this); @@ -82,6 +84,7 @@ Application::Application(QObject* parent) library_ = new Library(this, this); device_manager_ = new DeviceManager(this, this); podcast_updater_ = new PodcastUpdater(this, this); + podcast_downloader_ = new PodcastDownloader(this, this); gpodder_sync_ = new GPodderSync(this, this); library_->Init(); diff --git a/src/core/application.h b/src/core/application.h index bdf512cbc..a3a088738 100644 --- a/src/core/application.h +++ b/src/core/application.h @@ -34,6 +34,7 @@ class LibraryBackend; class LibraryModel; class Player; class PlaylistBackend; +class PodcastDownloader; class PlaylistManager; class PodcastBackend; class PodcastUpdater; @@ -64,6 +65,7 @@ public: Library* library() const { return library_; } DeviceManager* device_manager() const { return device_manager_; } PodcastUpdater* podcast_updater() const { return podcast_updater_; } + PodcastDownloader* podcast_downloader() const { return podcast_downloader_; } GPodderSync* gpodder_sync() const { return gpodder_sync_; } LibraryBackend* library_backend() const; @@ -97,6 +99,7 @@ private: Library* library_; DeviceManager* device_manager_; PodcastUpdater* podcast_updater_; + PodcastDownloader* podcast_downloader_; GPodderSync* gpodder_sync_; QList objects_in_threads_; diff --git a/src/core/network.cpp b/src/core/network.cpp index ae8e40d9f..fdfbf4985 100644 --- a/src/core/network.cpp +++ b/src/core/network.cpp @@ -134,3 +134,42 @@ void NetworkTimeouts::timerEvent(QTimerEvent* e) { reply->abort(); } } + + +RedirectFollower::RedirectFollower(QNetworkReply* first_reply, int max_redirects) + : QObject(NULL), + current_reply_(first_reply), + redirects_remaining_(max_redirects) { + ConnectReply(first_reply); +} + +void RedirectFollower::ConnectReply(QNetworkReply* reply) { + connect(reply, SIGNAL(readyRead()), SIGNAL(readyRead())); + connect(reply, SIGNAL(error(QNetworkReply::NetworkError)), SIGNAL(error(QNetworkReply::NetworkError))); + connect(reply, SIGNAL(downloadProgress(qint64,qint64)), SIGNAL(downloadProgress(qint64,qint64))); + connect(reply, SIGNAL(uploadProgress(qint64,qint64)), SIGNAL(uploadProgress(qint64,qint64))); + connect(reply, SIGNAL(finished()), SLOT(ReplyFinished())); +} + +void RedirectFollower::ReplyFinished() { + current_reply_->deleteLater(); + + if (current_reply_->attribute(QNetworkRequest::RedirectionTargetAttribute).isValid()) { + if (redirects_remaining_-- == 0) { + emit finished(); + return; + } + + const QUrl next_url = current_reply_->url().resolved( + current_reply_->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl()); + + QNetworkRequest req(current_reply_->request()); + req.setUrl(next_url); + + current_reply_ = current_reply_->manager()->get(req); + ConnectReply(current_reply_); + return; + } + + emit finished(); +} diff --git a/src/core/network.h b/src/core/network.h index 97d506887..82a5c0b87 100644 --- a/src/core/network.h +++ b/src/core/network.h @@ -21,6 +21,7 @@ #include #include #include +#include class QNetworkDiskCache; @@ -43,6 +44,7 @@ private: static QNetworkDiskCache* sCache; }; + class NetworkAccessManager : public QNetworkAccessManager { Q_OBJECT @@ -54,6 +56,7 @@ protected: QIODevice* outgoingData); }; + class NetworkTimeouts : public QObject { Q_OBJECT @@ -74,4 +77,41 @@ private: QMap timers_; }; + +class RedirectFollower : public QObject { + Q_OBJECT + +public: + RedirectFollower(QNetworkReply* first_reply, int max_redirects = 5); + + bool hit_redirect_limit() const { return redirects_remaining_ < 0; } + QNetworkReply* reply() const { return current_reply_; } + + // These are all forwarded to the current reply. + QNetworkReply::NetworkError error() const { return current_reply_->error(); } + QString errorString() const { return current_reply_->errorString(); } + QVariant attribute(QNetworkRequest::Attribute code) const { return current_reply_->attribute(code); } + QVariant header(QNetworkRequest::KnownHeaders header) const { return current_reply_->header(header); } + +signals: + // These are all forwarded from the current reply. + void readyRead(); + void error(QNetworkReply::NetworkError); + void uploadProgress(qint64 bytesSent, qint64 bytesTotal); + void downloadProgress(qint64 bytesReceived, qint64 bytesTotal); + + // This is NOT emitted when a request that has a redirect finishes. + void finished(); + +private slots: + void ReplyFinished(); + +private: + void ConnectReply(QNetworkReply* reply); + +private: + QNetworkReply* current_reply_; + int redirects_remaining_; +}; + #endif // NETWORK_H diff --git a/src/podcasts/podcastdownloader.cpp b/src/podcasts/podcastdownloader.cpp new file mode 100644 index 000000000..1c34846b6 --- /dev/null +++ b/src/podcasts/podcastdownloader.cpp @@ -0,0 +1,211 @@ +/* This file is part of Clementine. + Copyright 2012, David Sansome + + Clementine is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Clementine is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Clementine. If not, see . +*/ + +#include "podcastbackend.h" +#include "podcastdownloader.h" +#include "core/application.h" +#include "core/logging.h" +#include "core/network.h" +#include "core/utilities.h" +#include "library/librarydirectorymodel.h" +#include "library/librarymodel.h" + +#include +#include +#include +#include +#include + +const char* PodcastDownloader::kSettingsGroup = "Podcasts"; + + +struct PodcastDownloader::Task { + Task() : file(NULL) {} + ~Task() { delete file; } + + PodcastEpisode episode; + QFile* file; +}; + +PodcastDownloader::PodcastDownloader(Application* app, QObject* parent) + : QObject(parent), + app_(app), + network_(new NetworkAccessManager(this)), + disallowed_filename_characters_("[^a-zA-Z0-9_~ -]"), + current_task_(NULL), + last_progress_signal_(0) +{ + connect(app_->podcast_backend(), SIGNAL(EpisodesAdded(QList)), + SLOT(EpisodesAdded(QList))); + connect(app_->podcast_backend(), SIGNAL(SubscriptionAdded(Podcast)), + SLOT(SubscriptionAdded(Podcast))); + connect(app_, SIGNAL(SettingsChanged()), SLOT(ReloadSettings())); + + ReloadSettings(); +} + +QString PodcastDownloader::DefaultDownloadDir() const { + QString prefix = QDir::homePath(); + + LibraryDirectoryModel* model = app_->library_model()->directory_model(); + if (model->rowCount() > 0) { + // Download to the first library directory if there is one set + prefix = model->index(0, 0).data().toString(); + } + + return prefix + "/Podcasts"; +} + +void PodcastDownloader::ReloadSettings() { + QSettings s; + s.beginGroup(kSettingsGroup); + + download_dir_ = s.value("download_dir", DefaultDownloadDir()).toString(); +} + +void PodcastDownloader::DownloadEpisode(const PodcastEpisode& episode) { + Task* task = new Task; + task->episode = episode; + + if (current_task_) { + // Add it to the queue + queued_tasks_.enqueue(task); + emit ProgressChanged(episode, Queued, 0); + } else { + // Start downloading now + StartDownloading(task); + } +} + +void PodcastDownloader::StartDownloading(Task* task) { + current_task_ = task; + + // Need to get the name of the podcast to use in the directory name. + Podcast podcast = + app_->podcast_backend()->GetSubscriptionById(task->episode.podcast_database_id()); + if (!podcast.is_valid()) { + qLog(Warning) << "The podcast that contains episode" << task->episode.url() + << "doesn't exist any more"; + emit ProgressChanged(task->episode, Finished, 0); + delete task; + NextTask(); + return; + } + + const QString file_extension = QFileInfo(task->episode.url().path()).suffix(); + const QString directory = download_dir_ + "/" + + SanitiseFilenameComponent(podcast.title()); + const QString filename = + SanitiseFilenameComponent(task->episode.title()) + "." + file_extension; + const QString filepath = directory + "/" + filename; + + // Open the output file + QDir().mkpath(directory); + task->file = new QFile(filepath); + if (!task->file->open(QIODevice::WriteOnly)) { + qLog(Warning) << "Could not open the file" << filepath << "for writing"; + emit ProgressChanged(task->episode, Finished, 0); + delete task; + NextTask(); + return; + } + + qLog(Info) << "Downloading" << task->episode.url() << "to" << filepath; + + // Get the URL + QNetworkRequest req(task->episode.url()); + RedirectFollower* reply = new RedirectFollower(network_->get(req)); + connect(reply, SIGNAL(readyRead()), SLOT(ReplyReadyRead())); + connect(reply, SIGNAL(finished()), SLOT(ReplyFinished())); + connect(reply, SIGNAL(downloadProgress(qint64,qint64)), + SLOT(ReplyDownloadProgress(qint64,qint64))); + + emit ProgressChanged(task->episode, Downloading, 0); +} + +void PodcastDownloader::NextTask() { + current_task_ = NULL; + + if (!queued_tasks_.isEmpty()) { + StartDownloading(queued_tasks_.dequeue()); + } +} + +void PodcastDownloader::ReplyReadyRead() { + QNetworkReply* reply = qobject_cast(sender())->reply(); + if (!reply || !current_task_ || !current_task_->file) + return; + + forever { + const qint64 bytes = reply->bytesAvailable(); + if (bytes <= 0) + break; + + current_task_->file->write(reply->read(bytes)); + } +} + +void PodcastDownloader::ReplyDownloadProgress(qint64 received, qint64 total) { + if (!current_task_ || !current_task_->file) + return; + + const time_t current_time = QDateTime::currentDateTime().toTime_t(); + if (last_progress_signal_ == current_time) + return; + last_progress_signal_ = current_time; + + emit ProgressChanged(current_task_->episode, Downloading, + float(received) / total * 100); +} + +void PodcastDownloader::ReplyFinished() { + RedirectFollower* reply = qobject_cast(sender()); + if (!reply || !current_task_ || !current_task_->file) + return; + + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + qLog(Warning) << "Error downloading episode:" << reply->errorString(); + + // Delete the file + current_task_->file->remove(); + + emit ProgressChanged(current_task_->episode, Finished, 0); + delete current_task_; + NextTask(); + return; + } + + qLog(Info) << "Download of" << current_task_->file->fileName() << "finished"; + emit ProgressChanged(current_task_->episode, Finished, 100); + + delete current_task_; + NextTask(); +} + +QString PodcastDownloader::SanitiseFilenameComponent(const QString& text) const { + return QString(text).replace(disallowed_filename_characters_, " ").simplified(); +} + +void PodcastDownloader::SubscriptionAdded(const Podcast& podcast) { + // TODO +} + +void PodcastDownloader::EpisodesAdded(const QList& episodes) { + // TOOD +} diff --git a/src/podcasts/podcastdownloader.h b/src/podcasts/podcastdownloader.h new file mode 100644 index 000000000..975df3001 --- /dev/null +++ b/src/podcasts/podcastdownloader.h @@ -0,0 +1,89 @@ +/* This file is part of Clementine. + Copyright 2012, David Sansome + + Clementine is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Clementine is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Clementine. If not, see . +*/ + +#ifndef PODCASTDOWNLOADER_H +#define PODCASTDOWNLOADER_H + +#include "podcast.h" +#include "podcastepisode.h" + +#include +#include +#include +#include + +class Application; + +class QNetworkAccessManager; + +class PodcastDownloader : public QObject { + Q_OBJECT + +public: + PodcastDownloader(Application* app, QObject* parent = 0); + + enum State { + Queued, + Downloading, + Finished + }; + + static const char* kSettingsGroup; + + QString DefaultDownloadDir() const; + +public slots: + // Adds the episode to the download queue + void DownloadEpisode(const PodcastEpisode& episode); + +signals: + void ProgressChanged(const PodcastEpisode& episode, State state, int percent); + +private slots: + void ReloadSettings(); + + void SubscriptionAdded(const Podcast& podcast); + void EpisodesAdded(const QList& episodes); + + void ReplyReadyRead(); + void ReplyFinished(); + void ReplyDownloadProgress(qint64 received, qint64 total); + +private: + struct Task; + + void StartDownloading(Task* task); + void NextTask(); + + QString FilenameForEpisode(const PodcastEpisode& episode) const; + QString SanitiseFilenameComponent(const QString& text) const; + +private: + Application* app_; + QNetworkAccessManager* network_; + + QRegExp disallowed_filename_characters_; + + QString download_dir_; + + Task* current_task_; + QQueue queued_tasks_; + + time_t last_progress_signal_; +}; + +#endif // PODCASTDOWNLOADER_H diff --git a/src/podcasts/podcastservice.cpp b/src/podcasts/podcastservice.cpp index 1ca7bfa33..f63c4b899 100644 --- a/src/podcasts/podcastservice.cpp +++ b/src/podcasts/podcastservice.cpp @@ -17,6 +17,7 @@ #include "addpodcastdialog.h" #include "podcastbackend.h" +#include "podcastdownloader.h" #include "podcastservice.h" #include "podcastupdater.h" #include "core/application.h" @@ -202,9 +203,13 @@ void PodcastService::ShowContextMenu(const QModelIndex& index, remove_selected_action_ = context_menu_->addAction(IconLoader::Load("list-remove"), tr("Unsubscribe"), this, SLOT(RemoveSelectedPodcast())); + download_selected_action_ = context_menu_->addAction(IconLoader::Load("download"), + tr("Download this episode"), + this, SLOT(DownloadSelectedEpisode())); } current_index_ = index; + bool is_episode = false; switch (index.data(InternetModel::Role_Type).toInt()) { case Type_Podcast: @@ -213,6 +218,7 @@ void PodcastService::ShowContextMenu(const QModelIndex& index, case Type_Episode: current_podcast_index_ = index.parent(); + is_episode = true; break; default: @@ -222,6 +228,7 @@ void PodcastService::ShowContextMenu(const QModelIndex& index, update_selected_action_->setVisible(current_podcast_index_.isValid()); remove_selected_action_->setVisible(current_podcast_index_.isValid()); + download_selected_action_->setVisible(is_episode); context_menu_->popup(global_pos); } @@ -301,3 +308,8 @@ void PodcastService::EpisodesAdded(const QList& episodes) { } } } + +void PodcastService::DownloadSelectedEpisode() { + app_->podcast_downloader()->DownloadEpisode( + current_index_.data(Role_Episode).value()); +} diff --git a/src/podcasts/podcastservice.h b/src/podcasts/podcastservice.h index 13cbfed57..bc6c9840d 100644 --- a/src/podcasts/podcastservice.h +++ b/src/podcasts/podcastservice.h @@ -66,6 +66,8 @@ private slots: void UpdateSelectedPodcast(); void RemoveSelectedPodcast(); + void DownloadSelectedEpisode(); + void SubscriptionAdded(const Podcast& podcast); void SubscriptionRemoved(const Podcast& podcast); void EpisodesAdded(const QList& episodes); @@ -89,6 +91,7 @@ private: QMenu* context_menu_; QAction* update_selected_action_; QAction* remove_selected_action_; + QAction* download_selected_action_; QStandardItem* root_; QModelIndex current_index_; diff --git a/src/podcasts/podcastsettingspage.cpp b/src/podcasts/podcastsettingspage.cpp index d92092620..d3ed77a69 100644 --- a/src/podcasts/podcastsettingspage.cpp +++ b/src/podcasts/podcastsettingspage.cpp @@ -16,12 +16,16 @@ */ #include "gpoddersync.h" +#include "podcastdownloader.h" #include "podcastsettingspage.h" #include "ui_podcastsettingspage.h" #include "core/application.h" #include "core/closure.h" +#include "library/librarydirectorymodel.h" +#include "library/librarymodel.h" #include "ui/settingsdialog.h" +#include #include #include @@ -35,6 +39,7 @@ PodcastSettingsPage::PodcastSettingsPage(SettingsDialog* dialog) connect(ui_->login, SIGNAL(clicked()), SLOT(LoginClicked())); connect(ui_->login_state, SIGNAL(LoginClicked()), SLOT(LoginClicked())); connect(ui_->login_state, SIGNAL(LogoutClicked()), SLOT(LogoutClicked())); + connect(ui_->download_dir_browse, SIGNAL(clicked()), SLOT(DownloadDirBrowse())); ui_->login_state->AddCredentialField(ui_->username); ui_->login_state->AddCredentialField(ui_->device_name); @@ -62,12 +67,18 @@ void PodcastSettingsPage::Load() { const int update_interval = s.value("update_interval_secs", 0).toInt(); ui_->check_interval->setCurrentIndex(ui_->check_interval->findData(update_interval)); + const QString default_download_dir = + dialog()->app()->podcast_downloader()->DefaultDownloadDir(); + ui_->download_dir->setText(QDir::toNativeSeparators( + s.value("download_dir", default_download_dir).toString())); + ui_->auto_download->setChecked(s.value("auto_download", false).toBool()); ui_->delete_after->setValue(s.value("delete_after", 0).toInt() / (24*60*60)); ui_->delete_unplayed->setChecked(s.value("delete_unplayed", false).toBool()); ui_->username->setText(s.value("gpodder_username").toString()); ui_->device_name->setText(s.value("gpodder_device_name", GPodderSync::DefaultDeviceName()).toString()); + } void PodcastSettingsPage::Save() { @@ -76,6 +87,7 @@ void PodcastSettingsPage::Save() { s.setValue("update_interval_secs", ui_->check_interval->itemData(ui_->check_interval->currentIndex())); + s.setValue("download_dir", QDir::fromNativeSeparators(ui_->download_dir->text())); s.setValue("auto_download", ui_->auto_download->isChecked()); s.setValue("delete_after", ui_->delete_after->value()); s.setValue("delete_unplayed", ui_->delete_unplayed->isChecked()); @@ -111,3 +123,12 @@ void PodcastSettingsPage::LogoutClicked() { ui_->password->clear(); dialog()->app()->gpodder_sync()->Logout(); } + +void PodcastSettingsPage::DownloadDirBrowse() { + QString directory = QFileDialog::getExistingDirectory( + this, tr("Choose podcast download directory"), ui_->download_dir->text()); + if (directory.isEmpty()) + return; + + ui_->download_dir->setText(QDir::toNativeSeparators(directory)); +} diff --git a/src/podcasts/podcastsettingspage.h b/src/podcasts/podcastsettingspage.h index 53690c28e..46f8cc850 100644 --- a/src/podcasts/podcastsettingspage.h +++ b/src/podcasts/podcastsettingspage.h @@ -41,6 +41,8 @@ private slots: void LoginFinished(QNetworkReply* reply); void LogoutClicked(); + void DownloadDirBrowse(); + private: Ui_PodcastSettingsPage* ui_; }; diff --git a/src/podcasts/podcastsettingspage.ui b/src/podcasts/podcastsettingspage.ui index b30f51916..9151a0c8a 100644 --- a/src/podcasts/podcastsettingspage.ui +++ b/src/podcasts/podcastsettingspage.ui @@ -75,13 +75,34 @@ - + + + + Download episodes to + + + + Download new episodes automatically + + + + + + + + + Browse... + + + + + @@ -231,6 +252,8 @@ check_interval + download_dir + download_dir_browse auto_download delete_after delete_unplayed