1 /* GUI_TagEdit.cpp */
2 
3 /* Copyright (C) 2011-2020 Michael Lugmair (Lucio Carreras)
4  *
5  * This file is part of sayonara player
6  *
7  * This program is free software: you can redistribute it and/or modify
8  * it under the terms of the GNU General Public License as published by
9  * the Free Software Foundation, either version 3 of the License, or
10  * (at your option) any later version.
11 
12  * This program is distributed in the hope that it will be useful,
13  * but WITHOUT ANY WARRANTY; without even the implied warranty of
14  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15  * GNU General Public License for more details.
16 
17  * You should have received a copy of the GNU General Public License
18  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
19  */
20 
21 #include "GUI_TagEdit.h"
22 #include "GUI_TagFromPath.h"
23 #include "GUI_CoverEdit.h"
24 #include "GUI_FailMessageBox.h"
25 
26 #include "Gui/TagEdit/ui_GUI_TagEdit.h"
27 
28 #include "Components/Tagging/Editor.h"
29 
30 #include "Gui/Utils/Widgets/Completer.h"
31 #include "Gui/Utils/Style.h"
32 
33 #include "Utils/Algorithm.h"
34 #include "Utils/Utils.h"
35 #include "Utils/Set.h"
36 #include "Utils/FileUtils.h"
37 #include "Utils/Message/Message.h"
38 #include "Utils/Tagging/Tagging.h"
39 #include "Utils/MetaData/MetaDataList.h"
40 #include "Utils/MetaData/Album.h"
41 #include "Utils/MetaData/Artist.h"
42 #include "Utils/Language/Language.h"
43 
44 #include "Database/Connector.h"
45 #include "Database/LibraryDatabase.h"
46 
47 #include <QFileInfo>
48 #include <QTabBar>
49 
50 using namespace Tagging;
51 
52 namespace
53 {
54 	struct WidgetTuple
55 	{
56 		QLabel* label;
57 		QWidget* widget;
58 		QCheckBox* checkbox;
59 		Lang::Term term;
60 	};
61 
62 	void setCheckboxText(QCheckBox* checkbox, int n)
63 	{
64 		if(checkbox)
65 		{
66 			const auto text = QString("%1 (%2)")
67 				.arg(Lang::get(Lang::All))
68 				.arg(n);
69 
70 			checkbox->setText(text);
71 		}
72 	}
73 
74 	template<typename Container>
75 	QStringList extractNames(const Container& container)
76 	{
77 		QStringList names;
78 		for(const auto& item : container)
79 		{
80 			if(!item.name().isEmpty())
81 			{
82 				names << item.name();
83 			}
84 		}
85 
86 		return names;
87 	}
88 
89 	Message::Answer showInvalidFilepathsMessage(int count, QObject* parent)
90 	{
91 		const auto message = QString("%1<br><br>%2")
92 			.arg(parent->tr("Cannot apply expression to %n track(s)", "", count))
93 			.arg(parent->tr("Ignore these tracks?"));
94 
95 		return Message::question_yn(message);
96 	}
97 
98 	Message::Answer showAllChangesLostMessage(QObject* parent)
99 	{
100 		const auto question = QString("%1.\n%2?")
101 			.arg(parent->tr("All changes will be lost"))
102 			.arg(Lang::get(Lang::Continue));
103 
104 		return Message::question(question, "GUI_TagEdit", Message::QuestionType::YesNo);
105 	}
106 
107 	void showFailedCommitMessageBox(const QMap<QString, Tagging::Editor::FailReason>& failedFiles, QWidget* parent)
108 	{
109 		auto* failMessageBox = new GUI_FailMessageBox(parent);
110 		failMessageBox->setFailedFiles(failedFiles);
111 		failMessageBox->setModal(true);
112 
113 		parent->connect(failMessageBox, &GUI_FailMessageBox::sigClosed, failMessageBox, &QObject::deleteLater);
114 		failMessageBox->show();
115 	}
116 
117 	QStringList applyRegularExpression(int count, const QString& regex, Tagging::Editor* tagEditor)
118 	{
119 		QStringList invalidFilepaths;
120 
121 		for(auto i = 0; i < count; i++)
122 		{
123 			const auto success = tagEditor->applyRegularExpression(regex, i);
124 			if(!success)
125 			{
126 				const auto invalidFilepath = tagEditor->metadata(i).filepath();
127 				invalidFilepaths << invalidFilepath;
128 			}
129 		}
130 
131 		return invalidFilepaths;
132 	}
133 
134 	QCompleter* addCompleter(const QStringList& dataList, QLineEdit* lineEdit)
135 	{
136 		if(lineEdit->completer())
137 		{
138 			lineEdit->completer()->deleteLater();
139 		}
140 
141 		auto* completer = new Gui::Completer(dataList, lineEdit);
142 		lineEdit->setCompleter(completer);
143 
144 		return completer;
145 	}
146 
147 	void initCompleter(Ui::GUI_TagEdit* ui)
148 	{
149 		auto* db = DB::Connector::instance();
150 		auto* libraryDatabase = db->libraryDatabase(-1, 0);
151 
152 		AlbumList albums;
153 		ArtistList artists;
154 		libraryDatabase->getAllAlbums(albums, true);
155 		libraryDatabase->getAllArtists(artists, true);
156 		const auto genres = libraryDatabase->getAllGenres();
157 
158 		const auto albumNames = extractNames(albums);
159 		const auto artistNames = extractNames(artists);
160 		const auto genreNames = extractNames(genres);
161 
162 		addCompleter(albumNames, ui->leAlbum);
163 		addCompleter(artistNames, ui->leAlbumArtist);
164 		addCompleter(artistNames, ui->leArtist);
165 		addCompleter(genreNames, ui->leGenre);
166 	}
167 
168 	QPushButton* saveButton(Ui::GUI_TagEdit* ui)
169 	{
170 		return ui->buttonBox->button(QDialogButtonBox::StandardButton::Save);
171 	}
172 }
173 
174 struct GUI_TagEdit::Private
175 {
176 	Tagging::Editor* tagEditor;
177 	GUI_TagFromPath* uiTagFromPath;
178 	GUI_CoverEdit* uiCoverEdit;
179 	QList<WidgetTuple> widgetTuples;
180 	int currentIndex;
181 
182 	Private(GUI_TagEdit* parent, Ui::GUI_TagEdit* ui) :
183 		tagEditor {new Tagging::Editor()},
184 		uiTagFromPath {new GUI_TagFromPath(ui->tabFromPath)},
185 		uiCoverEdit {new GUI_CoverEdit(tagEditor, parent)},
186 		currentIndex {-1}
187 	{
188 		ui->tabFromPath->layout()->addWidget(uiTagFromPath);
189 		ui->tabCover->layout()->addWidget(uiCoverEdit);
190 		ui->tabWidget->setCurrentIndex(0);
191 		ui->widgetRating->setMouseTrackable(false);
192 
193 		widgetTuples = QList<WidgetTuple>
194 			{{ui->labTitle,       ui->leTitle,       nullptr,              Lang::Title},
195 			 {ui->labTrackNumber, ui->sbTrackNumber, nullptr,              Lang::TrackNo},
196 			 {ui->labAlbum,       ui->leAlbum,       ui->cbAlbumAll,       Lang::Album},
197 			 {ui->labArtist,      ui->leArtist,      ui->cbArtistAll,      Lang::Artist},
198 			 {ui->labAlbumArtist, ui->leAlbumArtist, ui->cbAlbumArtistAll, Lang::AlbumArtist},
199 			 {ui->labGenres,      ui->leGenre,       ui->cbGenreAll,       Lang::Genre},
200 			 {ui->labYear,        ui->sbYear,        ui->cbYearAll,        Lang::Year},
201 			 {ui->labDiscnumber,  ui->sbDiscnumber,  ui->cbDiscnumberAll,  Lang::Disc},
202 			 {ui->labRatingText,  ui->widgetRating,  ui->cbRatingAll,      Lang::Rating},
203 			 {ui->labComment,     ui->teComment,     ui->cbCommentAll,     Lang::Comment}};
204 	}
205 };
206 
207 GUI_TagEdit::GUI_TagEdit(QWidget* parent) :
208 	Widget(parent)
209 {
210 	ui = new Ui::GUI_TagEdit();
211 	ui->setupUi(this);
212 
213 	m = Pimpl::make<Private>(this, ui);
214 
215 	connect(m->tagEditor, &Editor::sigProgress, this, &GUI_TagEdit::progressChanged);
216 	connect(m->tagEditor, &Editor::sigMetadataReceived, this, &GUI_TagEdit::metadataChanged);
217 	connect(m->tagEditor, &Editor::sigStarted, this, &GUI_TagEdit::commitStarted);
218 	connect(m->tagEditor, &Editor::sigFinished, this, &GUI_TagEdit::commitFinished);
219 
220 	for(const auto& widgetTuple : m->widgetTuples)
221 	{
222 		if(widgetTuple.checkbox)
223 		{
224 			connect(widgetTuple.checkbox, &QCheckBox::toggled, widgetTuple.widget, &QWidget::setDisabled);
225 		}
226 	}
227 
228 	connect(ui->btnNext, &QPushButton::clicked, this, &GUI_TagEdit::nextButtonClicked);
229 	connect(ui->btnPrev, &QPushButton::clicked, this, &GUI_TagEdit::prevButtonClicked);
230 	connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &GUI_TagEdit::commit);
231 	connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &GUI_TagEdit::sigCancelled);
232 	connect(ui->btnUndo, &QPushButton::clicked, this, &GUI_TagEdit::undoClicked);
233 	connect(ui->btnUndoAll, &QPushButton::clicked, this, &GUI_TagEdit::undoAllClicked);
234 	connect(ui->btnLoadCompleteAlbum, &QPushButton::clicked, this, &GUI_TagEdit::loadEntireAlbumClicked);
235 
236 	connect(m->uiTagFromPath, &GUI_TagFromPath::sigApply, this, &GUI_TagEdit::applyTagFromPathTriggered);
237 	connect(m->uiTagFromPath, &GUI_TagFromPath::sigApplyAll, this, &GUI_TagEdit::applyAllTagFromPathTriggered);
238 
239 	metadataChanged(m->tagEditor->metadata());
240 }
241 
242 GUI_TagEdit::~GUI_TagEdit() = default;
243 
244 void GUI_TagEdit::runEditor(Editor* editor)
245 {
246 	auto* t = new QThread();
247 	editor->moveToThread(t);
248 
249 	connect(editor, &Tagging::Editor::sigFinished, t, &QThread::quit);
250 	connect(editor, &Tagging::Editor::sigFinished, editor, [=]() {
251 		editor->moveToThread(QApplication::instance()->thread());
252 	});
253 
254 	connect(t, &QThread::started, editor, &Editor::commit);
255 	connect(t, &QThread::finished, t, &QObject::deleteLater);
256 
257 	t->start();
258 }
259 
260 void GUI_TagEdit::languageChanged()
261 {
262 	for(const auto& widgetTuple : m->widgetTuples)
263 	{
264 		widgetTuple.label->setText(Lang::get(widgetTuple.term));
265 		setCheckboxText(widgetTuple.checkbox, m->tagEditor->count());
266 	}
267 
268 	ui->btnLoadCompleteAlbum->setText(tr("Load complete album"));
269 	ui->btnUndo->setText(Lang::get(Lang::Undo));
270 	ui->tabWidget->setTabText(0, tr("Metadata"));
271 	ui->tabWidget->setTabText(1, tr("Tags from path"));
272 	ui->tabWidget->setTabText(2, Lang::get(Lang::Covers));
273 
274 	ui->retranslateUi(this);
275 }
276 
277 void GUI_TagEdit::setMetadata(const MetaDataList& tracks)
278 {
279 	m->tagEditor->setMetadata(tracks);
280 }
281 
282 void GUI_TagEdit::metadataChanged([[maybe_unused]] const MetaDataList& changedTracks)
283 {
284 	reset();
285 
286 	for(const auto& widgetTuple : m->widgetTuples)
287 	{
288 		setCheckboxText(widgetTuple.checkbox, m->tagEditor->count());
289 	}
290 
291 	ui->btnLoadCompleteAlbum->setVisible(m->tagEditor->canLoadEntireAlbum());
292 	ui->btnLoadCompleteAlbum->setEnabled(true);
293 
294 	saveButton(ui)->setEnabled(true);
295 	ui->btnUndo->setEnabled(true);
296 	ui->btnUndoAll->setEnabled(true);
297 
298 	setCurrentIndex(0);
299 	refreshCurrentTrack();
300 }
301 
302 void GUI_TagEdit::applyTagFromPathTriggered()
303 {
304 	const auto success = m->tagEditor->applyRegularExpression(m->uiTagFromPath->getRegexString(), m->currentIndex);
305 	if(success)
306 	{
307 		ui->tabWidget->setCurrentIndex(0);
308 	}
309 
310 	refreshCurrentTrack();
311 }
312 
313 void GUI_TagEdit::applyAllTagFromPathTriggered()
314 {
315 	const auto count = m->tagEditor->count();
316 	const auto regex = m->uiTagFromPath->getRegexString();
317 	const auto invalidFilepaths = applyRegularExpression(count, regex, m->tagEditor);
318 	auto isValid = invalidFilepaths.isEmpty();
319 
320 	if(!invalidFilepaths.isEmpty())
321 	{
322 		for(const auto& invalidFilepath : invalidFilepaths)
323 		{
324 			this->m->uiTagFromPath->addInvalidFilepath(invalidFilepath);
325 		}
326 
327 		const auto answer = showInvalidFilepathsMessage(invalidFilepaths.count(), this);
328 		isValid = (answer == Message::Answer::Yes);
329 		if(!isValid)
330 		{
331 			m->tagEditor->undoAll();
332 		}
333 	}
334 
335 	if(isValid)
336 	{
337 		ui->tabWidget->setCurrentIndex(0);
338 	}
339 
340 	refreshCurrentTrack();
341 }
342 
343 void GUI_TagEdit::setCurrentIndex(int index)
344 {
345 	m->currentIndex = index;
346 	m->uiCoverEdit->setCurrentIndex(index);
347 }
348 
349 void GUI_TagEdit::nextButtonClicked()
350 {
351 	writeChanges(m->currentIndex);
352 	setCurrentIndex(m->currentIndex + 1);
353 	refreshCurrentTrack();
354 }
355 
356 void GUI_TagEdit::prevButtonClicked()
357 {
358 	writeChanges(m->currentIndex);
359 	setCurrentIndex(m->currentIndex - 1);
360 	refreshCurrentTrack();
361 }
362 
363 void GUI_TagEdit::refreshCurrentTrack()
364 {
365 	const auto trackCount = m->tagEditor->count();
366 	const auto isNotLast = (m->currentIndex >= 0) && (m->currentIndex < trackCount - 1);
367 	const auto isNotFirst = (m->currentIndex > 0) && (m->currentIndex < trackCount);
368 
369 	ui->btnNext->setEnabled(isNotLast);
370 	ui->btnPrev->setEnabled(isNotFirst);
371 
372 	if(!Util::between(m->currentIndex, m->tagEditor->count()))
373 	{
374 		return;
375 	}
376 
377 	const auto track = m->tagEditor->metadata(m->currentIndex);
378 
379 	{ // set filepath label
380 		const auto filepathLink =
381 			Util::createLink(track.filepath(), Style::isDark(), true, Util::File::getParentDirectory(track.filepath()));
382 
383 		const auto fileInfo = QFileInfo(track.filepath());
384 		ui->labReadOnly->setVisible(!fileInfo.isWritable());
385 		ui->labFilepath->setText(filepathLink);
386 		m->uiTagFromPath->setFilepath(track.filepath());
387 	}
388 
389 	ui->leTitle->setText(track.title());
390 
391 	if(!ui->cbAlbumAll->isChecked())
392 	{
393 		ui->leAlbum->setText(track.album());
394 	}
395 
396 	if(!ui->cbArtistAll->isChecked())
397 	{
398 		ui->leArtist->setText(track.artist());
399 	}
400 
401 	if(!ui->cbAlbumArtistAll->isChecked())
402 	{
403 		ui->leAlbumArtist->setText(track.albumArtist());
404 	}
405 
406 	if(!ui->cbGenreAll->isChecked())
407 	{
408 		ui->leGenre->setText(track.genresToList().join(", "));
409 	}
410 
411 	if(!ui->cbYearAll->isChecked())
412 	{
413 		ui->sbYear->setValue(track.year());
414 	}
415 
416 	if(!ui->cbDiscnumberAll->isChecked())
417 	{
418 		ui->sbDiscnumber->setValue(track.discnumber());
419 	}
420 
421 	if(!ui->cbRatingAll->isChecked())
422 	{
423 		ui->widgetRating->setRating(track.rating());
424 	}
425 
426 	if(!ui->cbCommentAll->isChecked())
427 	{
428 		ui->teComment->setPlainText(track.comment());
429 	}
430 
431 	const auto isCoverSupported = m->tagEditor->isCoverSupported(m->currentIndex);
432 	ui->tabCover->setEnabled(isCoverSupported);
433 	if(!isCoverSupported)
434 	{
435 		ui->tabWidget->setCurrentIndex(0);
436 	}
437 
438 	m->uiCoverEdit->refreshCurrentTrack();
439 
440 	ui->sbTrackNumber->setValue(track.trackNumber());
441 
442 	const auto trackIndexText = QString("%1 %2/%3")
443 		.arg(Lang::get(Lang::Track).toFirstUpper())
444 		.arg(m->currentIndex + 1)
445 		.arg(trackCount);
446 
447 	ui->labTrackIndex->setText(trackIndexText);
448 }
449 
450 void GUI_TagEdit::reset()
451 {
452 	setCurrentIndex(-1);
453 
454 	m->uiTagFromPath->reset();
455 	m->uiCoverEdit->reset();
456 
457 	ui->tabWidget->tabBar()->setEnabled(true);
458 
459 	for(const auto& widgetTuple : m->widgetTuples)
460 	{
461 		if(widgetTuple.checkbox)
462 		{
463 			widgetTuple.checkbox->setChecked(false);
464 		}
465 
466 		widgetTuple.widget->setEnabled(true);
467 	}
468 
469 	ui->leTitle->clear();
470 	ui->labTrackIndex->setText(Lang::get(Lang::Track) + " 0/0");
471 	ui->sbTrackNumber->setValue(0);
472 	ui->leAlbum->clear();
473 	ui->leArtist->clear();
474 	ui->leAlbumArtist->clear();
475 	ui->leGenre->clear();
476 	ui->sbYear->clear();
477 	ui->sbDiscnumber->clear();
478 	ui->teComment->clear();
479 
480 	ui->btnPrev->setEnabled(false);
481 	ui->btnNext->setEnabled(false);
482 
483 	ui->widgetRating->setRating(Rating::Zero);
484 
485 	ui->labFilepath->clear();
486 	ui->pbProgress->setVisible(false);
487 
488 	ui->btnLoadCompleteAlbum->setVisible(false);
489 
490 	initCompleter(ui);
491 }
492 
493 void GUI_TagEdit::undoClicked()
494 {
495 	m->tagEditor->undo(m->currentIndex);
496 	refreshCurrentTrack();
497 }
498 
499 void GUI_TagEdit::undoAllClicked()
500 {
501 	m->tagEditor->undoAll();
502 	refreshCurrentTrack();
503 }
504 
505 void GUI_TagEdit::writeChanges(int trackIndex)
506 {
507 	if(!Util::between(m->currentIndex, m->tagEditor->count()))
508 	{
509 		return;
510 	}
511 
512 	auto track = m->tagEditor->metadata(trackIndex);
513 
514 	track.setTitle(ui->leTitle->text());
515 	track.setArtist(ui->leArtist->text());
516 	track.setAlbum(ui->leAlbum->text());
517 	track.setAlbumArtist(ui->leAlbumArtist->text());
518 	track.setGenres(ui->leGenre->text().split(", "));
519 	track.setComment(ui->teComment->toPlainText());
520 	track.setDiscnumber(Disc(ui->sbDiscnumber->value()));
521 	track.setYear(Year(ui->sbYear->value()));
522 	track.setTrackNumber(TrackNum(ui->sbTrackNumber->value()));
523 	track.setRating(ui->widgetRating->rating());
524 
525 	const auto cover = m->uiCoverEdit->selectedCover(trackIndex);
526 
527 	m->tagEditor->updateTrack(trackIndex, track);
528 	m->tagEditor->updateCover(trackIndex, cover);
529 }
530 
531 void GUI_TagEdit::commit()
532 {
533 	if(!saveButton(ui)->isEnabled())
534 	{
535 		return;
536 	}
537 
538 	writeChanges(m->currentIndex);
539 
540 	for(auto i = 0; i < m->tagEditor->count(); i++)
541 	{
542 		if(i == m->currentIndex)
543 		{
544 			continue;
545 		}
546 
547 		auto track = m->tagEditor->metadata(i);
548 
549 		if(ui->cbAlbumAll->isChecked())
550 		{
551 			track.setAlbum(ui->leAlbum->text());
552 		}
553 		if(ui->cbArtistAll->isChecked())
554 		{
555 			track.setArtist(ui->leArtist->text());
556 		}
557 		if(ui->cbAlbumArtistAll->isChecked())
558 		{
559 			track.setAlbumArtist(ui->leAlbumArtist->text());
560 		}
561 		if(ui->cbGenreAll->isChecked())
562 		{
563 			const auto genres = ui->leGenre->text().split(", ");
564 			track.setGenres(genres);
565 		}
566 
567 		if(ui->cbDiscnumberAll->isChecked())
568 		{
569 			track.setDiscnumber(Disc(ui->sbDiscnumber->value()));
570 		}
571 
572 		if(ui->cbRatingAll->isChecked())
573 		{
574 			track.setRating(ui->widgetRating->rating());
575 		}
576 
577 		if(ui->cbYearAll->isChecked())
578 		{
579 			track.setYear(Year(ui->sbYear->value()));
580 		}
581 
582 		if(ui->cbCommentAll->isChecked())
583 		{
584 			track.setComment(ui->teComment->toPlainText());
585 		}
586 
587 		m->tagEditor->updateTrack(i, track);
588 
589 		const auto cover = m->uiCoverEdit->selectedCover(i);
590 		m->tagEditor->updateCover(i, cover);
591 	}
592 
593 	runEditor(m->tagEditor);
594 }
595 
596 void GUI_TagEdit::commitStarted()
597 {
598 	saveButton(ui)->setEnabled(false);
599 	ui->btnUndo->setEnabled(false);
600 	ui->btnUndoAll->setEnabled(false);
601 	ui->btnLoadCompleteAlbum->setEnabled(false);
602 
603 	ui->tabWidget->tabBar()->setEnabled(false);
604 
605 	ui->pbProgress->setVisible(true);
606 	ui->pbProgress->setMinimum(0);
607 	ui->pbProgress->setMaximum(100);
608 }
609 
610 void GUI_TagEdit::progressChanged(int val)
611 {
612 	if(val >= 0)
613 	{
614 		ui->pbProgress->setValue(val);
615 	}
616 
617 	else
618 	{
619 		ui->pbProgress->setMinimum(0);
620 		ui->pbProgress->setMaximum(0);
621 	}
622 }
623 
624 void GUI_TagEdit::commitFinished()
625 {
626 	saveButton(ui)->setEnabled(true);
627 	ui->btnLoadCompleteAlbum->setEnabled(m->tagEditor->canLoadEntireAlbum());
628 	ui->tabWidget->tabBar()->setEnabled(true);
629 	ui->pbProgress->setVisible(false);
630 
631 	const auto failedFiles = m->tagEditor->failedFiles();
632 	if(!failedFiles.isEmpty())
633 	{
634 		showFailedCommitMessageBox(failedFiles, this);
635 	}
636 
637 	metadataChanged(m->tagEditor->metadata());
638 }
639 
640 void GUI_TagEdit::showDefaultTab()
641 {
642 	ui->tabWidget->setCurrentIndex(0);
643 }
644 
645 void GUI_TagEdit::showCoverTab()
646 {
647 	ui->tabWidget->setCurrentIndex(2);
648 }
649 
650 void GUI_TagEdit::loadEntireAlbumClicked()
651 {
652 	if(m->tagEditor->hasChanges())
653 	{
654 		const auto answer = showAllChangesLostMessage(this);
655 		if(answer == Message::Answer::No)
656 		{
657 			return;
658 		}
659 	}
660 
661 	m->tagEditor->loadEntireAlbum();
662 }
663 
664 void GUI_TagEdit::showEvent(QShowEvent* e)
665 {
666 	Widget::showEvent(e);
667 
668 	refreshCurrentTrack();
669 	ui->leTitle->setFocus();
670 }
671