diff --git a/CMakeLists.txt b/CMakeLists.txt index f11ce8c997e..afce21f6e4f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -398,6 +398,8 @@ add_library(mixxx-lib STATIC EXCLUDE_FROM_ALL src/library/scanner/scannertask.cpp src/library/searchquery.cpp src/library/searchqueryparser.cpp + src/library/serato/seratofeature.cpp + src/library/serato/seratoplaylistmodel.cpp src/library/setlogfeature.cpp src/library/sidebarmodel.cpp src/library/songdownloader.cpp diff --git a/build/depends.py b/build/depends.py index f558598ac96..c4eb5bdb5c6 100644 --- a/build/depends.py +++ b/build/depends.py @@ -1081,6 +1081,8 @@ def sources(self, build): "src/library/itunes/itunesfeature.cpp", "src/library/traktor/traktorfeature.cpp", + "src/library/serato/seratofeature.cpp", + "src/library/serato/seratoplaylistmodel.cpp", "src/library/rekordbox/rekordboxfeature.cpp", "src/library/rekordbox/rekordbox_pdb.cpp", diff --git a/res/images/library/ic_library_serato.svg b/res/images/library/ic_library_serato.svg new file mode 100644 index 00000000000..bd99083931e --- /dev/null +++ b/res/images/library/ic_library_serato.svg @@ -0,0 +1,25 @@ + +image/svg+xml diff --git a/res/mixxx.qrc b/res/mixxx.qrc index 5af923f96a1..545cba1feac 100644 --- a/res/mixxx.qrc +++ b/res/mixxx.qrc @@ -26,7 +26,8 @@ images/library/ic_library_recordings.svg images/library/ic_library_rhythmbox.svg images/library/ic_library_traktor.svg - images/library/ic_library_rekordbox.svg + images/library/ic_library_rekordbox.svg + images/library/ic_library_serato.svg images/mixxx_logo.svg images/mixxx_icon.svg images/mixxx-icon-logo-symbolic.svg diff --git a/res/schema.xml b/res/schema.xml index d5babba85aa..a82abfbf60c 100644 --- a/res/schema.xml +++ b/res/schema.xml @@ -480,5 +480,5 @@ METADATA position INTEGER ); - + diff --git a/src/library/library.cpp b/src/library/library.cpp index 9d8c98df535..0c08e7df980 100644 --- a/src/library/library.cpp +++ b/src/library/library.cpp @@ -20,6 +20,7 @@ #include "library/trackcollectionmanager.h" #include "library/trackmodel.h" +#include "library/analysisfeature.h" #include "library/autodj/autodjfeature.h" #include "library/banshee/bansheefeature.h" #include "library/browse/browsefeature.h" @@ -28,11 +29,11 @@ #include "library/mixxxlibraryfeature.h" #include "library/playlistfeature.h" #include "library/recording/recordingfeature.h" +#include "library/rekordbox/rekordboxfeature.h" #include "library/rhythmbox/rhythmboxfeature.h" +#include "library/serato/seratofeature.h" #include "library/setlogfeature.h" #include "library/traktor/traktorfeature.h" -#include "library/rekordbox/rekordboxfeature.h" -#include "library/analysisfeature.h" #include "mixer/playermanager.h" @@ -168,6 +169,10 @@ Library::Library( addFeature(new RekordboxFeature(this, m_pConfig)); } + if (m_pConfig->getValue(ConfigKey(kConfigGroup, "ShowSeratoLibrary"), true)) { + addFeature(new SeratoFeature(this, m_pConfig)); + } + for (const auto& externalTrackCollection : m_pTrackCollectionManager->externalCollections()) { auto feature = externalTrackCollection->newLibraryFeature(this, m_pConfig); if (feature) { diff --git a/src/library/serato/seratofeature.cpp b/src/library/serato/seratofeature.cpp new file mode 100644 index 00000000000..212c1da774d --- /dev/null +++ b/src/library/serato/seratofeature.cpp @@ -0,0 +1,1127 @@ +// seratofeature.cpp +// Created 2020-01-31 by Jan Holthuis + +#include "library/serato/seratofeature.h" + +#include +#include +#include +#include +#include + +#include "engine/engine.h" +#include "library/dao/trackschema.h" +#include "library/library.h" +#include "library/librarytablemodel.h" +#include "library/missingtablemodel.h" +#include "library/queryutil.h" +#include "library/trackcollection.h" +#include "library/trackcollectionmanager.h" +#include "library/treeitem.h" +#include "track/beatfactory.h" +#include "track/cue.h" +#include "track/keyfactory.h" +#include "util/assert.h" +#include "util/color/color.h" +#include "util/db/dbconnectionpooled.h" +#include "util/db/dbconnectionpooler.h" +#include "util/file.h" +#include "util/sandbox.h" +#include "waveform/waveform.h" +#include "widget/wlibrary.h" +#include "widget/wlibrarytextbrowser.h" + +namespace { + +// Serato Database Field IDs +// The "magic" value is the short 4 byte ascii code intepreted as quint32, so +// that we can use the value in a switch statement instead of going through +// a strcmp if/else ladder. +enum class FieldId : quint32 { + Version = 0x7672736e, // vrsn + Track = 0x6f74726b, // otrk + FileType = 0x74747970, // ttyp + FilePath = 0x7066696c, // pfil + SongTitle = 0x74736e67, // tsng + Artist = 0x74617274, // tart + Album = 0x74616c62, // talb + Genre = 0x7467656e, // tgen + Comment = 0x74636f6d, // tcom + Grouping = 0x74677270, // tgrp + Label = 0x746c626c, // tlbl + Year = 0x74747972, // ttyr + Length = 0x746c656e, // tlen + Bitrate = 0x74626974, // tbit + SampleRate = 0x74736d70, // tsmp + Bpm = 0x7462706d, // tbpm + DateAddedText = 0x74616464, // tadd + DateAdded = 0x75616464, // uadd + Key = 0x746b6579, // tkey + BeatgridLocked = 0x6262676c, // bbgl + FileTime = 0x75746d65, // utme + Missing = 0x626d6973, // bmis + Sorting = 0x7472736f, // osrt + ReverseOrder = 0x62726576, // brev + ColumnTitle = 0x6f766374, // ovct + ColumnName = 0x7476636e, // tvcn + ColumnWidth = 0x74766377, // tvcw + TrackPath = 0x7074726b, // ptrk +}; + +struct serato_track_t { + QString filetype; + QString location; + QString title; + QString artist; + QString album; + QString genre; + QString comment; + QString grouping; + QString label; + int year = -1; + int duration = -1; + QString bitrate; + QString samplerate; + double bpm = -1.0; + QString key; + bool beatgridlocked = false; + bool missing = false; + quint32 filetime = 0; + quint32 datetimeadded = 0; +}; + +const QString kDatabaseDirectory = QStringLiteral("_Serato_"); +const QString kDatabaseFilename = QStringLiteral("database V2"); +const QString kCrateDirectory = QStringLiteral("Subcrates"); +const QString kCrateFilter = QStringLiteral("*.crate"); +const QString kSmartCrateDirectory = QStringLiteral("Smart Crates"); +const QString kSmartCrateFilter = QStringLiteral("*.scrate"); + +const QString kSeratoLibraryTable = QStringLiteral("serato_library"); +const QString kSeratoPlaylistsTable = QStringLiteral("serato_playlists"); +const QString kSeratoPlaylistTracksTable = QStringLiteral("serato_playlist_tracks"); + +constexpr int kHeaderSize = 2 * sizeof(quint32); + +int createPlaylist(const QSqlDatabase& database, const QString& name, const QString& databasePath) { + QSqlQuery query(database); + query.prepare( + "INSERT INTO serato_playlists (name, serato_db)" + "VALUES (:name, :serato_db)"); + query.bindValue(":name", name); + query.bindValue(":serato_db", databasePath); + + if (!query.exec()) { + LOG_FAILED_QUERY(query) << "databasePath: " << databasePath; + return -1; + } + + return query.lastInsertId().toInt(); +} + +int insertTrackIntoPlaylist(const QSqlDatabase& database, int playlistId, int trackId, int position) { + QSqlQuery query(database); + query.prepare( + "INSERT INTO serato_playlist_tracks (playlist_id, track_id, position) " + "VALUES (:playlist_id, :track_id, :position)"); + query.bindValue(":playlist_id", playlistId); + query.bindValue(":track_id", trackId); + query.bindValue(":position", position); + + if (!query.exec()) { + LOG_FAILED_QUERY(query); + return -1; + } + + return query.lastInsertId().toInt(); +} + +inline QString utf16beToQString(const QByteArray& data, const quint32 size) { + return QTextCodec::codecForName("UTF-16BE")->toUnicode(data, size); +} + +inline bool bytesToBoolean(const QByteArray& data) { + VERIFY_OR_DEBUG_ASSERT(!data.isEmpty()) { + return false; + } + return data.at(0) != 0; +} + +inline quint32 bytesToUInt32(const QByteArray& data) { + VERIFY_OR_DEBUG_ASSERT(data.size() >= static_cast(sizeof(quint32))) { + return 0; + } +#if QT_VERSION >= QT_VERSION_CHECK(5, 12, 0) + return qFromBigEndian(data.constData()); +#else + return qFromBigEndian( + reinterpret_cast(data.constData())); +#endif +} + +inline bool parseTrack(serato_track_t* track, QIODevice* buffer) { + QByteArray headerData = buffer->read(kHeaderSize); + while (headerData.length() == kHeaderSize) { + quint32 fieldId = bytesToUInt32(headerData.mid(0, sizeof(quint32))); + quint32 fieldSize = bytesToUInt32(headerData.mid(sizeof(quint32), kHeaderSize)); + + // Read field data + QByteArray data = buffer->read(fieldSize); + if (static_cast(data.length()) != fieldSize) { + QString fieldName = QString(headerData.mid(0, sizeof(quint32))); + qWarning() << "Failed to read " + << fieldSize + << " bytes for " + << fieldName + << " field."; + return false; + } + + // Parse field data + switch (static_cast(fieldId)) { + case FieldId::FileType: + track->filetype = utf16beToQString(data, fieldSize); + break; + case FieldId::FilePath: + track->location = utf16beToQString(data, fieldSize); + break; + case FieldId::SongTitle: + track->title = utf16beToQString(data, fieldSize); + break; + case FieldId::Artist: + track->artist = utf16beToQString(data, fieldSize); + break; + case FieldId::Album: + track->album = utf16beToQString(data, fieldSize); + break; + case FieldId::Genre: + track->genre = utf16beToQString(data, fieldSize); + break; + case FieldId::Length: { + bool ok; + int duration = utf16beToQString(data, fieldSize).toInt(&ok); + if (ok) { + track->duration = duration; + } + break; + } + case FieldId::Bitrate: + track->bitrate = utf16beToQString(data, fieldSize); + break; + case FieldId::SampleRate: + track->samplerate = utf16beToQString(data, fieldSize); + break; + case FieldId::Bpm: { + bool ok; + double bpm = utf16beToQString(data, fieldSize).toDouble(&ok); + if (ok) { + track->bpm = bpm; + } + break; + } + case FieldId::Comment: + track->comment = utf16beToQString(data, fieldSize); + break; + case FieldId::Grouping: + track->grouping = utf16beToQString(data, fieldSize); + break; + case FieldId::Label: + track->label = utf16beToQString(data, fieldSize); + break; + case FieldId::Year: { + // 4-digit year as string (YYYY) + bool ok; + int year = utf16beToQString(data, fieldSize).toInt(&ok); + if (ok) { + track->year = year; + } + break; + } + case FieldId::Key: + track->key = utf16beToQString(data, fieldSize); + break; + case FieldId::BeatgridLocked: + track->beatgridlocked = bytesToBoolean(data); + break; + case FieldId::Missing: + if (fieldSize == 1) { + track->missing = bytesToBoolean(data); + } + break; + case FieldId::FileTime: + // POSIX timestamp + if (fieldSize == sizeof(quint32)) { + track->filetime = bytesToUInt32(data); + } + break; + case FieldId::DateAdded: + // POSIX timestamp + if (fieldSize == sizeof(quint32)) { + track->datetimeadded = bytesToUInt32(data); + } + break; + case FieldId::DateAddedText: + // Ignore this field, but do not print a debug message. It's the + // same as the regular DateAdded field, but this time the timestamp + // is a string instead of an unsigned integer. Since we already + // parse the integer version, it doesn't make sense to parse this. + break; + default: { + QString fieldName = QString(headerData.mid(0, sizeof(quint32))); + qDebug() << "Ignoring unknown field " + << fieldName + << " (" + << fieldSize + << " bytes)."; + } + } + + headerData = buffer->read(kHeaderSize); + } + + if (headerData.length() != 0) { + qWarning() << "Found " + << headerData.length() + << " extra bytes at end of track definition."; + return false; + } + + // Ignore tracks with empty location fields. The track location is used as + // identifier by Serato (e.g. it's also used to reference them in Crates). + if (track->location.isEmpty()) { + qWarning() << "Found track with empty location field."; + return false; + } + + return true; +} + +inline QString parseCrateTrackPath(QIODevice* buffer) { + QString location; + QByteArray headerData = buffer->read(kHeaderSize); + while (headerData.length() == kHeaderSize) { + quint32 fieldId = bytesToUInt32(headerData.mid(0, sizeof(quint32))); + quint32 fieldSize = bytesToUInt32(headerData.mid(sizeof(quint32), kHeaderSize)); + + // Read field data + QByteArray data = buffer->read(fieldSize); + if (static_cast(data.length()) != fieldSize) { + QString fieldName = QString(headerData.mid(0, sizeof(quint32))); + qWarning() << "Failed to read " + << fieldSize + << " bytes for " + << fieldName + << " field."; + return QString(); + } + + // Parse field data + switch (static_cast(fieldId)) { + case FieldId::TrackPath: + location = utf16beToQString(data, fieldSize); + break; + default: { + QString fieldName = QString(headerData.mid(0, sizeof(quint32))); + qDebug() << "Ignoring unknown field " + << fieldName + << " (" + << fieldSize + << " bytes)."; + } + } + + headerData = buffer->read(kHeaderSize); + } + + if (headerData.length() != 0) { + qWarning() << "Found " + << headerData.length() + << " extra bytes at end of track definition."; + return QString(); + } + + return location; +} + +QString parseCrate( + const QSqlDatabase& database, + const QString& databasePath, + const QString& crateFilePath, + const QMap& trackIdMap) { + QString crateName = QFileInfo(crateFilePath).baseName(); + qDebug() << "Parsing crate" + << crateName + << "at" << crateFilePath; + + //Open the database connection in this thread. + VERIFY_OR_DEBUG_ASSERT(database.isOpen()) { + qWarning() << "Failed to open database for Serato parser." + << database.lastError(); + return QString(); + } + + QFile crateFile(crateFilePath); + if (!crateFile.open(QIODevice::ReadOnly)) { + qWarning() << "Failed to open file " + << crateFilePath + << " for reading."; + return QString(); + } + + int playlistId = createPlaylist(database, crateFilePath, databasePath); + if (playlistId < 0) { + qWarning() << "Failed to create library playlist for " + << crateFilePath; + return QString(); + } + + int trackCount = 0; + QByteArray headerData = crateFile.read(kHeaderSize); + while (headerData.length() == kHeaderSize) { + quint32 fieldId = bytesToUInt32(headerData.mid(0, sizeof(quint32))); + quint32 fieldSize = bytesToUInt32(headerData.mid(sizeof(quint32), kHeaderSize)); + + // Read field data + QByteArray data = crateFile.read(fieldSize); + if (static_cast(data.length()) != fieldSize) { + QString fieldName = QString(headerData.mid(0, sizeof(quint32))); + qWarning() << "Failed to read " + << fieldSize + << " bytes for " + << fieldName + << " field from " + << crateFilePath + << "."; + return QString(); + } + + // Parse field data + switch (static_cast(fieldId)) { + case FieldId::Version: { + QString version = utf16beToQString(data, fieldSize); + qDebug() << "Serato Database Version: " + << version; + break; + } + case FieldId::Track: { + QBuffer buffer(&data); + buffer.open(QIODevice::ReadOnly); + QString location = parseCrateTrackPath(&buffer); + if (!location.isEmpty()) { + int trackId = trackIdMap.value(location, -1); + insertTrackIntoPlaylist(database, playlistId, trackId, trackCount); + trackCount++; + break; + } + break; + } + default: { + QString fieldName = QString(headerData.mid(0, sizeof(quint32))); + qDebug() << "Ignoring unknown field " + << fieldName + << " (" + << fieldSize + << " bytes) in database " + << crateFilePath + << "."; + } + } + + headerData = crateFile.read(kHeaderSize); + } + + if (headerData.length() != 0) { + qWarning() << "Found " + << headerData.length() + << " extra bytes at end of Serato database file " + << crateFilePath + << "."; + } + + return crateName; +} + +QString parseDatabase(mixxx::DbConnectionPoolPtr dbConnectionPool, TreeItem* databaseItem) { + QString databaseName = databaseItem->getLabel(); + QString databaseFilePath = databaseItem->getData().toList()[0].toString(); + QDir databaseDir = QFileInfo(databaseFilePath).dir(); + + QDir databaseRootDir = QDir(databaseDir); + databaseRootDir.cdUp(); + +#if defined(__WINDOWS__) + // Find drive letter (paths are relative to drive root on Windows) + while (databaseRootDir.cdUp()) { + // Nothing to do here + } +#endif + + qDebug() << "Parsing Serato database" + << databaseName + << "at" << databaseFilePath; + + if (!QFile(databaseFilePath).exists()) { + qWarning() << "Serato database file not found: " + << databaseFilePath; + return databaseFilePath; + } + + // The pooler limits the lifetime all thread-local connections, + // that should be closed immediately before exiting this function. + const mixxx::DbConnectionPooler dbConnectionPooler(dbConnectionPool); + QSqlDatabase database = mixxx::DbConnectionPooled(dbConnectionPool); + + //Open the database connection in this thread. + VERIFY_OR_DEBUG_ASSERT(database.isOpen()) { + qWarning() << "Failed to open database for Serato parser." + << database.lastError(); + return QString(); + } + + //Give thread a low priority + QThread* thisThread = QThread::currentThread(); + thisThread->setPriority(QThread::LowPriority); + + ScopedTransaction transaction(database); + + QSqlQuery query(database); + query.prepare( + "INSERT INTO " + + kSeratoLibraryTable + " (" + + LIBRARYTABLE_TITLE + ", " + + LIBRARYTABLE_ARTIST + ", " + + LIBRARYTABLE_ALBUM + ", " + + LIBRARYTABLE_GENRE + ", " + + LIBRARYTABLE_COMMENT + ", " + + LIBRARYTABLE_GROUPING + ", " + + LIBRARYTABLE_YEAR + ", " + + LIBRARYTABLE_DURATION + ", " + + LIBRARYTABLE_BITRATE + ", " + + LIBRARYTABLE_SAMPLERATE + ", " + + LIBRARYTABLE_BPM + ", " + + LIBRARYTABLE_KEY + ", " + + LIBRARYTABLE_LOCATION + ", " + + LIBRARYTABLE_BPM_LOCK + ", " + + LIBRARYTABLE_DATETIMEADDED + + ", " + "label, " + "serato_db" + ") VALUES (" + ":title, " + ":artist, " + ":album, " + ":genre, " + ":comment, " + ":grouping, " + ":year, " + ":duration, " + ":bitrate, " + ":samplerate, " + ":bpm, " + ":key, " + ":location, " + ":bpm_lock, " + ":datetime_added, " + ":label, " + ":serato_db" + ")"); + + QFile databaseFile(databaseFilePath); + if (!databaseFile.open(QIODevice::ReadOnly)) { + qWarning() << "Failed to open file " + << databaseFilePath + << " for reading."; + return QString(); + } + + int playlistId = createPlaylist(database, databaseFilePath, databaseDir.path()); + if (playlistId < 0) { + qWarning() << "Failed to create library playlist for " + << databaseFilePath; + return QString(); + } + + int trackCount = 0; + QMap trackIdMap; + QByteArray headerData = databaseFile.read(kHeaderSize); + while (headerData.length() == kHeaderSize) { + quint32 fieldId = bytesToUInt32(headerData.mid(0, sizeof(quint32))); + quint32 fieldSize = bytesToUInt32(headerData.mid(sizeof(quint32), kHeaderSize)); + + // Read field data + QByteArray data = databaseFile.read(fieldSize); + if (static_cast(data.length()) != fieldSize) { + QString fieldName = QString(headerData.mid(0, sizeof(quint32))); + qWarning() << "Failed to read " + << fieldSize + << " bytes for " + << fieldName + << " field from " + << databaseFilePath + << "."; + return QString(); + } + + // Parse field data + switch (static_cast(fieldId)) { + case FieldId::Version: { + QString version = utf16beToQString(data, fieldSize); + qDebug() << "Serato Database Version: " + << version; + break; + } + case FieldId::Track: { + serato_track_t track; + QBuffer buffer(&data); + buffer.open(QIODevice::ReadOnly); + if (parseTrack(&track, &buffer)) { + QString location = databaseRootDir.absoluteFilePath(track.location); + query.bindValue(":title", track.title); + query.bindValue(":artist", track.artist); + query.bindValue(":album", track.album); + query.bindValue(":genre", track.genre); + query.bindValue(":comment", track.comment); + query.bindValue(":grouping", track.grouping); + query.bindValue(":year", track.year); + query.bindValue(":duration", track.duration); + query.bindValue(":bitrate", track.bitrate); + query.bindValue(":samplerate", track.samplerate); + query.bindValue(":bpm", track.bpm); + query.bindValue(":key", track.key); + query.bindValue(":location", location); + query.bindValue(":bpm_lock", track.beatgridlocked); + query.bindValue(":datetime_added", track.datetimeadded); + query.bindValue(":label", track.label); + query.bindValue(":serato_db", databaseDir.path()); + + if (!query.exec()) { + LOG_FAILED_QUERY(query); + } else { + int trackId = query.lastInsertId().toInt(); + insertTrackIntoPlaylist(database, playlistId, trackId, trackCount); + trackIdMap.insert(track.location, trackId); + trackCount++; + } + break; + } + break; + } + default: { + QString fieldName = QString(headerData.mid(0, sizeof(quint32))); + qDebug() << "Ignoring unknown field " + << fieldName + << " (" + << fieldSize + << " bytes) in database " + << databaseFilePath + << "."; + } + } + + headerData = databaseFile.read(kHeaderSize); + } + + if (headerData.length() != 0) { + qWarning() << "Found " + << headerData.length() + << " extra bytes at end of Serato database file " + << databaseFilePath + << "."; + } + + // Parse Crates + QDir crateDir = QDir(databaseDir); + if (crateDir.cd(kCrateDirectory)) { + QStringList filters; + filters << kCrateFilter; + foreach (const QString& entry, crateDir.entryList(filters)) { + QString crateFilePath = crateDir.filePath(entry); + QString crateName = parseCrate( + database, + databaseDir.path(), + crateFilePath, + trackIdMap); + if (!crateName.isEmpty()) { + QList data; + data << QVariant(crateFilePath) + << QVariant(true); + TreeItem* crateItem = databaseItem->appendChild(crateName, data); + crateItem->setIcon(QIcon(":/images/library/ic_library_crates.svg")); + } + } + } else { + qWarning() << "Failed to open crate directory: " + << databaseDir.filePath(kCrateDirectory); + } + + // TODO: Parse Smart Crates + + transaction.commit(); + + return databaseFilePath; +} + +// This function is executed in a separate thread other than the main thread +QList findSeratoDatabases() { + QThread* thisThread = QThread::currentThread(); + thisThread->setPriority(QThread::LowPriority); + + // Build a list of directories that could contain the _Serato_ directory + QFileInfoList databaseLocations; + foreach (const QString& musicDir, QStandardPaths::standardLocations(QStandardPaths::MusicLocation)) { + databaseLocations.append(QFileInfo(musicDir)); + } +#if defined(__WINDOWS__) + // Repopulate drive list + // Using drive.filePath() instead of drive.canonicalPath() as it + // freezes interface too much if there is a network share mounted + // (drive letter assigned) but unavailable + // + // drive.canonicalPath() make a system call to the underlying filesystem + // introducing delay if it is unreadable. + // drive.filePath() doesn't make any access to the filesystem and consequently + // shorten the delay + databaseLocations.append(QDir::drives()); +#elif defined(__LINUX__) + // To get devices on Linux, we look for directories under /media and + // /run/media/$USER. + const QString userName = QString::fromLocal8Bit(qgetenv("USER")); + + // Add folders under /media to devices. + QDir mediaDir = QDir(QStringLiteral("/media/")); + databaseLocations.append( + mediaDir.entryInfoList(QDir::AllDirs | QDir::NoDotAndDotDot)); + + // Add folders under /media/$USER to devices. + if (mediaDir.cd(userName)) { + databaseLocations.append( + mediaDir.entryInfoList(QDir::AllDirs | QDir::NoDotAndDotDot)); + } + + // Add folders under /run/media/$USER to devices. + QDir runMediaDir = QDir(QStringLiteral("/run/media/")); + if (runMediaDir.cd(userName)) { + databaseLocations.append( + runMediaDir.entryInfoList(QDir::AllDirs | QDir::NoDotAndDotDot)); + } +#elif defined(__APPLE__) + QDir volumesDir = QDir(QStringLiteral("/Volumes")); + databaseLocations.append( + volumesDir.entryInfoList(QDir::AllDirs | QDir::NoDotAndDotDot)); +#endif + + QList foundDatabases; + foreach (QFileInfo databaseLocation, databaseLocations) { + QDir databaseDir = QDir(databaseLocation.filePath()); + if (!databaseDir.cd(kDatabaseDirectory)) { + continue; + } + + if (!databaseDir.exists(kDatabaseFilename)) { + continue; + } + + QString displayPath = databaseLocation.filePath(); + if (displayPath.endsWith("/")) { + displayPath.chop(1); + } + + + QList data; + data << QVariant(databaseDir.filePath(kDatabaseFilename)) + << QVariant(false); + + TreeItem* foundDatabase = new TreeItem( + std::move(displayPath), + QVariant(data)); + + foundDatabases << foundDatabase; + } + + return foundDatabases; +} + +bool createLibraryTable(QSqlDatabase& database, const QString& tableName) { + qDebug() << "Creating Serato library table: " << tableName; + + QSqlQuery query(database); + query.prepare( + "CREATE TABLE IF NOT EXISTS " + tableName + + " (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " title TEXT," + " artist TEXT," + " album TEXT," + " genre TEXT," + " comment TEXT," + " grouping TEXT," + " year INTEGER," + " duration INTEGER," + " bitrate TEXT," + " samplerate TEXT," + " bpm FLOAT," + " key TEXT," + " location TEXT," + " bpm_lock INTEGER," + " datetime_added DEFAULT CURRENT_TIMESTAMP," + " label TEXT," + " composer TEXT," + " filename TEXT," + " filetype TEXT," + " remixer TEXT," + " size INTEGER," + " tracknumber TEXT," + " serato_db TEXT" + ");"); + + if (!query.exec()) { + LOG_FAILED_QUERY(query); + return false; + } + + return true; +} + +bool createPlaylistsTable(QSqlDatabase& database, const QString& tableName) { + qDebug() << "Creating Serato playlists table: " << tableName; + + QSqlQuery query(database); + query.prepare( + "CREATE TABLE IF NOT EXISTS " + tableName + + " (" + " id INTEGER PRIMARY KEY," + " name TEXT," + " serato_db TEXT" + ");"); + + if (!query.exec()) { + LOG_FAILED_QUERY(query); + return false; + } + + return true; +} + +bool createPlaylistTracksTable(QSqlDatabase& database, const QString& tableName) { + qDebug() << "Creating Serato playlist tracks table: " << tableName; + + QSqlQuery query(database); + query.prepare( + "CREATE TABLE IF NOT EXISTS " + tableName + + " (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " playlist_id INTEGER REFERENCES serato_playlists(id)," + " track_id INTEGER REFERENCES serato_library(id)," + " position INTEGER" + ");"); + + if (!query.exec()) { + LOG_FAILED_QUERY(query); + return false; + } + + return true; +} + +bool dropTable(QSqlDatabase& database, QString tableName) { + qDebug() << "Dropping Serato table: " << tableName; + + QSqlQuery query(database); + query.prepare("DROP TABLE IF EXISTS " + tableName); + + if (!query.exec()) { + LOG_FAILED_QUERY(query); + return false; + } + + return true; +} + +} // anonymous namespace + +SeratoFeature::SeratoFeature( + Library* pLibrary, + UserSettingsPointer pConfig) + : BaseExternalLibraryFeature(pLibrary, pConfig), + m_icon(":/images/library/ic_library_serato.svg") { + QStringList columns; + columns << LIBRARYTABLE_ID + << LIBRARYTABLE_TITLE + << LIBRARYTABLE_ARTIST + << LIBRARYTABLE_ALBUM + << LIBRARYTABLE_GENRE + << LIBRARYTABLE_COMMENT + << LIBRARYTABLE_GROUPING + << LIBRARYTABLE_YEAR + << LIBRARYTABLE_DURATION + << LIBRARYTABLE_BITRATE + << LIBRARYTABLE_SAMPLERATE + << LIBRARYTABLE_BPM + << LIBRARYTABLE_KEY + << LIBRARYTABLE_TRACKNUMBER + << LIBRARYTABLE_LOCATION + << LIBRARYTABLE_BPM_LOCK; + + QStringList searchColumns; + searchColumns + << LIBRARYTABLE_ARTIST + << LIBRARYTABLE_TITLE + << LIBRARYTABLE_ALBUM + << LIBRARYTABLE_YEAR + << LIBRARYTABLE_GENRE + << LIBRARYTABLE_TRACKNUMBER + << LIBRARYTABLE_LOCATION + << LIBRARYTABLE_COMMENT + << LIBRARYTABLE_DURATION + << LIBRARYTABLE_BITRATE + << LIBRARYTABLE_BPM + << LIBRARYTABLE_KEY; + + m_trackSource = QSharedPointer( + new BaseTrackCache(m_pTrackCollection, kSeratoLibraryTable, LIBRARYTABLE_ID, columns, false)); + m_trackSource->setSearchColumns(searchColumns); + m_pSeratoPlaylistModel = new SeratoPlaylistModel(this, pLibrary->trackCollections(), m_trackSource); + + m_title = tr("Serato"); + + QSqlDatabase database = m_pTrackCollection->database(); + ScopedTransaction transaction(database); + // Drop any leftover temporary Serato database tables if they exist + dropTable(database, kSeratoPlaylistTracksTable); + dropTable(database, kSeratoPlaylistsTable); + dropTable(database, kSeratoLibraryTable); + + // Create new temporary Serato database tables + createLibraryTable(database, kSeratoLibraryTable); + createPlaylistsTable(database, kSeratoPlaylistsTable); + createPlaylistTracksTable(database, kSeratoPlaylistTracksTable); + transaction.commit(); + + connect(&m_databasesFutureWatcher, + &QFutureWatcher>::finished, + this, + &SeratoFeature::onSeratoDatabasesFound); + connect(&m_tracksFutureWatcher, + &QFutureWatcher::finished, + this, + &SeratoFeature::onTracksFound); + + // initialize the model + m_childModel.setRootItem(TreeItem::newRoot(this)); +} + +SeratoFeature::~SeratoFeature() { + m_databasesFuture.waitForFinished(); + m_tracksFuture.waitForFinished(); + + // Drop temporary Serato database tables on shutdown + QSqlDatabase database = m_pTrackCollection->database(); + ScopedTransaction transaction(database); + dropTable(database, kSeratoPlaylistTracksTable); + dropTable(database, kSeratoPlaylistsTable); + dropTable(database, kSeratoLibraryTable); + transaction.commit(); + + delete m_pSeratoPlaylistModel; +} + +void SeratoFeature::bindLibraryWidget(WLibrary* libraryWidget, + KeyboardEventFilter* keyboard) { + Q_UNUSED(keyboard); + WLibraryTextBrowser* edit = new WLibraryTextBrowser(libraryWidget); + edit->setHtml(formatRootViewHtml()); + edit->setOpenLinks(false); + connect(edit, SIGNAL(anchorClicked(const QUrl)), this, SLOT(htmlLinkClicked(const QUrl))); + libraryWidget->registerView("SERATOHOME", edit); +} + +void SeratoFeature::htmlLinkClicked(const QUrl& link) { + if (QString(link.path()) == "refresh") { + activate(); + } else { + qDebug() << "Unknown link clicked" << link; + } +} + +BaseSqlTableModel* SeratoFeature::getPlaylistModelForPlaylist(QString playlist) { + SeratoPlaylistModel* model = new SeratoPlaylistModel(this, m_pLibrary->trackCollections(), m_trackSource); + model->setPlaylist(playlist); + return model; +} + +QVariant SeratoFeature::title() { + return m_title; +} + +QIcon SeratoFeature::getIcon() { + return m_icon; +} + +bool SeratoFeature::isSupported() { + return true; +} + +TreeItemModel* SeratoFeature::getChildModel() { + return &m_childModel; +} + +QString SeratoFeature::formatRootViewHtml() const { + QString title = tr("Serato"); + QString summary = tr("Reads the following from the Serato Music directory and removable devices:"); + QStringList items; + + items << tr("Tracks") + << tr("Crates"); + + QString html; + QString refreshLink = tr("Check for Serato databases (refresh)"); + html.append(QString("

%1

").arg(title)); + html.append(QString("

%1

").arg(summary)); + html.append(QString("
    ")); + for (const auto& item : items) { + html.append(QString("
  • %1
  • ").arg(item)); + } + html.append(QString("
")); + + //Colorize links in lighter blue, instead of QT default dark blue. + //Links are still different from regular text, but readable on dark/light backgrounds. + //https://bugs.launchpad.net/mixxx/+bug/1744816 + html.append(QString("%1") + .arg(refreshLink)); + return html; +} + +void SeratoFeature::refreshLibraryModels() { +} + +void SeratoFeature::activate() { + qDebug() << "SeratoFeature::activate()"; + + // Let a worker thread do the parsing + m_databasesFuture = QtConcurrent::run(findSeratoDatabases); + m_databasesFutureWatcher.setFuture(m_databasesFuture); + m_title = tr("(loading) Serato"); + //calls a slot in the sidebar model such that 'Serato (isLoading)' is displayed. + emit featureIsLoading(this, true); + + emit enableCoverArtDisplay(true); + emit switchToView("SERATOHOME"); +} + +void SeratoFeature::activateChild(const QModelIndex& index) { + if (!index.isValid()) + return; + + //access underlying TreeItem object + TreeItem* item = static_cast(index.internalPointer()); + if (!(item && item->getData().isValid())) { + return; + } + + // TreeItem list data holds 2 values in a QList: + // + // 1. Playlist Name/Path (QString) + // 2. isPlaylist (boolean) + // + // If the second element is false, then the database does still have to be + // parsed. + QList data = item->getData().toList(); + VERIFY_OR_DEBUG_ASSERT(data.size() == 2) { + return; + } + QString playlist = data[0].toString(); + bool isPlaylist = data[1].toBool(); + + qDebug() << "SeratoFeature::activateChild " << item->getLabel(); + + if (!isPlaylist) { + // Let a worker thread do the parsing + m_tracksFuture = QtConcurrent::run(parseDatabase, static_cast(parent())->dbConnectionPool(), item); + m_tracksFutureWatcher.setFuture(m_tracksFuture); + + // This device is now a playlist element, future activations should + // treat is as such + data[1] = QVariant(true); + item->setData(QVariant(data)); + } else { + qDebug() << "Activate Serato Playlist: " << playlist; + m_pSeratoPlaylistModel->setPlaylist(playlist); + emit showTrackModel(m_pSeratoPlaylistModel); + } +} + +void SeratoFeature::onSeratoDatabasesFound() { + QList foundDatabases = m_databasesFuture.result(); + TreeItem* root = m_childModel.getRootItem(); + + QSqlDatabase database = m_pTrackCollection->database(); + + if (foundDatabases.size() == 0) { + // No Serato databases found + + if (root->childRows() > 0) { + // Devices have since been unmounted + m_childModel.removeRows(0, root->childRows()); + } + } else { + for (int databaseIndex = 0; databaseIndex < root->childRows(); databaseIndex++) { + TreeItem* child = root->child(databaseIndex); + bool removeChild = true; + + for (int foundDatabaseIndex = 0; foundDatabaseIndex < foundDatabases.size(); foundDatabaseIndex++) { + TreeItem* databaseFound = foundDatabases[foundDatabaseIndex]; + + if (databaseFound->getLabel() == child->getLabel()) { + removeChild = false; + break; + } + } + + if (removeChild) { + // Device has since been unmounted, cleanup DB + + m_childModel.removeRows(databaseIndex, 1); + } + } + + QList childrenToAdd; + + for (int foundDatabaseIndex = 0; foundDatabaseIndex < foundDatabases.size(); foundDatabaseIndex++) { + TreeItem* databaseFound = foundDatabases[foundDatabaseIndex]; + bool addNewChild = true; + + for (int databaseIndex = 0; databaseIndex < root->childRows(); databaseIndex++) { + TreeItem* child = root->child(databaseIndex); + + if (databaseFound->getLabel() == child->getLabel()) { + // This database already exists in the TreeModel, don't add or parse is again + addNewChild = false; + } + } + + if (addNewChild) { + childrenToAdd << databaseFound; + } + } + + if (!childrenToAdd.empty()) { + m_childModel.insertTreeItemRows(childrenToAdd, 0); + } + } + + // calls a slot in the sidebarmodel such that 'isLoading' is removed from the feature title. + m_title = tr("Serato"); + emit featureLoadingFinished(this); +} + +void SeratoFeature::onTracksFound() { + qDebug() << "onTracksFound"; + m_childModel.triggerRepaint(); + + QString databasePlaylist = m_tracksFuture.result(); + + qDebug() << "Show Serato Database Playlist: " << databasePlaylist; + + m_pSeratoPlaylistModel->setPlaylist(databasePlaylist); + emit showTrackModel(m_pSeratoPlaylistModel); +} diff --git a/src/library/serato/seratofeature.h b/src/library/serato/seratofeature.h new file mode 100644 index 00000000000..c917b07a65d --- /dev/null +++ b/src/library/serato/seratofeature.h @@ -0,0 +1,64 @@ +#pragma once +// seratofeature.h +// Created 2020-01-31 by Jan Holthuis +// +// This feature reads tracks and crates from removable Serato Libraries, +// either in the Music directory or on removable devices (USB drives, etc), +// by parsing the contents of the _Serato_ directory on each device. +// +// Most of the groundwork for this has been done here: +// +// https://github.com/Holzhaus/serato-tags +// https://github.com/Holzhaus/serato-tags/blob/master/scripts/database_v2.py + +#include +#include +#include +#include +#include + +#include "library/baseexternallibraryfeature.h" +#include "library/baseexternaltrackmodel.h" +#include "library/serato/seratoplaylistmodel.h" +#include "library/treeitemmodel.h" + +class SeratoFeature : public BaseExternalLibraryFeature { + Q_OBJECT + public: + SeratoFeature(Library* pLibrary, UserSettingsPointer pConfig); + ~SeratoFeature() override; + + QVariant title() override; + QIcon getIcon() override; + static bool isSupported(); + void bindLibraryWidget(WLibrary* libraryWidget, + KeyboardEventFilter* keyboard) override; + + TreeItemModel* getChildModel() override; + + public slots: + void activate() override; + void activateChild(const QModelIndex& index) override; + void refreshLibraryModels(); + void onSeratoDatabasesFound(); + void onTracksFound(); + + private slots: + void htmlLinkClicked(const QUrl& link); + + private: + QString formatRootViewHtml() const; + BaseSqlTableModel* getPlaylistModelForPlaylist(QString playlist) override; + + TreeItemModel m_childModel; + SeratoPlaylistModel* m_pSeratoPlaylistModel; + + QFutureWatcher> m_databasesFutureWatcher; + QFuture> m_databasesFuture; + QFutureWatcher m_tracksFutureWatcher; + QFuture m_tracksFuture; + QString m_title; + + QSharedPointer m_trackSource; + QIcon m_icon; +}; diff --git a/src/library/serato/seratoplaylistmodel.cpp b/src/library/serato/seratoplaylistmodel.cpp new file mode 100644 index 00000000000..cb8be77a10f --- /dev/null +++ b/src/library/serato/seratoplaylistmodel.cpp @@ -0,0 +1,91 @@ +#include "library/serato/seratoplaylistmodel.h" + +SeratoPlaylistModel::SeratoPlaylistModel(QObject* parent, + TrackCollectionManager* trackCollectionManager, + QSharedPointer trackSource) + : BaseExternalPlaylistModel( + parent, + trackCollectionManager, + "mixxx.db.model.serato.playlistmodel", + "serato_playlists", + "serato_playlist_tracks", + trackSource) { +} + +void SeratoPlaylistModel::initSortColumnMapping() { + // Add a bijective mapping between the SortColumnIds and column indices + for (int i = 0; i < TrackModel::SortColumnId::NUM_SORTCOLUMNIDS; ++i) { + m_columnIndexBySortColumnId[i] = -1; + } + + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_ARTIST] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_ARTIST); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_TITLE] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_TITLE); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_ALBUM] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_ALBUM); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_ALBUMARTIST] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_ALBUMARTIST); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_YEAR] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_YEAR); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_GENRE] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_GENRE); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_COMPOSER] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_COMPOSER); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_GROUPING] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_GROUPING); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_TRACKNUMBER] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_TRACKNUMBER); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_FILETYPE] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_FILETYPE); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_NATIVELOCATION] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_NATIVELOCATION); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_COMMENT] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_COMMENT); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_DURATION] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_DURATION); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_BITRATE] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_BITRATE); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_BPM] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_BPM); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_REPLAYGAIN] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_REPLAYGAIN); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_DATETIMEADDED] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_DATETIMEADDED); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_TIMESPLAYED] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_TIMESPLAYED); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_RATING] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_RATING); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_KEY] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_KEY); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_PREVIEW] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_PREVIEW); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_COVERART] = + fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_COVERART); + m_columnIndexBySortColumnId[TrackModel::SortColumnId::SORTCOLUMN_POSITION] = + fieldIndex(ColumnCache::COLUMN_PLAYLISTTRACKSTABLE_POSITION); + + m_sortColumnIdByColumnIndex.clear(); + for (int i = 0; i < TrackModel::SortColumnId::NUM_SORTCOLUMNIDS; ++i) { + TrackModel::SortColumnId sortColumn = static_cast(i); + m_sortColumnIdByColumnIndex.insert(m_columnIndexBySortColumnId[sortColumn], sortColumn); + } +} + +TrackPointer SeratoPlaylistModel::getTrack(const QModelIndex& index) const { + qDebug() << "SeratoTrackModel::getTrack"; + + TrackPointer track = BaseExternalPlaylistModel::getTrack(index); + + return track; +} + +bool SeratoPlaylistModel::isColumnHiddenByDefault(int column) { + if ( + column == fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_BITRATE) || + column == fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_BPM_LOCK) || + column == fieldIndex(ColumnCache::COLUMN_LIBRARYTABLE_ID)) { + return true; + } + return BaseSqlTableModel::isColumnHiddenByDefault(column); +} diff --git a/src/library/serato/seratoplaylistmodel.h b/src/library/serato/seratoplaylistmodel.h new file mode 100644 index 00000000000..18a73b311f0 --- /dev/null +++ b/src/library/serato/seratoplaylistmodel.h @@ -0,0 +1,19 @@ +#pragma once +// seratoplaylistmodel.h +// Created 2020-02-15 by Jan Holthuis +#include "library/baseexternalplaylistmodel.h" + +class TrackCollectionManager; +class BaseExternalPlaylistModel; + +class SeratoPlaylistModel : public BaseExternalPlaylistModel { + public: + SeratoPlaylistModel(QObject* parent, + TrackCollectionManager* pTrackCollectionManager, + QSharedPointer trackSource); + TrackPointer getTrack(const QModelIndex& index) const override; + bool isColumnHiddenByDefault(int column) override; + + protected: + void initSortColumnMapping() override; +}; diff --git a/src/preferences/dialog/dlgpreflibrary.cpp b/src/preferences/dialog/dlgpreflibrary.cpp index 4c7d6faff66..85ed2fb2a67 100644 --- a/src/preferences/dialog/dlgpreflibrary.cpp +++ b/src/preferences/dialog/dlgpreflibrary.cpp @@ -171,6 +171,8 @@ void DlgPrefLibrary::slotUpdate() { ConfigKey("[Library]","ShowTraktorLibrary"), true)); checkBox_show_rekordbox->setChecked(m_pConfig->getValue( ConfigKey("[Library]","ShowRekordboxLibrary"), true)); + checkBox_show_serato->setChecked(m_pConfig->getValue( + ConfigKey("[Library]", "ShowSeratoLibrary"), true)); switch (m_pConfig->getValue( ConfigKey("[Library]","TrackLoadAction"), LOAD_TO_DECK)) { @@ -311,6 +313,8 @@ void DlgPrefLibrary::slotApply() { ConfigValue((int)checkBox_show_traktor->isChecked())); m_pConfig->set(ConfigKey("[Library]","ShowRekordboxLibrary"), ConfigValue((int)checkBox_show_rekordbox->isChecked())); + m_pConfig->set(ConfigKey("[Library]", "ShowSeratoLibrary"), + ConfigValue((int)checkBox_show_serato->isChecked())); int dbclick_status; if (radioButton_dbclick_bottom->isChecked()) { dbclick_status = ADD_TO_AUTODJ_BOTTOM; diff --git a/src/preferences/dialog/dlgpreflibrarydlg.ui b/src/preferences/dialog/dlgpreflibrarydlg.ui index c9ebc06d15e..38dde4c4f9b 100644 --- a/src/preferences/dialog/dlgpreflibrarydlg.ui +++ b/src/preferences/dialog/dlgpreflibrarydlg.ui @@ -337,6 +337,16 @@ + + + + Show Serato Library + + + true + + + @@ -344,7 +354,7 @@ - + Qt::Horizontal