1 /* This file is part of Clementine.
2    Copyright 2010, David Sansome <me@davidsansome.com>
3 
4    Clementine is free software: you can redistribute it and/or modify
5    it under the terms of the GNU General Public License as published by
6    the Free Software Foundation, either version 3 of the License, or
7    (at your option) any later version.
8 
9    Clementine is distributed in the hope that it will be useful,
10    but WITHOUT ANY WARRANTY; without even the implied warranty of
11    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12    GNU General Public License for more details.
13 
14    You should have received a copy of the GNU General Public License
15    along with Clementine.  If not, see <http://www.gnu.org/licenses/>.
16 */
17 
18 #include "organisedialog.h"
19 #include "ui_organisedialog.h"
20 
21 #include <algorithm>
22 #include <memory>
23 
24 #include <QDir>
25 #include <QFileInfo>
26 #include <QHash>
27 #include <QMenu>
28 #include <QPushButton>
29 #include <QResizeEvent>
30 #include <QSettings>
31 #include <QtConcurrentRun>
32 #include <QtDebug>
33 
34 #include "iconloader.h"
35 #include "organiseerrordialog.h"
36 #include "core/musicstorage.h"
37 #include "core/organise.h"
38 #include "core/tagreaderclient.h"
39 #include "core/utilities.h"
40 #include "library/librarybackend.h"
41 
42 const char* OrganiseDialog::kDefaultFormat =
43     "%artist/%album{ (Disc %disc)}/{%track - }%title.%extension";
44 const char* OrganiseDialog::kSettingsGroup = "OrganiseDialog";
45 
OrganiseDialog(TaskManager * task_manager,LibraryBackend * backend,QWidget * parent)46 OrganiseDialog::OrganiseDialog(
47     TaskManager* task_manager, LibraryBackend* backend, QWidget* parent)
48     : QDialog(parent),
49       ui_(new Ui_OrganiseDialog),
50       task_manager_(task_manager),
51       backend_(backend),
52       total_size_(0),
53       resized_by_user_(false) {
54   ui_->setupUi(this);
55   connect(ui_->button_box->button(QDialogButtonBox::Reset), SIGNAL(clicked()),
56           SLOT(Reset()));
57 
58   ui_->aftercopying->setItemIcon(
59       1, IconLoader::Load("edit-delete", IconLoader::Base));
60 
61   // Valid tags
62   QMap<QString, QString> tags;
63   tags[tr("Title")] = "title";
64   tags[tr("Album")] = "album";
65   tags[tr("Artist")] = "artist";
66   tags[tr("Artist's initial")] = "artistinitial";
67   tags[tr("Album artist")] = "albumartist";
68   tags[tr("Composer")] = "composer";
69   tags[tr("Performer")] = "performer";
70   tags[tr("Grouping")] = "grouping";
71   tags[tr("Lyrics")] = "lyrics";
72   tags[tr("Track")] = "track";
73   tags[tr("Disc")] = "disc";
74   tags[tr("BPM")] = "bpm";
75   tags[tr("Year")] = "year";
76   tags[tr("Original year")] = "originalyear";
77   tags[tr("Genre")] = "genre";
78   tags[tr("Comment")] = "comment";
79   tags[tr("Length")] = "length";
80   tags[tr("Bitrate", "Refers to bitrate in file organise dialog.")] = "bitrate";
81   tags[tr("Samplerate")] = "samplerate";
82   tags[tr("File extension")] = "extension";
83 
84   // Naming scheme input field
85   new OrganiseFormat::SyntaxHighlighter(ui_->naming);
86 
87   connect(ui_->destination, SIGNAL(currentIndexChanged(int)),
88           SLOT(UpdatePreviews()));
89   connect(ui_->naming, SIGNAL(textChanged()), SLOT(UpdatePreviews()));
90   connect(ui_->replace_ascii, SIGNAL(toggled(bool)), SLOT(UpdatePreviews()));
91   connect(ui_->replace_the, SIGNAL(toggled(bool)), SLOT(UpdatePreviews()));
92   connect(ui_->replace_spaces, SIGNAL(toggled(bool)), SLOT(UpdatePreviews()));
93 
94   // Get the titles of the tags to put in the insert menu
95   QStringList tag_titles = tags.keys();
96   std::stable_sort(tag_titles.begin(), tag_titles.end());
97 
98   // Build the insert menu
99   QMenu* tag_menu = new QMenu(this);
100   for (const QString& title : tag_titles) {
101     QAction* action = tag_menu->addAction(title);
102     QString tag = tags[title];
103     connect(action, &QAction::triggered, [this, tag]() { InsertTag(tag); });
104   }
105 
106   ui_->insert->setMenu(tag_menu);
107 }
108 
~OrganiseDialog()109 OrganiseDialog::~OrganiseDialog() { delete ui_; }
110 
SetDestinationModel(QAbstractItemModel * model,bool devices)111 void OrganiseDialog::SetDestinationModel(QAbstractItemModel* model,
112                                          bool devices) {
113   ui_->destination->setModel(model);
114 
115   ui_->eject_after->setVisible(devices);
116 }
117 
SetSongs(const SongList & songs)118 bool OrganiseDialog::SetSongs(const SongList& songs) {
119   total_size_ = 0;
120   songs_.clear();
121 
122   for (const Song& song : songs) {
123     if (song.url().scheme() != "file") {
124       continue;
125     }
126 
127     if (song.filesize() > 0) total_size_ += song.filesize();
128 
129     songs_ << song;
130   }
131 
132   ui_->free_space->set_additional_bytes(total_size_);
133   UpdatePreviews();
134   SetLoadingSongs(false);
135 
136   if (songs_future_.isRunning()) {
137     songs_future_.cancel();
138   }
139   songs_future_ = QFuture<SongList>();
140 
141   return songs_.count();
142 }
143 
SetUrls(const QList<QUrl> & urls)144 bool OrganiseDialog::SetUrls(const QList<QUrl>& urls) {
145   QStringList filenames;
146 
147   // Only add file:// URLs
148   for (const QUrl& url : urls) {
149     if (url.scheme() == "file") {
150       filenames << url.toLocalFile();
151     }
152   }
153 
154   return SetFilenames(filenames);
155 }
156 
SetFilenames(const QStringList & filenames)157 bool OrganiseDialog::SetFilenames(const QStringList& filenames) {
158   songs_future_ =
159       QtConcurrent::run(this, &OrganiseDialog::LoadSongsBlocking, filenames);
160   NewClosure(songs_future_, [=]() { SetSongs(songs_future_.result()); });
161 
162   SetLoadingSongs(true);
163   return true;
164 }
165 
SetLoadingSongs(bool loading)166 void OrganiseDialog::SetLoadingSongs(bool loading) {
167   if (loading) {
168     ui_->preview_stack->setCurrentWidget(ui_->loading_page);
169     ui_->button_box->button(QDialogButtonBox::Ok)->setEnabled(false);
170   } else {
171     ui_->preview_stack->setCurrentWidget(ui_->preview_page);
172     // The Ok button is enabled by UpdatePreviews
173   }
174 }
175 
LoadSongsBlocking(const QStringList & filenames)176 SongList OrganiseDialog::LoadSongsBlocking(const QStringList& filenames) {
177   SongList songs;
178   Song song;
179 
180   QStringList filenames_copy = filenames;
181   while (!filenames_copy.isEmpty()) {
182     const QString filename = filenames_copy.takeFirst();
183 
184     // If it's a directory, add all the files inside.
185     if (QFileInfo(filename).isDir()) {
186       const QDir dir(filename);
187       for (const QString& entry :
188            dir.entryList(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot |
189                          QDir::Readable)) {
190         filenames_copy << dir.filePath(entry);
191       }
192       continue;
193     }
194 
195     TagReaderClient::Instance()->ReadFileBlocking(filename, &song);
196     if (song.is_valid()) songs << song;
197   }
198 
199   return songs;
200 }
201 
SetCopy(bool copy)202 void OrganiseDialog::SetCopy(bool copy) {
203   ui_->aftercopying->setCurrentIndex(copy ? 0 : 1);
204 }
205 
InsertTag(const QString & tag)206 void OrganiseDialog::InsertTag(const QString& tag) {
207   ui_->naming->insertPlainText("%" + tag);
208 }
209 
ComputeNewSongsFilenames(const SongList & songs,const OrganiseFormat & format)210 Organise::NewSongInfoList OrganiseDialog::ComputeNewSongsFilenames(
211     const SongList& songs, const OrganiseFormat& format) {
212   // Check if we will have multiple files with the same name.
213   // If so, they will erase each other if the overwrite flag is set.
214   // Better to rename them: e.g. foo.bar -> foo(2).bar
215   QHash<QString, int> filenames;
216   Organise::NewSongInfoList new_songs_info;
217 
218   for (const Song& song : songs) {
219     QString new_filename = format.GetFilenameForSong(song);
220     if (filenames.contains(new_filename)) {
221       QString song_number = QString::number(++filenames[new_filename]);
222       new_filename = Utilities::PathWithoutFilenameExtension(new_filename) +
223                      "(" + song_number + ")." +
224                      QFileInfo(new_filename).suffix();
225     }
226     filenames.insert(new_filename, 1);
227     new_songs_info << Organise::NewSongInfo(song, new_filename);
228   }
229   return new_songs_info;
230 }
231 
UpdatePreviews()232 void OrganiseDialog::UpdatePreviews() {
233   if (songs_future_.isRunning()) {
234     return;
235   }
236 
237   const QModelIndex destination =
238       ui_->destination->model()->index(ui_->destination->currentIndex(), 0);
239   std::shared_ptr<MusicStorage> storage;
240   bool has_local_destination = false;
241 
242   if (destination.isValid()) {
243     storage = destination.data(MusicStorage::Role_Storage)
244                   .value<std::shared_ptr<MusicStorage>>();
245     if (storage) {
246       has_local_destination = !storage->LocalPath().isEmpty();
247     }
248   }
249 
250   // Update the free space bar
251   quint64 capacity = destination.data(MusicStorage::Role_Capacity).toLongLong();
252   quint64 free = destination.data(MusicStorage::Role_FreeSpace).toLongLong();
253 
254   if (!capacity) {
255     ui_->free_space->hide();
256   } else {
257     ui_->free_space->show();
258     ui_->free_space->set_free_bytes(free);
259     ui_->free_space->set_total_bytes(capacity);
260   }
261 
262   // Update the format object
263   format_.set_format(ui_->naming->toPlainText());
264   format_.set_replace_non_ascii(ui_->replace_ascii->isChecked());
265   format_.set_replace_spaces(ui_->replace_spaces->isChecked());
266   format_.set_replace_the(ui_->replace_the->isChecked());
267 
268   const bool format_valid = !has_local_destination || format_.IsValid();
269 
270   // Are we gonna enable the ok button?
271   bool ok = format_valid && !songs_.isEmpty();
272   if (capacity != 0 && total_size_ > free) ok = false;
273 
274   ui_->button_box->button(QDialogButtonBox::Ok)->setEnabled(ok);
275   if (!format_valid) return;
276 
277   new_songs_info_ = ComputeNewSongsFilenames(songs_, format_);
278 
279   // Update the previews
280   ui_->preview->clear();
281   ui_->preview_group->setVisible(has_local_destination);
282   ui_->naming_group->setVisible(has_local_destination);
283   if (has_local_destination) {
284     for (const Organise::NewSongInfo& song_info : new_songs_info_) {
285       QString filename = storage->LocalPath() + "/" + song_info.new_filename_;
286       ui_->preview->addItem(QDir::toNativeSeparators(filename));
287     }
288   }
289 
290   if (!resized_by_user_) {
291     adjustSize();
292   }
293 }
294 
sizeHint() const295 QSize OrganiseDialog::sizeHint() const { return QSize(650, 0); }
296 
Reset()297 void OrganiseDialog::Reset() {
298   ui_->naming->setPlainText(kDefaultFormat);
299   ui_->replace_ascii->setChecked(false);
300   ui_->replace_spaces->setChecked(false);
301   ui_->replace_the->setChecked(false);
302   ui_->overwrite->setChecked(false);
303   ui_->mark_as_listened->setChecked(false);
304   ui_->eject_after->setChecked(false);
305 }
306 
showEvent(QShowEvent *)307 void OrganiseDialog::showEvent(QShowEvent*) {
308   resized_by_user_ = false;
309 
310   QSettings s;
311   s.beginGroup(kSettingsGroup);
312   ui_->naming->setPlainText(s.value("format", kDefaultFormat).toString());
313   ui_->replace_ascii->setChecked(s.value("replace_ascii", false).toBool());
314   ui_->replace_spaces->setChecked(s.value("replace_spaces", false).toBool());
315   ui_->replace_the->setChecked(s.value("replace_the", false).toBool());
316   ui_->overwrite->setChecked(s.value("overwrite", false).toBool());
317   ui_->mark_as_listened->setChecked(
318       s.value("mark_as_listened", false).toBool());
319   ui_->eject_after->setChecked(s.value("eject_after", false).toBool());
320 
321   QString destination = s.value("destination").toString();
322   int index = ui_->destination->findText(destination);
323   if (index != -1 && !destination.isEmpty()) {
324     ui_->destination->setCurrentIndex(index);
325   }
326 }
327 
accept()328 void OrganiseDialog::accept() {
329   QSettings s;
330   s.beginGroup(kSettingsGroup);
331   s.setValue("format", ui_->naming->toPlainText());
332   s.setValue("replace_ascii", ui_->replace_ascii->isChecked());
333   s.setValue("replace_spaces", ui_->replace_spaces->isChecked());
334   s.setValue("replace_the", ui_->replace_the->isChecked());
335   s.setValue("overwrite", ui_->overwrite->isChecked());
336   s.setValue("mark_as_listened", ui_->overwrite->isChecked());
337   s.setValue("destination", ui_->destination->currentText());
338   s.setValue("eject_after", ui_->eject_after->isChecked());
339 
340   const QModelIndex destination =
341       ui_->destination->model()->index(ui_->destination->currentIndex(), 0);
342   std::shared_ptr<MusicStorage> storage =
343       destination.data(MusicStorage::Role_StorageForceConnect)
344           .value<std::shared_ptr<MusicStorage>>();
345 
346   if (!storage) return;
347 
348   // It deletes itself when it's finished.
349   const bool copy = ui_->aftercopying->currentIndex() == 0;
350   Organise* organise = new Organise(
351       task_manager_, storage, format_, copy, ui_->overwrite->isChecked(),
352       ui_->mark_as_listened->isChecked(), new_songs_info_,
353       ui_->eject_after->isChecked());
354   connect(organise, SIGNAL(Finished(QStringList)),
355           SLOT(OrganiseFinished(QStringList)));
356   connect(organise, SIGNAL(FileCopied(int)), this, SIGNAL(FileCopied(int)));
357   if (backend_ != nullptr) {
358     connect(organise, SIGNAL(SongPathChanged(const Song&, const QFileInfo&)),
359         backend_, SLOT(SongPathChanged(const Song&, const QFileInfo&)));
360   }
361   organise->Start();
362 
363   QDialog::accept();
364 }
365 
OrganiseFinished(const QStringList & files_with_errors)366 void OrganiseDialog::OrganiseFinished(const QStringList& files_with_errors) {
367   if (files_with_errors.isEmpty()) return;
368 
369   error_dialog_.reset(new OrganiseErrorDialog);
370   error_dialog_->Show(OrganiseErrorDialog::Type_Copy, files_with_errors);
371 }
372 
resizeEvent(QResizeEvent * e)373 void OrganiseDialog::resizeEvent(QResizeEvent* e) {
374   if (e->spontaneous()) {
375     resized_by_user_ = true;
376   }
377 
378   QDialog::resizeEvent(e);
379 }
380