diff --git a/build/depends.py b/build/depends.py index a127271a46d1..3cb0980d8ba5 100644 --- a/build/depends.py +++ b/build/depends.py @@ -975,6 +975,7 @@ def sources(self, build): "src/database/schemamanager.cpp", "src/library/trackcollection.cpp", + "src/library/externaltrackcollection.cpp", "src/library/basesqltablemodel.cpp", "src/library/basetrackcache.cpp", "src/library/columncache.cpp", diff --git a/default.nix b/default.nix index 1b674e00416c..165206dc65ea 100644 --- a/default.nix +++ b/default.nix @@ -1,4 +1,7 @@ -{ nixroot ? (import {}) }: +{ nixroot ? (import {}) +, defaultLv2Plugins ? false +, lv2Plugins ? [] +}: let inherit (nixroot) stdenv pkgs lib chromaprint fftw flac libid3tag libmad libopus libshout libsndfile lilv libusb1 libvorbis libebur128 pkgconfig portaudio portmidi protobuf qt5 glib @@ -42,7 +45,7 @@ let inherit (nixroot) stdenv pkgs lib shell-run = nixroot.writeShellScriptBin "run" '' BUILDDIR=$(ls -1 -d -t lin64_build lin_build | head -1) - $BUILDDIR/mixxx --settingsPath ./devsettings/ --resourcePath ./res "$@" + /usr/bin/env LV2_PATH=${lib.makeSearchPathOutput "lib" "lib/lv2" allLv2Plugins}:$LV2_PATH $BUILDDIR/mixxx --settingsPath ./devsettings/ --resourcePath ./res "$@" ''; shell-debug = nixroot.writeShellScriptBin "debug" '' @@ -50,6 +53,11 @@ let inherit (nixroot) stdenv pkgs lib gdb --args $BUILDDIR/mixxx --settingsPath ./devsettings/ --resourcePath ./res "$@" ''; + allLv2Plugins = lv2Plugins ++ (if defaultLv2Plugins then [ + nixroot.x42-plugins nixroot.zam-plugins nixroot.rkrlv2 nixroot.mod-distortion + nixroot.infamousPlugins nixroot.artyFX + ] else []); + in stdenv.mkDerivation rec { name = "mixxx-${version}"; # reading the version from git output is very hard to do without wasting lots of diskspace and runtime @@ -83,7 +91,7 @@ in stdenv.mkDerivation rec { libusb1 libvorbis libebur128 pkgconfig portaudio portmidi protobuf qt5.full rubberband scons sqlite taglib soundtouch vamp.vampSDK opusfile upower hidapi ccache git glib x11 libGLU lilv lame lv2 makeWrapper qt5.qtbase - ]; + ] ++ allLv2Plugins; sconsFlags = [ "build=debug" @@ -102,7 +110,7 @@ in stdenv.mkDerivation rec { installPhase = '' runHook preInstall scons $sconsFlags "prefix=$out" install - wrapProgram $out/bin/mixxx --suffix QT_PLUGIN_PATH : ${qt5.qtbase}/${qt5.qtbase.qtPluginPrefix} --set QTDIR ${qt5.full} + wrapProgram $out/bin/mixxx --suffix QT_PLUGIN_PATH : ${qt5.qtbase}/${qt5.qtbase.qtPluginPrefix} --set QTDIR ${qt5.full} --prefix LV2_PATH : ${lib.makeSearchPath "lib/lv2" allLv2Plugins} runHook postInstall ''; diff --git a/res/controllers/midi-components-0.0.js b/res/controllers/midi-components-0.0.js index 4dc43be1e962..7bb5c009578e 100644 --- a/res/controllers/midi-components-0.0.js +++ b/res/controllers/midi-components-0.0.js @@ -533,6 +533,34 @@ } } }, + forEachComponentContainer: function (operation, recursive) { + if (typeof operation !== 'function') { + print('ERROR: ComponentContainer.forEachComponentContainer requires a function argument'); + return; + } + if (recursive === undefined) { recursive = true; } + + var that = this; + var applyOperationTo = function (obj) { + if (obj instanceof ComponentContainer) { + operation.call(that, obj); + + if (recursive) { + obj.forEachComponentContainer(operation); + } + } else if (Array.isArray(obj)) { + obj.forEach(function (element) { + applyOperationTo(element); + }); + } + }; + + for (var memberName in this) { + if (this.hasOwnProperty(memberName)) { + applyOperationTo(this[memberName]); + } + } + }, reconnectComponents: function (operation, recursive) { this.forEachComponent(function (component) { component.disconnect(); @@ -545,6 +573,7 @@ }, isShifted: false, shift: function () { + // Shift direct child Components this.forEachComponent(function (component) { // Controls for push type Buttons depend on getting reset to 0 when the // Button is released for correct behavior. If there is a skin button @@ -568,11 +597,18 @@ } component.shift(); } - // Set isShifted for child ComponentContainers forEachComponent is iterating through recursively - this.isShifted = true; - }); + }, false); + + // Shift child ComponentContainers + this.forEachComponentContainer(function (container) { + container.shift(); + }, false); + + // Set isShifted for each ComponentContainer recursively + this.isShifted = true; }, unshift: function () { + // Unshift direct child Components this.forEachComponent(function (component) { // Refer to comment in ComponentContainer.shift() above for explanation if (typeof component.unshift === 'function') { @@ -588,9 +624,15 @@ } component.unshift(); } - // Set isShifted for child ComponentContainers forEachComponent is iterating through recursively - this.isShifted = false; - }); + }, false); + + // Unshift child ComponentContainers + this.forEachComponentContainer(function (container) { + container.unshift(); + }, false); + + // Unset isShifted for each ComponentContainer recursively + this.isShifted = false; }, applyLayer: function (newLayer, reconnectComponents) { if (reconnectComponents !== false) { diff --git a/src/engine/controls/loopingcontrol.cpp b/src/engine/controls/loopingcontrol.cpp index 02f9e10fb4cd..245201df7631 100644 --- a/src/engine/controls/loopingcontrol.cpp +++ b/src/engine/controls/loopingcontrol.cpp @@ -53,7 +53,7 @@ LoopingControl::LoopingControl(QString group, m_loopSamples.setValue(m_oldLoopSamples); m_currentSample.setValue(0.0); m_pActiveBeatLoop = NULL; - + m_pRateControl = NULL; //Create loop-in, loop-out, loop-exit, and reloop/exit ControlObjects m_pLoopInButton = new ControlPushButton(ConfigKey(group, "loop_in")); connect(m_pLoopInButton, &ControlObject::valueChanged, @@ -682,6 +682,10 @@ void LoopingControl::setLoopOutToCurrentPosition() { m_loopSamples.setValue(loopSamples); } +void LoopingControl::setRateControl(RateControl* rateControl) { + m_pRateControl = rateControl; +} + void LoopingControl::slotLoopOut(double pressed) { if (m_pTrack == nullptr) { return; @@ -1060,16 +1064,19 @@ void LoopingControl::slotBeatLoop(double beats, bool keepStartPoint, bool enable newloopSamples.start = currentSample; } } else { - // loop_in is set to the previous beat if quantize is on. The - // closest beat might be ahead of play position which would cause a seek. - // TODO: If in reverse, should probably choose nextBeat. + // loop_in is set to the closest beat if quantize is on and the loop size is >= 1 beat. + // The closest beat might be ahead of play position and will cause a catching loop. double prevBeat; double nextBeat; pBeats->findPrevNextBeats(currentSample, &prevBeat, &nextBeat); if (m_pQuantizeEnabled->toBool() && prevBeat != -1) { + double beatLength = nextBeat - prevBeat; + double loopLength = beatLength * beats; + + double closestBeat = pBeats->findClosestBeat(currentSample); if (beats >= 1.0) { - newloopSamples.start = prevBeat; + newloopSamples.start = closestBeat; } else { // In case of beat length less then 1 beat: // (| - beats, ^ - current track's position): @@ -1078,15 +1085,29 @@ void LoopingControl::slotBeatLoop(double beats, bool keepStartPoint, bool enable // // If we press 1/2 beatloop we want loop from 50% to 100%, // If I press 1/4 beatloop, we want loop from 50% to 75% etc - double beat_len = nextBeat - prevBeat; - double loops_per_beat = 1.0 / beats; - double beat_pos = currentSample - prevBeat; - int beat_frac = - static_cast(floor((beat_pos / beat_len) * - loops_per_beat)); - newloopSamples.start = prevBeat + beat_len / loops_per_beat * beat_frac; + double samplesSinceLastBeat = currentSample - prevBeat; + + // find the previous beat fraction and check if the current position is closer to this or the next one + // place the new loop start to the closer one + double previousFractionBeat = prevBeat + floor(samplesSinceLastBeat / loopLength) * loopLength; + double samplesSinceLastFractionBeat = currentSample - previousFractionBeat; + + if (samplesSinceLastFractionBeat <= (loopLength / 2.0)) { + newloopSamples.start = previousFractionBeat; + } else { + newloopSamples.start = previousFractionBeat + loopLength; + } } + // If running reverse, move the loop one loop size to the left. + // Thus, the loops end will be closest to the current position + bool reverse = false; + if (m_pRateControl != NULL) { + reverse = m_pRateControl->isReverseButtonPressed(); + } + if (reverse) { + newloopSamples.start -= loopLength; + } } else { newloopSamples.start = currentSample; } diff --git a/src/engine/controls/loopingcontrol.h b/src/engine/controls/loopingcontrol.h index 55d4f0c15f17..6be5fdb78b01 100644 --- a/src/engine/controls/loopingcontrol.h +++ b/src/engine/controls/loopingcontrol.h @@ -8,11 +8,12 @@ #include #include -#include "preferences/usersettings.h" +#include "control/controlvalue.h" #include "engine/controls/enginecontrol.h" -#include "track/track.h" +#include "engine/controls/ratecontrol.h" +#include "preferences/usersettings.h" #include "track/beats.h" -#include "control/controlvalue.h" +#include "track/track.h" #define MINIMUM_AUDIBLE_LOOP_SIZE 300 // In samples @@ -50,7 +51,7 @@ class LoopingControl : public EngineControl { double getSyncPositionInsideLoop(double dRequestedPlaypos, double dSyncedPlayPos); void notifySeek(double dNewPlaypos) override; - + void setRateControl(RateControl* rateControl); bool isLoopingEnabled(); public slots: @@ -129,6 +130,7 @@ class LoopingControl : public EngineControl { ControlPushButton* m_pLoopHalveButton; ControlPushButton* m_pLoopDoubleButton; ControlObject* m_pSlipEnabled; + RateControl* m_pRateControl; ControlObject* m_pPlayButton; bool m_bLoopingEnabled; diff --git a/src/engine/controls/ratecontrol.cpp b/src/engine/controls/ratecontrol.cpp index 75c7327b10c2..5ee5af58ffe8 100644 --- a/src/engine/controls/ratecontrol.cpp +++ b/src/engine/controls/ratecontrol.cpp @@ -556,3 +556,10 @@ void RateControl::resetRateTemp(void) void RateControl::notifySeek(double playPos) { m_pScratchController->notifySeek(playPos); } + +bool RateControl::isReverseButtonPressed() { + if (m_pReverseButton) { + return m_pReverseButton->toBool(); + } + return false; +} diff --git a/src/engine/controls/ratecontrol.h b/src/engine/controls/ratecontrol.h index 635ae15b2e37..a53dfd383f15 100644 --- a/src/engine/controls/ratecontrol.h +++ b/src/engine/controls/ratecontrol.h @@ -78,6 +78,7 @@ class RateControl : public EngineControl { static void setRateRampSensitivity(int); static int getRateRampSensitivity(); void notifySeek(double dNewPlaypos) override; + bool isReverseButtonPressed(); public slots: void slotReverseRollActivate(double); diff --git a/src/engine/enginebuffer.cpp b/src/engine/enginebuffer.cpp index 2c10c3bfe8a4..5ba47c24a5cf 100644 --- a/src/engine/enginebuffer.cpp +++ b/src/engine/enginebuffer.cpp @@ -180,14 +180,13 @@ EngineBuffer::EngineBuffer(const QString& group, UserSettingsPointer pConfig, // quantization (alignment) of loop in/out positions and (hot)cues with // beats. QuantizeControl* quantize_control = new QuantizeControl(group, pConfig); + addControl(quantize_control); + m_pQuantize = ControlObject::getControl(ConfigKey(group, "quantize")); // Create the Loop Controller m_pLoopingControl = new LoopingControl(group, pConfig); addControl(m_pLoopingControl); - addControl(quantize_control); - m_pQuantize = ControlObject::getControl(ConfigKey(group, "quantize")); - m_pEngineSync = pMixingEngine->getEngineSync(); m_pSyncControl = new SyncControl(group, pConfig, pChannel, m_pEngineSync); @@ -198,9 +197,12 @@ EngineBuffer::EngineBuffer(const QString& group, UserSettingsPointer pConfig, addControl(m_pVinylControlControl); #endif + // Create the Rate Controller m_pRateControl = new RateControl(group, pConfig); // Add the Rate Controller addControl(m_pRateControl); + // Looping Control needs Rate Control for Reverse Button + m_pLoopingControl->setRateControl(m_pRateControl); // Create the BPM Controller m_pBpmControl = new BpmControl(group, pConfig); @@ -532,6 +534,10 @@ TrackPointer EngineBuffer::getLoadedTrack() const { return m_pCurrentTrack; } +bool EngineBuffer::isReverse() { + return m_reverse_old; +} + void EngineBuffer::ejectTrack() { // clear track values in any case, this may fix Bug #1450424 //qDebug() << "EngineBuffer::ejectTrack()"; diff --git a/src/engine/enginebuffer.h b/src/engine/enginebuffer.h index 46735d16fa11..394794225fbd 100644 --- a/src/engine/enginebuffer.h +++ b/src/engine/enginebuffer.h @@ -146,6 +146,8 @@ class EngineBuffer : public EngineObject { bool getQueuedSeekPosition(double* pSeekPosition); TrackPointer getLoadedTrack() const; + bool isReverse(); + double getExactPlayPos(); double getVisualPlayPos(); double getTrackSamples(); diff --git a/src/library/basesqltablemodel.cpp b/src/library/basesqltablemodel.cpp index a5f991652edf..96270efa87f8 100644 --- a/src/library/basesqltablemodel.cpp +++ b/src/library/basesqltablemodel.cpp @@ -2,7 +2,6 @@ #include #include -#include #include "library/basesqltablemodel.h" @@ -21,6 +20,7 @@ #include "util/duration.h" #include "util/assert.h" #include "util/performancetimer.h" +#include "widget/wlibrarytableview.h" static const bool sDebug = false; @@ -1113,7 +1113,7 @@ QMimeData* BaseSqlTableModel::mimeData(const QModelIndexList &indexes) const { } QAbstractItemDelegate* BaseSqlTableModel::delegateForColumn(const int i, QObject* pParent) { - WLibraryTableView* pTableView = qobject_cast(pParent); + auto* pTableView = qobject_cast(pParent); DEBUG_ASSERT(pTableView); if (i == fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_RATING)) { diff --git a/src/library/coverartdelegate.cpp b/src/library/coverartdelegate.cpp index 02e27e8fd076..db399862bba2 100644 --- a/src/library/coverartdelegate.cpp +++ b/src/library/coverartdelegate.cpp @@ -1,11 +1,15 @@ -#include #include #include "library/coverartdelegate.h" #include "library/coverartcache.h" #include "library/dao/trackschema.h" +#include "library/trackmodel.h" + +#include "widget/wlibrarytableview.h" + #include "util/math.h" + CoverArtDelegate::CoverArtDelegate(WLibraryTableView* parent) : TableItemDelegate(parent), m_pTableView(parent), @@ -25,16 +29,15 @@ CoverArtDelegate::CoverArtDelegate(WLibraryTableView* parent) CoverArtCache* pCache = CoverArtCache::instance(); if (pCache) { - connect(pCache, SIGNAL(coverFound(const QObject*, const CoverInfoRelative&, - QPixmap, bool)), - this, SLOT(slotCoverFound(const QObject*, const CoverInfoRelative&, - QPixmap, bool))); + connect(pCache, + &CoverArtCache::coverFound, + this, + &CoverArtDelegate::slotCoverFound); } - TrackModel* pTrackModel = NULL; - QTableView* pTableView = NULL; - if (QTableView *tableView = qobject_cast(parent)) { - pTableView = tableView; + TrackModel* pTrackModel = nullptr; + QTableView* pTableView = qobject_cast(parent); + if (pTableView) { pTrackModel = dynamic_cast(pTableView->model()); } @@ -56,9 +59,6 @@ CoverArtDelegate::CoverArtDelegate(WLibraryTableView* parent) } } -CoverArtDelegate::~CoverArtDelegate() { -} - void CoverArtDelegate::slotOnlyCachedCoverArt(bool b) { m_bOnlyCachedCover = b; diff --git a/src/library/coverartdelegate.h b/src/library/coverartdelegate.h index 005b6f6348af..2713555bd445 100644 --- a/src/library/coverartdelegate.h +++ b/src/library/coverartdelegate.h @@ -1,18 +1,21 @@ #ifndef COVERARTDELEGATE_H #define COVERARTDELEGATE_H -#include #include +#include #include #include "library/tableitemdelegate.h" -#include "library/trackmodel.h" + +class CoverInfoRelative; +class TrackModel; +class WLibraryTableView; class CoverArtDelegate : public TableItemDelegate { Q_OBJECT public: explicit CoverArtDelegate(WLibraryTableView* parent); - virtual ~CoverArtDelegate(); + ~CoverArtDelegate() override = default; void paintItem(QPainter* painter, const QStyleOptionViewItem& option, diff --git a/src/library/dao/directorydao.cpp b/src/library/dao/directorydao.cpp index c490ef959d69..15338680c7e9 100644 --- a/src/library/dao/directorydao.cpp +++ b/src/library/dao/directorydao.cpp @@ -14,7 +14,6 @@ int DirectoryDAO::addDirectory(const QString& newDir) { // Do nothing if the dir to add is a child of a directory that is already in // the db. - ScopedTransaction transaction(m_database); QStringList dirs = getDirs(); QString childDir; QString parentDir; @@ -46,7 +45,6 @@ int DirectoryDAO::addDirectory(const QString& newDir) { LOG_FAILED_QUERY(query) << "Adding new dir (" % newDir % ") failed."; return SQL_ERROR; } - transaction.commit(); return ALL_FINE; } @@ -80,13 +78,12 @@ int DirectoryDAO::removeDirectory(const QString& dir) { } -QSet DirectoryDAO::relocateDirectory(const QString& oldFolder, +QList DirectoryDAO::relocateDirectory(const QString& oldFolder, const QString& newFolder) { // TODO(rryan): This method could use error reporting. It can fail in // mysterious ways for example if a track in the oldFolder also has a zombie // track location in newFolder then the replace query will fail because the // location column becomes non-unique. - ScopedTransaction transaction(m_database); QSqlQuery query(m_database); query.prepare("UPDATE " % DIRECTORYDAO_TABLE % " SET " % DIRECTORYDAO_DIR % "=:newFolder WHERE " % DIRECTORYDAO_DIR % " = :oldFolder"); @@ -95,7 +92,7 @@ QSet DirectoryDAO::relocateDirectory(const QString& oldFolder, if (!query.exec()) { LOG_FAILED_QUERY(query) << "could not relocate directory" << oldFolder << "to" << newFolder; - return QSet(); + return {}; } // on Windows the absolute path starts with the drive name @@ -113,35 +110,32 @@ QSet DirectoryDAO::relocateDirectory(const QString& oldFolder, .arg(startsWithOldFolder, kSqlLikeMatchAll)); if (!query.exec()) { LOG_FAILED_QUERY(query) << "could not relocate path of tracks"; - return QSet(); + return {}; } - QSet trackIds; - QList loc_ids; - QStringList old_locs; + QList loc_ids; + QList trackRefs; while (query.next()) { - trackIds.insert(TrackId(query.value(0))); - loc_ids.append(query.value(1).toInt()); - old_locs.append(query.value(2).toString()); + loc_ids.append(DbId(query.value(1).toInt())); + trackRefs.append(TrackRef::fromFileInfo(query.value(2).toString(), TrackId(query.value(0)))); } QString replacement = "UPDATE track_locations SET location = :newloc " "WHERE id = :id"; query.prepare(replacement); for (int i = 0; i < loc_ids.size(); ++i) { - QString newloc = old_locs.at(i); + QString newloc = trackRefs.at(i).getLocation(); newloc.replace(0, oldFolder.size(), newFolder); query.bindValue("newloc", newloc); - query.bindValue("id", loc_ids.at(i)); + query.bindValue("id", loc_ids.at(i).toVariant()); if (!query.exec()) { LOG_FAILED_QUERY(query) << "could not relocate path of tracks"; - return QSet(); + return {}; } } - qDebug() << "Relocated tracks:" << trackIds.size(); - transaction.commit(); - return trackIds; + qDebug() << "Relocated tracks:" << trackRefs.size(); + return trackRefs; } QStringList DirectoryDAO::getDirs() { diff --git a/src/library/dao/directorydao.h b/src/library/dao/directorydao.h index 827bd4a7037c..6892021a961a 100644 --- a/src/library/dao/directorydao.h +++ b/src/library/dao/directorydao.h @@ -21,7 +21,7 @@ class DirectoryDAO : public DAO { int addDirectory(const QString& dir); int removeDirectory(const QString& dir); - QSet relocateDirectory(const QString& oldFolder, const QString& newFolder); + QList relocateDirectory(const QString& oldFolder, const QString& newFolder); QStringList getDirs(); private: diff --git a/src/library/dao/trackdao.cpp b/src/library/dao/trackdao.cpp index ba08954a3c1c..a3e4d50d99fa 100644 --- a/src/library/dao/trackdao.cpp +++ b/src/library/dao/trackdao.cpp @@ -114,7 +114,7 @@ void TrackDAO::finish() { @return the track id for the track located at location, or -1 if the track is not in the database. */ -TrackId TrackDAO::getTrackId(const QString& absoluteFilePath) { +TrackId TrackDAO::getTrackId(const QString& absoluteFilePath) const { TrackId trackId; @@ -206,6 +206,37 @@ QString TrackDAO::getTrackLocation(TrackId trackId) { return trackLocation; } +QStringList TrackDAO::getTrackLocations(const QList& ids) { + QString stmt = + "SELECT track_locations.location FROM track_locations " + "INNER JOIN library on library.location=track_locations.id " + "WHERE library.id IN (%1)"; + { + QStringList idList; + idList.reserve(ids.size()); + for (const auto& id : ids) { + idList.append(id.toString()); + } + stmt = stmt.arg(idList.join(",")); + } + FwdSqlQuery query(m_database, stmt); + VERIFY_OR_DEBUG_ASSERT(!query.hasError()) { + return QStringList(); + } + if (!query.execPrepared()) { + return QStringList(); + } + QStringList locations; + locations.reserve(ids.size()); + const int locationColumn = query.record().indexOf("location"); + DEBUG_ASSERT(locationColumn >= 0); + while (query.next()) { + locations.append(query.fieldValue(locationColumn).toString()); + } + DEBUG_ASSERT(locations.size() <= ids.size()); + return locations; +} + void TrackDAO::saveTrack(Track* pTrack) { DEBUG_ASSERT(pTrack); if (pTrack->isDirty()) { @@ -268,18 +299,46 @@ void TrackDAO::slotTrackClean(Track* pTrack) { } void TrackDAO::databaseTrackAdded(TrackPointer pTrack) { - emit(dbTrackAdded(pTrack)); -} - -void TrackDAO::databaseTracksMoved(QSet tracksMovedSetOld, QSet tracksMovedSetNew) { - emit(tracksRemoved(tracksMovedSetNew)); - // results in a call of BaseTrackCache::updateTracksInIndex(trackIds); - emit(tracksAdded(tracksMovedSetOld)); + DEBUG_ASSERT(pTrack); + emit dbTrackAdded(pTrack); } void TrackDAO::databaseTracksChanged(QSet tracksChanged) { // results in a call of BaseTrackCache::updateTracksInIndex(trackIds); - emit(tracksAdded(tracksChanged)); + if (!tracksChanged.isEmpty()) { + emit tracksAdded(tracksChanged); + } +} + +void TrackDAO::databaseTracksReplaced(QList> replacedTracks) { + QSet removedTrackIds; + QSet changedTrackIds; + for (const auto& replacedTrack : replacedTracks) { + const auto& removedTrackRef = replacedTrack.first; + const auto& changedTrackRef = replacedTrack.second; + DEBUG_ASSERT(removedTrackRef.getId().isValid()); + DEBUG_ASSERT(changedTrackRef.getId().isValid()); + // The (old)) location of the (re)moved track must be known! + DEBUG_ASSERT(!removedTrackRef.getLocation().isEmpty()); + // The (new) location of the changed track might be empty. + DEBUG_ASSERT(removedTrackRef.getLocation() != changedTrackRef.getLocation()); + changedTrackIds.insert(changedTrackRef.getId()); + // The ids might be identical if the same track has been only been + // relocated. In this case the track has not been removed. + if (removedTrackRef.getId() != changedTrackRef.getId()) { + // The id must also not match with any other changed track! + DEBUG_ASSERT(!changedTrackIds.contains(removedTrackRef.getId())); + removedTrackIds.insert(removedTrackRef.getId()); + } + } + DEBUG_ASSERT(removedTrackIds.size() <= changedTrackIds.size()); +#if QT_VERSION >= QT_VERSION_CHECK(5, 6, 0) + DEBUG_ASSERT(!removedTrackIds.intersects(changedTrackIds)); +#endif + if (!removedTrackIds.isEmpty()) { + emit tracksRemoved(removedTrackIds); + } + databaseTracksChanged(changedTrackIds); } void TrackDAO::slotTrackChanged(Track* pTrack) { @@ -828,11 +887,11 @@ void TrackDAO::afterUnhidingTracks( emit(tracksAdded(QSet::fromList(trackIds))); } -QList TrackDAO::getTrackIds(const QDir& dir) { +QList TrackDAO::getAllTrackIds(const QDir& rootDir) { // Capture entries that start with the directory prefix dir. // dir needs to end in a slash otherwise we might match other // directories. - const QString dirPath = dir.absolutePath(); + const QString dirPath = rootDir.absolutePath(); QString likeClause = SqlLikeWildcardEscaper::apply(dirPath + "/", kSqlLikeMatchAll) + kSqlLikeMatchAll; QSqlQuery query(m_database); @@ -1566,8 +1625,7 @@ namespace { // moved instead of being deleted outright, and so we can salvage your // existing metadata that you have in your DB (like cue points, etc.). // returns falls if canceled -bool TrackDAO::detectMovedTracks(QSet* pTracksMovedSetOld, - QSet* pTracksMovedSetNew, +bool TrackDAO::detectMovedTracks(QList>* pReplacedTracks, const QStringList& addedTracks, volatile const bool* pCancel) { // This function should not start a transaction on it's own! @@ -1722,10 +1780,11 @@ bool TrackDAO::detectMovedTracks(QSet* pTracksMovedSetOld, } } - // We collect all the old tracks that has to be updated in BaseTrackCache - pTracksMovedSetOld->insert(oldTrackId); - // We collect collect all the new tracks the where added and deleted to BaseTrackCache - pTracksMovedSetNew->insert(newTrackId); + if (pReplacedTracks) { + auto oldTrackRef = TrackRef::fromFileInfo(oldTrackLocation, oldTrackId); + auto newTrackRef = TrackRef::fromFileInfo(newTrackLocation, newTrackId); + pReplacedTracks->append(qMakePair(oldTrackRef, newTrackRef)); + } } return true; } diff --git a/src/library/dao/trackdao.h b/src/library/dao/trackdao.h index c27c41e31284..2d4bc8bb21a3 100644 --- a/src/library/dao/trackdao.h +++ b/src/library/dao/trackdao.h @@ -39,9 +39,9 @@ class TrackDAO : public QObject, public virtual DAO, public virtual GlobalTrackC } void finish(); - TrackId getTrackId(const QString& absoluteFilePath); + TrackId getTrackId(const QString& absoluteFilePath) const; QList getTrackIds(const QList& files); - QList getTrackIds(const QDir& dir); + QList getAllTrackIds(const QDir& rootDir); // WARNING: Only call this from the main thread instance of TrackDAO. TrackPointer getTrack(TrackId trackId) const; @@ -49,6 +49,7 @@ class TrackDAO : public QObject, public virtual DAO, public virtual GlobalTrackC // Returns a set of all track locations in the library. QSet getTrackLocations(); QString getTrackLocation(TrackId trackId); + QStringList getTrackLocations(const QList& trackIds); TrackPointer addSingleTrack(const TrackFile& trackFile, bool unremove); QList addMultipleTracks(const QList& fileInfoList, bool unremove); @@ -90,8 +91,7 @@ class TrackDAO : public QObject, public virtual DAO, public virtual GlobalTrackC void markTracksInDirectoriesAsVerified(const QStringList& directories); void invalidateTrackLocationsInLibrary(); void markUnverifiedTracksAsDeleted(); - bool detectMovedTracks(QSet* pTracksMovedSetOld, - QSet* pTracksMovedSetNew, + bool detectMovedTracks(QList>* pReplacedTracks, const QStringList& addedTracks, volatile const bool* pCancel); @@ -117,8 +117,8 @@ class TrackDAO : public QObject, public virtual DAO, public virtual GlobalTrackC public slots: void databaseTrackAdded(TrackPointer pTrack); - void databaseTracksMoved(QSet tracksMovedSetOld, QSet tracksMovedSetNew); void databaseTracksChanged(QSet tracksChanged); + void databaseTracksReplaced(QList> replacedTracks); private slots: void slotTrackDirty(Track* pTrack); diff --git a/src/library/dlghidden.cpp b/src/library/dlghidden.cpp index 3c9ed62bf39e..162c1005ae83 100644 --- a/src/library/dlghidden.cpp +++ b/src/library/dlghidden.cpp @@ -24,7 +24,7 @@ DlgHidden::DlgHidden(QWidget* parent, UserSettingsPointer pConfig, box->insertWidget(1, m_pTrackTableView); } - m_pHiddenTableModel = new HiddenTableModel(this, pTrackCollection); + m_pHiddenTableModel = new HiddenTableModel(this, pLibrary); m_pTrackTableView->loadTrackModel(m_pHiddenTableModel); connect(btnUnhide, diff --git a/src/library/dlgmissing.cpp b/src/library/dlgmissing.cpp index 5fc623d3a12d..71627c4d5b9e 100644 --- a/src/library/dlgmissing.cpp +++ b/src/library/dlgmissing.cpp @@ -23,7 +23,7 @@ DlgMissing::DlgMissing(QWidget* parent, UserSettingsPointer pConfig, box->insertWidget(1, m_pTrackTableView); } - m_pMissingTableModel = new MissingTableModel(this, pTrackCollection); + m_pMissingTableModel = new MissingTableModel(this, pLibrary); m_pTrackTableView->loadTrackModel(m_pMissingTableModel); connect(btnPurge, &QPushButton::clicked, m_pTrackTableView, &WTrackTableView::slotPurge); diff --git a/src/library/externaltrackcollection.cpp b/src/library/externaltrackcollection.cpp new file mode 100644 index 000000000000..9157adbb9360 --- /dev/null +++ b/src/library/externaltrackcollection.cpp @@ -0,0 +1,16 @@ +#include "library/externaltrackcollection.h" + + +void ExternalTrackCollection::deduplicateTracks( + const QList& duplicateTracks) { + QList purgedTracks; + QList updatedTracks; + purgedTracks.reserve(duplicateTracks.size()); + updatedTracks.reserve(duplicateTracks.size()); + for (const auto& duplicateTrack : duplicateTracks) { + purgedTracks += duplicateTrack.removed.getLocation(); + updatedTracks += duplicateTrack.replacedBy; + } + purgeTracks(purgedTracks); + updateTracks(updatedTracks); +} diff --git a/src/library/externaltrackcollection.h b/src/library/externaltrackcollection.h new file mode 100644 index 000000000000..eeed8b5d42a1 --- /dev/null +++ b/src/library/externaltrackcollection.h @@ -0,0 +1,103 @@ +#pragma once + +#include +#include +#include + +#include "track/trackref.h" + + +class Track; + +class LibraryFeature; + +// This interface and base class enable to synchronize external +// track collections with Mixxx. It provides methods that will +// be invoked by Mixxx after tracks have been added, modified or +// deleted in the internal track collection. It also notifies +// external track collections if the metadata of a single track +// has been saved. A track in the internal track collection always +// refers to a single, local file. +// +// All functions must be implemented in a non-blocking fashion, +// i.e. asynchronously. They will be invoked AFTER the corresponding +// operation has been executed on the internal track collection. +// +// WARNING: External track collections MUST NOT modify the track +// files while Mixxx is running to avoid file corruption caused by +// concurrent write access! +class ExternalTrackCollection : public QObject { +Q_OBJECT + + public: + virtual ~ExternalTrackCollection() = default; + + virtual QString name() const = 0; + + // Check if the connection to the extenal track collection + // has been established, i.e. if the synchronization is active. + virtual bool isActive() const = 0; + + // Synchronously (blocking) stop the synchronization by + // finishing all pending requests. + virtual void shutdown() {} + + // All tracks in the corresponding directory need to be + // relocated recursively by updating their location. + virtual /*async*/ void relocateDirectory( + const QString& oldRootDir, + const QString& newRootDir) = 0; + + // A (potentially large) number of tracks has recently been + // modified by a batch update in the internal track collection. + // The metadata of those tracks might need to be loaded in order + // to send it to the external track collection. + virtual /*async*/ void updateTracks( + const QList& updatedTracks) = 0; + + // The tracks referenced by their (local) file path have been + // removed from the track collection and may also have disappeared + // from the file system. + virtual /*async*/ void purgeTracks( + const QList& trackLocations) = 0; + + // All tracks in the corresponding directory have been removed + // recursively from the root directory and the directory may + // have disappeared from the file system. + virtual /*async*/ void purgeAllTracks( + const QDir& rootDir) = 0; + + // Duplications have been resolved by removing the duplicate track + // and replacing any references with the corresponding replacement + // track. + // The default implementation first purges all duplicate tracks that + // have been removed and then updates all the replaced tracks. + struct DuplicateTrack { + TrackRef removed; + TrackRef replacedBy; + }; + virtual /*async*/ void deduplicateTracks( + const QList& duplicateTracks); + + // A new track has been added to the internal track collection or the + // modified metadata of an existing track has just been saved. + enum class ChangeHint { + Added, + Modified, + }; + virtual /*async*/ void saveTrack( + const Track& track, + ChangeHint changeHint) = 0; + + // Create the corresponding library feature (if desired) that will + // be hooked into the side pane in Mixxx. + virtual LibraryFeature* newLibraryFeature( + QObject* /*parent*/) { + return nullptr; + } + + protected: + explicit ExternalTrackCollection(QObject* parent = nullptr) + : QObject(parent) { + } +}; diff --git a/src/library/hiddentablemodel.cpp b/src/library/hiddentablemodel.cpp index bc62631b2d33..dc3738617a84 100644 --- a/src/library/hiddentablemodel.cpp +++ b/src/library/hiddentablemodel.cpp @@ -1,11 +1,13 @@ #include "library/hiddentablemodel.h" +#include "library/library.h" #include "library/dao/trackschema.h" HiddenTableModel::HiddenTableModel(QObject* parent, - TrackCollection* pTrackCollection) - : BaseSqlTableModel(parent, pTrackCollection, "mixxx.db.model.missing") { + Library* pLibrary) + : BaseSqlTableModel(parent, &pLibrary->trackCollection(), "mixxx.db.model.missing"), + m_pLibrary(pLibrary) { setTableModel(); } @@ -51,7 +53,7 @@ void HiddenTableModel::purgeTracks(const QModelIndexList& indices) { trackIds.append(getTrackId(index)); } - m_pTrackCollection->purgeTracks(trackIds); + m_pLibrary->purgeTracks(trackIds); // TODO(rryan) : do not select, instead route event to BTC and notify from // there. diff --git a/src/library/hiddentablemodel.h b/src/library/hiddentablemodel.h index 7551c4524c9b..9223613cbe00 100644 --- a/src/library/hiddentablemodel.h +++ b/src/library/hiddentablemodel.h @@ -3,10 +3,12 @@ #include "library/basesqltablemodel.h" +class Library; + class HiddenTableModel : public BaseSqlTableModel { Q_OBJECT public: - HiddenTableModel(QObject* parent, TrackCollection* pTrackCollection); + HiddenTableModel(QObject* parent, Library* pLibrary); ~HiddenTableModel() final; void setTableModel(int id = -1); @@ -16,6 +18,9 @@ class HiddenTableModel : public BaseSqlTableModel { void unhideTracks(const QModelIndexList& indices) final; Qt::ItemFlags flags(const QModelIndex &index) const final; CapabilitiesFlags getCapabilities() const final; + + private: + Library* m_pLibrary; }; #endif diff --git a/src/library/library.cpp b/src/library/library.cpp index bd9dfa3376a1..e8225b4d43bd 100644 --- a/src/library/library.cpp +++ b/src/library/library.cpp @@ -40,6 +40,7 @@ #include "controllers/keyboard/keyboardeventfilter.h" +#include "library/externaltrackcollection.h" namespace { @@ -91,14 +92,38 @@ Library::Library( kLogger.info() << "Connecting database"; m_pTrackCollection->connectDatabase(dbConnection); +#if defined(__AOIDE__) + m_externalTrackCollections += new mixxx::aoide::TrackCollection(pConfig, m_pTrackCollection, this); +#endif + qRegisterMetaType("Library::RemovalType"); m_pKeyNotation.reset(new ControlObject(ConfigKey(kConfigGroup, "key_notation"))); - connect(&m_scanner, &LibraryScanner::scanStarted, this, &Library::scanStarted); - connect(&m_scanner, &LibraryScanner::scanFinished, this, &Library::scanFinished); - // Refresh the library models when the library (re)scan is finished. - connect(&m_scanner, &LibraryScanner::scanFinished, this, &Library::slotRefreshLibraryModels); + connect(&m_scanner, + &LibraryScanner::scanStarted, + this, + &Library::scanStarted); + connect(&m_scanner, + &LibraryScanner::scanFinished, + this, + &Library::scanFinished); + connect(&m_scanner, + &LibraryScanner::scanFinished, + this, + &Library::slotRefreshLibraryModels); + connect(&m_scanner, + &LibraryScanner::trackAdded, + this, + &Library::slotScanTrackAdded); + connect(&m_scanner, + &LibraryScanner::tracksChanged, + this, + &Library::slotScanTracksUpdated); + connect(&m_scanner, + &LibraryScanner::tracksReplaced, + this, + &Library::slotScanTracksReplaced); // TODO(rryan) -- turn this construction / adding of features into a static // method or something -- CreateDefaultLibrary @@ -164,6 +189,21 @@ Library::Library( addFeature(new TraktorFeature(this, m_pTrackCollection)); } + for (const auto& externalTrackCollection : m_externalTrackCollections) { + auto feature = externalTrackCollection->newLibraryFeature(this); + if (feature) { + kLogger.info() + << "Adding library feature for" + << externalTrackCollection->name(); + addFeature(feature); + } else { + kLogger.info() + << "Library feature for" + << externalTrackCollection->name() + << "is not available"; + } + } + // On startup we need to check if all of the user's library folders are // accessible to us. If the user is using a database from <1.12.0 with // sandboxing then we will need them to give us permission. @@ -203,6 +243,10 @@ Library::~Library() { delete m_pLibraryControl; + for (const auto& externalTrackCollection : m_externalTrackCollections) { + externalTrackCollection->shutdown(); + } + kLogger.info() << "Disconnecting database"; m_pTrackCollection->disconnectDatabase(); @@ -260,7 +304,12 @@ void Library::bindSidebarWidget(WLibrarySidebar* pSidebarWidget) { void Library::bindWidget(WLibrary* pLibraryWidget, KeyboardEventFilter* pKeyboard) { WTrackTableView* pTrackTableView = - new WTrackTableView(pLibraryWidget, m_pConfig, m_pTrackCollection); + new WTrackTableView( + pLibraryWidget, + m_pConfig, + m_pTrackCollection, + true, + m_externalTrackCollections); pTrackTableView->installEventFilter(pKeyboard); connect(this, &Library::showTrackModel, @@ -435,7 +484,7 @@ void Library::slotRequestAddDir(QString dir) { QDir directory(dir); Sandbox::createSecurityToken(directory); - if (!m_pTrackCollection->getDirectoryDAO().addDirectory(dir)) { + if (!m_pTrackCollection->addDirectory(dir)) { QMessageBox::information(0, tr("Add Directory to Library"), tr("Could not add the directory to your library. Either this " "directory is already in your library or you are currently " @@ -457,7 +506,7 @@ void Library::slotRequestRemoveDir(QString dir, RemovalType removalType) { break; case Library::PurgeTracks: // The user requested that we purge all metadata. - m_pTrackCollection->purgeTracks(dir); + purgeAllTracks(dir); break; case Library::LeaveTracksUnchanged: default: @@ -485,7 +534,7 @@ void Library::slotRequestRemoveDir(QString dir, RemovalType removalType) { } void Library::slotRequestRelocateDir(QString oldDir, QString newDir) { - m_pTrackCollection->relocateDirectory(oldDir, newDir); + relocateDirectory(oldDir, newDir); // also update the config file if necessary so that downgrading is still // possible @@ -529,8 +578,222 @@ void Library::saveCachedTrack(Track* pTrack) noexcept { // concurrently. m_pTrackCollection->exportTrackMetadata(pTrack); - // The track must be saved while the cache is locked to - // prevent that a new track is created from the outdated - // metadata that is is the database before saving is finished. + // Th dirty flag is reset while saving the track in the internal + // collection! + const bool trackDirty = pTrack->isDirty(); + + // This operation must be executed synchronously while the cache is + // locked to prevent that a new track is created from outdated + // metadata in the database before saving finished. + kLogger.debug() + << "Saving cached track" + << pTrack->getLocation() + << "in internal collection"; m_pTrackCollection->saveTrack(pTrack); + + if (m_externalTrackCollections.isEmpty()) { + return; + } + if (pTrack->getId().isValid()) { + // Track still exists in the internal collection/database + if (trackDirty) { + kLogger.debug() + << "Saving modified track" + << pTrack->getLocation() + << "in" + << m_externalTrackCollections.size() + << "external collection(s)"; + for (const auto& externalTrackCollection : m_externalTrackCollections) { + externalTrackCollection->saveTrack( + *pTrack, + ExternalTrackCollection::ChangeHint::Modified); + } + } + } else { + // Track has been deleted from the local internal collection/database + // while it was cached in-memory + kLogger.debug() + << "Purging deleted track" + << pTrack->getLocation() + << "from" + << m_externalTrackCollections.size() + << "external collection(s)"; + for (const auto& externalTrackCollection : m_externalTrackCollections) { + externalTrackCollection->purgeTracks( + QStringList{pTrack->getLocation()}); + } + } +} + +void Library::relocateDirectory(QString oldDir, QString newDir) { + kLogger.debug() + << "Relocating directory in internal track collection:" + << oldDir + << "->" + << newDir; + // TODO(XXX): Add error handling in TrackCollection::relocateDirectory() + m_pTrackCollection->relocateDirectory(oldDir, newDir); + if (m_externalTrackCollections.isEmpty()) { + return; + } + kLogger.debug() + << "Relocating directory in" + << m_externalTrackCollections.size() + << "external track collection(s):" + << oldDir + << "->" + << newDir; + for (const auto& externalTrackCollection : m_externalTrackCollections) { + externalTrackCollection->relocateDirectory(oldDir, newDir); + } +} + +void Library::purgeTracks(const QList& trackIds) { + if (trackIds.isEmpty()) { + return; + } + // Collect the corresponding track locations BEFORE purging the + // tracks from the internal collection! + QList trackLocations; + if (!m_externalTrackCollections.isEmpty()) { + trackLocations = + m_pTrackCollection->getTrackDAO().getTrackLocations(trackIds); + } + DEBUG_ASSERT(trackLocations.size() <= trackIds.size()); + kLogger.debug() + << "Purging" + << trackIds.size() + << "tracks from internal collection"; + if (!m_pTrackCollection->purgeTracks(trackIds)) { + kLogger.warning() + << "Failed to purge tracks from internal collection"; + return; + } + if (m_externalTrackCollections.isEmpty()) { + return; + } + VERIFY_OR_DEBUG_ASSERT(trackLocations.size() == trackIds.size()) { + kLogger.warning() + << "Purging only" + << trackLocations.size() + << "of" + << trackIds.size() + << "tracks from" + << m_externalTrackCollections.size() + << "external collection(s)"; + } else { + kLogger.debug() + << "Purging" + << trackLocations.size() + << "tracks from" + << m_externalTrackCollections.size() + << "external collection(s)"; + } + for (const auto& externalTrackCollection : m_externalTrackCollections) { + externalTrackCollection->purgeTracks(trackLocations); + } +} + +void Library::purgeAllTracks(const QDir& rootDir) { + kLogger.debug() + << "Purging directory" + << rootDir + << "from internal track collection"; + if (!m_pTrackCollection->purgeAllTracks(rootDir)) { + kLogger.warning() + << "Failed to purge directory from internal collection"; + return; + } + if (m_externalTrackCollections.isEmpty()) { + return; + } + kLogger.debug() + << "Purging directory" + << rootDir + << "from" + << m_externalTrackCollections.size() + << "external track collection(s)"; + for (const auto& externalTrackCollection : m_externalTrackCollections) { + externalTrackCollection->purgeAllTracks(rootDir); + } +} + +void Library::slotScanTrackAdded(TrackPointer pTrack) { + DEBUG_ASSERT(pTrack); + // Already added to m_pTrackCollection + if (m_externalTrackCollections.isEmpty()) { + return; + } + kLogger.debug() + << "Adding new track" + << pTrack->getLocation() + << "to" + << m_externalTrackCollections.size() + << "external track collection(s)"; + for (const auto& externalTrackCollection : m_externalTrackCollections) { + externalTrackCollection->saveTrack(*pTrack, ExternalTrackCollection::ChangeHint::Added); + } +} + +void Library::slotScanTracksUpdated(QSet updatedTrackIds) { + // Already updated in m_pTrackCollection + if (updatedTrackIds.isEmpty()) { + return; + } + if (m_externalTrackCollections.isEmpty()) { + return; + } + QList trackRefs; + trackRefs.reserve(updatedTrackIds.size()); + for (const auto& trackId : updatedTrackIds) { + auto trackLocation = m_pTrackCollection->getTrackDAO().getTrackLocation(trackId); + if (!trackLocation.isEmpty()) { + trackRefs.append(TrackRef::fromFileInfo(trackLocation, trackId)); + } + } + DEBUG_ASSERT(trackRefs.size() <= updatedTrackIds.size()); + VERIFY_OR_DEBUG_ASSERT(trackRefs.size() == updatedTrackIds.size()) { + kLogger.warning() + << "Updating only" + << trackRefs.size() + << "of" + << updatedTrackIds.size() + << "track(s) in" + << m_externalTrackCollections.size() + << "external collection(s)"; + } else { + kLogger.debug() + << "Updating" + << trackRefs.size() + << "track(s) in" + << m_externalTrackCollections.size() + << "external collection(s)"; + } + for (const auto& externalTrackCollection : m_externalTrackCollections) { + externalTrackCollection->updateTracks(trackRefs); + } +} + +void Library::slotScanTracksReplaced(QList> replacedTracks) { + // Already replaced in m_pTrackCollection + if (m_externalTrackCollections.isEmpty()) { + return; + } + QList duplicateTracks; + duplicateTracks.reserve(replacedTracks.size()); + for (const auto& replacedTrack : replacedTracks) { + ExternalTrackCollection::DuplicateTrack duplicateTrack; + duplicateTrack.removed = replacedTrack.first; + duplicateTrack.replacedBy = replacedTrack.second; + duplicateTracks.append(duplicateTrack); + } + kLogger.debug() + << "Deduplicating" + << duplicateTracks.size() + << "replaced track(s) in" + << m_externalTrackCollections.size() + << "external collection(s)"; + for (const auto& externalTrackCollection : m_externalTrackCollections) { + externalTrackCollection->deduplicateTracks(duplicateTracks); + } } diff --git a/src/library/library.h b/src/library/library.h index fa686a1406fa..6cf8c6a54011 100644 --- a/src/library/library.h +++ b/src/library/library.h @@ -35,6 +35,8 @@ class LibraryControl; class KeyboardEventFilter; class PlayerManager; +class ExternalTrackCollection; + class Library: public QObject, public virtual /*implements*/ GlobalTrackCacheSaver { Q_OBJECT @@ -91,6 +93,11 @@ class Library: public QObject, void setRowHeight(int rowHeight); void setEditMedatataSelectedClick(bool enable); + void relocateDirectory(QString oldDir, QString newDir); + + void purgeTracks(const QList& trackIds); + void purgeAllTracks(const QDir& rootDir); + public slots: void slotShowTrackModel(QAbstractItemModel* model); void slotSwitchToView(const QString& view); @@ -110,6 +117,9 @@ class Library: public QObject, void scan() { m_scanner.scan(); } + void slotScanTrackAdded(TrackPointer pTrack); + void slotScanTracksUpdated(QSet updatedTrackIds); + void slotScanTracksReplaced(QList> replacedTracks); signals: void showTrackModel(QAbstractItemModel* model); @@ -146,6 +156,9 @@ class Library: public QObject, SidebarModel* m_pSidebarModel; TrackCollection* m_pTrackCollection; + + QList m_externalTrackCollections; + LibraryControl* m_pLibraryControl; QList m_features; const static QString m_sTrackViewName; diff --git a/src/library/locationdelegate.cpp b/src/library/locationdelegate.cpp index 7e1f07bb1c27..e54c2698d70e 100644 --- a/src/library/locationdelegate.cpp +++ b/src/library/locationdelegate.cpp @@ -1,20 +1,19 @@ -#include +#include "library/locationdelegate.h" -#include #include -#include "library/locationdelegate.h" LocationDelegate::LocationDelegate(QTableView* pTableView) - : QStyledItemDelegate(pTableView), - m_pTableView(pTableView) { + : TableItemDelegate(pTableView) { } -void LocationDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, - const QModelIndex& index) const { - QString elidedLocation = - option.fontMetrics.elidedText(index.data().toString(), - Qt::ElideLeft, - m_pTableView->columnWidth(index.column())); - painter->drawText(option.rect, Qt::AlignVCenter, elidedLocation); +void LocationDelegate::paintItem( + QPainter* painter, + const QStyleOptionViewItem& option, + const QModelIndex& index) const { + QString elidedText = option.fontMetrics.elidedText( + index.data().toString(), + Qt::ElideLeft, + columnWidth(index)); + painter->drawText(option.rect, Qt::AlignVCenter, elidedText); } diff --git a/src/library/locationdelegate.h b/src/library/locationdelegate.h index 1f20dcc8e22f..9fb698dab21a 100644 --- a/src/library/locationdelegate.h +++ b/src/library/locationdelegate.h @@ -1,21 +1,16 @@ -#ifndef LOCATIONDELEGATE_H -#define LOCATIONDELEGATE_H +#pragma once -#include +#include "library/tableitemdelegate.h" -class QTableView; -class QStyleOptionViewItem; -class LocationDelegate : public QStyledItemDelegate { +class LocationDelegate : public TableItemDelegate { Q_OBJECT public: - LocationDelegate(QTableView* pTrackTable); + explicit LocationDelegate(QTableView* pTrackTable); + ~LocationDelegate() override = default; - void paint(QPainter* painter, const QStyleOptionViewItem& option, - const QModelIndex& index) const; - - private: - QTableView* m_pTableView; + void paintItem( + QPainter* painter, + const QStyleOptionViewItem& option, + const QModelIndex& index) const override; }; - -#endif /* LOCATIONDELEGATE_H */ diff --git a/src/library/missingtablemodel.cpp b/src/library/missingtablemodel.cpp index 0344a89e5ee9..b41f230eebc9 100644 --- a/src/library/missingtablemodel.cpp +++ b/src/library/missingtablemodel.cpp @@ -1,8 +1,6 @@ -#include - -#include "library/trackcollection.h" #include "library/missingtablemodel.h" -#include "library/librarytablemodel.h" + +#include "library/library.h" #include "library/dao/trackschema.h" namespace { @@ -12,9 +10,10 @@ const QString kMissingFilter = "mixxx_deleted=0 AND fs_deleted=1"; } // anonymous namespace MissingTableModel::MissingTableModel(QObject* parent, - TrackCollection* pTrackCollection) - : BaseSqlTableModel(parent, pTrackCollection, - "mixxx.db.model.missing") { + Library* pLibrary) + : BaseSqlTableModel(parent, &pLibrary->trackCollection(), + "mixxx.db.model.missing"), + m_pLibrary(pLibrary) { setTableModel(); } @@ -64,7 +63,7 @@ void MissingTableModel::purgeTracks(const QModelIndexList& indices) { trackIds.append(getTrackId(index)); } - m_pTrackCollection->purgeTracks(trackIds); + m_pLibrary->purgeTracks(trackIds); // TODO(rryan) : do not select, instead route event to BTC and notify from // there. diff --git a/src/library/missingtablemodel.h b/src/library/missingtablemodel.h index 196dc40e58a6..28d63fa8cdff 100644 --- a/src/library/missingtablemodel.h +++ b/src/library/missingtablemodel.h @@ -9,12 +9,12 @@ #include "trackmodel.h" #include "library/basesqltablemodel.h" -class TrackCollection; +class Library; class MissingTableModel : public BaseSqlTableModel { Q_OBJECT public: - MissingTableModel(QObject* parent, TrackCollection* pTrackCollection); + MissingTableModel(QObject* parent, Library* pLibrary); ~MissingTableModel() final; void setTableModel(int id = -1); @@ -23,6 +23,9 @@ class MissingTableModel : public BaseSqlTableModel { void purgeTracks(const QModelIndexList& indices) final; Qt::ItemFlags flags(const QModelIndex &index) const final; CapabilitiesFlags getCapabilities() const final; + + private: + Library* m_pLibrary; }; #endif diff --git a/src/library/scanner/libraryscanner.cpp b/src/library/scanner/libraryscanner.cpp index 6aadbe2bcb07..5a426724ac0a 100644 --- a/src/library/scanner/libraryscanner.cpp +++ b/src/library/scanner/libraryscanner.cpp @@ -57,19 +57,19 @@ LibraryScanner::LibraryScanner( // scan is finished, because we might have modified the database directly // when we detected moved files, and the TIOs corresponding to the moved // files would then have the wrong track location. - TrackDAO* dao = &(m_pTrackCollection->getTrackDAO()); + TrackDAO* trackDao = &(m_pTrackCollection->getTrackDAO()); connect(this, &LibraryScanner::trackAdded, - dao, + trackDao, &TrackDAO::databaseTrackAdded); - connect(this, - &LibraryScanner::tracksMoved, - dao, - &TrackDAO::databaseTracksMoved); connect(this, &LibraryScanner::tracksChanged, - dao, + trackDao, &TrackDAO::databaseTracksChanged); + connect(this, + &LibraryScanner::tracksReplaced, + trackDao, + &TrackDAO::databaseTracksReplaced); m_pProgressDlg.reset(new LibraryScannerDlg()); connect(this, @@ -300,14 +300,22 @@ void LibraryScanner::cleanUpScan() { // Check to see if the "deleted" tracks showed up in another location, // and if so, do some magic to update all our tables. kLogger.debug() << "Detecting moved files"; - QSet tracksMovedSetOld; - QSet tracksMovedSetNew; - if (!m_trackDao.detectMovedTracks(&tracksMovedSetOld, - &tracksMovedSetNew, - m_scannerGlobal->addedTracks(), - m_scannerGlobal->shouldCancelPointer())) { - // canceled - return; + { + QList> replacedTracks; + if (!m_trackDao.detectMovedTracks(&replacedTracks, + m_scannerGlobal->addedTracks(), + m_scannerGlobal->shouldCancelPointer())) { + kLogger.info() + << "Detecting moved files has been canceled or aborted"; + return; + } + if (!replacedTracks.isEmpty()) { + kLogger.info() + << "Found" + << replacedTracks.size() + << "moved track(s)"; + emit tracksReplaced(replacedTracks); + } } // Remove the hashes for any directories that have been marked as @@ -324,8 +332,9 @@ void LibraryScanner::cleanUpScan() { m_scannerGlobal->shouldCancelPointer(), &coverArtTracksChanged); // Update BaseTrackCache via signals connected to the main TrackDAO. - emit(tracksMoved(tracksMovedSetOld, tracksMovedSetNew)); - emit(tracksChanged(coverArtTracksChanged)); + if (!coverArtTracksChanged.isEmpty()) { + emit tracksChanged(coverArtTracksChanged); + } } diff --git a/src/library/scanner/libraryscanner.h b/src/library/scanner/libraryscanner.h index b45e908422b1..60eb4970198b 100644 --- a/src/library/scanner/libraryscanner.h +++ b/src/library/scanner/libraryscanner.h @@ -49,8 +49,8 @@ class LibraryScanner : public QThread { void progressLoading(QString path); void progressCoverArt(QString file); void trackAdded(TrackPointer pTrack); - void tracksMoved(QSet oldTrackIds, QSet newTrackIds); void tracksChanged(QSet changedTrackIds); + void tracksReplaced(QList> replacedTracks); // Emitted by scan() to invoke slotStartScan in the scanner thread's event // loop. diff --git a/src/library/stareditor.cpp b/src/library/stareditor.cpp index b2cd82d07d0b..54691173e030 100644 --- a/src/library/stareditor.cpp +++ b/src/library/stareditor.cpp @@ -28,6 +28,7 @@ #include "library/stareditor.h" #include "library/starrating.h" +#include "util/painterscope.h" /* * We enable mouse tracking on the widget so we can follow the cursor even @@ -54,7 +55,8 @@ void StarEditor::renderHelper(QPainter* painter, QTableView* pTableView, const QStyleOptionViewItem& option, StarRating* pStarRating) { - painter->save(); + PainterScope painterScope(painter); + painter->setClipRect(option.rect); if (pTableView != NULL) { @@ -80,7 +82,6 @@ void StarEditor::renderHelper(QPainter* painter, } pStarRating->paint(painter, option.rect); - painter->restore(); } void StarEditor::paintEvent(QPaintEvent*) { diff --git a/src/library/tableitemdelegate.cpp b/src/library/tableitemdelegate.cpp index 3d1b2a31e6d4..2bae8d1ced70 100644 --- a/src/library/tableitemdelegate.cpp +++ b/src/library/tableitemdelegate.cpp @@ -1,8 +1,9 @@ +#include "library/tableitemdelegate.h" #include #include -#include "library/tableitemdelegate.h" +#include "util/painterscope.h" TableItemDelegate::TableItemDelegate(QTableView* pTableView) @@ -10,23 +11,25 @@ TableItemDelegate::TableItemDelegate(QTableView* pTableView) m_pTableView(pTableView) { } -TableItemDelegate::~TableItemDelegate() { -} - -void TableItemDelegate::paint(QPainter* painter,const QStyleOptionViewItem& option, - const QModelIndex& index) const { - - painter->save(); +void TableItemDelegate::paint( + QPainter* painter, + const QStyleOptionViewItem& option, + const QModelIndex& index) const { + PainterScope painterScope(painter); painter->setClipRect(option.rect); // Set the palette appropriately based on whether the row is selected or // not. We also have to check if it is inactive or not and use the // appropriate ColorGroup. - QPalette::ColorGroup cg = option.state & QStyle::State_Enabled - ? QPalette::Normal : QPalette::Disabled; - if (cg == QPalette::Normal && !(option.state & QStyle::State_Active)) - cg = QPalette::Inactive; + QPalette::ColorGroup cg = QPalette::Normal; + if (option.state & QStyle::State_Enabled) { + if (!(option.state & QStyle::State_Active)) { + cg = QPalette::Disabled; + } + } else { + cg = QPalette::Disabled; + } if (option.state & QStyle::State_Selected) { painter->setBrush(option.palette.color(cg, QPalette::HighlightedText)); @@ -37,12 +40,17 @@ void TableItemDelegate::paint(QPainter* painter,const QStyleOptionViewItem& opti if (m_pTableView) { QStyle* style = m_pTableView->style(); if (style) { - style->drawControl(QStyle::CE_ItemViewItem, &option, painter, + style->drawControl( + QStyle::CE_ItemViewItem, + &option, + painter, m_pTableView); } } paintItem(painter, option, index); +} - painter->restore(); +int TableItemDelegate::columnWidth(const QModelIndex &index) const { + return m_pTableView->columnWidth(index.column()); } diff --git a/src/library/tableitemdelegate.h b/src/library/tableitemdelegate.h index a39071aac319..117796599616 100644 --- a/src/library/tableitemdelegate.h +++ b/src/library/tableitemdelegate.h @@ -1,25 +1,28 @@ -#ifndef TABLEITEMDELEGATE_H -#define TABLEITEMDELEGATE_H +#pragma once #include +#include -class QTableView; -class QStyleOptionViewItem; class TableItemDelegate : public QStyledItemDelegate { Q_OBJECT public: explicit TableItemDelegate(QTableView* pTableView); - virtual ~TableItemDelegate(); + ~TableItemDelegate() override = default; - void paint(QPainter *painter, const QStyleOptionViewItem &option, - const QModelIndex &index) const; + void paint( + QPainter *painter, + const QStyleOptionViewItem &option, + const QModelIndex &index) const override; - virtual void paintItem(QPainter *painter, const QStyleOptionViewItem &option, + virtual void paintItem( + QPainter *painter, + const QStyleOptionViewItem &option, const QModelIndex &index) const = 0; + protected: + int columnWidth(const QModelIndex &index) const; + private: QTableView* m_pTableView; }; - -#endif // TABLEITEMDELEGATE_H diff --git a/src/library/trackcollection.cpp b/src/library/trackcollection.cpp index 18758a8bbf6d..bf2462e5368a 100644 --- a/src/library/trackcollection.cpp +++ b/src/library/trackcollection.cpp @@ -67,6 +67,21 @@ void TrackCollection::setTrackSource(QSharedPointer pTrackSource m_pTrackSource = pTrackSource; } +bool TrackCollection::addDirectory(const QString& dir) { + SqlTransaction transaction(m_database); + switch (m_directoryDao.addDirectory(dir)) { + case SQL_ERROR: + return false; + case ALREADY_WATCHING: + return true; + case ALL_FINE: + transaction.commit(); + return true; + } + DEBUG_ASSERT("unreachable"); + return false; +} + void TrackCollection::relocateDirectory(QString oldDir, QString newDir) { DEBUG_ASSERT(QApplication::instance()->thread() == QThread::currentThread()); @@ -79,10 +94,20 @@ void TrackCollection::relocateDirectory(QString oldDir, QString newDir) { QDir directory(newDir); Sandbox::createSecurityToken(directory); - QSet movedIds( - m_directoryDao.relocateDirectory(oldDir, newDir)); + SqlTransaction transaction(m_database); + QList movedTrackRefs = + m_directoryDao.relocateDirectory(oldDir, newDir); + transaction.commit(); - m_trackDao.databaseTracksMoved(std::move(movedIds), QSet()); + QList> replacedTrackRefs; + replacedTrackRefs.reserve(movedTrackRefs.size()); + for (const auto& movedTrackRef : movedTrackRefs) { + auto removedTrackRef = movedTrackRef; + // The actual new location is unknown, only the id remains the same + auto changedTrackRef = TrackRef::fromFileInfo(TrackFile(), movedTrackRef.getId()); + replacedTrackRefs.append(qMakePair(removedTrackRef, changedTrackRef)); + } + m_trackDao.databaseTracksReplaced(std::move(replacedTrackRefs)); GlobalTrackCacheLocker().relocateCachedTracks(&m_trackDao); } @@ -221,9 +246,9 @@ bool TrackCollection::purgeTracks( return true; } -bool TrackCollection::purgeTracks( - const QDir& dir) { - QList trackIds(m_trackDao.getTrackIds(dir)); +bool TrackCollection::purgeAllTracks( + const QDir& rootDir) { + QList trackIds = m_trackDao.getAllTrackIds(rootDir); return purgeTracks(trackIds); } diff --git a/src/library/trackcollection.h b/src/library/trackcollection.h index eefd00bb9a78..8e470e3a1c10 100644 --- a/src/library/trackcollection.h +++ b/src/library/trackcollection.h @@ -64,14 +64,9 @@ class TrackCollection : public QObject, void cancelLibraryScan(); - void relocateDirectory(QString oldDir, QString newDir); - bool hideTracks(const QList& trackIds); bool unhideTracks(const QList& trackIds); - bool purgeTracks(const QList& trackIds); - bool purgeTracks(const QDir& dir); - bool insertCrate(const Crate& crate, CrateId* pCrateId = nullptr); bool updateCrate(const Crate& crate); bool deleteCrate(CrateId crateId); @@ -99,6 +94,13 @@ class TrackCollection : public QObject, const QSet& crates); private: + friend class Library; + friend class Upgrade; + bool purgeTracks(const QList& trackIds); + bool purgeAllTracks(const QDir& rootDir); + bool addDirectory(const QString& dir); + void relocateDirectory(QString oldDir, QString newDir); + UserSettingsPointer m_pConfig; QSqlDatabase m_database; diff --git a/src/mixxxapplication.cpp b/src/mixxxapplication.cpp index a38afc432012..13e6dc68ecc5 100644 --- a/src/mixxxapplication.cpp +++ b/src/mixxxapplication.cpp @@ -7,6 +7,7 @@ #include "control/controlproxy.h" #include "library/crate/crateid.h" #include "track/track.h" +#include "track/trackref.h" #include "util/math.h" // When linking Qt statically on Windows we have to Q_IMPORT_PLUGIN all the @@ -52,13 +53,16 @@ MixxxApplication::~MixxxApplication() { void MixxxApplication::registerMetaTypes() { // Register custom data types for signal processing - qRegisterMetaType("TrackId"); - qRegisterMetaType>("QList"); - qRegisterMetaType>("QSet"); - qRegisterMetaType("CrateId"); - qRegisterMetaType>("QList"); - qRegisterMetaType>("QSet"); - qRegisterMetaType("TrackPointer"); + qRegisterMetaType(); + qRegisterMetaType>(); + qRegisterMetaType>(); + qRegisterMetaType(); + qRegisterMetaType>(); + qRegisterMetaType>>(); + qRegisterMetaType(); + qRegisterMetaType>(); + qRegisterMetaType>(); + qRegisterMetaType(); qRegisterMetaType("mixxx::ReplayGain"); qRegisterMetaType("mixxx::Bpm"); qRegisterMetaType("mixxx::Duration"); diff --git a/src/preferences/upgrade.cpp b/src/preferences/upgrade.cpp index 8273abb08e2c..fc810ed02372 100644 --- a/src/preferences/upgrade.cpp +++ b/src/preferences/upgrade.cpp @@ -379,7 +379,7 @@ UserSettingsPointer Upgrade::versionUpgrade(const QString& settingsPath) { // Sandbox isn't setup yet at this point in startup because it relies on // the config settings path and this function is what loads the config // so it's not ready yet. - successful = tc.getDirectoryDAO().addDirectory(currentFolder); + successful = tc.addDirectory(currentFolder); tc.disconnectDatabase(); } diff --git a/src/sources/soundsourceproxy.cpp b/src/sources/soundsourceproxy.cpp index 6bfa20774142..79d12b4e936e 100644 --- a/src/sources/soundsourceproxy.cpp +++ b/src/sources/soundsourceproxy.cpp @@ -298,39 +298,6 @@ void SoundSourceProxy::initSoundSource() { } } -namespace { -// Parses artist/title from the file name and returns the file type. -// Assumes that the file name is written like: "artist - title.xxx" -// or "artist_-_title.xxx". -// This function does not overwrite any existing (non-empty) artist -// and title fields! -bool parseMetadataFromFileName(mixxx::TrackMetadata* pTrackMetadata, QString fileName) { - fileName.replace("_", " "); - QString titleWithFileType; - bool parsed = false; - if (fileName.count('-') == 1) { - if (pTrackMetadata->getTrackInfo().getArtist().isEmpty()) { - const QString artist(fileName.section('-', 0, 0).trimmed()); - if (!artist.isEmpty()) { - pTrackMetadata->refTrackInfo().setArtist(artist); - parsed = true; - } - } - titleWithFileType = fileName.section('-', 1, 1).trimmed(); - } else { - titleWithFileType = fileName.trimmed(); - } - if (pTrackMetadata->getTrackInfo().getTitle().isEmpty()) { - const QString title(titleWithFileType.section('.', 0, -2).trimmed()); - if (!title.isEmpty()) { - pTrackMetadata->refTrackInfo().setTitle(title); - parsed = true; - } - } - return parsed; -} -} // anonymous namespace - void SoundSourceProxy::updateTrackFromSource( ImportTrackMetadataMode importTrackMetadataMode) const { DEBUG_ASSERT(m_pTrack); @@ -433,16 +400,32 @@ void SoundSourceProxy::updateTrackFromSource( } } - // Fallback: If artist or title fields are blank then try to populate - // them from the file name. This might happen if tags are unavailable, - // unreadable, or partially/completely missing. - if (trackMetadata.getTrackInfo().getArtist().isEmpty() || - trackMetadata.getTrackInfo().getTitle().isEmpty()) { - kLogger.info() - << "Adding missing artist/title from file name" - << getUrl().toString(); + // Fallback: If the title field is empty then try to populate title + // (and optionally artist) from the file name. This might happen if + // tags are unavailable, unreadable, or partially/completely missing. + if (trackMetadata.getTrackInfo().getTitle().trimmed().isEmpty()) { + // Only parse artist and title if both fields are empty to avoid + // inconsistencies. Otherwise the file name (without extension) + // is used as the title and the artist is unmodified. + // + // TODO(XXX): Disable splitting of artist/title in settings, i.e. + // optionally don't split even if both title and artist are empty? + // Some users might want to import the whole file name of untagged + // files as the title without splitting the artist: + // https://www.mixxx.org/forums/viewtopic.php?f=3&t=12838 + // NOTE(uklotzde, 2019-09-26): Whoever needs this should simply set + // splitArtistTitle = false here and compile their custom version! + // It is not worth extending the settings and injecting them into + // SoundSourceProxy for just a few people. + const bool splitArtistTitle = + trackMetadata.getTrackInfo().getArtist().trimmed().isEmpty(); const auto trackFile = m_pTrack->getFileInfo(); - if (parseMetadataFromFileName(&trackMetadata, trackFile.fileName()) && + kLogger.info() + << "Parsing missing" + << (splitArtistTitle ? "artist/title" : "title") + << "from file name:" + << trackFile; + if (trackMetadata.refTrackInfo().parseArtistTitleFromFileName(trackFile.fileName(), splitArtistTitle) && metadataImported.second.isNull()) { // Since this is also some kind of metadata import, we mark the // track's metadata as synchronized with the time stamp of the file. diff --git a/src/test/cuecontrol_test.cpp b/src/test/cuecontrol_test.cpp index 24fc38750474..66e24d2d08ba 100644 --- a/src/test/cuecontrol_test.cpp +++ b/src/test/cuecontrol_test.cpp @@ -29,7 +29,7 @@ class CueControlTest : public BaseSignalPathTest { TrackPointer createTestTrack() const { const QString kTrackLocationTest = QDir::currentPath() + "/src/test/sine-30.wav"; - return std::make_unique(kTrackLocationTest, SecurityTokenPointer()); + return Track::newTemporary(kTrackLocationTest, SecurityTokenPointer()); } void loadTrack(TrackPointer pTrack) { diff --git a/src/test/directorydaotest.cpp b/src/test/directorydaotest.cpp index b53fc7297280..6cc3ba5f6388 100644 --- a/src/test/directorydaotest.cpp +++ b/src/test/directorydaotest.cpp @@ -145,8 +145,8 @@ TEST_F(DirectoryDAOTest, relocateDirTest) { trackDAO.addTracksAddTrack(Track::newTemporary(TrackFile(test2, "d" + m_supportedFileExt)), false); trackDAO.addTracksFinish(false); - QSet ids = directoryDao.relocateDirectory(testdir, testnew); - EXPECT_EQ(2, ids.size()); + QList refs = directoryDao.relocateDirectory(testdir, testnew); + EXPECT_EQ(2, refs.size()); QStringList dirs = directoryDao.getDirs(); EXPECT_EQ(2, dirs.size()); diff --git a/src/test/trackdao_test.cpp b/src/test/trackdao_test.cpp index bad48ac0b6ba..88572e29101a 100644 --- a/src/test/trackdao_test.cpp +++ b/src/test/trackdao_test.cpp @@ -39,14 +39,20 @@ TEST_F(TrackDAOTest, detectMovedTracks) { query.bindValue(":location", oldFile.location()); query.exec(); - QSet tracksMovedSetOld; - QSet tracksMovedSetNew; + QList> replacedTracks; QStringList addedTracks(newFile.location()); bool cancel = false; - trackDAO.detectMovedTracks(&tracksMovedSetOld, &tracksMovedSetNew, addedTracks, &cancel); + trackDAO.detectMovedTracks(&replacedTracks, addedTracks, &cancel); - EXPECT_THAT(tracksMovedSetOld, UnorderedElementsAre(oldId)); - EXPECT_THAT(tracksMovedSetNew, UnorderedElementsAre(newId)); + QSet removedTrackIds; + QSet addedTrackIds; + for (const auto& replacedTrack : replacedTracks) { + removedTrackIds += replacedTrack.first.getId(); + addedTrackIds += replacedTrack.second.getId(); + } + + EXPECT_THAT(removedTrackIds, UnorderedElementsAre(oldId)); + EXPECT_THAT(addedTrackIds, UnorderedElementsAre(newId)); QSet trackLocations = trackDAO.getTrackLocations(); EXPECT_THAT(trackLocations, UnorderedElementsAre(newFile.location(), otherFile.location())); diff --git a/src/test/trackmetadata_test.cpp b/src/test/trackmetadata_test.cpp new file mode 100644 index 000000000000..0bdd95891bcd --- /dev/null +++ b/src/test/trackmetadata_test.cpp @@ -0,0 +1,63 @@ +#include + +#include "track/trackmetadata.h" + +class TrackMetadataTest : public testing::Test { +}; + +TEST_F(TrackMetadataTest, parseArtistTitleFromFileName) { + { + mixxx::TrackInfo trackInfo; + trackInfo.parseArtistTitleFromFileName(" only - title ", false); + EXPECT_EQ(QString(), trackInfo.getArtist()); + EXPECT_EQ("only - title", trackInfo.getTitle()); + } + { + mixxx::TrackInfo trackInfo; + trackInfo.parseArtistTitleFromFileName(" only-title ", true); + EXPECT_EQ(QString(), trackInfo.getArtist()); + EXPECT_EQ("only-title", trackInfo.getTitle()); + } + { + mixxx::TrackInfo trackInfo; + trackInfo.parseArtistTitleFromFileName(" only -_title ", true); + EXPECT_EQ(QString(), trackInfo.getArtist()); + EXPECT_EQ("only -_title", trackInfo.getTitle()); + } + { + mixxx::TrackInfo trackInfo; + trackInfo.parseArtistTitleFromFileName(" only - title ", false); + EXPECT_EQ(QString(), trackInfo.getArtist()); + EXPECT_EQ("only - title", trackInfo.getTitle()); + } + { + mixxx::TrackInfo trackInfo; + trackInfo.parseArtistTitleFromFileName(" artist - title ", true); + EXPECT_EQ("artist", trackInfo.getArtist()); + EXPECT_EQ("title", trackInfo.getTitle()); + } + { + mixxx::TrackInfo trackInfo; + trackInfo.parseArtistTitleFromFileName(" only -\ttitle\t", true); + EXPECT_EQ(QString(), trackInfo.getArtist()); + EXPECT_EQ("only -\ttitle", trackInfo.getTitle()); + } + { + mixxx::TrackInfo trackInfo; + trackInfo.parseArtistTitleFromFileName(" - artist__-__title - ", true); + EXPECT_EQ("- artist_", trackInfo.getArtist()); + EXPECT_EQ("_title -", trackInfo.getTitle()); + } + { + mixxx::TrackInfo trackInfo; + trackInfo.parseArtistTitleFromFileName(" - only__-__title_-_", true); + EXPECT_EQ(QString(), trackInfo.getArtist()); + EXPECT_EQ("- only__-__title_-_", trackInfo.getTitle()); + } + { + mixxx::TrackInfo trackInfo; + trackInfo.parseArtistTitleFromFileName(" again - only_-_title _ ", true); + EXPECT_EQ(QString(), trackInfo.getArtist()); + EXPECT_EQ("again - only_-_title _", trackInfo.getTitle()); + } +} diff --git a/src/track/albuminfo.cpp b/src/track/albuminfo.cpp index 43cf3fc4c53a..7fe14602f7b8 100644 --- a/src/track/albuminfo.cpp +++ b/src/track/albuminfo.cpp @@ -4,17 +4,23 @@ namespace mixxx { void AlbumInfo::resetUnsupportedValues() { + setCopyright(QString()); + setLicense(QString()); setMusicBrainzArtistId(QString()); setMusicBrainzReleaseId(QString()); setMusicBrainzReleaseGroupId(QString()); + setRecordLabel(QString()); setReplayGain(ReplayGain()); } bool operator==(const AlbumInfo& lhs, const AlbumInfo& rhs) { return (lhs.getArtist() == rhs.getArtist()) && + (lhs.getCopyright() == rhs.getCopyright()) && + (lhs.getLicense() == rhs.getLicense()) && (lhs.getMusicBrainzArtistId() == rhs.getMusicBrainzArtistId()) && (lhs.getMusicBrainzReleaseId() == rhs.getMusicBrainzReleaseId()) && (lhs.getMusicBrainzReleaseGroupId() == rhs.getMusicBrainzReleaseGroupId()) && + (lhs.getRecordLabel() == rhs.getRecordLabel()) && (lhs.getReplayGain() == rhs.getReplayGain()) && (lhs.getTitle() == rhs.getTitle()); } @@ -22,9 +28,12 @@ bool operator==(const AlbumInfo& lhs, const AlbumInfo& rhs) { QDebug operator<<(QDebug dbg, const AlbumInfo& arg) { dbg << '{'; arg.dbgArtist(dbg); + arg.dbgCopyright(dbg); + arg.dbgLicense(dbg); arg.dbgMusicBrainzArtistId(dbg); arg.dbgMusicBrainzReleaseId(dbg); arg.dbgMusicBrainzReleaseGroupId(dbg); + arg.dbgRecordLabel(dbg); arg.dbgReplayGain(dbg); arg.dbgTitle(dbg); dbg << '}'; diff --git a/src/track/albuminfo.h b/src/track/albuminfo.h index f0e4ce65c176..6cf15ccf11da 100644 --- a/src/track/albuminfo.h +++ b/src/track/albuminfo.h @@ -11,11 +11,14 @@ namespace mixxx { class AlbumInfo final { - // Album properties (in alphabetical order) + // Album and release properties (in alphabetical order) PROPERTY_SET_BYVAL_GET_BYREF(QString, artist, Artist) + PROPERTY_SET_BYVAL_GET_BYREF(QString, copyright, Copyright) + PROPERTY_SET_BYVAL_GET_BYREF(QString, license, License) PROPERTY_SET_BYVAL_GET_BYREF(QUuid, musicBrainzArtistId, MusicBrainzArtistId) PROPERTY_SET_BYVAL_GET_BYREF(QUuid, musicBrainzReleaseId, MusicBrainzReleaseId) PROPERTY_SET_BYVAL_GET_BYREF(QUuid, musicBrainzReleaseGroupId, MusicBrainzReleaseGroupId) + PROPERTY_SET_BYVAL_GET_BYREF(QString, recordLabel, RecordLabel) PROPERTY_SET_BYVAL_GET_BYREF(ReplayGain, replayGain, ReplayGain) PROPERTY_SET_BYVAL_GET_BYREF(QString, title, Title) diff --git a/src/track/trackinfo.cpp b/src/track/trackinfo.cpp index c4bb7641ca87..1891389a1cc0 100644 --- a/src/track/trackinfo.cpp +++ b/src/track/trackinfo.cpp @@ -5,15 +5,61 @@ namespace mixxx { void TrackInfo::resetUnsupportedValues() { setConductor(QString()); + setDiscNumber(QString()); + setDiscTotal(QString()); + setEncoder(QString()); + setEncoderSettings(QString()); setISRC(QString()); setLanguage(QString()); setLyricist(QString()); setMood(QString()); + setMovement(QString()); setMusicBrainzArtistId(QString()); + setMusicBrainzRecordingId(QString()); setMusicBrainzReleaseId(QString()); - setRecordLabel(QString()); + setMusicBrainzWorkId(QString()); setRemixer(QString()); setSubtitle(QString()); + setWork(QString()); +} + +namespace { + +const QString kArtistTitleSeparatorWithSpaces = " - "; +const QString kArtistTitleSeparator = "_-_"; + +const QChar kFileExtensionSeparator = '.'; + +} // anonymous namespace + +bool TrackInfo::parseArtistTitleFromFileName( + QString fileName, + bool splitArtistTitle) { + bool modified = false; + fileName = fileName.trimmed(); + auto titleWithFileType = fileName; + if (splitArtistTitle) { + fileName.replace(kArtistTitleSeparatorWithSpaces, kArtistTitleSeparator); + if (fileName.count(kArtistTitleSeparator) == 1) { + auto artist = fileName.section(kArtistTitleSeparator, 0, 0).trimmed(); + if (!artist.isEmpty()) { + setArtist(artist); + modified = true; + } + titleWithFileType = fileName.section(kArtistTitleSeparator, 1).trimmed(); + } + } + auto title = titleWithFileType; + if (titleWithFileType.contains(kFileExtensionSeparator)) { + // Strip file extension starting at the right-most '.' + title = titleWithFileType.section(kFileExtensionSeparator, 0, -2); + } + title = title.trimmed(); + if (!title.isEmpty()) { + setTitle(title); + modified = true; + } + return modified; } bool operator==(const TrackInfo& lhs, const TrackInfo& rhs) { @@ -22,22 +68,29 @@ bool operator==(const TrackInfo& lhs, const TrackInfo& rhs) { (lhs.getComment() == rhs.getComment()) && (lhs.getComposer() == rhs.getComposer()) && (lhs.getConductor() == rhs.getConductor()) && - (lhs.getGrouping() == rhs.getGrouping()) && + (lhs.getDiscNumber() == rhs.getDiscNumber()) && + (lhs.getDiscTotal() == rhs.getDiscTotal()) && + (lhs.getEncoder() == rhs.getEncoder()) && + (lhs.getEncoderSettings() == rhs.getEncoderSettings()) && (lhs.getGenre() == rhs.getGenre()) && + (lhs.getGrouping() == rhs.getGrouping()) && (lhs.getISRC() == rhs.getISRC()) && (lhs.getKey() == rhs.getKey()) && (lhs.getLanguage() == rhs.getLanguage()) && (lhs.getLyricist() == rhs.getLyricist()) && (lhs.getMood() == rhs.getMood()) && + (lhs.getMovement() == rhs.getMovement()) && (lhs.getMusicBrainzArtistId() == rhs.getMusicBrainzArtistId()) && + (lhs.getMusicBrainzRecordingId() == rhs.getMusicBrainzRecordingId()) && (lhs.getMusicBrainzReleaseId() == rhs.getMusicBrainzReleaseId()) && - (lhs.getRecordLabel() == rhs.getRecordLabel()) && + (lhs.getMusicBrainzWorkId() == rhs.getMusicBrainzWorkId()) && (lhs.getRemixer() == rhs.getRemixer()) && (lhs.getReplayGain() == rhs.getReplayGain()) && (lhs.getSubtitle() == rhs.getSubtitle()) && (lhs.getTitle() == rhs.getTitle()) && (lhs.getTrackNumber() == rhs.getTrackNumber()) && (lhs.getTrackTotal() == rhs.getTrackTotal()) && + (lhs.getWork() == rhs.getWork()) && (lhs.getYear() == rhs.getYear()); } @@ -48,22 +101,29 @@ QDebug operator<<(QDebug dbg, const TrackInfo& arg) { arg.dbgComment(dbg); arg.dbgComposer(dbg); arg.dbgConductor(dbg); - arg.dbgGrouping(dbg); + arg.dbgDiscNumber(dbg); + arg.dbgDiscTotal(dbg); + arg.dbgEncoder(dbg); + arg.dbgEncoderSettings(dbg); arg.dbgGenre(dbg); + arg.dbgGrouping(dbg); arg.dbgISRC(dbg); arg.dbgKey(dbg); arg.dbgLanguage(dbg); arg.dbgLyricist(dbg); arg.dbgMood(dbg); + arg.dbgMovement(dbg); arg.dbgMusicBrainzArtistId(dbg); + arg.dbgMusicBrainzRecordingId(dbg); arg.dbgMusicBrainzReleaseId(dbg); - arg.dbgRecordLabel(dbg); + arg.dbgMusicBrainzWorkId(dbg); arg.dbgRemixer(dbg); arg.dbgReplayGain(dbg); arg.dbgSubtitle(dbg); arg.dbgTitle(dbg); arg.dbgTrackNumber(dbg); arg.dbgTrackTotal(dbg); + arg.dbgWork(dbg); arg.dbgYear(dbg); dbg << '}'; return dbg; diff --git a/src/track/trackinfo.h b/src/track/trackinfo.h index 6304d78434c8..b20b3f4d5ab7 100644 --- a/src/track/trackinfo.h +++ b/src/track/trackinfo.h @@ -21,6 +21,10 @@ class TrackInfo final { PROPERTY_SET_BYVAL_GET_BYREF(QString, comment, Comment) PROPERTY_SET_BYVAL_GET_BYREF(QString, composer, Composer) PROPERTY_SET_BYVAL_GET_BYREF(QString, conductor, Conductor) + PROPERTY_SET_BYVAL_GET_BYREF(QString, discNumber, DiscNumber) + PROPERTY_SET_BYVAL_GET_BYREF(QString, discTotal, DiscTotal) + PROPERTY_SET_BYVAL_GET_BYREF(QString, encoder, Encoder) + PROPERTY_SET_BYVAL_GET_BYREF(QString, encoderSettings, EncoderSettings) PROPERTY_SET_BYVAL_GET_BYREF(QString, genre, Genre) PROPERTY_SET_BYVAL_GET_BYREF(QString, grouping, Grouping) PROPERTY_SET_BYVAL_GET_BYREF(QString, key, Key) @@ -28,15 +32,18 @@ class TrackInfo final { PROPERTY_SET_BYVAL_GET_BYREF(QString, language, Language) PROPERTY_SET_BYVAL_GET_BYREF(QString, lyricist, Lyricist) PROPERTY_SET_BYVAL_GET_BYREF(QString, mood, Mood) + PROPERTY_SET_BYVAL_GET_BYREF(QString, movement, Movement) PROPERTY_SET_BYVAL_GET_BYREF(QUuid, musicBrainzArtistId, MusicBrainzArtistId) + PROPERTY_SET_BYVAL_GET_BYREF(QUuid, musicBrainzRecordingId, MusicBrainzRecordingId) PROPERTY_SET_BYVAL_GET_BYREF(QUuid, musicBrainzReleaseId, MusicBrainzReleaseId) - PROPERTY_SET_BYVAL_GET_BYREF(QString, recordLabel, RecordLabel) + PROPERTY_SET_BYVAL_GET_BYREF(QUuid, musicBrainzWorkId, MusicBrainzWorkId) PROPERTY_SET_BYVAL_GET_BYREF(QString, remixer, Remixer) PROPERTY_SET_BYVAL_GET_BYREF(ReplayGain, replayGain, ReplayGain) PROPERTY_SET_BYVAL_GET_BYREF(QString, subtitle, Subtitle) PROPERTY_SET_BYVAL_GET_BYREF(QString, title, Title) PROPERTY_SET_BYVAL_GET_BYREF(QString, trackNumber, TrackNumber) PROPERTY_SET_BYVAL_GET_BYREF(QString, trackTotal, TrackTotal) + PROPERTY_SET_BYVAL_GET_BYREF(QString, work, Work) PROPERTY_SET_BYVAL_GET_BYREF(QString, year, Year) // = release date public: @@ -48,6 +55,11 @@ class TrackInfo final { TrackInfo& operator=(TrackInfo&&) = default; TrackInfo& operator=(const TrackInfo&) = default; + // Returns true if modified + bool parseArtistTitleFromFileName( + QString fileName, + bool splitArtistTitle); + // TODO(XXX): Remove after all new fields have been added to the library void resetUnsupportedValues(); diff --git a/src/track/trackmetadatataglib.cpp b/src/track/trackmetadatataglib.cpp index 6b02a4ea1119..54404a5b1196 100644 --- a/src/track/trackmetadatataglib.cpp +++ b/src/track/trackmetadatataglib.cpp @@ -7,6 +7,10 @@ #include "util/logger.h" #include "util/memory.h" +/////////////////////////////////////////////////////////////////////// +// The common source for all tag mappings is MusicBrainz Picard: +// https://picard.musicbrainz.org/docs/mappings/ +/////////////////////////////////////////////////////////////////////// // TagLib has support for has() style functions since version 1.9 #define TAGLIB_HAS_TAG_CHECK \ @@ -26,8 +30,10 @@ #include #include +#include #include #include +#include #include @@ -201,10 +207,16 @@ QString toQStringFirstNotEmpty( const TagLib::ID3v2::FrameList& frameList) { for (const TagLib::ID3v2::Frame* pFrame: frameList) { if (pFrame) { - TagLib::String str(pFrame->toString()); + TagLib::String str = pFrame->toString(); if (!str.isEmpty()) { return toQString(str); } + auto pUnknownFrame = dynamic_cast(pFrame); + if (pUnknownFrame) { + kLogger.warning() + << "Unsupported ID3v2 frame" + << pUnknownFrame->frameID().data(); + } } } return QString(); @@ -330,6 +342,7 @@ bool parseTrackPeak( return isPeakValid; } +#if defined(__EXTRA_METADATA__) inline bool hasAlbumGain(const TrackMetadata& trackMetadata) { return trackMetadata.getAlbumInfo().getReplayGain().hasRatio(); @@ -375,6 +388,7 @@ bool parseAlbumPeak( } return isPeakValid; } +#endif // __EXTRA_METADATA__ void readAudioProperties( TrackMetadata* pTrackMetadata, @@ -510,7 +524,7 @@ TagLib::ID3v2::CommentsFrame* findFirstCommentsFrameWithoutDescription( } // Finds the first text frame that with a matching description (case-insensitive). -// If multiple comments frames with matching descriptions exist prefer the first +// If multiple text frames with matching descriptions exist prefer the first // with a non-empty content if requested. TagLib::ID3v2::UserTextIdentificationFrame* findFirstUserTextIdentificationFrame( const TagLib::ID3v2::Tag& tag, @@ -521,13 +535,12 @@ TagLib::ID3v2::UserTextIdentificationFrame* findFirstUserTextIdentificationFrame // Bind the const-ref result to avoid a local copy const TagLib::ID3v2::FrameList& textFrames = tag.frameListMap()["TXXX"]; - for (TagLib::ID3v2::FrameList::ConstIterator it(textFrames.begin()); + for (TagLib::ID3v2::FrameList::ConstIterator it = textFrames.begin(); it != textFrames.end(); ++it) { auto pFrame = dynamic_cast(*it); if (pFrame) { - const QString frameDescription( - toQString(pFrame->description())); + const QString frameDescription = toQString(pFrame->description()); if (0 == frameDescription.compare( description, Qt::CaseInsensitive)) { if (preferNotEmpty && pFrame->toString().isEmpty()) { @@ -547,6 +560,43 @@ TagLib::ID3v2::UserTextIdentificationFrame* findFirstUserTextIdentificationFrame return pFirstFrame; } +// Finds the first UFID frame that with a matching owner (case-insensitive). +// If multiple UFID frames with matching descriptions exist prefer the first +// with a non-empty content if requested. +TagLib::ID3v2::UniqueFileIdentifierFrame* findFirstUniqueFileIdentifierFrame( + const TagLib::ID3v2::Tag& tag, + const QString& owner, + bool preferNotEmpty = true) { + DEBUG_ASSERT(!owner.isEmpty()); + TagLib::ID3v2::UniqueFileIdentifierFrame* pFirstFrame = nullptr; + // Bind the const-ref result to avoid a local copy + const TagLib::ID3v2::FrameList& ufidFrames = + tag.frameListMap()["UFID"]; + for (TagLib::ID3v2::FrameList::ConstIterator it = ufidFrames.begin(); + it != ufidFrames.end(); ++it) { + auto pFrame = + dynamic_cast(*it); + if (pFrame) { + const QString frameOwner = toQString(pFrame->owner()); + if (0 == frameOwner.compare( + owner, Qt::CaseInsensitive)) { + if (preferNotEmpty && pFrame->toString().isEmpty()) { + // we might need the first matching frame later + // even if it is empty + if (!pFirstFrame) { + pFirstFrame = pFrame; + } + } else { + // found what we are looking for + return pFrame; + } + } + } + } + // simply return the first matching frame + return pFirstFrame; +} + inline QString readFirstUserTextIdentificationFrame( const TagLib::ID3v2::Tag& tag, @@ -561,6 +611,19 @@ QString readFirstUserTextIdentificationFrame( } } +inline +QByteArray readFirstUniqueFileIdentifierFrame( + const TagLib::ID3v2::Tag& tag, + const QString& owner) { + const TagLib::ID3v2::UniqueFileIdentifierFrame* pFrame = + findFirstUniqueFileIdentifierFrame(tag, owner); + if (pFrame) { + return QByteArray(pFrame->identifier().data(), pFrame->identifier().size()); + } else { + return QByteArray(); + } +} + // Deletes all TXXX frame with the given description (case-insensitive). int removeUserTextIdentificationFrames( TagLib::ID3v2::Tag* pTag, @@ -625,18 +688,6 @@ void writeID3v2TextIdentificationFrame( } } -bool writeID3v2TextIdentificationFrameStringIfNotNull( - TagLib::ID3v2::Tag* pTag, - const TagLib::ByteVector &id, - const QString& text) { - if (text.isNull()) { - return false; - } else { - writeID3v2TextIdentificationFrame(pTag, id, text); - return true; - } -} - void writeID3v2CommentsFrame( TagLib::ID3v2::Tag* pTag, const QString& text, @@ -719,6 +770,50 @@ void writeID3v2UserTextIdentificationFrame( } } +#if defined(__EXTRA_METADATA__) +bool writeID3v2TextIdentificationFrameStringIfNotNull( + TagLib::ID3v2::Tag* pTag, + const TagLib::ByteVector &id, + const QString& text) { + if (text.isNull()) { + return false; + } else { + writeID3v2TextIdentificationFrame(pTag, id, text); + return true; + } +} + +void writeID3v2UniqueFileIdentifierFrame( + TagLib::ID3v2::Tag* pTag, + const QString& owner, + const QByteArray& identifier) { + TagLib::ID3v2::UniqueFileIdentifierFrame* pFrame = + findFirstUniqueFileIdentifierFrame(*pTag, owner); + if (pFrame) { + // Modify existing frame + if (identifier.isEmpty()) { + // Purge empty frames + pTag->removeFrame(pFrame); + } else { + pFrame->setOwner(toTagLibString(owner)); + pFrame->setIdentifier(TagLib::ByteVector(identifier.constData(), identifier.size())); + } + } else { + // Add a new (non-empty) frame + if (!identifier.isEmpty()) { + auto pFrame = + std::make_unique( + toTagLibString(owner), + TagLib::ByteVector(identifier.constData(), identifier.size())); + pTag->addFrame(pFrame.get()); + // Now that the plain pointer in pFrame is owned and managed by + // pTag we need to release the ownership to avoid double deletion! + pFrame.release(); + } + } +} +#endif // __EXTRA_METADATA__ + bool readMP4Atom( const TagLib::MP4::Tag& tag, const TagLib::String& key, @@ -1168,25 +1263,52 @@ void importTrackMetadataFromID3v2Tag( } } - const TagLib::ID3v2::FrameList albumArtistFrame(tag.frameListMap()["TPE2"]); - if (!albumArtistFrame.isEmpty()) { - pTrackMetadata->refAlbumInfo().setArtist(toQStringFirstNotEmpty(albumArtistFrame)); + const TagLib::ID3v2::FrameList albumArtistFrames(tag.frameListMap()["TPE2"]); + if (!albumArtistFrames.isEmpty()) { + pTrackMetadata->refAlbumInfo().setArtist(toQStringFirstNotEmpty(albumArtistFrames)); } if (pTrackMetadata->getAlbumInfo().getTitle().isEmpty()) { - const TagLib::ID3v2::FrameList originalAlbumFrame( + // Use the original album title as a fallback + const TagLib::ID3v2::FrameList originalAlbumFrames( tag.frameListMap()["TOAL"]); - pTrackMetadata->refAlbumInfo().setTitle(toQStringFirstNotEmpty(originalAlbumFrame)); + if (!originalAlbumFrames.isEmpty()) { + pTrackMetadata->refAlbumInfo().setTitle(toQStringFirstNotEmpty(originalAlbumFrames)); + } } - const TagLib::ID3v2::FrameList composerFrame(tag.frameListMap()["TCOM"]); - if (!composerFrame.isEmpty()) { - pTrackMetadata->refTrackInfo().setComposer(toQStringFirstNotEmpty(composerFrame)); + const TagLib::ID3v2::FrameList composerFrames(tag.frameListMap()["TCOM"]); + if (!composerFrames.isEmpty()) { + pTrackMetadata->refTrackInfo().setComposer(toQStringFirstNotEmpty(composerFrames)); } - const TagLib::ID3v2::FrameList groupingFrame(tag.frameListMap()["TIT1"]); - if (!groupingFrame.isEmpty()) { - pTrackMetadata->refTrackInfo().setGrouping(toQStringFirstNotEmpty(groupingFrame)); + // Apple decided to store the Work in the traditional ID3v2 Content Group + // frame (TIT1) and introduced new Grouping (GRP1) and Movement Name (MVNM) + // frames. + // https://discussions.apple.com/thread/7900430 + // http://blog.jthink.net/2016/11/the-reason-why-is-grouping-field-no.html + if (tag.frameListMap().contains("GRP1")) { + // New grouping/work mapping + const TagLib::ID3v2::FrameList appleGroupingFrames = tag.frameListMap()["GRP1"]; + if (!appleGroupingFrames.isEmpty()) { + pTrackMetadata->refTrackInfo().setGrouping(toQStringFirstNotEmpty(appleGroupingFrames)); + } +#if defined(__EXTRA_METADATA__) + const TagLib::ID3v2::FrameList workFrames = tag.frameListMap()["TIT1"]; + if (!workFrames.isEmpty()) { + pTrackMetadata->refTrackInfo().setWork(toQStringFirstNotEmpty(workFrames)); + } + const TagLib::ID3v2::FrameList movementFrames = tag.frameListMap()["MVNM"]; + if (!movementFrames.isEmpty()) { + pTrackMetadata->refTrackInfo().setMovement(toQStringFirstNotEmpty(movementFrames)); + } +#endif // __EXTRA_METADATA__ + } else { + // No Apple grouping frame found -> Use the traditional mapping + const TagLib::ID3v2::FrameList traditionalGroupingFrames = tag.frameListMap()["TIT1"]; + if (!traditionalGroupingFrames.isEmpty()) { + pTrackMetadata->refTrackInfo().setGrouping(toQStringFirstNotEmpty(traditionalGroupingFrames)); + } } // ID3v2.4.0: TDRC replaces TYER + TDAT @@ -1217,21 +1339,35 @@ void importTrackMetadataFromID3v2Tag( } } - const TagLib::ID3v2::FrameList trackNumberFrame(tag.frameListMap()["TRCK"]); - if (!trackNumberFrame.isEmpty()) { + const TagLib::ID3v2::FrameList trackNumberFrames(tag.frameListMap()["TRCK"]); + if (!trackNumberFrames.isEmpty()) { QString trackNumber; QString trackTotal; TrackNumbers::splitString( - toQStringFirstNotEmpty(trackNumberFrame), + toQStringFirstNotEmpty(trackNumberFrames), &trackNumber, &trackTotal); pTrackMetadata->refTrackInfo().setTrackNumber(trackNumber); pTrackMetadata->refTrackInfo().setTrackTotal(trackTotal); } - const TagLib::ID3v2::FrameList bpmFrame(tag.frameListMap()["TBPM"]); - if (!bpmFrame.isEmpty()) { - parseBpm(pTrackMetadata, toQStringFirstNotEmpty(bpmFrame)); +#if defined(__EXTRA_METADATA__) + const TagLib::ID3v2::FrameList discNumberFrames(tag.frameListMap()["TPOS"]); + if (!discNumberFrames.isEmpty()) { + QString discNumber; + QString discTotal; + TrackNumbers::splitString( + toQStringFirstNotEmpty(discNumberFrames), + &discNumber, + &discTotal); + pTrackMetadata->refTrackInfo().setDiscNumber(discNumber); + pTrackMetadata->refTrackInfo().setDiscTotal(discTotal); + } +#endif // __EXTRA_METADATA__ + + const TagLib::ID3v2::FrameList bpmFrames(tag.frameListMap()["TBPM"]); + if (!bpmFrames.isEmpty()) { + parseBpm(pTrackMetadata, toQStringFirstNotEmpty(bpmFrames)); double bpmValue = pTrackMetadata->getTrackInfo().getBpm().getValue(); // Some software use (or used) to write decimated values without comma, // so the number reads as 1352 or 14525 when it is 135.2 or 145.25 @@ -1273,9 +1409,9 @@ void importTrackMetadataFromID3v2Tag( } } - const TagLib::ID3v2::FrameList keyFrame(tag.frameListMap()["TKEY"]); - if (!keyFrame.isEmpty()) { - pTrackMetadata->refTrackInfo().setKey(toQStringFirstNotEmpty(keyFrame)); + const TagLib::ID3v2::FrameList keyFrames(tag.frameListMap()["TKEY"]); + if (!keyFrames.isEmpty()) { + pTrackMetadata->refTrackInfo().setKey(toQStringFirstNotEmpty(keyFrames)); } QString trackGain = @@ -1289,6 +1425,7 @@ void importTrackMetadataFromID3v2Tag( parseTrackPeak(pTrackMetadata, trackPeak); } +#if defined(__EXTRA_METADATA__) QString albumGain = readFirstUserTextIdentificationFrame(tag, "REPLAYGAIN_ALBUM_GAIN"); if (!albumGain.isEmpty()) { @@ -1305,11 +1442,21 @@ void importTrackMetadataFromID3v2Tag( if (!trackArtistId.isNull()) { pTrackMetadata->refTrackInfo().setMusicBrainzArtistId(QUuid(trackArtistId)); } + QByteArray trackRecordingId = + readFirstUniqueFileIdentifierFrame(tag, "http://musicbrainz.org"); + if (!trackRecordingId.isEmpty()) { + pTrackMetadata->refTrackInfo().setMusicBrainzRecordingId(QUuid(trackRecordingId)); + } QString trackReleaseId = readFirstUserTextIdentificationFrame(tag, "MusicBrainz Release Track Id"); if (!trackReleaseId.isNull()) { pTrackMetadata->refTrackInfo().setMusicBrainzReleaseId(QUuid(trackReleaseId)); } + QString trackWorkId = + readFirstUserTextIdentificationFrame(tag, "MusicBrainz Work Id"); + if (!trackWorkId.isNull()) { + pTrackMetadata->refTrackInfo().setMusicBrainzWorkId(QUuid(trackWorkId)); + } QString albumArtistId = readFirstUserTextIdentificationFrame(tag, "MusicBrainz Album Artist Id"); if (!albumArtistId.isNull()) { @@ -1326,40 +1473,57 @@ void importTrackMetadataFromID3v2Tag( pTrackMetadata->refAlbumInfo().setMusicBrainzReleaseGroupId(QUuid(albumReleaseGroupId)); } - const TagLib::ID3v2::FrameList conductorFrame(tag.frameListMap()["TPE3"]); - if (!conductorFrame.isEmpty()) { - pTrackMetadata->refTrackInfo().setConductor(toQStringFirstNotEmpty(conductorFrame)); + const TagLib::ID3v2::FrameList conductorFrames(tag.frameListMap()["TPE3"]); + if (!conductorFrames.isEmpty()) { + pTrackMetadata->refTrackInfo().setConductor(toQStringFirstNotEmpty(conductorFrames)); } - const TagLib::ID3v2::FrameList isrcFrame(tag.frameListMap()["TSRC"]); - if (!isrcFrame.isEmpty()) { - pTrackMetadata->refTrackInfo().setISRC(toQStringFirstNotEmpty(isrcFrame)); + const TagLib::ID3v2::FrameList isrcFrames(tag.frameListMap()["TSRC"]); + if (!isrcFrames.isEmpty()) { + pTrackMetadata->refTrackInfo().setISRC(toQStringFirstNotEmpty(isrcFrames)); } - const TagLib::ID3v2::FrameList languageFrame(tag.frameListMap()["TLAN"]); - if (!languageFrame.isEmpty()) { - pTrackMetadata->refTrackInfo().setLanguage(toQStringFirstNotEmpty(languageFrame)); + const TagLib::ID3v2::FrameList languageFrames(tag.frameListMap()["TLAN"]); + if (!languageFrames.isEmpty()) { + pTrackMetadata->refTrackInfo().setLanguage(toQStringFirstNotEmpty(languageFrames)); } - const TagLib::ID3v2::FrameList lyricistFrame(tag.frameListMap()["TEXT"]); - if (!lyricistFrame.isEmpty()) { - pTrackMetadata->refTrackInfo().setLyricist(toQStringFirstNotEmpty(lyricistFrame)); + const TagLib::ID3v2::FrameList lyricistFrames(tag.frameListMap()["TEXT"]); + if (!lyricistFrames.isEmpty()) { + pTrackMetadata->refTrackInfo().setLyricist(toQStringFirstNotEmpty(lyricistFrames)); } if (tag.header()->majorVersion() >= 4) { - const TagLib::ID3v2::FrameList moodFrame(tag.frameListMap()["TMOO"]); - if (!moodFrame.isEmpty()) { - pTrackMetadata->refTrackInfo().setMood(toQStringFirstNotEmpty(moodFrame)); + const TagLib::ID3v2::FrameList moodFrames(tag.frameListMap()["TMOO"]); + if (!moodFrames.isEmpty()) { + pTrackMetadata->refTrackInfo().setMood(toQStringFirstNotEmpty(moodFrames)); } } - const TagLib::ID3v2::FrameList recordLabelFrame(tag.frameListMap()["TPUB"]); - if (!recordLabelFrame.isEmpty()) { - pTrackMetadata->refTrackInfo().setRecordLabel(toQStringFirstNotEmpty(recordLabelFrame)); + const TagLib::ID3v2::FrameList copyrightFrames(tag.frameListMap()["TCOP"]); + if (!copyrightFrames.isEmpty()) { + pTrackMetadata->refAlbumInfo().setCopyright(toQStringFirstNotEmpty(copyrightFrames)); + } + const TagLib::ID3v2::FrameList licenseFrames(tag.frameListMap()["WCOP"]); + if (!licenseFrames.isEmpty()) { + pTrackMetadata->refAlbumInfo().setLicense(toQStringFirstNotEmpty(licenseFrames)); + } + const TagLib::ID3v2::FrameList recordLabelFrames(tag.frameListMap()["TPUB"]); + if (!recordLabelFrames.isEmpty()) { + pTrackMetadata->refAlbumInfo().setRecordLabel(toQStringFirstNotEmpty(recordLabelFrames)); } - const TagLib::ID3v2::FrameList remixerFrame(tag.frameListMap()["TPE4"]); - if (!remixerFrame.isEmpty()) { - pTrackMetadata->refTrackInfo().setRemixer(toQStringFirstNotEmpty(remixerFrame)); + const TagLib::ID3v2::FrameList remixerFrames(tag.frameListMap()["TPE4"]); + if (!remixerFrames.isEmpty()) { + pTrackMetadata->refTrackInfo().setRemixer(toQStringFirstNotEmpty(remixerFrames)); } - const TagLib::ID3v2::FrameList subtitleFrame(tag.frameListMap()["TIT3"]); - if (!subtitleFrame.isEmpty()) { - pTrackMetadata->refTrackInfo().setSubtitle(toQStringFirstNotEmpty(subtitleFrame)); + const TagLib::ID3v2::FrameList subtitleFrames(tag.frameListMap()["TIT3"]); + if (!subtitleFrames.isEmpty()) { + pTrackMetadata->refTrackInfo().setSubtitle(toQStringFirstNotEmpty(subtitleFrames)); } + const TagLib::ID3v2::FrameList encoderFrames(tag.frameListMap()["TENC"]); + if (!encoderFrames.isEmpty()) { + pTrackMetadata->refTrackInfo().setEncoder(toQStringFirstNotEmpty(encoderFrames)); + } + const TagLib::ID3v2::FrameList encoderSettingsFrames(tag.frameListMap()["TSSE"]); + if (!encoderSettingsFrames.isEmpty()) { + pTrackMetadata->refTrackInfo().setEncoderSettings(toQStringFirstNotEmpty(encoderSettingsFrames)); + } +#endif // __EXTRA_METADATA__ } void importTrackMetadataFromAPETag(TrackMetadata* pTrackMetadata, const TagLib::APE::Tag& tag) { @@ -1416,6 +1580,20 @@ void importTrackMetadataFromAPETag(TrackMetadata* pTrackMetadata, const TagLib:: pTrackMetadata->refTrackInfo().setTrackTotal(trackTotal); } +#if defined(__EXTRA_METADATA__) + QString discNumber; + if (readAPEItem(tag, "Disc", &discNumber) || + readAPEItem(tag, "DISC", &discNumber)) { + QString discTotal; + TrackNumbers::splitString( + discNumber, + &discNumber, + &discTotal); + pTrackMetadata->refTrackInfo().setDiscNumber(discNumber); + pTrackMetadata->refTrackInfo().setDiscTotal(discTotal); + } +#endif // __EXTRA_METADATA__ + QString bpm; if (readAPEItem(tag, "BPM", &bpm)) { parseBpm(pTrackMetadata, bpm); @@ -1430,6 +1608,7 @@ void importTrackMetadataFromAPETag(TrackMetadata* pTrackMetadata, const TagLib:: parseTrackPeak(pTrackMetadata, trackPeak); } +#if defined(__EXTRA_METADATA__) QString albumGain; if (readAPEItem(tag, "REPLAYGAIN_ALBUM_GAIN", &albumGain)) { parseTrackGain(pTrackMetadata, albumGain); @@ -1443,10 +1622,18 @@ void importTrackMetadataFromAPETag(TrackMetadata* pTrackMetadata, const TagLib:: if (readAPEItem(tag, "MUSICBRAINZ_ARTISTID", &trackArtistId)) { pTrackMetadata->refTrackInfo().setMusicBrainzArtistId(QUuid(trackArtistId)); } + QString trackRecordingId; + if (readAPEItem(tag, "MUSICBRAINZ_TRACKID", &trackRecordingId)) { + pTrackMetadata->refTrackInfo().setMusicBrainzRecordingId(QUuid(trackRecordingId)); + } QString trackReleaseId; if (readAPEItem(tag, "MUSICBRAINZ_RELEASETRACKID", &trackReleaseId)) { pTrackMetadata->refTrackInfo().setMusicBrainzReleaseId(QUuid(trackReleaseId)); } + QString trackWorkId; + if (readAPEItem(tag, "MUSICBRAINZ_WORKID", &trackWorkId)) { + pTrackMetadata->refTrackInfo().setMusicBrainzWorkId(QUuid(trackWorkId)); + } QString albumArtistId; if (readAPEItem(tag, "MUSICBRAINZ_ALBUMARTISTID", &albumArtistId)) { pTrackMetadata->refAlbumInfo().setMusicBrainzArtistId(QUuid(albumArtistId)); @@ -1490,16 +1677,37 @@ void importTrackMetadataFromAPETag(TrackMetadata* pTrackMetadata, const TagLib:: readAPEItem(tag, "REMIXER", &remixer)) { pTrackMetadata->refTrackInfo().setRemixer(remixer); } + QString copyright; + if (readAPEItem(tag, "Copyright", ©right) || + readAPEItem(tag, "COPYRIGHT", ©right)) { + pTrackMetadata->refAlbumInfo().setCopyright(copyright); + } + QString license; + if (readAPEItem(tag, "License", &license) || + readAPEItem(tag, "LICENSE", &license)) { + pTrackMetadata->refAlbumInfo().setLicense(license); + } QString recordLabel; if (readAPEItem(tag, "Label", &recordLabel) || readAPEItem(tag, "LABEL", &recordLabel)) { - pTrackMetadata->refTrackInfo().setRecordLabel(recordLabel); + pTrackMetadata->refAlbumInfo().setRecordLabel(recordLabel); } QString subtitle; if (readAPEItem(tag, "Subtitle", &subtitle) || readAPEItem(tag, "SUBTITLE", &subtitle)) { pTrackMetadata->refTrackInfo().setSubtitle(subtitle); } + QString encoder; + if (readAPEItem(tag, "EncodedBy", &encoder) || + readAPEItem(tag, "ENCODEDBY", &encoder)) { + pTrackMetadata->refTrackInfo().setEncoder(encoder); + } + QString encoderSettings; + if (readAPEItem(tag, "EncoderSettings", &encoderSettings) || + readAPEItem(tag, "ENCODERSETTINGS", &encoderSettings)) { + pTrackMetadata->refTrackInfo().setEncoderSettings(encoderSettings); + } +#endif // __EXTRA_METADATA__ } void importTrackMetadataFromVorbisCommentTag( @@ -1566,6 +1774,25 @@ void importTrackMetadataFromVorbisCommentTag( pTrackMetadata->refTrackInfo().setTrackTotal(trackTotal); } +#if defined(__EXTRA_METADATA__) + QString discNumber; + if (readXiphCommentField(tag, "DISCNUMBER", &discNumber)) { + QString discTotal; + // Split the string, because some applications might decide + // to store "/" in "DISCNUMBER" + // even if this is not recommended. + TrackNumbers::splitString( + discNumber, + &discNumber, + &discTotal); + if (!readXiphCommentField(tag, "DISCTOTAL", &discTotal)) { // recommended field + (void)readXiphCommentField(tag, "TOTALDISCS", &discTotal); // alternative field + } + pTrackMetadata->refTrackInfo().setDiscNumber(discNumber); + pTrackMetadata->refTrackInfo().setDiscTotal(discTotal); + } +#endif // __EXTRA_METADATA__ + // The release date formatted according to ISO 8601. Might // be followed by a space character and arbitrary text. // http://age.hobba.nl/audio/mirroredpages/ogg-tagging.html @@ -1605,6 +1832,7 @@ void importTrackMetadataFromVorbisCommentTag( parseTrackPeak(pTrackMetadata, trackPeak); } +#if defined(__EXTRA_METADATA__) QString albumGain; if (readXiphCommentField(tag, "REPLAYGAIN_ALBUM_GAIN", &albumGain)) { parseAlbumGain(pTrackMetadata, albumGain); @@ -1618,10 +1846,18 @@ void importTrackMetadataFromVorbisCommentTag( if (readXiphCommentField(tag, "MUSICBRAINZ_ARTISTID", &trackArtistId)) { pTrackMetadata->refTrackInfo().setMusicBrainzArtistId(trackArtistId); } + QString trackRecordingId; + if (readXiphCommentField(tag, "MUSICBRAINZ_TRACKID", &trackRecordingId)) { + pTrackMetadata->refTrackInfo().setMusicBrainzRecordingId(trackRecordingId); + } QString trackReleaseId; if (readXiphCommentField(tag, "MUSICBRAINZ_RELEASETRACKID", &trackReleaseId)) { pTrackMetadata->refTrackInfo().setMusicBrainzReleaseId(trackReleaseId); } + QString trackWorkId; + if (readXiphCommentField(tag, "MUSICBRAINZ_WORKID", &trackWorkId)) { + pTrackMetadata->refTrackInfo().setMusicBrainzWorkId(trackWorkId); + } QString albumArtistId; if (readXiphCommentField(tag, "MUSICBRAINZ_ALBUMARTISTID", &albumArtistId)) { pTrackMetadata->refAlbumInfo().setMusicBrainzArtistId(QUuid(albumArtistId)); @@ -1655,9 +1891,17 @@ void importTrackMetadataFromVorbisCommentTag( if (readXiphCommentField(tag, "MOOD", &mood)) { pTrackMetadata->refTrackInfo().setMood(mood); } + QString copyright; + if (readXiphCommentField(tag, "COPYRIGHT", ©right)) { + pTrackMetadata->refAlbumInfo().setCopyright(copyright); + } + QString license; + if (readXiphCommentField(tag, "LICENSE", &license)) { + pTrackMetadata->refAlbumInfo().setLicense(license); + } QString recordLabel; if (readXiphCommentField(tag, "LABEL", &recordLabel)) { - pTrackMetadata->refTrackInfo().setRecordLabel(recordLabel); + pTrackMetadata->refAlbumInfo().setRecordLabel(recordLabel); } QString remixer; if (readXiphCommentField(tag, "REMIXER", &remixer)) { @@ -1667,6 +1911,15 @@ void importTrackMetadataFromVorbisCommentTag( if (readXiphCommentField(tag, "SUBTITLE", &subtitle)) { pTrackMetadata->refTrackInfo().setSubtitle(subtitle); } + QString encoder; + if (readXiphCommentField(tag, "ENCODEDBY", &encoder)) { + pTrackMetadata->refTrackInfo().setEncoder(encoder); + } + QString encoderSettings; + if (readXiphCommentField(tag, "ENCODERSETTINGS", &encoderSettings)) { + pTrackMetadata->refTrackInfo().setEncoderSettings(encoderSettings); + } +#endif // __EXTRA_METADATA__ } void importTrackMetadataFromMP4Tag(TrackMetadata* pTrackMetadata, const TagLib::MP4::Tag& tag) { @@ -1708,6 +1961,20 @@ void importTrackMetadataFromMP4Tag(TrackMetadata* pTrackMetadata, const TagLib:: pTrackMetadata->refTrackInfo().setTrackTotal(trackTotal); } +#if defined(__EXTRA_METADATA__) + // Read disc number/total pair + if (getItemListMap(tag).contains("disk")) { + const TagLib::MP4::Item trknItem = getItemListMap(tag)["disk"]; + const TagLib::MP4::Item::IntPair trknPair = trknItem.toIntPair(); + const TrackNumbers discNumbers(trknPair.first, trknPair.second); + QString discNumber; + QString discTotal; + discNumbers.toStrings(&discNumber, &discTotal); + pTrackMetadata->refTrackInfo().setDiscNumber(discNumber); + pTrackMetadata->refTrackInfo().setDiscTotal(discTotal); + } +#endif // __EXTRA_METADATA__ + QString bpm; if (readMP4Atom(tag, "----:com.apple.iTunes:BPM", &bpm)) { // This is the preferred field for storing the BPM @@ -1740,6 +2007,7 @@ void importTrackMetadataFromMP4Tag(TrackMetadata* pTrackMetadata, const TagLib:: parseTrackPeak(pTrackMetadata, trackPeak); } +#if defined(__EXTRA_METADATA__) QString albumGain; if (readMP4Atom(tag, "----:com.apple.iTunes:replaygain_album_gain", &albumGain)) { parseAlbumGain(pTrackMetadata, albumGain); @@ -1753,10 +2021,18 @@ void importTrackMetadataFromMP4Tag(TrackMetadata* pTrackMetadata, const TagLib:: if (readMP4Atom(tag, "----:com.apple.iTunes:MusicBrainz Artist Id", &trackArtistId)) { pTrackMetadata->refTrackInfo().setMusicBrainzArtistId(trackArtistId); } + QString trackRecordingId; + if (readMP4Atom(tag, "----:com.apple.iTunes:MusicBrainz Track Id", &trackRecordingId)) { + pTrackMetadata->refTrackInfo().setMusicBrainzRecordingId(trackRecordingId); + } QString trackReleaseId; if (readMP4Atom(tag, "----:com.apple.iTunes:MusicBrainz Release Track Id", &trackReleaseId)) { pTrackMetadata->refTrackInfo().setMusicBrainzReleaseId(trackReleaseId); } + QString trackWorkId; + if (readMP4Atom(tag, "----:com.apple.iTunes:MusicBrainz Work Id", &trackWorkId)) { + pTrackMetadata->refTrackInfo().setMusicBrainzWorkId(trackWorkId); + } QString albumArtistId; if (readMP4Atom(tag, "----:com.apple.iTunes:MusicBrainz Album Artist Id", &albumArtistId)) { pTrackMetadata->refAlbumInfo().setMusicBrainzArtistId(QUuid(albumArtistId)); @@ -1790,9 +2066,17 @@ void importTrackMetadataFromMP4Tag(TrackMetadata* pTrackMetadata, const TagLib:: if (readMP4Atom(tag, "----:com.apple.iTunes:MOOD", &mood)) { pTrackMetadata->refTrackInfo().setMood(mood); } + QString copyright; + if (readMP4Atom(tag, "cprt", ©right)) { + pTrackMetadata->refAlbumInfo().setCopyright(copyright); + } + QString license; + if (readMP4Atom(tag, "----:com.apple.iTunes:LICENSE", &license)) { + pTrackMetadata->refAlbumInfo().setLicense(license); + } QString recordLabel; if (readMP4Atom(tag, "----:com.apple.iTunes:LABEL", &recordLabel)) { - pTrackMetadata->refTrackInfo().setRecordLabel(recordLabel); + pTrackMetadata->refAlbumInfo().setRecordLabel(recordLabel); } QString remixer; if (readMP4Atom(tag, "----:com.apple.iTunes:REMIXER", &remixer)) { @@ -1802,6 +2086,19 @@ void importTrackMetadataFromMP4Tag(TrackMetadata* pTrackMetadata, const TagLib:: if (readMP4Atom(tag, "----:com.apple.iTunes:SUBTITLE", &subtitle)) { pTrackMetadata->refTrackInfo().setSubtitle(subtitle); } + QString encoder; + if (readMP4Atom(tag, "\251too", &encoder)) { + pTrackMetadata->refTrackInfo().setEncoder(encoder); + } + QString work; + if (readMP4Atom(tag, "\251wrk", &work)) { + pTrackMetadata->refTrackInfo().setWork(work); + } + QString movement; + if (readMP4Atom(tag, "\251mvn", &movement)) { + pTrackMetadata->refTrackInfo().setMovement(movement); + } +#endif // __EXTRA_METADATA__ } void importTrackMetadataFromRIFFTag(TrackMetadata* pTrackMetadata, const TagLib::RIFF::Info::Tag& tag) { @@ -1916,8 +2213,43 @@ bool exportTrackMetadataIntoID3v2Tag(TagLib::ID3v2::Tag* pTag, trackMetadata.getAlbumInfo().getArtist()); writeID3v2TextIdentificationFrame(pTag, "TCOM", trackMetadata.getTrackInfo().getComposer()); - writeID3v2TextIdentificationFrame(pTag, "TIT1", - trackMetadata.getTrackInfo().getGrouping()); + + // We can use the TIT1 frame only once, either for storing the Work + // like Apple decided to do or traditionally for the Content Group. + // Rationale: If the the file already has one or more GRP1 frames + // or if the track has a Work field then store the Grouping in a + // GRP1 frame instead of using TIT1. + // See also: importTrackMetadataFromID3v2Tag() + if ( +#if defined(__EXTRA_METADATA__) + !trackMetadata.getTrackInfo().getWork().isNull() || + !trackMetadata.getTrackInfo().getMovement().isNull() || +#endif // __EXTRA_METADATA__ + pTag->frameListMap().contains("GRP1")) { + // New grouping/work/movement mapping if properties for classical + // music are available or if the GRP1 frame is already present in + // the file. + writeID3v2TextIdentificationFrame( + pTag, + "GRP1", + trackMetadata.getTrackInfo().getGrouping()); +#if defined(__EXTRA_METADATA__) + writeID3v2TextIdentificationFrameStringIfNotNull( + pTag, + "TIT1", + trackMetadata.getTrackInfo().getWork()); + writeID3v2TextIdentificationFrameStringIfNotNull( + pTag, + "MVNM", + trackMetadata.getTrackInfo().getMovement()); +#endif // __EXTRA_METADATA__ + } else { + // Stick to the traditional CONTENTGROUP mapping. + writeID3v2TextIdentificationFrame( + pTag, + "TIT1", + trackMetadata.getTrackInfo().getGrouping()); + } // According to the specification "The 'TBPM' frame contains the number // of beats per minute in the mainpart of the audio. The BPM is an @@ -1945,10 +2277,10 @@ bool exportTrackMetadataIntoID3v2Tag(TagLib::ID3v2::Tag* pTag, true); } - // TODO(XXX): The following tags are currently not stored in the - // Mixxx library. Only write properties that have non-null values - // to prevent deleting existing tags! - + // TODO(XXX): The following tags have been added later and are currently + // not stored in the Mixxx library. Only write fields that have non-null + // values to preserve any existing file tags instead of removing them! +#if defined(__EXTRA_METADATA__) if (hasAlbumGain(trackMetadata)) { writeID3v2UserTextIdentificationFrame( pTag, @@ -1971,6 +2303,18 @@ bool exportTrackMetadataIntoID3v2Tag(TagLib::ID3v2::Tag* pTag, trackMetadata.getTrackInfo().getMusicBrainzArtistId().toString(), false); } + if (!trackMetadata.getTrackInfo().getMusicBrainzRecordingId().isNull()) { + QByteArray identifier = trackMetadata.getTrackInfo().getMusicBrainzRecordingId().toByteArray(); + if (identifier.size() == 38) { + // Strip leading/trailing curly braces + identifier = identifier.mid(1, 36); + } + DEBUG_ASSERT(identifier.size() == 36); + writeID3v2UniqueFileIdentifierFrame( + pTag, + "http://musicbrainz.org", + identifier); + } if (!trackMetadata.getTrackInfo().getMusicBrainzReleaseId().isNull()) { writeID3v2UserTextIdentificationFrame( pTag, @@ -1978,6 +2322,13 @@ bool exportTrackMetadataIntoID3v2Tag(TagLib::ID3v2::Tag* pTag, trackMetadata.getTrackInfo().getMusicBrainzReleaseId().toString(), false); } + if (!trackMetadata.getTrackInfo().getMusicBrainzWorkId().isNull()) { + writeID3v2UserTextIdentificationFrame( + pTag, + "MusicBrainz Work Id", + trackMetadata.getTrackInfo().getMusicBrainzWorkId().toString(), + false); + } if (!trackMetadata.getAlbumInfo().getMusicBrainzArtistId().isNull()) { writeID3v2UserTextIdentificationFrame( pTag, @@ -2000,6 +2351,9 @@ bool exportTrackMetadataIntoID3v2Tag(TagLib::ID3v2::Tag* pTag, false); } + writeID3v2TextIdentificationFrameStringIfNotNull(pTag, "TPOS", TrackNumbers::joinStrings( + trackMetadata.getTrackInfo().getDiscNumber(), + trackMetadata.getTrackInfo().getDiscTotal())); writeID3v2TextIdentificationFrameStringIfNotNull( pTag, "TPE3", @@ -2022,10 +2376,18 @@ bool exportTrackMetadataIntoID3v2Tag(TagLib::ID3v2::Tag* pTag, "TMOO", trackMetadata.getTrackInfo().getMood()); } + writeID3v2TextIdentificationFrameStringIfNotNull( + pTag, + "TCOP", + trackMetadata.getAlbumInfo().getCopyright()); + writeID3v2TextIdentificationFrameStringIfNotNull( + pTag, + "WCOP", + trackMetadata.getAlbumInfo().getLicense()); writeID3v2TextIdentificationFrameStringIfNotNull( pTag, "TPUB", - trackMetadata.getTrackInfo().getRecordLabel()); + trackMetadata.getAlbumInfo().getRecordLabel()); writeID3v2TextIdentificationFrameStringIfNotNull( pTag, "TPE4", @@ -2034,6 +2396,15 @@ bool exportTrackMetadataIntoID3v2Tag(TagLib::ID3v2::Tag* pTag, pTag, "TIT3", trackMetadata.getTrackInfo().getSubtitle()); + writeID3v2TextIdentificationFrameStringIfNotNull( + pTag, + "TENC", + trackMetadata.getTrackInfo().getEncoder()); + writeID3v2TextIdentificationFrameStringIfNotNull( + pTag, + "TSSE", + trackMetadata.getTrackInfo().getEncoderSettings()); +#endif // __EXTRA_METADATA__ return true; } @@ -2081,9 +2452,16 @@ bool exportTrackMetadataIntoAPETag(TagLib::APE::Tag* pTag, const TrackMetadata& toTagLibString(formatTrackPeak(trackMetadata))); } - // TODO(XXX): The following tags are currently not stored in the - // Mixxx library. Only write properties that have non-null values - // to prevent deleting existing tags! + // TODO(XXX): The following tags have been added later and are currently + // not stored in the Mixxx library. Only write fields that have non-null + // values to preserve any existing file tags instead of removing them! +#if defined(__EXTRA_METADATA__) + auto discNumbers = TrackNumbers::joinStrings( + trackMetadata.getTrackInfo().getDiscNumber(), + trackMetadata.getTrackInfo().getDiscTotal()); + if (!discNumbers.isNull()) { + writeAPEItem(pTag, "Disc", toTagLibString(discNumbers)); + } if (hasAlbumGain(trackMetadata)) { writeAPEItem(pTag, "REPLAYGAIN_ALBUM_GAIN", @@ -2098,10 +2476,18 @@ bool exportTrackMetadataIntoAPETag(TagLib::APE::Tag* pTag, const TrackMetadata& writeAPEItem(pTag, "MUSICBRAINZ_ARTISTID", toTagLibString(trackMetadata.getTrackInfo().getMusicBrainzArtistId().toString())); } + if (!trackMetadata.getTrackInfo().getMusicBrainzRecordingId().isNull()) { + writeAPEItem(pTag, "MUSICBRAINZ_TRACKID", + toTagLibString(trackMetadata.getTrackInfo().getMusicBrainzRecordingId().toString())); + } if (!trackMetadata.getTrackInfo().getMusicBrainzReleaseId().isNull()) { writeAPEItem(pTag, "MUSICBRAINZ_RELEASETRACKID", toTagLibString(trackMetadata.getTrackInfo().getMusicBrainzReleaseId().toString())); } + if (!trackMetadata.getTrackInfo().getMusicBrainzWorkId().isNull()) { + writeAPEItem(pTag, "MUSICBRAINZ_WORKID", + toTagLibString(trackMetadata.getTrackInfo().getMusicBrainzWorkId().toString())); + } if (!trackMetadata.getAlbumInfo().getMusicBrainzArtistId().isNull()) { writeAPEItem(pTag, "MUSICBRAINZ_ALBUMARTISTID", toTagLibString(trackMetadata.getAlbumInfo().getMusicBrainzArtistId().toString())); @@ -2135,9 +2521,17 @@ bool exportTrackMetadataIntoAPETag(TagLib::APE::Tag* pTag, const TrackMetadata& writeAPEItem(pTag, "Mood", toTagLibString(trackMetadata.getTrackInfo().getMood())); } - if (!trackMetadata.getTrackInfo().getRecordLabel().isNull()) { + if (!trackMetadata.getAlbumInfo().getCopyright().isNull()) { + writeAPEItem(pTag, "Copyright", + toTagLibString(trackMetadata.getAlbumInfo().getCopyright())); + } + if (!trackMetadata.getAlbumInfo().getLicense().isNull()) { + writeAPEItem(pTag, "LICENSE", + toTagLibString(trackMetadata.getAlbumInfo().getLicense())); + } + if (!trackMetadata.getAlbumInfo().getRecordLabel().isNull()) { writeAPEItem(pTag, "Label", - toTagLibString(trackMetadata.getTrackInfo().getRecordLabel())); + toTagLibString(trackMetadata.getAlbumInfo().getRecordLabel())); } if (!trackMetadata.getTrackInfo().getRemixer().isNull()) { writeAPEItem(pTag, "MixArtist", @@ -2147,6 +2541,15 @@ bool exportTrackMetadataIntoAPETag(TagLib::APE::Tag* pTag, const TrackMetadata& writeAPEItem(pTag, "Subtitle", toTagLibString(trackMetadata.getTrackInfo().getSubtitle())); } + if (!trackMetadata.getTrackInfo().getEncoder().isNull()) { + writeAPEItem(pTag, "EncodedBy", + toTagLibString(trackMetadata.getTrackInfo().getEncoder())); + } + if (!trackMetadata.getTrackInfo().getEncoderSettings().isNull()) { + writeAPEItem(pTag, "EncoderSettings", + toTagLibString(trackMetadata.getTrackInfo().getEncoderSettings())); + } +#endif // __EXTRA_METADATA__ return true; } @@ -2232,10 +2635,10 @@ bool exportTrackMetadataIntoXiphComment(TagLib::Ogg::XiphComment* pTag, toTagLibString(formatTrackPeak(trackMetadata))); } - // TODO(XXX): The following tags are currently not stored in the - // Mixxx library. Only write properties that have non-null values - // to prevent deleting existing tags! - + // TODO(XXX): The following tags have been added later and are currently + // not stored in the Mixxx library. Only write fields that have non-null + // values to preserve any existing file tags instead of removing them! +#if defined(__EXTRA_METADATA__) if (hasAlbumGain(trackMetadata)) { writeXiphCommentField(pTag, "REPLAYGAIN_ALBUM_GAIN", toTagLibString(formatAlbumGain(trackMetadata))); @@ -2248,10 +2651,18 @@ bool exportTrackMetadataIntoXiphComment(TagLib::Ogg::XiphComment* pTag, writeXiphCommentField(pTag, "MUSICBRAINZ_ARTISTID", toTagLibString(trackMetadata.getTrackInfo().getMusicBrainzArtistId().toString())); } + if (!trackMetadata.getTrackInfo().getMusicBrainzRecordingId().isNull()) { + writeXiphCommentField(pTag, "MUSICBRAINZ_TRACKID", + toTagLibString(trackMetadata.getTrackInfo().getMusicBrainzRecordingId().toString())); + } if (!trackMetadata.getTrackInfo().getMusicBrainzReleaseId().isNull()) { writeXiphCommentField(pTag, "MUSICBRAINZ_RELEASETRACKID", toTagLibString(trackMetadata.getTrackInfo().getMusicBrainzReleaseId().toString())); } + if (!trackMetadata.getTrackInfo().getMusicBrainzWorkId().isNull()) { + writeXiphCommentField(pTag, "MUSICBRAINZ_WORKID", + toTagLibString(trackMetadata.getTrackInfo().getMusicBrainzWorkId().toString())); + } if (!trackMetadata.getAlbumInfo().getMusicBrainzArtistId().isNull()) { writeXiphCommentField(pTag, "MUSICBRAINZ_ALBUMARTISTID", toTagLibString(trackMetadata.getAlbumInfo().getMusicBrainzArtistId().toString())); @@ -2284,9 +2695,17 @@ bool exportTrackMetadataIntoXiphComment(TagLib::Ogg::XiphComment* pTag, writeXiphCommentField(pTag, "MOOD", toTagLibString(trackMetadata.getTrackInfo().getMood())); } - if (!trackMetadata.getTrackInfo().getRecordLabel().isNull()) { + if (!trackMetadata.getAlbumInfo().getCopyright().isNull()) { + writeXiphCommentField(pTag, "COPYRIGHT", + toTagLibString(trackMetadata.getAlbumInfo().getCopyright())); + } + if (!trackMetadata.getAlbumInfo().getLicense().isNull()) { + writeXiphCommentField(pTag, "LICENSE", + toTagLibString(trackMetadata.getAlbumInfo().getLicense())); + } + if (!trackMetadata.getAlbumInfo().getRecordLabel().isNull()) { writeXiphCommentField(pTag, "LABEL", - toTagLibString(trackMetadata.getTrackInfo().getRecordLabel())); + toTagLibString(trackMetadata.getAlbumInfo().getRecordLabel())); } if (!trackMetadata.getTrackInfo().getRemixer().isNull()) { writeXiphCommentField(pTag, "REMIXER", @@ -2296,6 +2715,26 @@ bool exportTrackMetadataIntoXiphComment(TagLib::Ogg::XiphComment* pTag, writeXiphCommentField(pTag, "SUBTITLE", toTagLibString(trackMetadata.getTrackInfo().getSubtitle())); } + if (!trackMetadata.getTrackInfo().getEncoder().isNull()) { + writeXiphCommentField(pTag, "ENCODEDBY", + toTagLibString(trackMetadata.getTrackInfo().getEncoder())); + } + if (!trackMetadata.getTrackInfo().getEncoderSettings().isNull()) { + writeXiphCommentField(pTag, "ENCODERSETTINGS", + toTagLibString(trackMetadata.getTrackInfo().getEncoderSettings())); + } + if (!trackMetadata.getTrackInfo().getDiscNumber().isNull()) { + writeXiphCommentField( + pTag, "DISCNUMBER", toTagLibString(trackMetadata.getTrackInfo().getDiscNumber())); + } + // According to https://wiki.xiph.org/Field_names "DISCTOTAL" is + // the proposed field name, but some applications use "TOTALDISCS". + if (!trackMetadata.getTrackInfo().getDiscTotal().isNull()) { + const TagLib::String discTotal(toTagLibString(trackMetadata.getTrackInfo().getDiscTotal())); + writeXiphCommentField(pTag, "DISCTOTAL", discTotal); // recommended field + updateXiphCommentField(pTag, "TOTALDISCS", discTotal); // alternative field + } +#endif // __EXTRA_METADATA__ return true; } @@ -2310,12 +2749,12 @@ bool exportTrackMetadataIntoMP4Tag(TagLib::MP4::Tag* pTag, const TrackMetadata& // Write track number/total pair TrackNumbers parsedTrackNumbers; - const TrackNumbers::ParseResult parseResult = + const TrackNumbers::ParseResult trackParseResult = TrackNumbers::parseFromStrings( trackMetadata.getTrackInfo().getTrackNumber(), trackMetadata.getTrackInfo().getTrackTotal(), &parsedTrackNumbers); - switch (parseResult) { + switch (trackParseResult) { case TrackNumbers::ParseResult::EMPTY: pTag->itemListMap().erase("trkn"); break; @@ -2365,9 +2804,33 @@ bool exportTrackMetadataIntoMP4Tag(TagLib::MP4::Tag* pTag, const TrackMetadata& toTagLibString(formatTrackPeak(trackMetadata))); } - // TODO(XXX): The following tags are currently not stored in the - // Mixxx library. Only write properties that have non-null values - // to prevent deleting existing tags! + // TODO(XXX): The following tags have been added later and are currently + // not stored in the Mixxx library. Only write fields that have non-null + // values to preserve any existing file tags instead of removing them! +#if defined(__EXTRA_METADATA__) + // Write disc number/total pair + QString discNumberText; + QString discTotalText; + TrackNumbers parsedDiscNumbers; + const TrackNumbers::ParseResult discParseResult = TrackNumbers::parseFromStrings( + trackMetadata.getTrackInfo().getDiscNumber(), + trackMetadata.getTrackInfo().getDiscTotal(), &parsedDiscNumbers); + switch (discParseResult) { + case TrackNumbers::ParseResult::EMPTY: + // Preserve disc numbers in file and do NOT delete them + // if not already stored in the Mixxx database! Support + // for these fields have been added later. + break; + case TrackNumbers::ParseResult::VALID: + pTag->itemListMap()["disk"] = + TagLib::MP4::Item(parsedDiscNumbers.getActual(), parsedDiscNumbers.getTotal()); + break; + default: + kLogger.warning() << "Invalid disc numbers:" + << TrackNumbers::joinStrings( + trackMetadata.getTrackInfo().getDiscNumber(), + trackMetadata.getTrackInfo().getDiscTotal()); + } if (hasAlbumGain(trackMetadata)) { writeMP4Atom(pTag, "----:com.apple.iTunes:replaygain_album_gain", @@ -2381,10 +2844,18 @@ bool exportTrackMetadataIntoMP4Tag(TagLib::MP4::Tag* pTag, const TrackMetadata& writeMP4Atom(pTag, "----:com.apple.iTunes:MusicBrainz Artist Id", toTagLibString(trackMetadata.getTrackInfo().getMusicBrainzArtistId().toString())); } + if (!trackMetadata.getTrackInfo().getMusicBrainzRecordingId().isNull()) { + writeMP4Atom(pTag, "----:com.apple.iTunes:MusicBrainz Track Id", + toTagLibString(trackMetadata.getTrackInfo().getMusicBrainzRecordingId().toString())); + } if (!trackMetadata.getTrackInfo().getMusicBrainzReleaseId().isNull()) { writeMP4Atom(pTag, "----:com.apple.iTunes:MusicBrainz Release Track Id", toTagLibString(trackMetadata.getTrackInfo().getMusicBrainzReleaseId().toString())); } + if (!trackMetadata.getTrackInfo().getMusicBrainzWorkId().isNull()) { + writeMP4Atom(pTag, "----:com.apple.iTunes:MusicBrainz Work Id", + toTagLibString(trackMetadata.getTrackInfo().getMusicBrainzWorkId().toString())); + } if (!trackMetadata.getAlbumInfo().getMusicBrainzArtistId().isNull()) { writeMP4Atom(pTag, "----:com.apple.iTunes:MusicBrainz Album Artist Id", toTagLibString(trackMetadata.getAlbumInfo().getMusicBrainzArtistId().toString())); @@ -2417,9 +2888,17 @@ bool exportTrackMetadataIntoMP4Tag(TagLib::MP4::Tag* pTag, const TrackMetadata& writeMP4Atom(pTag, "----:com.apple.iTunes:MOOD", toTagLibString(trackMetadata.getTrackInfo().getMood())); } - if (!trackMetadata.getTrackInfo().getRecordLabel().isNull()) { + if (!trackMetadata.getAlbumInfo().getCopyright().isNull()) { + writeMP4Atom(pTag, "cprt", + toTagLibString(trackMetadata.getAlbumInfo().getCopyright())); + } + if (!trackMetadata.getAlbumInfo().getLicense().isNull()) { + writeMP4Atom(pTag, "----:com.apple.iTunes:LICENSE", + toTagLibString(trackMetadata.getAlbumInfo().getLicense())); + } + if (!trackMetadata.getAlbumInfo().getRecordLabel().isNull()) { writeMP4Atom(pTag, "----:com.apple.iTunes:LABEL", - toTagLibString(trackMetadata.getTrackInfo().getRecordLabel())); + toTagLibString(trackMetadata.getAlbumInfo().getRecordLabel())); } if (!trackMetadata.getTrackInfo().getRemixer().isNull()) { writeMP4Atom(pTag, "----:com.apple.iTunes:REMIXER", @@ -2429,6 +2908,17 @@ bool exportTrackMetadataIntoMP4Tag(TagLib::MP4::Tag* pTag, const TrackMetadata& writeMP4Atom(pTag, "----:com.apple.iTunes:SUBTITLE", toTagLibString(trackMetadata.getTrackInfo().getSubtitle())); } + if (!trackMetadata.getTrackInfo().getEncoder().isNull()) { + writeMP4Atom(pTag, "\251too", + toTagLibString(trackMetadata.getTrackInfo().getEncoder())); + } + if (!trackMetadata.getTrackInfo().getWork().isNull()) { + writeMP4Atom(pTag, "\251wrk", toTagLibString(trackMetadata.getTrackInfo().getWork())); + } + if (!trackMetadata.getTrackInfo().getMovement().isNull()) { + writeMP4Atom(pTag, "\251mvn", toTagLibString(trackMetadata.getTrackInfo().getMovement())); + } +#endif // __EXTRA_METADATA__ return true; } diff --git a/src/track/trackrecord.h b/src/track/trackrecord.h index e2ae213e88c9..860a0ecde045 100644 --- a/src/track/trackrecord.h +++ b/src/track/trackrecord.h @@ -57,6 +57,10 @@ class TrackRecord final { TrackRecord& operator=(TrackRecord&&) = default; TrackRecord& operator=(const TrackRecord&) = default; + bool hasRating() const { + return getRating() > 0; + } + void setKeys(const Keys& keys); void resetKeys() { setKeys(Keys()); diff --git a/src/util/painterscope.h b/src/util/painterscope.h index c0521c026855..fc7d4093e7de 100644 --- a/src/util/painterscope.h +++ b/src/util/painterscope.h @@ -6,6 +6,8 @@ #include +#include "util/assert.h" + // This class provides RAII style management of a QPainter properties. // // PainterScope will save the painter state on creation, and restore it diff --git a/src/waveform/renderers/qtvsynctestrenderer.cpp b/src/waveform/renderers/qtvsynctestrenderer.cpp index 3d8caf7f9a48..835770361387 100644 --- a/src/waveform/renderers/qtvsynctestrenderer.cpp +++ b/src/waveform/renderers/qtvsynctestrenderer.cpp @@ -3,6 +3,7 @@ #include "waveform/renderers/waveformwidgetrenderer.h" #include "waveform/waveform.h" #include "waveform/waveformwidgetfactory.h" +#include "util/painterscope.h" #include "util/performancetimer.h" QtVSyncTestRenderer::QtVSyncTestRenderer( @@ -51,7 +52,7 @@ void QtVSyncTestRenderer::draw(QPainter* pPainter, QPaintEvent* /*event*/) { return; } - pPainter->save(); + PainterScope PainterScope(pPainter); auto brush = QBrush(Qt::SolidPattern); if (++m_drawcount & 1) { @@ -64,6 +65,4 @@ void QtVSyncTestRenderer::draw(QPainter* pPainter, QPaintEvent* /*event*/) { pPainter->drawRect(0, 0, m_waveformRenderer->getWidth(), m_waveformRenderer->getHeight()); - - pPainter->restore(); } diff --git a/src/waveform/renderers/qtwaveformrendererfilteredsignal.cpp b/src/waveform/renderers/qtwaveformrendererfilteredsignal.cpp index bcd5dc219100..2552db397994 100644 --- a/src/waveform/renderers/qtwaveformrendererfilteredsignal.cpp +++ b/src/waveform/renderers/qtwaveformrendererfilteredsignal.cpp @@ -6,6 +6,7 @@ #include "control/controlproxy.h" #include "track/track.h" #include "util/math.h" +#include "util/painterscope.h" #include #include @@ -275,7 +276,7 @@ void QtWaveformRendererFilteredSignal::draw(QPainter* painter, QPaintEvent* /*ev if (!pTrack) return; - painter->save(); + PainterScope PainterScope(painter); painter->setRenderHint(QPainter::Antialiasing); painter->resetTransform(); @@ -335,6 +336,4 @@ void QtWaveformRendererFilteredSignal::draw(QPainter* painter, QPaintEvent* /*ev painter->setBrush(m_highBrush); } painter->drawPolygon(&m_polygon[2][0], numberOfPoints); - - painter->restore(); } diff --git a/src/waveform/renderers/qtwaveformrenderersimplesignal.cpp b/src/waveform/renderers/qtwaveformrenderersimplesignal.cpp index b15f460c9fb4..0669422b844a 100644 --- a/src/waveform/renderers/qtwaveformrenderersimplesignal.cpp +++ b/src/waveform/renderers/qtwaveformrenderersimplesignal.cpp @@ -7,6 +7,7 @@ #include "widget/wwidget.h" #include "track/track.h" #include "util/math.h" +#include "util/painterscope.h" #include @@ -58,7 +59,7 @@ void QtWaveformRendererSimpleSignal::draw(QPainter* painter, QPaintEvent* /*even return; } - painter->save(); + PainterScope PainterScope(painter); painter->setRenderHint(QPainter::Antialiasing); painter->resetTransform(); @@ -214,8 +215,6 @@ void QtWaveformRendererSimpleSignal::draw(QPainter* painter, QPaintEvent* /*even painter->setBrush(m_brush); painter->drawPolygon(&m_polygon[0], m_polygon.size()); - - painter->restore(); } void QtWaveformRendererSimpleSignal::onResize() { diff --git a/src/waveform/renderers/waveformrenderbeat.cpp b/src/waveform/renderers/waveformrenderbeat.cpp index a4816c2e3361..aba5de594426 100644 --- a/src/waveform/renderers/waveformrenderbeat.cpp +++ b/src/waveform/renderers/waveformrenderbeat.cpp @@ -10,6 +10,7 @@ #include "waveform/renderers/waveformwidgetrenderer.h" #include "widget/wskincolor.h" #include "widget/wwidget.h" +#include "util/painterscope.h" WaveformRenderBeat::WaveformRenderBeat(WaveformWidgetRenderer* waveformWidgetRenderer) : WaveformRendererAbstract(waveformWidgetRenderer) { @@ -62,7 +63,8 @@ void WaveformRenderBeat::draw(QPainter* painter, QPaintEvent* /*event*/) { return; } - painter->save(); + PainterScope PainterScope(painter); + painter->setRenderHint(QPainter::Antialiasing); QPen beatPen(m_beatColor); @@ -96,6 +98,4 @@ void WaveformRenderBeat::draw(QPainter* painter, QPaintEvent* /*event*/) { // Make sure to use constData to prevent detaches! painter->drawLines(m_beats.constData(), beatCount); - - painter->restore(); } diff --git a/src/waveform/renderers/waveformrendererendoftrack.cpp b/src/waveform/renderers/waveformrendererendoftrack.cpp index 1058e04818b0..edef583da39a 100644 --- a/src/waveform/renderers/waveformrendererendoftrack.cpp +++ b/src/waveform/renderers/waveformrendererendoftrack.cpp @@ -11,6 +11,7 @@ #include "widget/wskincolor.h" #include "widget/wwidget.h" +#include "util/painterscope.h" #include "util/timer.h" namespace { @@ -74,7 +75,8 @@ void WaveformRendererEndOfTrack::draw(QPainter* painter, const double criticalIntensity = (remainingTimeTriggerSeconds - remainingTime) / remainingTimeTriggerSeconds; - painter->save(); + PainterScope PainterScope(painter); + painter->resetTransform(); painter->setOpacity(0.5 * blinkIntensity); painter->setPen(m_pen); @@ -90,7 +92,6 @@ void WaveformRendererEndOfTrack::draw(QPainter* painter, //painter->fillRect(m_waveformRenderer->getWidth()/2, 1, // m_waveformRenderer->getWidth() - 2, m_waveformRenderer->getHeight() - 2, // m_gradient); - painter->restore(); } void WaveformRendererEndOfTrack::generateBackRects() { diff --git a/src/waveform/renderers/waveformrendererfilteredsignal.cpp b/src/waveform/renderers/waveformrendererfilteredsignal.cpp index 25ddb8984696..608e26532290 100644 --- a/src/waveform/renderers/waveformrendererfilteredsignal.cpp +++ b/src/waveform/renderers/waveformrendererfilteredsignal.cpp @@ -8,6 +8,7 @@ #include "track/track.h" #include "widget/wwidget.h" #include "util/math.h" +#include "util/painterscope.h" WaveformRendererFilteredSignal::WaveformRendererFilteredSignal( WaveformWidgetRenderer* waveformWidgetRenderer) @@ -49,7 +50,8 @@ void WaveformRendererFilteredSignal::draw(QPainter* painter, return; } - painter->save(); + PainterScope PainterScope(painter); + painter->setRenderHints(QPainter::Antialiasing, false); painter->setRenderHints(QPainter::HighQualityAntialiasing, false); painter->setRenderHints(QPainter::SmoothPixmapTransform, false); @@ -235,6 +237,4 @@ void WaveformRendererFilteredSignal::draw(QPainter* painter, if (m_pHighKillControlObject && m_pHighKillControlObject->get() == 0.0) { painter->drawLines(&m_highLines[0], actualHighLineNumber); } - - painter->restore(); } diff --git a/src/waveform/renderers/waveformrendererhsv.cpp b/src/waveform/renderers/waveformrendererhsv.cpp index 350010b473fa..550f87be2b1b 100644 --- a/src/waveform/renderers/waveformrendererhsv.cpp +++ b/src/waveform/renderers/waveformrendererhsv.cpp @@ -8,6 +8,7 @@ #include "track/track.h" #include "widget/wwidget.h" #include "util/math.h" +#include "util/painterscope.h" WaveformRendererHSV::WaveformRendererHSV( WaveformWidgetRenderer* waveformWidgetRenderer) @@ -43,7 +44,8 @@ void WaveformRendererHSV::draw(QPainter* painter, return; } - painter->save(); + PainterScope PainterScope(painter); + painter->setRenderHints(QPainter::Antialiasing, false); painter->setRenderHints(QPainter::HighQualityAntialiasing, false); painter->setRenderHints(QPainter::SmoothPixmapTransform, false); @@ -182,6 +184,4 @@ void WaveformRendererHSV::draw(QPainter* painter, } } } - - painter->restore(); } diff --git a/src/waveform/renderers/waveformrendererpreroll.cpp b/src/waveform/renderers/waveformrendererpreroll.cpp index 64f35e31d261..10633bde7621 100644 --- a/src/waveform/renderers/waveformrendererpreroll.cpp +++ b/src/waveform/renderers/waveformrendererpreroll.cpp @@ -9,6 +9,7 @@ #include "waveform/waveform.h" #include "widget/wskincolor.h" #include "widget/wwidget.h" +#include "util/painterscope.h" WaveformRendererPreroll::WaveformRendererPreroll(WaveformWidgetRenderer* waveformWidgetRenderer) : WaveformRendererAbstract(waveformWidgetRenderer) { @@ -51,7 +52,8 @@ void WaveformRendererPreroll::draw(QPainter* painter, QPaintEvent* event) { const float halfBreadth = m_waveformRenderer->getBreadth() / 2.0; const float halfPolyBreadth = m_waveformRenderer->getBreadth() / 5.0; - painter->save(); + PainterScope PainterScope(painter); + painter->setRenderHint(QPainter::Antialiasing); //painter->setRenderHint(QPainter::HighQualityAntialiasing); //painter->setBackgroundMode(Qt::TransparentMode); @@ -81,7 +83,5 @@ void WaveformRendererPreroll::draw(QPainter* painter, QPaintEvent* event) { polygon.translate(-(polyLength + 1), 0); index -= (polyLength + 1) * samplesPerPixel; } - - painter->restore(); } } diff --git a/src/waveform/renderers/waveformrendererrgb.cpp b/src/waveform/renderers/waveformrendererrgb.cpp index a87a614b9295..aae9445035e1 100644 --- a/src/waveform/renderers/waveformrendererrgb.cpp +++ b/src/waveform/renderers/waveformrendererrgb.cpp @@ -8,6 +8,7 @@ #include "track/track.h" #include "widget/wwidget.h" #include "util/math.h" +#include "util/painterscope.h" WaveformRendererRGB::WaveformRendererRGB( WaveformWidgetRenderer* waveformWidgetRenderer) @@ -42,7 +43,8 @@ void WaveformRendererRGB::draw(QPainter* painter, return; } - painter->save(); + PainterScope PainterScope(painter); + painter->setRenderHints(QPainter::Antialiasing, false); painter->setRenderHints(QPainter::HighQualityAntialiasing, false); painter->setRenderHints(QPainter::SmoothPixmapTransform, false); @@ -176,6 +178,4 @@ void WaveformRendererRGB::draw(QPainter* painter, } } } - - painter->restore(); } diff --git a/src/waveform/renderers/waveformrendermark.cpp b/src/waveform/renderers/waveformrendermark.cpp index f6a2638969ee..896c85583e29 100644 --- a/src/waveform/renderers/waveformrendermark.cpp +++ b/src/waveform/renderers/waveformrendermark.cpp @@ -12,6 +12,7 @@ #include "widget/wskincolor.h" #include "widget/wwidget.h" #include "widget/wimagestore.h" +#include "util/painterscope.h" namespace { const int kMaxCueLabelLength = 23; @@ -33,7 +34,7 @@ void WaveformRenderMark::setup(const QDomNode& node, const SkinContext& context) } void WaveformRenderMark::draw(QPainter* painter, QPaintEvent* /*event*/) { - painter->save(); + PainterScope PainterScope(painter); /* //DEBUG @@ -82,8 +83,6 @@ void WaveformRenderMark::draw(QPainter* painter, QPaintEvent* /*event*/) { } } } - - painter->restore(); } void WaveformRenderMark::onResize() { diff --git a/src/waveform/renderers/waveformrendermarkrange.cpp b/src/waveform/renderers/waveformrendermarkrange.cpp index 2b258b71e741..97a304b4efd6 100644 --- a/src/waveform/renderers/waveformrendermarkrange.cpp +++ b/src/waveform/renderers/waveformrendermarkrange.cpp @@ -13,6 +13,7 @@ #include "waveform/renderers/waveformwidgetrenderer.h" #include "widget/wskincolor.h" #include "widget/wwidget.h" +#include "util/painterscope.h" WaveformRenderMarkRange::WaveformRenderMarkRange(WaveformWidgetRenderer* waveformWidgetRenderer) : WaveformRendererAbstract(waveformWidgetRenderer) { @@ -37,7 +38,7 @@ void WaveformRenderMarkRange::setup(const QDomNode& node, const SkinContext& con } void WaveformRenderMarkRange::draw(QPainter *painter, QPaintEvent * /*event*/) { - painter->save(); + PainterScope PainterScope(painter); painter->setWorldMatrixEnabled(false); @@ -82,8 +83,6 @@ void WaveformRenderMarkRange::draw(QPainter *painter, QPaintEvent * /*event*/) { } painter->drawImage(rect, *selectedImage, rect); } - - painter->restore(); } void WaveformRenderMarkRange::generateImages() { diff --git a/src/widget/paintable.cpp b/src/widget/paintable.cpp index a144e7e138b9..38132b06e199 100644 --- a/src/widget/paintable.cpp +++ b/src/widget/paintable.cpp @@ -5,9 +5,11 @@ #include #include +#include "skin/imgloader.h" + #include "util/math.h" #include "util/memory.h" -#include "skin/imgloader.h" +#include "util/painterscope.h" // static Paintable::DrawMode Paintable::DrawModeFromString(const QString& str) { @@ -244,12 +246,11 @@ void Paintable::drawInternal(const QRectF& targetRect, QPainter* pPainter, // entire SVG to the painter. We save/restore the QPainter in case // there is an existing clip region (I don't know of any Mixxx code // that uses one but we may in the future). - pPainter->save(); + PainterScope PainterScope(pPainter); pPainter->setClipping(true); pPainter->setClipRect(targetRect); m_pSvg->setViewBox(sourceRect); m_pSvg->render(pPainter, targetRect); - pPainter->restore(); } } } diff --git a/src/widget/wanalysislibrarytableview.cpp b/src/widget/wanalysislibrarytableview.cpp index f60091223633..1f1491736e9a 100644 --- a/src/widget/wanalysislibrarytableview.cpp +++ b/src/widget/wanalysislibrarytableview.cpp @@ -4,7 +4,7 @@ WAnalysisLibraryTableView::WAnalysisLibraryTableView(QWidget* parent, UserSettingsPointer pConfig, TrackCollection* pTrackCollection) - : WTrackTableView(parent, pConfig, pTrackCollection) { + : WTrackTableView(parent, pConfig, pTrackCollection, true) { setDragDropMode(QAbstractItemView::DragOnly); setDragEnabled(true); //Always enable drag for now (until we have a model that doesn't support this.) } diff --git a/src/widget/wcoverart.cpp b/src/widget/wcoverart.cpp index dfdab514733d..4d64463b6716 100644 --- a/src/widget/wcoverart.cpp +++ b/src/widget/wcoverart.cpp @@ -231,13 +231,27 @@ void WCoverArt::mousePressEvent(QMouseEvent* event) { if (event->button() == Qt::RightButton && m_loadedTrack) { // show context-menu m_pMenu->setCoverArt(m_lastRequestedCover); m_pMenu->popup(event->globalPos()); - } else if (event->button() == Qt::LeftButton) { // init/close fullsize cover + } else if (event->button() == Qt::LeftButton) { + // do nothing if left button is pressed, + // wait for button release + m_clickTimer.setSingleShot(true); + m_clickTimer.start(500); + } +} + +void WCoverArt::mouseReleaseEvent(QMouseEvent* event) { + if (!m_bEnable) { + return; + } + + if (event->button() == Qt::LeftButton && m_loadedTrack && + m_clickTimer.isActive()) { // init/close fullsize cover if (m_pDlgFullSize->isVisible()) { m_pDlgFullSize->close(); } else { m_pDlgFullSize->init(m_loadedTrack); } - } + } // else it was a long leftclick or a right click that's already been processed } void WCoverArt::mouseMoveEvent(QMouseEvent* event) { diff --git a/src/widget/wcoverart.h b/src/widget/wcoverart.h index 1c71a365da5f..c89255cd566c 100644 --- a/src/widget/wcoverart.h +++ b/src/widget/wcoverart.h @@ -5,6 +5,7 @@ #include #include #include +#include #include "mixer/basetrackplayer.h" #include "preferences/usersettings.h" @@ -47,6 +48,7 @@ class WCoverArt : public QWidget, public WBaseWidget, public TrackDropTarget { void paintEvent(QPaintEvent* /*unused*/) override; void resizeEvent(QResizeEvent* /*unused*/) override; void mousePressEvent(QMouseEvent* /*unused*/) override; + void mouseReleaseEvent(QMouseEvent* /*unused*/) override; void dragEnterEvent(QDragEnterEvent *event) override; void dropEvent(QDropEvent *event) override; @@ -67,6 +69,7 @@ class WCoverArt : public QWidget, public WBaseWidget, public TrackDropTarget { CoverInfo m_lastRequestedCover; BaseTrackPlayer* m_pPlayer; DlgCoverArtFullSize* m_pDlgFullSize; + QTimer m_clickTimer; }; #endif // WCOVERART_H diff --git a/src/widget/wlibrarytableview.cpp b/src/widget/wlibrarytableview.cpp index d8bff69a8169..297a8347d71a 100644 --- a/src/widget/wlibrarytableview.cpp +++ b/src/widget/wlibrarytableview.cpp @@ -6,6 +6,7 @@ #include #include +#include "library/trackmodel.h" #include "widget/wwidget.h" #include "widget/wskincolor.h" #include "widget/wlibrarytableview.h" diff --git a/src/widget/wlibrarytableview.h b/src/widget/wlibrarytableview.h index 41714eb55b42..ad142ef194e9 100644 --- a/src/widget/wlibrarytableview.h +++ b/src/widget/wlibrarytableview.h @@ -11,9 +11,8 @@ #include "preferences/usersettings.h" #include "library/libraryview.h" #include "track/track.h" -#include "library/coverartcache.h" -#include "library/trackmodel.h" +class TrackModel; class WLibraryTableView : public QTableView, public virtual LibraryView { Q_OBJECT @@ -23,6 +22,7 @@ class WLibraryTableView : public QTableView, public virtual LibraryView { UserSettingsPointer pConfig, ConfigKey vScrollBarPosKey); ~WLibraryTableView() override; + void moveSelection(int delta) override; /** diff --git a/src/widget/wtracktableview.cpp b/src/widget/wtracktableview.cpp index f6a9323e40a8..5a39c4dc0253 100644 --- a/src/widget/wtracktableview.cpp +++ b/src/widget/wtracktableview.cpp @@ -21,6 +21,7 @@ #include "library/crate/cratefeaturehelper.h" #include "library/dao/trackschema.h" #include "library/dlgtrackmetadataexport.h" +#include "library/externaltrackcollection.h" #include "control/controlobject.h" #include "control/controlproxy.h" #include "track/track.h" @@ -37,7 +38,9 @@ WTrackTableView::WTrackTableView(QWidget * parent, UserSettingsPointer pConfig, - TrackCollection* pTrackCollection, bool sorting) + TrackCollection* pTrackCollection, + bool sorting, + const QList& externalTrackCollections) : WLibraryTableView(parent, pConfig, ConfigKey(LIBRARY_CONFIGVALUE, WTRACKTABLEVIEW_VSCROLLBARPOS_KEY)), @@ -90,7 +93,10 @@ WTrackTableView::WTrackTableView(QWidget * parent, this, SLOT(slotPopulateCrateMenu())); m_pMetadataMenu = new QMenu(this); - m_pMetadataMenu->setTitle("Metadata"); + m_pMetadataMenu->setTitle(tr("Metadata")); + + m_pMetadataUpdateExternalCollectionsMenu = new QMenu(this); + m_pMetadataUpdateExternalCollectionsMenu->setTitle(tr("Update external collections")); m_pBPMMenu = new QMenu(this); m_pBPMMenu->setTitle(tr("Change BPM")); @@ -109,7 +115,7 @@ WTrackTableView::WTrackTableView(QWidget * parent, // Create all the context m_pMenu->actions (stuff that shows up when you // right-click) - createActions(); + createActions(externalTrackCollections); // Connect slots and signals to make the world go 'round. connect(this, SIGNAL(doubleClicked(const QModelIndex &)), @@ -218,20 +224,23 @@ void WTrackTableView::slotGuiTick50ms(double /*unused*/) { mixxx::Duration timeDelta = mixxx::Time::elapsed() - m_lastUserAction; if (m_loadCachedOnly && timeDelta > mixxx::Duration::fromMillis(100)) { - // Show the currently selected track in the large cover art view. Doing - // this in selectionChanged slows down scrolling performance so we wait - // until the user has stopped interacting first. + // Show the currently selected track in the large cover art view and + // hightlights crate and playlists. Doing this in selectionChanged + // slows down scrolling performance so we wait until the user has + // stopped interacting first. if (m_selectionChangedSinceLastGuiTick) { const QModelIndexList indices = selectionModel()->selectedRows(); - if (indices.size() > 0 && indices.last().isValid()) { + if (indices.size() == 1 && indices.first().isValid()) { + // A single track has been selected TrackModel* trackModel = getTrackModel(); if (trackModel) { - TrackPointer pTrack = trackModel->getTrack(indices.last()); + TrackPointer pTrack = trackModel->getTrack(indices.first()); if (pTrack) { emit(trackSelected(pTrack)); } } } else { + // None or multiple tracks have been selected emit(trackSelected(TrackPointer())); } m_selectionChangedSinceLastGuiTick = false; @@ -423,7 +432,8 @@ void WTrackTableView::loadTrackModel(QAbstractItemModel *model) { // scrollbar positions with respect to different models are backed by map } -void WTrackTableView::createActions() { +void WTrackTableView::createActions( + const QList& externalTrackCollections) { DEBUG_ASSERT(m_pMenu); DEBUG_ASSERT(m_pSamplerMenu); @@ -477,6 +487,20 @@ void WTrackTableView::createActions() { connect(m_pExportMetadataAct, SIGNAL(triggered()), this, SLOT(slotExportTrackMetadataIntoFileTags())); + for (const auto& externalTrackCollection : externalTrackCollections) { + if (!externalTrackCollection->isActive()) { + continue; // skip + } + UpdateExternalTrackCollection updateInExternalTrackCollection; + updateInExternalTrackCollection.externalTrackCollection = externalTrackCollection; + updateInExternalTrackCollection.action = new QAction(externalTrackCollection->name(), this); + m_updateInExternalTrackCollections += updateInExternalTrackCollection; + auto externalTrackCollectionPtr = updateInExternalTrackCollection.externalTrackCollection; + connect(updateInExternalTrackCollection.action, &QAction::triggered, + [=](){ + slotUpdateExternalTrackCollection(externalTrackCollectionPtr);}); + } + m_pAddToPreviewDeck = new QAction(tr("Preview Deck"), this); // currently there is only one preview deck so just map it here. QString previewDeckGroup = PlayerManager::groupForPreviewDeck(0); @@ -904,6 +928,7 @@ void WTrackTableView::contextMenuEvent(QContextMenuEvent* event) { m_pMenu->addSeparator(); m_pMetadataMenu->clear(); + m_pMetadataUpdateExternalCollectionsMenu->clear(); if (modelHasCapabilities(TrackModel::TRACKMODELCAPS_EDITMETADATA)) { m_pMetadataMenu->addAction(m_pImportMetadataFromFileAct); @@ -911,6 +936,34 @@ void WTrackTableView::contextMenuEvent(QContextMenuEvent* event) { m_pMetadataMenu->addAction(m_pImportMetadataFromMusicBrainzAct); m_pMetadataMenu->addAction(m_pExportMetadataAct); + for (const auto& updateInExternalTrackCollection : m_updateInExternalTrackCollections) { + ExternalTrackCollection* externalTrackCollection = + updateInExternalTrackCollection.externalTrackCollection; + if (externalTrackCollection) { + updateInExternalTrackCollection.action->setEnabled( + externalTrackCollection->isActive()); + m_pMetadataUpdateExternalCollectionsMenu->addAction( + updateInExternalTrackCollection.action); + } + } + if (!m_pMetadataUpdateExternalCollectionsMenu->isEmpty()) { + m_pMetadataMenu->addMenu(m_pMetadataUpdateExternalCollectionsMenu); + } + + for (const auto& updateInExternalTrackCollection : m_updateInExternalTrackCollections) { + ExternalTrackCollection* externalTrackCollection = + updateInExternalTrackCollection.externalTrackCollection; + if (externalTrackCollection) { + updateInExternalTrackCollection.action->setEnabled( + externalTrackCollection->isActive()); + m_pMetadataUpdateExternalCollectionsMenu->addAction( + updateInExternalTrackCollection.action); + } + } + if (!m_pMetadataUpdateExternalCollectionsMenu->isEmpty()) { + m_pMetadataMenu->addMenu(m_pMetadataUpdateExternalCollectionsMenu); + } + m_pClearMetadataMenu->clear(); if (trackModel == nullptr) { @@ -1512,6 +1565,38 @@ void WTrackTableView::slotExportTrackMetadataIntoFileTags() { } } +void WTrackTableView::slotUpdateExternalTrackCollection( + ExternalTrackCollection* externalTrackCollection) { + VERIFY_OR_DEBUG_ASSERT(externalTrackCollection) { + return; + } + + if (!modelHasCapabilities(TrackModel::TRACKMODELCAPS_EDITMETADATA)) { + return; + } + + TrackModel* pTrackModel = getTrackModel(); + if (!pTrackModel) { + return; + } + + const QModelIndexList indices = selectionModel()->selectedRows(); + if (indices.isEmpty()) { + return; + } + + QList trackRefs; + trackRefs.reserve(indices.size()); + for (const QModelIndex& index : indices) { + trackRefs.append( + TrackRef::fromFileInfo( + pTrackModel->getTrackLocation(index), + pTrackModel->getTrackId(index))); + } + + externalTrackCollection->updateTracks(std::move(trackRefs)); +} + //slot for reset played count, sets count to 0 of one or more tracks void WTrackTableView::slotClearPlayCount() { QModelIndexList indices = selectionModel()->selectedRows(); diff --git a/src/widget/wtracktableview.h b/src/widget/wtracktableview.h index 807875613359..36cc1ab3b935 100644 --- a/src/widget/wtracktableview.h +++ b/src/widget/wtracktableview.h @@ -20,6 +20,8 @@ class DlgTrackInfo; class TrackCollection; class WCoverArtMenu; +class ExternalTrackCollection; + const QString WTRACKTABLEVIEW_VSCROLLBARPOS_KEY = "VScrollBarPos"; /** ConfigValue key for QTable vertical scrollbar position */ const QString LIBRARY_CONFIGVALUE = "[Library]"; /** ConfigValue "value" (wtf) for library stuff */ @@ -27,8 +29,12 @@ const QString LIBRARY_CONFIGVALUE = "[Library]"; /** ConfigValue "value" (wtf) f class WTrackTableView : public WLibraryTableView { Q_OBJECT public: - WTrackTableView(QWidget* parent, UserSettingsPointer pConfig, - TrackCollection* pTrackCollection, bool sorting = true); + WTrackTableView( + QWidget* parent, + UserSettingsPointer pConfig, + TrackCollection* pTrackCollection, + bool sorting, + const QList& externalTrackCollections = {}); ~WTrackTableView() override; void contextMenuEvent(QContextMenuEvent * event) override; void onSearch(const QString& text) override; @@ -64,6 +70,7 @@ class WTrackTableView : public WLibraryTableView { void slotShowTrackInTagFetcher(TrackPointer track); void slotImportTrackMetadataFromFileTags(); void slotExportTrackMetadataIntoFileTags(); + void slotUpdateExternalTrackCollection(ExternalTrackCollection*); void slotPopulatePlaylistMenu(); void addSelectionToPlaylist(int iPlaylistId); void updateSelectionCrates(QWidget* qc); @@ -103,7 +110,7 @@ class WTrackTableView : public WLibraryTableView { void sendToAutoDJ(PlaylistDAO::AutoDJSendLoc loc); void showTrackInfo(QModelIndex index); void showDlgTagFetcher(QModelIndex index); - void createActions(); + void createActions(const QList& externalTrackCollections); void dragMoveEvent(QDragMoveEvent * event) override; void dragEnterEvent(QDragEnterEvent * event) override; void dropEvent(QDropEvent * event) override; @@ -146,6 +153,7 @@ class WTrackTableView : public WLibraryTableView { QMenu *m_pPlaylistMenu; QMenu *m_pCrateMenu; QMenu *m_pMetadataMenu; + QMenu *m_pMetadataUpdateExternalCollectionsMenu; QMenu *m_pClearMetadataMenu; QMenu *m_pBPMMenu; @@ -202,6 +210,12 @@ class WTrackTableView : public WLibraryTableView { QAction* m_pClearReplayGainAction; QAction* m_pClearAllMetadataAction; + struct UpdateExternalTrackCollection { + QPointer externalTrackCollection; + QAction* action; + }; + QList m_updateInExternalTrackCollections; + bool m_sorting; // Column numbers