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