1 /* 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 "Editor.h"
22 #include "Expression.h"
23 #include "ChangeNotifier.h"
24 #include "ChangeInformation.h"
25 
26 #include "Components/Covers/CoverLocation.h"
27 #include "Components/Covers/CoverChangeNotifier.h"
28 #include "Components/Directories/MetaDataScanner.h"
29 #include "Components/MetaDataInfo/MetaDataInfo.h"
30 
31 #include "Database/Connector.h"
32 #include "Database/CoverConnector.h"
33 #include "Database/LibraryDatabase.h"
34 
35 #include "Utils/Algorithm.h"
36 #include "Utils/FileUtils.h"
37 #include "Utils/Library/Filter.h"
38 #include "Utils/Library/Sortorder.h"
39 #include "Utils/Logger/Logger.h"
40 #include "Utils/MetaData/Album.h"
41 #include "Utils/MetaData/Artist.h"
42 #include "Utils/MetaData/Genre.h"
43 #include "Utils/MetaData/MetaDataList.h"
44 #include "Utils/Set.h"
45 #include "Utils/Tagging/Tagging.h"
46 #include "Utils/Tagging/TaggingCover.h"
47 
48 #include <QHash>
49 #include <QFileInfo>
50 
51 using Tagging::Editor;
52 
53 namespace
54 {
getOriginalAlbumIds(const QList<Tagging::ChangeInformation> & changeInfos)55 	QList<AlbumId> getOriginalAlbumIds(const QList<Tagging::ChangeInformation>& changeInfos)
56 	{
57 		Util::Set<AlbumId> albumIds;
58 		for(const auto& changeInfo : changeInfos)
59 		{
60 			albumIds << changeInfo.originalMetadata().albumId();
61 		}
62 
63 		return albumIds.toList();
64 	}
65 
checkFailReason(const QString & filepath,Editor * editor)66 	Editor::FailReason checkFailReason(const QString& filepath, Editor* editor)
67 	{
68 		if(const auto fileInfo = QFileInfo(filepath); !fileInfo.exists())
69 		{
70 			spLog(Log::Warning, editor) << "Failed to write tags to file: File not found: " << filepath;
71 			return Editor::FailReason::FileNotFound;
72 		}
73 
74 		else if(!fileInfo.isWritable())
75 		{
76 			spLog(Log::Warning, editor) << "Failed to write tags to file: File not writeable: " << filepath;
77 			return Editor::FailReason::FileNotWriteable;
78 		}
79 
80 		spLog(Log::Warning, editor) << "Failed to write tags to file: Other error: " << filepath;
81 		return Editor::FailReason::TagLibError;
82 	}
83 
scalePixmap(const QPixmap & pixmap)84 	QPixmap scalePixmap(const QPixmap& pixmap)
85 	{
86 		constexpr const auto MaxSize = 1000;
87 		return ((pixmap.size().width() > MaxSize) || (pixmap.size().height() > MaxSize))
88 		       ? pixmap.scaled(QSize(MaxSize, MaxSize), Qt::KeepAspectRatio, Qt::SmoothTransformation)
89 		       : pixmap;
90 	}
91 
saveCoverInTrack(const QString & filepath,const QPixmap & pixmap,Editor * editor)92 	bool saveCoverInTrack(const QString& filepath, const QPixmap& pixmap, Editor* editor)
93 	{
94 		const auto success = Tagging::writeCover(filepath, pixmap);
95 		if(!success)
96 		{
97 			spLog(Log::Warning, editor) << "Failed to write cover";
98 		}
99 
100 		return success;
101 	}
102 
saveCoverToPersistence(const MetaData & track,const QPixmap & cover,DB::Covers * coverDatabase,Editor * editor)103 	bool saveCoverToPersistence(const MetaData& track, const QPixmap& cover, DB::Covers* coverDatabase, Editor* editor)
104 	{
105 		const auto coverLocation = Cover::Location::coverLocation(track);
106 
107 		const auto pixmap = scalePixmap(cover);
108 		saveCoverInTrack(track.filepath(), pixmap, editor);
109 
110 		pixmap.save(coverLocation.audioFileTarget());
111 
112 		return coverDatabase->setCover(coverLocation.hash(), pixmap);
113 	}
114 
applyTrackChangesToDatabase(const MetaData & currentMetadata,DB::LibraryDatabase * libraryDatabase)115 	bool applyTrackChangesToDatabase(const MetaData& currentMetadata, DB::LibraryDatabase* libraryDatabase)
116 	{
117 		return (!currentMetadata.isExtern() && (currentMetadata.id() >= 0))
118 		       ? libraryDatabase->updateTrack(currentMetadata)
119 		       : true;
120 	}
121 
applyTrackChangesToFile(const MetaData & currentMetadata,Editor * editor)122 	Editor::FailReason applyTrackChangesToFile(const MetaData& currentMetadata, Editor* editor)
123 	{
124 		return Tagging::Utils::setMetaDataOfFile(currentMetadata)
125 		       ? Editor::FailReason::NoError
126 		       : checkFailReason(currentMetadata.filepath(), editor);
127 	}
128 
checkForChanges(const QList<Tagging::ChangeInformation> & changeInformation)129 	int checkForChanges(const QList<Tagging::ChangeInformation>& changeInformation)
130 	{
131 		return Util::Algorithm::count(changeInformation, [](const auto& changeInfo) {
132 			return (changeInfo.hasChanges() || changeInfo.hasNewCover());
133 		});
134 	}
135 }
136 
137 struct Editor::Private
138 {
139 	QList<ChangeInformation> changeInfo;
140 	QMap<QString, Editor::FailReason> failedFiles;
141 };
142 
Editor(QObject * parent)143 Editor::Editor(QObject* parent) :
144 	QObject(parent)
145 {
146 	m = Pimpl::make<Editor::Private>();
147 }
148 
Editor(const MetaDataList & tracks,QObject * parent)149 Editor::Editor(const MetaDataList& tracks, QObject* parent) :
150 	Editor(parent)
151 {
152 	setMetadata(tracks);
153 }
154 
155 Editor::~Editor() = default;
156 
updateTrack(int index,const MetaData & track)157 void Editor::updateTrack(int index, const MetaData& track)
158 {
159 	if(Util::between(index, m->changeInfo))
160 	{
161 		m->changeInfo[index].update(track);
162 	}
163 }
164 
undo(int index)165 void Editor::undo(int index)
166 {
167 	if(Util::between(index, m->changeInfo))
168 	{
169 		m->changeInfo[index].undo();
170 	}
171 }
172 
undoAll()173 void Editor::undoAll()
174 {
175 	for(auto& changeInfo : m->changeInfo)
176 	{
177 		changeInfo.undo();
178 	}
179 }
180 
metadata(int index) const181 MetaData Editor::metadata(int index) const
182 {
183 	return (Util::between(index, m->changeInfo))
184 	       ? m->changeInfo[index].currentMetadata()
185 	       : MetaData();
186 }
187 
metadata() const188 MetaDataList Editor::metadata() const
189 {
190 	MetaDataList tracks;
191 
192 	Util::Algorithm::transform(m->changeInfo, tracks, [](const auto& changeInfo) {
193 		return (changeInfo.currentMetadata());
194 	});
195 
196 	return tracks;
197 }
198 
applyRegularExpression(const QString & regex,int index)199 bool Editor::applyRegularExpression(const QString& regex, int index)
200 {
201 	if(!Util::between(index, m->changeInfo))
202 	{
203 		return false;
204 	}
205 
206 	auto& changeInfo = m->changeInfo[index];
207 	auto& currentMetadata = changeInfo.currentMetadata();
208 	const auto expression = Tagging::Expression(regex, currentMetadata.filepath());
209 
210 	if(expression.isValid())
211 	{
212 		const auto success = expression.apply(currentMetadata);
213 		changeInfo.setChanged(success);
214 	}
215 
216 	return expression.isValid();
217 }
218 
count() const219 int Editor::count() const
220 {
221 	return m->changeInfo.count();
222 }
223 
hasChanges() const224 bool Editor::hasChanges() const
225 {
226 	return Util::Algorithm::contains(m->changeInfo, [](const auto& changeInfo) {
227 		return changeInfo.hasChanges();
228 	});
229 }
230 
addGenre(int index,const Genre & genre)231 void Editor::addGenre(int index, const Genre& genre)
232 {
233 	if(Util::between(index, m->changeInfo))
234 	{
235 		auto& currentMetadata = m->changeInfo[index].currentMetadata();
236 		if(currentMetadata.addGenre(genre))
237 		{
238 			m->changeInfo[index].setChanged(true);
239 		}
240 	}
241 }
242 
deleteGenre(int index,const Genre & genre)243 void Editor::deleteGenre(int index, const Genre& genre)
244 {
245 	if(Util::between(index, m->changeInfo))
246 	{
247 		auto& currentMetadata = m->changeInfo[index].currentMetadata();
248 		if(currentMetadata.removeGenre(genre))
249 		{
250 			m->changeInfo[index].setChanged(true);
251 		}
252 	}
253 }
254 
renameGenre(int index,const Genre & genre,const Genre & newGenre)255 void Editor::renameGenre(int index, const Genre& genre, const Genre& newGenre)
256 {
257 	deleteGenre(index, genre);
258 	addGenre(index, newGenre);
259 }
260 
setMetadata(const MetaDataList & tracks)261 void Editor::setMetadata(const MetaDataList& tracks)
262 {
263 	m->failedFiles.clear();
264 	m->changeInfo.clear();
265 
266 	Util::Algorithm::transform(tracks, m->changeInfo, [](const auto& track) {
267 		return ChangeInformation(track);
268 	});
269 
270 	emit sigMetadataReceived(tracks);
271 }
272 
isCoverSupported(int index) const273 bool Editor::isCoverSupported(int index) const
274 {
275 	if(Util::between(index, m->changeInfo))
276 	{
277 		const auto& originalMetadata = m->changeInfo[index].originalMetadata();
278 		return Tagging::isCoverSupported(originalMetadata.filepath());
279 	}
280 
281 	return false;
282 }
283 
canLoadEntireAlbum() const284 bool Editor::canLoadEntireAlbum() const
285 {
286 	const auto albumIds = getOriginalAlbumIds(m->changeInfo);
287 	return (albumIds.count() == 1);
288 }
289 
loadEntireAlbum()290 void Editor::loadEntireAlbum()
291 {
292 	const auto albumIds = getOriginalAlbumIds(m->changeInfo);
293 	if(albumIds.size() != 1)
294 	{
295 		return;
296 	}
297 
298 	const auto albumId = albumIds.first();
299 	if((albumId < 0) && (!m->changeInfo.isEmpty()))
300 	{
301 		const auto filepath = m->changeInfo[0].originalMetadata().filepath();
302 		startSameAlbumCrawler(filepath);
303 
304 		emit sigStarted();
305 		emit sigProgress(-1);
306 	}
307 
308 	else
309 	{
310 		auto* libraryDatabase = DB::Connector::instance()->libraryDatabase(-1, 0);
311 
312 		MetaDataList tracks;
313 		libraryDatabase->getAllTracksByAlbum(IdList {albumId}, tracks, ::Library::Filter(), -1);
314 		tracks.sort(::Library::SortOrder::TrackDiscnumberAsc);
315 		setMetadata(tracks);
316 	}
317 }
318 
startSameAlbumCrawler(const QString & filepath)319 void Editor::startSameAlbumCrawler(const QString& filepath)
320 {
321 	using Directory::MetaDataScanner;
322 
323 	const auto[dir, filename] = Util::File::splitFilename(filepath);
324 
325 	auto* thread = new QThread();
326 	auto* worker = new MetaDataScanner({dir}, true);
327 	worker->moveToThread(thread);
328 
329 	connect(thread, &QThread::finished, thread, &QObject::deleteLater);
330 	connect(thread, &QThread::started, worker, &MetaDataScanner::start);
331 	connect(worker, &MetaDataScanner::sigFinished, thread, &QThread::quit);
332 	connect(worker, &MetaDataScanner::sigFinished, this, &Editor::loadEntireAlbumFinished);
333 
334 	thread->start();
335 }
336 
loadEntireAlbumFinished()337 void Editor::loadEntireAlbumFinished()
338 {
339 	auto* worker = static_cast<Directory::MetaDataScanner*>(sender());
340 
341 	if(const auto tracks = worker->metadata(); !tracks.isEmpty())
342 	{
343 		setMetadata(tracks);
344 	}
345 
346 	worker->deleteLater();
347 
348 	emit sigFinished();
349 }
350 
insertMissingArtistsAndAlbums()351 void Editor::insertMissingArtistsAndAlbums()
352 {
353 	MetaDataList tracksToBeModified;
354 	Util::Algorithm::transform(m->changeInfo, tracksToBeModified, [](const auto& changeInfo) {
355 		return changeInfo.currentMetadata();
356 	});
357 
358 	auto* libraryDatabase = DB::Connector::instance()->libraryDatabase(-1, 0);
359 	const auto modifiedTracks = libraryDatabase->insertMissingArtistsAndAlbums(tracksToBeModified);
360 
361 	auto i = 0;
362 	for(auto it = m->changeInfo.begin(); it != m->changeInfo.end(); it++, i++)
363 	{
364 		if(!Util::between(i, modifiedTracks))
365 		{
366 			spLog(Log::Warning, this) << "Index out of bounds when updating tracks!";
367 			break;
368 		}
369 
370 		it->update(modifiedTracks[i]);
371 	}
372 }
373 
updateCover(int index,const QPixmap & cover)374 void Editor::updateCover(int index, const QPixmap& cover)
375 {
376 	if(isCoverSupported(index) && !cover.isNull())
377 	{
378 		m->changeInfo[index].updateCover(cover);
379 	}
380 }
381 
hasCoverReplacement(int index) const382 bool Editor::hasCoverReplacement(int index) const
383 {
384 	return (Util::between(index, m->changeInfo) && m->changeInfo[index].hasNewCover());
385 }
386 
387 struct CommitResult
388 {
389 	bool coverChanged {false};
390 	QList<MetaDataPair> changedTracks;
391 	QMap<QString, Editor::FailReason> failedFiles;
392 };
393 
commit()394 void Editor::commit()
395 {
396 	const auto numChanges = checkForChanges(m->changeInfo);
397 	if(numChanges == 0)
398 	{
399 		return;
400 	}
401 
402 	emit sigStarted();
403 
404 	insertMissingArtistsAndAlbums();
405 
406 	auto* db = DB::Connector::instance();
407 	auto* libraryDatabase = db->libraryDatabase(-1, 0);
408 	auto* coverDatabase = db->coverConnector();
409 
410 	db->transaction();
411 
412 	auto progress = 0;
413 	auto commitResult = CommitResult();
414 
415 	for(auto& changeInfo : m->changeInfo)
416 	{
417 		const auto& currentMetadata = changeInfo.currentMetadata();
418 		const auto& originalMetadata = changeInfo.originalMetadata();
419 
420 		if(changeInfo.hasNewCover())
421 		{
422 			commitResult.coverChanged |=
423 				saveCoverToPersistence(currentMetadata, changeInfo.cover(), coverDatabase, this);
424 		}
425 
426 		if(changeInfo.hasChanges())
427 		{
428 			const auto writeResult = applyTrackChangesToFile(currentMetadata, this);
429 
430 			if((writeResult == Editor::FailReason::NoError) &&
431 			   applyTrackChangesToDatabase(currentMetadata, libraryDatabase))
432 			{
433 				commitResult.changedTracks << MetaDataPair(originalMetadata, currentMetadata);
434 				changeInfo.apply();
435 			}
436 
437 			else
438 			{
439 				commitResult.failedFiles.insert(originalMetadata.filepath(), writeResult);
440 				changeInfo.undo();
441 			}
442 		}
443 
444 		emit sigProgress((++progress * 100) / numChanges);
445 	}
446 
447 	db->commit();
448 	db->libraryConnector()->createIndexes();
449 	db->closeDatabase();
450 
451 	m->failedFiles = std::move(commitResult.failedFiles);
452 
453 	if(commitResult.coverChanged)
454 	{
455 		Cover::ChangeNotfier::instance()->shout();
456 	}
457 
458 	if(!commitResult.changedTracks.isEmpty())
459 	{
460 		Tagging::ChangeNotifier::instance()->changeMetadata(commitResult.changedTracks);
461 	}
462 
463 	emit sigFinished();
464 }
465 
failedFiles() const466 QMap<QString, Editor::FailReason> Editor::failedFiles() const
467 {
468 	return m->failedFiles;
469 }
470