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