1 /* This file is part of Clementine.
2    Copyright 2012, David Sansome <me@davidsansome.com>
3    Copyright 2014, Krzysztof A. Sobiecki <sobkas@gmail.com>
4    Copyright 2014, John Maguire <john.maguire@gmail.com>
5 
6    Clementine is free software: you can redistribute it and/or modify
7    it under the terms of the GNU General Public License as published by
8    the Free Software Foundation, either version 3 of the License, or
9    (at your option) any later version.
10 
11    Clementine is distributed in the hope that it will be useful,
12    but WITHOUT ANY WARRANTY; without even the implied warranty of
13    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14    GNU General Public License for more details.
15 
16    You should have received a copy of the GNU General Public License
17    along with Clementine.  If not, see <http://www.gnu.org/licenses/>.
18 */
19 
20 #include "podcastdownloader.h"
21 
22 #include <QDateTime>
23 #include <QDir>
24 #include <QFile>
25 #include <QNetworkReply>
26 #include <QSettings>
27 #include <QTimer>
28 
29 #include "core/application.h"
30 #include "core/logging.h"
31 #include "core/network.h"
32 #include "core/tagreaderclient.h"
33 #include "core/timeconstants.h"
34 #include "core/utilities.h"
35 #include "library/librarydirectorymodel.h"
36 #include "library/librarymodel.h"
37 #include "podcastbackend.h"
38 
39 const char* PodcastDownloader::kSettingsGroup = "Podcasts";
40 
Task(PodcastEpisode episode,QFile * file,PodcastBackend * backend)41 Task::Task(PodcastEpisode episode, QFile* file, PodcastBackend* backend)
42     : file_(file),
43       episode_(episode),
44       req_(QNetworkRequest(episode.url())),
45       backend_(backend),
46       network_(new NetworkAccessManager(this)),
47       repl(new RedirectFollower(network_->get(req_))) {
48   connect(repl.get(), SIGNAL(readyRead()), SLOT(reading()));
49   connect(repl.get(), SIGNAL(finished()), SLOT(finishedInternal()));
50   connect(repl.get(), SIGNAL(downloadProgress(qint64, qint64)),
51           SLOT(downloadProgressInternal(qint64, qint64)));
52   emit ProgressChanged(episode_, PodcastDownload::Queued, 0);
53 }
54 
episode() const55 PodcastEpisode Task::episode() const { return episode_; }
56 
reading()57 void Task::reading() {
58   qint64 bytes = 0;
59   forever {
60     bytes = repl->bytesAvailable();
61     if (bytes <= 0) break;
62 
63     file_->write(repl->reply()->read(bytes));
64   }
65 }
finishedPublic()66 void Task::finishedPublic() {
67   disconnect(repl.get(), SIGNAL(readyRead()), 0, 0);
68   disconnect(repl.get(), SIGNAL(downloadProgress(qint64, qint64)), 0, 0);
69   disconnect(repl.get(), SIGNAL(finished()), 0, 0);
70   emit ProgressChanged(episode_, PodcastDownload::NotDownloading, 0);
71   // Delete the file
72   file_->remove();
73   emit finished(this);
74 }
75 
finishedInternal()76 void Task::finishedInternal() {
77   if (repl->error() != QNetworkReply::NoError) {
78     qLog(Warning) << "Error downloading episode:" << repl->errorString();
79     emit ProgressChanged(episode_, PodcastDownload::NotDownloading, 0);
80     // Delete the file
81     file_->remove();
82     emit finished(this);
83     return;
84   }
85 
86   qLog(Info) << "Download of" << file_->fileName() << "finished";
87 
88   // Tell the database the episode has been updated.  Get it from the DB again
89   // in case the listened field changed in the mean time.
90   PodcastEpisode episode = episode_;
91   episode.set_downloaded(true);
92   episode.set_local_url(QUrl::fromLocalFile(file_->fileName()));
93   backend_->UpdateEpisodes(PodcastEpisodeList() << episode);
94   Podcast podcast =
95       backend_->GetSubscriptionById(episode.podcast_database_id());
96   Song song = episode_.ToSong(podcast);
97 
98   emit ProgressChanged(episode_, PodcastDownload::Finished, 0);
99 
100   // I didn't ecountered even a single podcast with a correct metadata
101   TagReaderClient::Instance()->SaveFileBlocking(file_->fileName(), song);
102   emit finished(this);
103 }
104 
downloadProgressInternal(qint64 received,qint64 total)105 void Task::downloadProgressInternal(qint64 received, qint64 total) {
106   if (total <= 0) {
107     emit ProgressChanged(episode_, PodcastDownload::Downloading, 0);
108   } else {
109     emit ProgressChanged(episode_, PodcastDownload::Downloading,
110                          static_cast<float>(received) / total * 100);
111   }
112 }
113 
PodcastDownloader(Application * app,QObject * parent)114 PodcastDownloader::PodcastDownloader(Application* app, QObject* parent)
115     : QObject(parent),
116       app_(app),
117       backend_(app_->podcast_backend()),
118       network_(new NetworkAccessManager(this)),
119       disallowed_filename_characters_("[^a-zA-Z0-9_~ -]"),
120       auto_download_(false) {
121   connect(backend_, SIGNAL(EpisodesAdded(PodcastEpisodeList)),
122           SLOT(EpisodesAdded(PodcastEpisodeList)));
123   connect(backend_, SIGNAL(SubscriptionAdded(Podcast)),
124           SLOT(SubscriptionAdded(Podcast)));
125   connect(app_, SIGNAL(SettingsChanged()), SLOT(ReloadSettings()));
126 
127   ReloadSettings();
128 }
129 
DefaultDownloadDir() const130 QString PodcastDownloader::DefaultDownloadDir() const {
131   QString prefix = QDir::homePath();
132 
133   LibraryDirectoryModel* model = app_->library_model()->directory_model();
134   if (model->rowCount() > 0) {
135     // Download to the first library directory if there is one set
136     prefix = model->index(0, 0).data().toString();
137   }
138 
139   return prefix + "/Podcasts";
140 }
141 
ReloadSettings()142 void PodcastDownloader::ReloadSettings() {
143   QSettings s;
144   s.beginGroup(kSettingsGroup);
145 
146   auto_download_ = s.value("auto_download", false).toBool();
147   download_dir_ = s.value("download_dir", DefaultDownloadDir()).toString();
148 }
149 
FilenameForEpisode(const QString & directory,const PodcastEpisode & episode) const150 QString PodcastDownloader::FilenameForEpisode(const QString& directory,
151                                               const PodcastEpisode& episode) const {
152   const QString file_extension = QFileInfo(episode.url().path()).suffix();
153   int count = 0;
154 
155   // The file name contains the publication date and episode title
156   QString base_filename =
157       episode.publication_date().date().toString(Qt::ISODate) + "-" +
158       SanitiseFilenameComponent(episode.title());
159 
160   // Add numbers on to the end of the filename until we find one that doesn't
161   // exist.
162   forever {
163     QString filename;
164 
165     if (count == 0) {
166       filename =
167           QString("%1/%2.%3").arg(directory, base_filename, file_extension);
168     } else {
169       filename = QString("%1/%2 (%3).%4").arg(
170           directory, base_filename, QString::number(count), file_extension);
171     }
172 
173     if (!QFile::exists(filename)) {
174       return filename;
175     }
176 
177     count++;
178   }
179 }
180 
DownloadEpisode(const PodcastEpisode & episode)181 void PodcastDownloader::DownloadEpisode(const PodcastEpisode& episode) {
182   for (Task* tas : list_tasks_) {
183     if (tas->episode().database_id() == episode.database_id()) {
184       return;
185     }
186   }
187 
188   Podcast podcast =
189       backend_->GetSubscriptionById(episode.podcast_database_id());
190   if (!podcast.is_valid()) {
191     qLog(Warning) << "The podcast that contains episode" << episode.url()
192                   << "doesn't exist any more";
193     return;
194   }
195   const QString directory =
196       download_dir_ + "/" + SanitiseFilenameComponent(podcast.title());
197   const QString filepath = FilenameForEpisode(directory, episode);
198 
199   // Open the output file
200   QDir().mkpath(directory);
201   QFile* file = new QFile(filepath);
202   if (!file->open(QIODevice::WriteOnly)) {
203     qLog(Warning) << "Could not open the file" << filepath << "for writing";
204     return;
205   }
206 
207   Task* task = new Task(episode, file, backend_);
208 
209   list_tasks_ << task;
210   qLog(Info) << "Downloading" << task->episode().url() << "to" << filepath;
211   connect(task, SIGNAL(finished(Task*)), SLOT(ReplyFinished(Task*)));
212   connect(task, SIGNAL(ProgressChanged(const PodcastEpisode&,
213                                        PodcastDownload::State, int)),
214           SIGNAL(ProgressChanged(const PodcastEpisode&,
215                                  PodcastDownload::State, int)));
216 }
217 
ReplyFinished(Task * task)218 void PodcastDownloader::ReplyFinished(Task* task) {
219   list_tasks_.removeAll(task);
220   delete task;
221 }
222 
SanitiseFilenameComponent(const QString & text) const223 QString PodcastDownloader::SanitiseFilenameComponent(const QString& text)
224     const {
225   return QString(text)
226       .replace(disallowed_filename_characters_, " ")
227       .simplified();
228 }
229 
SubscriptionAdded(const Podcast & podcast)230 void PodcastDownloader::SubscriptionAdded(const Podcast& podcast) {
231   EpisodesAdded(podcast.episodes());
232 }
233 
EpisodesAdded(const PodcastEpisodeList & episodes)234 void PodcastDownloader::EpisodesAdded(const PodcastEpisodeList& episodes) {
235   if (auto_download_) {
236     for (const PodcastEpisode& episode : episodes) {
237       DownloadEpisode(episode);
238     }
239   }
240 }
241 
EpisodesDownloading(const PodcastEpisodeList & episodes)242 PodcastEpisodeList PodcastDownloader::EpisodesDownloading(const PodcastEpisodeList& episodes) {
243   PodcastEpisodeList ret;
244   for (Task* tas : list_tasks_) {
245     for (PodcastEpisode episode : episodes) {
246       if (tas->episode().database_id() == episode.database_id()) {
247         ret << episode;
248       }
249     }
250   }
251   return ret;
252 }
253 
cancelDownload(const PodcastEpisodeList & episodes)254 void PodcastDownloader::cancelDownload(const PodcastEpisodeList& episodes) {
255   QList<Task*> ta;
256   for (Task* tas : list_tasks_) {
257     for (PodcastEpisode episode : episodes) {
258       if (tas->episode().database_id() == episode.database_id()) {
259         ta << tas;
260       }
261     }
262   }
263   for (Task* tas : ta) {
264     tas->finishedPublic();
265     list_tasks_.removeAll(tas);
266   }
267 }
268