1 /*
2 * Cantata
3 *
4 * Copyright (c) 2011-2020 Craig Drummond <craig.p.drummond@gmail.com>
5 *
6 * ----
7 *
8 * This program is free software; you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License as published by
10 * the Free Software Foundation; either version 2 of the License, or
11 * (at your option) any later version.
12 *
13 * This program is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 * General Public License for more details.
17 *
18 * You should have received a copy of the GNU General Public License
19 * along with this program; see the file COPYING. If not, write to
20 * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
21 * Boston, MA 02110-1301, USA.
22 */
23
24 #include "trackorganiser.h"
25 #include "devices/filenameschemedialog.h"
26 #ifdef ENABLE_DEVICES_SUPPORT
27 #include "models/devicesmodel.h"
28 #endif
29 #include "devices/device.h"
30 #include "gui/settings.h"
31 #include "mpd-interface/mpdconnection.h"
32 #include "support/utils.h"
33 #include "context/songview.h"
34 #include "support/messagebox.h"
35 #include "support/action.h"
36 #include "widgets/icons.h"
37 #include "widgets/basicitemdelegate.h"
38 #include "mpd-interface/cuefile.h"
39 #include "gui/covers.h"
40 #include "context/contextwidget.h"
41 #include <QTimer>
42 #include <QFile>
43 #include <QDir>
44 #include <algorithm>
45
46 #define REMOVE(w) \
47 w->setVisible(false); \
48 w->deleteLater(); \
49 w=0;
50
51 static int iCount=0;
52
instanceCount()53 int TrackOrganiser::instanceCount()
54 {
55 return iCount;
56 }
57
TrackOrganiser(QWidget * parent)58 TrackOrganiser::TrackOrganiser(QWidget *parent)
59 : SongDialog(parent, "TrackOrganiser", QSize(800, 500))
60 , schemeDlg(nullptr)
61 , autoSkip(false)
62 , paused(false)
63 , updated(false)
64 , alwaysUpdate(false)
65 {
66 iCount++;
67 setButtons(Ok|Cancel);
68 setCaption(tr("Organize Files"));
69 setAttribute(Qt::WA_DeleteOnClose);
70 QWidget *mainWidet = new QWidget(this);
71 setupUi(mainWidet);
72 setMainWidget(mainWidet);
73 configFilename->setIcon(Icons::self()->configureIcon);
74 setButtonGuiItem(Ok, GuiItem(tr("Rename")));
75 connect(this, SIGNAL(update()), MPDConnection::self(), SLOT(updateMaybe()));
76 progress->setVisible(false);
77 files->setItemDelegate(new BasicItemDelegate(files));
78 files->setAlternatingRowColors(false);
79 files->setContextMenuPolicy(Qt::ActionsContextMenu);
80 files->setSelectionMode(QAbstractItemView::ExtendedSelection);
81 removeAct=new Action(tr("Remove From List"), files);
82 removeAct->setEnabled(false);
83 files->addAction(removeAct);
84 connect(files, SIGNAL(itemSelectionChanged()), SLOT(controlRemoveAct()));
85 connect(removeAct, SIGNAL(triggered()), SLOT(removeItems()));
86 }
87
~TrackOrganiser()88 TrackOrganiser::~TrackOrganiser()
89 {
90 iCount--;
91 }
92
show(const QList<Song> & songs,const QString & udi,bool forceUpdate)93 void TrackOrganiser::show(const QList<Song> &songs, const QString &udi, bool forceUpdate)
94 {
95 // If we are called from the TagEditor dialog, then forceUpdate will be true. This is so that we dont do 2
96 // MPD updates (one from TagEditor, and one from here!)
97 alwaysUpdate=forceUpdate;
98 for (const Song &s: songs) {
99 if (!CueFile::isCue(s.file)) {
100 origSongs.append(s);
101 }
102 }
103
104 if (origSongs.isEmpty()) {
105 deleteLater();
106 if (alwaysUpdate) {
107 doUpdate();
108 }
109 return;
110 }
111
112 QString musicFolder;
113 #ifdef ENABLE_DEVICES_SUPPORT
114 if (udi.isEmpty()) {
115 musicFolder=MPDConnection::self()->getDetails().dir;
116 opts.load(MPDConnectionDetails::configGroupName(MPDConnection::self()->getDetails().name), true);
117 } else {
118 deviceUdi=udi;
119 Device *dev=getDevice(parentWidget());
120
121 if (!dev) {
122 deleteLater();
123 return;
124 }
125
126 opts=dev->options();
127 musicFolder=dev->path();
128 }
129 #else
130 opts.load(MPDConnectionDetails::configGroupName(MPDConnection::self()->getDetails().name), true);
131 musicFolder=MPDConnection::self()->getDetails().dir;
132 #endif
133 std::sort(origSongs.begin(), origSongs.end());
134
135 filenameScheme->setText(opts.scheme);
136 vfatSafe->setChecked(opts.vfatSafe);
137 asciiOnly->setChecked(opts.asciiOnly);
138 ignoreThe->setChecked(opts.ignoreThe);
139 replaceSpaces->setChecked(opts.replaceSpaces);
140
141 connect(configFilename, SIGNAL(clicked()), SLOT(configureFilenameScheme()));
142 connect(filenameScheme, SIGNAL(textChanged(const QString &)), this, SLOT(updateView()));
143 connect(vfatSafe, SIGNAL(toggled(bool)), this, SLOT(updateView()));
144 connect(asciiOnly, SIGNAL(toggled(bool)), this, SLOT(updateView()));
145 connect(ignoreThe, SIGNAL(toggled(bool)), this, SLOT(updateView()));
146 connect(replaceSpaces, SIGNAL(toggled(bool)), this, SLOT(updateView()));
147
148 if (!songsOk(origSongs, musicFolder, udi.isEmpty())) {
149 return;
150 }
151 connect(ratingsNote, SIGNAL(leftClickedUrl()), SLOT(showRatingsMessage()));
152 Dialog::show();
153 enableButtonOk(false);
154 updateView();
155 }
156
slotButtonClicked(int button)157 void TrackOrganiser::slotButtonClicked(int button)
158 {
159 switch (button) {
160 case Ok:
161 startRename();
162 break;
163 case Cancel:
164 if (!optionsBox->isEnabled()) {
165 paused=true;
166 if (MessageBox::No==MessageBox::questionYesNo(this, tr("Abort renaming of files?"), tr("Abort"), GuiItem(tr("Abort")), StdGuiItem::cancel())) {
167 paused=false;
168 QTimer::singleShot(0, this, SLOT(renameFile()));
169 return;
170 }
171 }
172 finish(false);
173 // Need to call this - if not, when dialog is closed by window X control, it is not deleted!!!!
174 Dialog::slotButtonClicked(button);
175 break;
176 default:
177 break;
178 }
179 }
180
configureFilenameScheme()181 void TrackOrganiser::configureFilenameScheme()
182 {
183 if (!schemeDlg) {
184 schemeDlg=new FilenameSchemeDialog(this);
185 connect(schemeDlg, SIGNAL(scheme(const QString &)), this, SLOT(setFilenameScheme(const QString &)));
186 }
187 readOptions();
188 schemeDlg->show(opts);
189 }
190
readOptions()191 void TrackOrganiser::readOptions()
192 {
193 opts.scheme=filenameScheme->text().trimmed();
194 opts.vfatSafe=vfatSafe->isChecked();
195 opts.asciiOnly=asciiOnly->isChecked();
196 opts.ignoreThe=ignoreThe->isChecked();
197 opts.replaceSpaces=replaceSpaces->isChecked();
198 }
199
updateView()200 void TrackOrganiser::updateView()
201 {
202 QFont f(font());
203 f.setItalic(true);
204 files->clear();
205 bool different=false;
206 readOptions();
207
208 QString musicFolder;
209 #ifdef ENABLE_DEVICES_SUPPORT
210 if (!deviceUdi.isEmpty()) {
211 Device *dev=getDevice();
212 if (!dev) {
213 return;
214 }
215 musicFolder=dev->path();
216 } else
217 #endif
218 musicFolder=MPDConnection::self()->getDetails().dir;
219
220 for (const Song &s: origSongs) {
221 QString modified=musicFolder + opts.createFilename(s);
222 //different=different||(modified!=s.file);
223 QString orig=s.filePath(musicFolder);
224 bool diff=modified!=orig;
225 different|=diff;
226 QTreeWidgetItem *item=new QTreeWidgetItem(files, QStringList() << orig << modified);
227 if (diff) {
228 item->setFont(0, f);
229 item->setFont(1, f);
230 }
231 }
232 files->resizeColumnToContents(0);
233 files->resizeColumnToContents(1);
234 enableButtonOk(different);
235 }
236
startRename()237 void TrackOrganiser::startRename()
238 {
239 optionsBox->setEnabled(false);
240 progress->setVisible(true);
241 progress->setRange(1, origSongs.count());
242 enableButtonOk(false);
243 index=0;
244 paused=autoSkip=false;
245 saveOptions();
246
247 QTimer::singleShot(100, this, SLOT(renameFile()));
248 }
249
renameFile()250 void TrackOrganiser::renameFile()
251 {
252 if (paused) {
253 return;
254 }
255
256 progress->setValue(progress->value()+1);
257
258 QTreeWidgetItem *item=files->topLevelItem(index);
259 files->scrollToItem(item);
260 Song s=origSongs.at(index);
261 QString modified=opts.createFilename(s);
262 QString musicFolder;
263
264 #ifdef ENABLE_DEVICES_SUPPORT
265 if (!deviceUdi.isEmpty()) {
266 Device *dev=getDevice();
267 if (!dev) {
268 return;
269 }
270 musicFolder=dev->path();
271 } else
272 #endif
273 musicFolder=MPDConnection::self()->getDetails().dir;
274
275 QString source=s.filePath(musicFolder);
276 QString dest=musicFolder+modified;
277 if (source!=dest) {
278 bool skip=false;
279 if (!QFile::exists(source)) {
280 if (autoSkip) {
281 skip=true;
282 } else {
283 switch(MessageBox::questionYesNoCancel(this, tr("Source file does not exist!")+QLatin1String("\n\n")+dest,
284 QString(), GuiItem(tr("Skip")), GuiItem(tr("Auto Skip")))) {
285 case MessageBox::Yes:
286 skip=true;
287 break;
288 case MessageBox::No:
289 autoSkip=skip=true;
290 break;
291 case MessageBox::Cancel:
292 finish(false);
293 return;
294 }
295 }
296 }
297 // Check if dest exists...
298 if (!skip && QFile::exists(dest)) {
299 if (autoSkip) {
300 skip=true;
301 } else {
302 switch(MessageBox::questionYesNoCancel(this, tr("Destination file already exists!")+QLatin1String("\n\n")+dest,
303 QString(), GuiItem(tr("Skip")), GuiItem(tr("Auto Skip")))) {
304 case MessageBox::Yes:
305 skip=true;
306 break;
307 case MessageBox::No:
308 autoSkip=skip=true;
309 break;
310 case MessageBox::Cancel:
311 finish(false);
312 return;
313 }
314 }
315 }
316
317 // Create dest folder...
318 if (!skip) {
319 QDir dir(Utils::getDir(dest));
320 if(!dir.exists() && !Utils::createWorldReadableDir(dir.absolutePath(), musicFolder)) {
321 if (autoSkip) {
322 skip=true;
323 } else {
324 switch(MessageBox::questionYesNoCancel(this, tr("Failed to create destination folder!")+QLatin1String("\n\n")+dir.absolutePath(),
325 QString(), GuiItem(tr("Skip")), GuiItem(tr("Auto Skip")))) {
326 case MessageBox::Yes:
327 skip=true;
328 break;
329 case MessageBox::No:
330 autoSkip=skip=true;
331 break;
332 case MessageBox::Cancel:
333 finish(false);
334 return;
335 }
336 }
337 }
338 }
339
340 bool renamed=false;
341 if (!skip && !(renamed=QFile::rename(source, dest))) {
342 if (autoSkip) {
343 skip=true;
344 } else {
345 switch(MessageBox::questionYesNoCancel(this, tr("Failed to rename '%1' to '%2'").arg(source, dest),
346 QString(), GuiItem(tr("Skip")), GuiItem(tr("Auto Skip")))) {
347 case MessageBox::Yes:
348 skip=true;
349 break;
350 case MessageBox::No:
351 autoSkip=skip=true;
352 break;
353 case MessageBox::Cancel:
354 finish(false);
355 return;
356 }
357 }
358 }
359
360 // If file was renamed, then also rename any other matching files...
361 QDir sDir(Utils::getDir(source));
362 if (renamed) {
363 QFileInfoList files = sDir.entryInfoList(QStringList() << Utils::changeExtension(Utils::getFile(source), ".*"));
364 for (const auto &file : files) {
365 QString destFile = Utils::changeExtension(dest, "."+file.suffix());
366 if (!QFile::exists(destFile)) {
367 QFile::rename(file.absoluteFilePath(), destFile);
368 }
369 }
370 }
371
372 if (!skip) {
373 QDir sArtistDir(sDir); sArtistDir.cdUp();
374 QDir dDir(Utils::getDir(dest));
375 #ifdef ENABLE_DEVICES_SUPPORT
376 Device *dev=deviceUdi.isEmpty() ? nullptr : getDevice();
377 if (sDir.absolutePath()!=dDir.absolutePath()) {
378 Device::moveDir(sDir.absolutePath(), dDir.absolutePath(), musicFolder, dev ? dev->coverFile()
379 : QString(Covers::albumFileName(s)+QLatin1String(".jpg")));
380 }
381 #else
382 if (sDir.absolutePath()!=dDir.absolutePath()) {
383 Device::moveDir(sDir.absolutePath(), dDir.absolutePath(), musicFolder, QString(Covers::albumFileName(s)+QLatin1String(".jpg")));
384 }
385 #endif
386 QDir dArtistDir(dDir); dArtistDir.cdUp();
387
388 // Move any artist, or backdrop, image...
389 if (sArtistDir.exists() && dArtistDir.exists() && sArtistDir.absolutePath()!=sDir.absolutePath() && sArtistDir.absolutePath()!=dArtistDir.absolutePath()) {
390 QStringList artistImages;
391 QFileInfoList entries=sArtistDir.entryInfoList(QDir::Files|QDir::Dirs|QDir::NoDotAndDotDot);
392 QSet<QString> acceptable=QSet<QString>() << Covers::constArtistImage+QLatin1String(".jpg")
393 << Covers::constArtistImage+QLatin1String(".png")
394 << Covers::constComposerImage+QLatin1String(".jpg")
395 << Covers::constComposerImage+QLatin1String(".png")
396 << ContextWidget::constBackdropFileName+QLatin1String(".jpg")
397 << ContextWidget::constBackdropFileName+QLatin1String(".png");
398
399 for (const QFileInfo &entry: entries) {
400 if (entry.isDir() || !acceptable.contains(entry.fileName())) {
401 artistImages.clear();
402 break;
403 } else {
404 artistImages.append(entry.fileName());
405 }
406 }
407 if (!artistImages.isEmpty()) {
408 bool delDir=true;
409 for (const QString &f: artistImages) {
410 if (!QFile::rename(sArtistDir.absolutePath()+Utils::constDirSep+f, dArtistDir.absolutePath()+Utils::constDirSep+f)) {
411 delDir=false;
412 break;
413 }
414 }
415 if (delDir) {
416 QString dirName=sArtistDir.dirName();
417 if (!dirName.isEmpty()) {
418 sArtistDir.cdUp();
419 sArtistDir.rmdir(dirName);
420 }
421 }
422 }
423 }
424 item->setText(0, dest);
425 item->setFont(0, font());
426 item->setFont(1, font());
427 Song to=s;
428 QString origPath;
429 if (s.file.startsWith(Song::constMopidyLocal)) {
430 origPath=to.file;
431 to.file=Song::encodePath(to.file);
432 } else if (MPDConnection::self()->isForkedDaapd()) {
433 to.file=Song::constForkedDaapdLocal + dest;
434 } else {
435 to.file=modified;
436 }
437 origSongs.replace(index, to);
438 updated=true;
439
440 if (deviceUdi.isEmpty()) {
441 // MusicLibraryModel::self()->updateSongFile(s, to);
442 // DirViewModel::self()->removeFileFromList(s.file);
443 // DirViewModel::self()->addFileToList(origPath.isEmpty() ? to.file : origPath,
444 // origPath.isEmpty() ? QString() : to.file);
445 }
446 #ifdef ENABLE_DEVICES_SUPPORT
447 else {
448 if (!dev) {
449 return;
450 }
451 dev->updateSongFile(s, to);
452 }
453 #endif
454 }
455 }
456 index++;
457 if (index>=origSongs.count()) {
458 finish(true);
459 } else {
460 QTimer::singleShot(100, this, SLOT(renameFile()));
461 }
462 }
463
controlRemoveAct()464 void TrackOrganiser::controlRemoveAct()
465 {
466 removeAct->setEnabled(files->topLevelItemCount()>1 && !files->selectedItems().isEmpty());
467 }
468
removeItems()469 void TrackOrganiser::removeItems()
470 {
471 if (files->topLevelItemCount()<1) {
472 return;
473 }
474
475 if (MessageBox::Yes==MessageBox::questionYesNo(this, tr("Remove the selected tracks from the list?"),
476 tr("Remove Tracks"), StdGuiItem::remove(), StdGuiItem::cancel())) {
477
478 QList<QTreeWidgetItem *> selection=files->selectedItems();
479 for (QTreeWidgetItem *item: selection) {
480 int idx=files->indexOfTopLevelItem(item);
481 if (idx>-1 && idx<origSongs.count()) {
482 origSongs.removeAt(idx);
483 delete files->takeTopLevelItem(idx);
484 }
485 }
486 }
487 }
488
showRatingsMessage()489 void TrackOrganiser::showRatingsMessage()
490 {
491 MessageBox::information(this, tr("Song ratings are not stored in the song files, but within MPD's 'sticker' database.\n\n"
492 "If you rename a file (or the folder it is within), then the rating associated with the song will be lost."),
493 QLatin1String("Ratings"));
494 }
495
setFilenameScheme(const QString & text)496 void TrackOrganiser::setFilenameScheme(const QString &text)
497 {
498 if (filenameScheme->text()!=text) {
499 filenameScheme->setText(text);
500 saveOptions();
501 }
502 }
503
saveOptions()504 void TrackOrganiser::saveOptions()
505 {
506 readOptions();
507 #ifdef ENABLE_DEVICES_SUPPORT
508 if (!deviceUdi.isEmpty()) {
509 Device *dev=getDevice();
510 if (!dev) {
511 return;
512 }
513 dev->setOptions(opts);
514 } else
515 #endif
516 opts.save(MPDConnectionDetails::configGroupName(MPDConnection::self()->getDetails().name), true);
517 }
518
doUpdate()519 void TrackOrganiser::doUpdate()
520 {
521 if (deviceUdi.isEmpty()) {
522 emit update();
523 }
524 #ifdef ENABLE_DEVICES_SUPPORT
525 else {
526 Device *dev=getDevice();
527 if (dev) {
528 dev->saveCache();
529 }
530 }
531 #endif
532 }
533
finish(bool ok)534 void TrackOrganiser::finish(bool ok)
535 {
536 if (updated || alwaysUpdate) {
537 doUpdate();
538 }
539 if (ok) {
540 accept();
541 } else {
542 reject();
543 }
544 }
545
546 #ifdef ENABLE_DEVICES_SUPPORT
getDevice(QWidget * p)547 Device * TrackOrganiser::getDevice(QWidget *p)
548 {
549 Device *dev=DevicesModel::self()->device(deviceUdi);
550 if (!dev) {
551 MessageBox::error(p ? p : this, tr("Device has been removed!"));
552 reject();
553 return nullptr;
554 }
555 if (!dev->isConnected()) {
556 MessageBox::error(p ? p : this, tr("Device is not connected."));
557 reject();
558 return nullptr;
559 }
560 if (!dev->isIdle()) {
561 MessageBox::error(p ? p : this, tr("Device is busy?"));
562 reject();
563 return nullptr;
564 }
565 return dev;
566 }
567 #endif
568
569 #include "moc_trackorganiser.cpp"
570