diff --git a/CMakeLists.txt b/CMakeLists.txt index da9678428ca4..d8bed529bc33 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2764,6 +2764,7 @@ if(BUILD_TESTING) src/test/portmidienumeratortest.cpp src/test/queryutiltest.cpp src/test/rangelist_test.cpp + src/test/ratingexportimport_test.cpp src/test/readaheadmanager_test.cpp src/test/replaygaintest.cpp src/test/rescalertest.cpp diff --git a/src/library/library_prefs.cpp b/src/library/library_prefs.cpp index a95e6e951563..95274d423765 100644 --- a/src/library/library_prefs.cpp +++ b/src/library/library_prefs.cpp @@ -95,6 +95,17 @@ const ConfigKey mixxx::library::prefs::kSyncSeratoMetadataConfigKey = mixxx::library::prefs::kConfigGroup, QStringLiteral("SeratoMetadataExport")}; +// Rating import/export using FMPS_Rating standard +const ConfigKey mixxx::library::prefs::kExportRatingToFileTagsConfigKey = + ConfigKey{ + mixxx::library::prefs::kConfigGroup, + QStringLiteral("export_rating_to_file_tags")}; + +const ConfigKey mixxx::library::prefs::kImportRatingFromFileTagsConfigKey = + ConfigKey{ + mixxx::library::prefs::kConfigGroup, + QStringLiteral("import_rating_from_file_tags")}; + const ConfigKey mixxx::library::prefs::kUseRelativePathOnExportConfigKey = ConfigKey{ mixxx::library::prefs::kConfigGroup, diff --git a/src/library/library_prefs.h b/src/library/library_prefs.h index 38d8f9f7139c..654464154d44 100644 --- a/src/library/library_prefs.h +++ b/src/library/library_prefs.h @@ -50,6 +50,10 @@ extern const ConfigKey kResetMissingTagMetadataOnImportConfigKey; extern const ConfigKey kSyncSeratoMetadataConfigKey; +extern const ConfigKey kExportRatingToFileTagsConfigKey; + +extern const ConfigKey kImportRatingFromFileTagsConfigKey; + extern const ConfigKey kUseRelativePathOnExportConfigKey; extern const ConfigKey kCoverArtFetcherQualityConfigKey; diff --git a/src/library/trackcollectionmanager.cpp b/src/library/trackcollectionmanager.cpp index ed9aff2b2da9..1b247f4afd84 100644 --- a/src/library/trackcollectionmanager.cpp +++ b/src/library/trackcollectionmanager.cpp @@ -310,11 +310,15 @@ ExportTrackMetadataResult TrackCollectionManager::exportTrackMetadataBeforeSavin // last synchronized. Exporting metadata will update this time // stamp on the track object! if (pTrack->isMarkedForMetadataExport() || - (pTrack->isDirty() && - m_pConfig && - m_pConfig->getValueString( - mixxx::library::prefs::kSyncTrackMetadataConfigKey) - .toInt() == 1)) { + (pTrack->isDirty() && m_pConfig && + (m_pConfig->getValueString( + mixxx::library::prefs:: + kSyncTrackMetadataConfigKey) + .toInt() == 1 || + m_pConfig->getValueString( + mixxx::library::prefs:: + kExportRatingToFileTagsConfigKey) + .toInt() == 1))) { switch (mode) { case TrackMetadataExportMode::Immediate: { // Export track metadata now by saving as file tags. diff --git a/src/library/trackset/baseplaylistfeature.cpp b/src/library/trackset/baseplaylistfeature.cpp index e9614818c787..b50be12d6e0c 100644 --- a/src/library/trackset/baseplaylistfeature.cpp +++ b/src/library/trackset/baseplaylistfeature.cpp @@ -55,6 +55,7 @@ BasePlaylistFeature::BasePlaylistFeature( initActions(); connectPlaylistDAO(); + connect(m_pLibrary, &Library::trackSelected, this, diff --git a/src/preferences/dialog/dlgpreflibrary.cpp b/src/preferences/dialog/dlgpreflibrary.cpp index b93124d79144..b6a025535e9d 100644 --- a/src/preferences/dialog/dlgpreflibrary.cpp +++ b/src/preferences/dialog/dlgpreflibrary.cpp @@ -247,6 +247,8 @@ void DlgPrefLibrary::slotResetToDefaults() { spinbox_history_min_tracks_to_keep->setValue(1); checkBox_sync_track_metadata->setChecked(false); checkBox_serato_metadata_export->setChecked(false); + checkBox_rating_export->setChecked(false); + checkBox_rating_import->setChecked(false); checkBox_use_relative_path->setChecked(false); checkBox_edit_metadata_selected_clicked->setChecked(kEditMetadataSelectedClickDefault); radioButton_dbclick_deck->setChecked(true); @@ -293,6 +295,10 @@ void DlgPrefLibrary::slotUpdate() { checkBox_serato_metadata_export->setChecked( m_pConfig->getValue(kSyncSeratoMetadataConfigKey, false)); setSeratoMetadataEnabled(checkBox_sync_track_metadata->isChecked()); + checkBox_rating_export->setChecked( + m_pConfig->getValue(kExportRatingToFileTagsConfigKey, false)); + checkBox_rating_import->setChecked( + m_pConfig->getValue(kImportRatingFromFileTagsConfigKey, false)); checkBox_use_relative_path->setChecked(m_pConfig->getValue( kUseRelativePathOnExportConfigKey, false)); @@ -531,6 +537,12 @@ void DlgPrefLibrary::slotApply() { m_pConfig->set( kSyncSeratoMetadataConfigKey, ConfigValue{checkBox_serato_metadata_export->isChecked()}); + m_pConfig->set( + kExportRatingToFileTagsConfigKey, + ConfigValue{checkBox_rating_export->isChecked()}); + m_pConfig->set( + kImportRatingFromFileTagsConfigKey, + ConfigValue{checkBox_rating_import->isChecked()}); m_pConfig->set(kUseRelativePathOnExportConfigKey, ConfigValue((int)checkBox_use_relative_path->isChecked())); diff --git a/src/preferences/dialog/dlgpreflibrarydlg.ui b/src/preferences/dialog/dlgpreflibrarydlg.ui index a584305b985c..c2dd788ffa55 100644 --- a/src/preferences/dialog/dlgpreflibrarydlg.ui +++ b/src/preferences/dialog/dlgpreflibrarydlg.ui @@ -156,6 +156,28 @@ + + + Export star ratings to file tags using the FMPS_Rating standard when saving track metadata.<br/>This allows ratings to be shared with other media players that support FMPS (e.g., Strawberry, Clementine). + + + Export star ratings to file tags (FMPS_Rating) + + + + + + + + Import star ratings from file tags during library scan.<br/>Reads FMPS_Rating (and POPM for MP3 files) from file tags.<br/>Only imports if the track doesn't already have a rating in Mixxx. + + + Import star ratings from file tags on library scan + + + + + Use relative paths for playlist export if possible @@ -685,6 +707,8 @@ checkBox_library_scan_summary checkBox_sync_track_metadata checkBox_serato_metadata_export + checkBox_rating_export + checkBox_rating_import checkBox_use_relative_path checkBox_edit_metadata_selected_clicked radioButton_dbclick_deck diff --git a/src/sources/metadatasource.h b/src/sources/metadatasource.h index 0e0880aafcdf..e703fcca0c4b 100644 --- a/src/sources/metadatasource.h +++ b/src/sources/metadatasource.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include "track/trackmetadata.h" @@ -61,6 +62,20 @@ class MetadataSource { const TrackMetadata& /*trackMetadata*/) const { return std::make_pair(ExportResult::Unsupported, QDateTime()); } + + /// Import rating from file tags using FMPS_Rating standard. + /// Returns the rating (0-5) if found, nullopt otherwise. + /// Default implementation returns nullopt (no rating support). + virtual std::optional importRating() const { + return std::nullopt; + } + + /// Export rating to file tags using FMPS_Rating standard. + /// Returns true if export succeeded, false otherwise. + /// Default implementation returns false (no rating support). + virtual bool exportRating(int /*rating*/) const { + return false; + } }; typedef std::shared_ptr MetadataSourcePointer; diff --git a/src/sources/metadatasourcetaglib.cpp b/src/sources/metadatasourcetaglib.cpp index f20e2af24d5a..cfa9fe7f1204 100644 --- a/src/sources/metadatasourcetaglib.cpp +++ b/src/sources/metadatasourcetaglib.cpp @@ -5,9 +5,14 @@ #include #include +#include #include "track/taglib/trackmetadata.h" +#include "track/taglib/trackmetadata_ape.h" #include "track/taglib/trackmetadata_common.h" +#include "track/taglib/trackmetadata_id3v2.h" +#include "track/taglib/trackmetadata_mp4.h" +#include "track/taglib/trackmetadata_xiph.h" #include "util/logger.h" #include "util/safelywritablefile.h" @@ -697,4 +702,200 @@ MetadataSourceTagLib::exportTrackMetadata( return afterExport(ExportResult::Failed); } +std::optional MetadataSourceTagLib::importRating() const { + switch (m_fileType) { + case taglib::FileType::MPEG: { + TagLib::MPEG::File file(TAGLIB_FILENAME_FROM_QSTRING(m_fileName)); + if (!file.isOpen()) { + return std::nullopt; + } + // Try ID3v2 first, then APE + if (file.hasID3v2Tag()) { + auto rating = taglib::id3v2::importRatingFromTag(*file.ID3v2Tag()); + if (rating.has_value()) { + return rating; + } + } + if (taglib::hasAPETag(file) && file.APETag()) { + return taglib::ape::importRatingFromTag(*file.APETag()); + } + return std::nullopt; + } + case taglib::FileType::MP4: { + TagLib::MP4::File file(TAGLIB_FILENAME_FROM_QSTRING(m_fileName)); + if (!file.isOpen() || !file.tag()) { + return std::nullopt; + } + return taglib::mp4::importRatingFromTag(*file.tag()); + } + case taglib::FileType::FLAC: { + TagLib::FLAC::File file(TAGLIB_FILENAME_FROM_QSTRING(m_fileName)); + if (!file.isOpen()) { + return std::nullopt; + } + // Try ID3v2 first, then Xiph comment + if (file.hasID3v2Tag()) { + auto rating = taglib::id3v2::importRatingFromTag(*file.ID3v2Tag()); + if (rating.has_value()) { + return rating; + } + } + if (taglib::hasXiphComment(file) && file.xiphComment()) { + return taglib::xiph::importRatingFromTag(*file.xiphComment()); + } + return std::nullopt; + } + case taglib::FileType::OggVorbis: { + TagLib::Ogg::Vorbis::File file(TAGLIB_FILENAME_FROM_QSTRING(m_fileName)); + if (!file.isOpen() || !file.tag()) { + return std::nullopt; + } + return taglib::xiph::importRatingFromTag(*file.tag()); + } + case taglib::FileType::Opus: { + TagLib::Ogg::Opus::File file(TAGLIB_FILENAME_FROM_QSTRING(m_fileName)); + if (!file.isOpen() || !file.tag()) { + return std::nullopt; + } + return taglib::xiph::importRatingFromTag(*file.tag()); + } + case taglib::FileType::WavPack: { + TagLib::WavPack::File file(TAGLIB_FILENAME_FROM_QSTRING(m_fileName)); + if (!file.isOpen() || !file.APETag()) { + return std::nullopt; + } + return taglib::ape::importRatingFromTag(*file.APETag()); + } + case taglib::FileType::WAV: { + TagLib::RIFF::WAV::File file(TAGLIB_FILENAME_FROM_QSTRING(m_fileName)); + if (!file.isOpen()) { + return std::nullopt; + } + if (file.hasID3v2Tag()) { + return taglib::id3v2::importRatingFromTag(*file.ID3v2Tag()); + } + return std::nullopt; + } + case taglib::FileType::AIFF: { + TagLib::RIFF::AIFF::File file(TAGLIB_FILENAME_FROM_QSTRING(m_fileName)); + if (!file.isOpen() || !file.tag()) { + return std::nullopt; + } + return taglib::id3v2::importRatingFromTag(*file.tag()); + } + default: + return std::nullopt; + } +} + +bool MetadataSourceTagLib::exportRating(int rating) const { + SafelyWritableFile safelyWritableFile(m_fileName, + SafelyWritableFile::SafetyMode::Edit); + if (!safelyWritableFile.isReady()) { + kLogger.warning() + << "Unable to export rating into file" + << m_fileName + << "- Please check file permissions and storage space"; + return false; + } + + bool success = false; + switch (m_fileType) { + case taglib::FileType::MPEG: { + TagLib::MPEG::File file(TAGLIB_FILENAME_FROM_QSTRING(safelyWritableFile.fileName())); + if (file.isOpen()) { + // Export to ID3v2 (preferred) + TagLib::ID3v2::Tag* pTag = file.ID3v2Tag(true); + if (pTag && taglib::id3v2::exportRatingIntoTag(pTag, rating)) { + success = file.save(TagLib::MPEG::File::ID3v2); + } + } + break; + } + case taglib::FileType::MP4: { + TagLib::MP4::File file(TAGLIB_FILENAME_FROM_QSTRING(safelyWritableFile.fileName())); + if (file.isOpen() && file.tag()) { + if (taglib::mp4::exportRatingIntoTag(file.tag(), rating)) { + success = file.save(); + } + } + break; + } + case taglib::FileType::FLAC: { + TagLib::FLAC::File file(TAGLIB_FILENAME_FROM_QSTRING(safelyWritableFile.fileName())); + if (file.isOpen()) { + TagLib::Ogg::XiphComment* pTag = file.xiphComment(true); + if (pTag && taglib::xiph::exportRatingIntoTag(pTag, rating)) { + success = file.save(); + } + } + break; + } + case taglib::FileType::OggVorbis: { + TagLib::Ogg::Vorbis::File file(TAGLIB_FILENAME_FROM_QSTRING(safelyWritableFile.fileName())); + if (file.isOpen() && file.tag()) { + if (taglib::xiph::exportRatingIntoTag(file.tag(), rating)) { + success = file.save(); + } + } + break; + } + case taglib::FileType::Opus: { + TagLib::Ogg::Opus::File file(TAGLIB_FILENAME_FROM_QSTRING(safelyWritableFile.fileName())); + if (file.isOpen() && file.tag()) { + if (taglib::xiph::exportRatingIntoTag(file.tag(), rating)) { + success = file.save(); + } + } + break; + } + case taglib::FileType::WavPack: { + TagLib::WavPack::File file(TAGLIB_FILENAME_FROM_QSTRING(safelyWritableFile.fileName())); + if (file.isOpen()) { + TagLib::APE::Tag* pTag = file.APETag(true); + if (pTag && taglib::ape::exportRatingIntoTag(pTag, rating)) { + success = file.save(); + } + } + break; + } + case taglib::FileType::WAV: { + TagLib::RIFF::WAV::File file(TAGLIB_FILENAME_FROM_QSTRING(safelyWritableFile.fileName())); + if (file.isOpen()) { + TagLib::ID3v2::Tag* pTag = file.ID3v2Tag(); + if (pTag && taglib::id3v2::exportRatingIntoTag(pTag, rating)) { + success = file.save(); + } + } + break; + } + case taglib::FileType::AIFF: { + TagLib::RIFF::AIFF::File file(TAGLIB_FILENAME_FROM_QSTRING(safelyWritableFile.fileName())); + if (file.isOpen() && file.tag()) { + if (taglib::id3v2::exportRatingIntoTag(file.tag(), rating)) { + success = file.save(); + } + } + break; + } + default: + kLogger.warning() + << "Rating export not supported for file type" + << static_cast(m_fileType); + return false; + } + + if (success) { + if (!safelyWritableFile.commit()) { + kLogger.warning() << "Failed to commit rating changes to file" << m_fileName; + return false; + } + kLogger.debug() << "Exported rating" << rating << "to file" << m_fileName; + return true; + } + + kLogger.warning() << "Failed to export rating to file" << m_fileName; + return false; +} + } // namespace mixxx diff --git a/src/sources/metadatasourcetaglib.h b/src/sources/metadatasourcetaglib.h index 12b680fc0cf9..25051e5c86bf 100644 --- a/src/sources/metadatasourcetaglib.h +++ b/src/sources/metadatasourcetaglib.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "sources/metadatasource.h" namespace mixxx { @@ -22,6 +24,14 @@ class MetadataSourceTagLib : public MetadataSource { std::pair exportTrackMetadata( const TrackMetadata& trackMetadata) const override; + /// Import rating from file tags using FMPS_Rating standard + /// Returns the rating (0-5) if found, nullopt otherwise + std::optional importRating() const override; + + /// Export rating to file tags using FMPS_Rating standard + /// Returns true if export succeeded, false otherwise + bool exportRating(int rating) const override; + private: std::pair afterImport(ImportResult importResult) const; std::pair afterExport(ExportResult exportResult) const; diff --git a/src/sources/soundsourceproxy.cpp b/src/sources/soundsourceproxy.cpp index 6e9e0bd8a1fb..5e860bdd60d1 100644 --- a/src/sources/soundsourceproxy.cpp +++ b/src/sources/soundsourceproxy.cpp @@ -752,14 +752,43 @@ SoundSourceProxy::UpdateTrackFromSourceResult SoundSourceProxy::updateTrackFromS // Only import and merge extra metadata that might be missing // in the database. DEBUG_ASSERT(!pCoverImg); + + // Import rating from file tags even during partial import, + // if enabled. File tags are the source of truth for ratings. + bool ratingImported = false; + if (syncParams.importRatingFromFile && m_pSoundSource) { + auto rating = m_pSoundSource->importRating(); + if (rating.has_value()) { + int newRating = rating.value(); + int currentRating = m_pTrack->getRating(); + if (newRating != currentRating) { + m_pTrack->setRating(newRating); + ratingImported = true; + if (kLogger.debugEnabled()) { + kLogger.debug() + << "Imported rating" + << newRating + << "(was" << currentRating << ")" + << "from file" + << getUrl().toString(QUrl::PreferLocalFile); + } + } + } + } + if (metadataImportResult == mixxx::MetadataSource::ImportResult::Succeeded) { if (m_pTrack->mergeExtraMetadataFromSource(trackMetadata)) { return UpdateTrackFromSourceResult::ExtraMetadataImportedAndMerged; + } else if (ratingImported) { + return UpdateTrackFromSourceResult::ExtraMetadataImportedAndMerged; } else { return UpdateTrackFromSourceResult::NotUpdated; } } else { // Nothing to do if no metadata has been imported + if (ratingImported) { + return UpdateTrackFromSourceResult::ExtraMetadataImportedAndMerged; + } return UpdateTrackFromSourceResult::NotUpdated; } } @@ -845,6 +874,27 @@ SoundSourceProxy::UpdateTrackFromSourceResult SoundSourceProxy::updateTrackFromS std::move(trackMetadata), sourceSynchronizedAt); + // Import rating from file tags if enabled. + // File tags are the source of truth for ratings. + if (syncParams.importRatingFromFile && m_pSoundSource) { + auto rating = m_pSoundSource->importRating(); + if (rating.has_value()) { + int newRating = rating.value(); + int currentRating = m_pTrack->getRating(); + if (newRating != currentRating) { + m_pTrack->setRating(newRating); + if (kLogger.debugEnabled()) { + kLogger.debug() + << "Imported rating" + << newRating + << "(was" << currentRating << ")" + << "from file" + << getUrl().toString(QUrl::PreferLocalFile); + } + } + } + } + const bool pendingBeatsImport = m_pTrack->getBeatsImportStatus() == Track::ImportStatus::Pending; const bool pendingCueImport = diff --git a/src/test/ratingexportimport_test.cpp b/src/test/ratingexportimport_test.cpp new file mode 100644 index 000000000000..92d4dd8e3dae --- /dev/null +++ b/src/test/ratingexportimport_test.cpp @@ -0,0 +1,142 @@ +#include + +#include + +#include "sources/metadatasourcetaglib.h" +#include "test/mixxxtest.h" +#include "test/soundsourceproviderregistration.h" + +namespace { + +struct FormatTestParam { + const char* fixture; + const char* fileType; + const char* extension; +}; + +const FormatTestParam kFormatTestParams[] = { + {"empty.mp3", "mp3", "mp3"}, + {"cover-test.flac", "flac", "flac"}, + {"cover-test.ogg", "ogg", "ogg"}, + {"cover-test.opus", "opus", "opus"}, + {"cover-test-ffmpeg-aac.m4a", "m4a", "m4a"}, + {"cover-test.wav", "wav", "wav"}, + {"cover-test.aiff", "aiff", "aiff"}, + {"cover-test.wv", "wv", "wv"}, +}; + +} // namespace + +class RatingExportImportTest : public MixxxTest, + private SoundSourceProviderRegistration { + public: + RatingExportImportTest() + : m_testDataDir(getTestDir().absoluteFilePath( + QStringLiteral("id3-test-data"))) { + } + + protected: + const QDir m_testDataDir; + QTemporaryDir m_tempDir; +}; + +class RatingExportImportFormatTest + : public RatingExportImportTest, + public ::testing::WithParamInterface {}; + +TEST_P(RatingExportImportFormatTest, RoundTrip) { + const auto& param = GetParam(); + const QString srcPath = m_testDataDir.absoluteFilePath( + QString::fromLatin1(param.fixture)); + const QString dstPath = m_tempDir.filePath( + QStringLiteral("rating_test.") + + QString::fromLatin1(param.extension)); + mixxxtest::copyFile(srcPath, dstPath); + + const int kTestRating = 3; + + // Export + { + mixxx::MetadataSourceTagLib source(dstPath, QString::fromLatin1(param.fileType)); + ASSERT_TRUE(source.exportRating(kTestRating)); + } + + // Import + { + mixxx::MetadataSourceTagLib source(dstPath, QString::fromLatin1(param.fileType)); + const auto rating = source.importRating(); + ASSERT_TRUE(rating.has_value()); + EXPECT_EQ(rating.value(), kTestRating); + } +} + +INSTANTIATE_TEST_SUITE_P( + AllFormats, + RatingExportImportFormatTest, + ::testing::ValuesIn(kFormatTestParams), + [](const ::testing::TestParamInfo& info) { + return std::string(info.param.fileType); + }); + +TEST_F(RatingExportImportTest, AllRatingValues_MP3) { + for (int rating = 1; rating <= 5; ++rating) { + const QString dstPath = m_tempDir.filePath( + QStringLiteral("rating_%1.mp3").arg(rating)); + mixxxtest::copyFile( + m_testDataDir.absoluteFilePath(QStringLiteral("empty.mp3")), + dstPath); + + { + mixxx::MetadataSourceTagLib source(dstPath, QStringLiteral("mp3")); + ASSERT_TRUE(source.exportRating(rating)) + << "Failed to export rating " << rating; + } + + { + mixxx::MetadataSourceTagLib source(dstPath, QStringLiteral("mp3")); + const auto imported = source.importRating(); + ASSERT_TRUE(imported.has_value()) + << "Failed to import rating " << rating; + EXPECT_EQ(imported.value(), rating) + << "Rating mismatch for value " << rating; + } + } +} + +TEST_F(RatingExportImportTest, ClearRating_MP3) { + const QString dstPath = m_tempDir.filePath(QStringLiteral("clear_rating.mp3")); + mixxxtest::copyFile( + m_testDataDir.absoluteFilePath(QStringLiteral("empty.mp3")), + dstPath); + + // First export a non-zero rating + { + mixxx::MetadataSourceTagLib source(dstPath, QStringLiteral("mp3")); + ASSERT_TRUE(source.exportRating(4)); + } + + // Now clear it by exporting 0 + { + mixxx::MetadataSourceTagLib source(dstPath, QStringLiteral("mp3")); + ASSERT_TRUE(source.exportRating(0)); + } + + // Import should return nullopt (no rating) + { + mixxx::MetadataSourceTagLib source(dstPath, QStringLiteral("mp3")); + const auto rating = source.importRating(); + EXPECT_FALSE(rating.has_value()); + } +} + +TEST_F(RatingExportImportTest, NoRatingInitially_MP3) { + const QString dstPath = m_tempDir.filePath(QStringLiteral("no_rating.mp3")); + mixxxtest::copyFile( + m_testDataDir.absoluteFilePath(QStringLiteral("empty.mp3")), + dstPath); + + // A file with no FMPS_Rating tag should return nullopt + mixxx::MetadataSourceTagLib source(dstPath, QStringLiteral("mp3")); + const auto rating = source.importRating(); + EXPECT_FALSE(rating.has_value()); +} diff --git a/src/track/taglib/trackmetadata_ape.cpp b/src/track/taglib/trackmetadata_ape.cpp index b4af3b5afbc6..9fd489db6a49 100644 --- a/src/track/taglib/trackmetadata_ape.cpp +++ b/src/track/taglib/trackmetadata_ape.cpp @@ -1,5 +1,7 @@ #include "track/taglib/trackmetadata_ape.h" +#include + #include "track/taglib/trackmetadata_common.h" #include "track/tracknumbers.h" #include "util/logger.h" @@ -47,10 +49,97 @@ void writeItem( } } +// FMPS Rating - APE item for cross-application rating compatibility +// https://www.freedesktop.org/wiki/Specifications/free-media-player-specs/ +// APE keys must be uppercase to match TagLib's case normalization on save/reload +const TagLib::String kItemKeyFMPSRating = "FMPS_RATING"; + +// Rating conversion functions +// FMPS uses 0.0-1.0 scale, Mixxx uses 0-5 (with 0 meaning unrated) + +/// Convert Mixxx rating (0-5) to FMPS rating (0.0-1.0) +double mixxxRatingToFMPS(int rating) { + if (rating <= 0 || rating > 5) { + return 0.0; + } + return rating / 5.0; +} + +/// Convert FMPS rating (0.0-1.0) to Mixxx rating (0-5) +int fmpsRatingToMixxx(double fmps) { + if (fmps < 0.1) { + return 0; // Unrated + } else if (fmps < 0.3) { + return 1; + } else if (fmps < 0.5) { + return 2; + } else if (fmps < 0.7) { + return 3; + } else if (fmps < 0.9) { + return 4; + } else { + return 5; + } +} + } // anonymous namespace namespace ape { +std::optional importRatingFromTag(const TagLib::APE::Tag& tag) { + QString fmpsRatingStr; + if (readItem(tag, kItemKeyFMPSRating, &fmpsRatingStr) && + !fmpsRatingStr.isEmpty()) { + bool ok = false; + double fmpsValue = fmpsRatingStr.toDouble(&ok); + if (ok && fmpsValue >= 0.0 && fmpsValue <= 1.0) { + int rating = fmpsRatingToMixxx(fmpsValue); + kLogger.debug() + << "Imported FMPS_Rating from APE tag:" + << fmpsValue << "->" << rating; + return rating; + } else { + kLogger.warning() + << "Invalid FMPS_Rating value in APE tag:" + << fmpsRatingStr; + } + } + + // No rating found + return std::nullopt; +} + +bool exportRatingIntoTag( + TagLib::APE::Tag* pTag, + int rating) { + DEBUG_ASSERT(pTag); + + // Convert rating to FMPS format and write as APE item + if (rating > 0 && rating <= 5) { + double fmpsRating = mixxxRatingToFMPS(rating); + QString fmpsRatingStr = QString::number(fmpsRating, 'f', 1); + writeItem( + pTag, + kItemKeyFMPSRating, + toTString(fmpsRatingStr)); + kLogger.debug() + << "Exported rating to FMPS_Rating APE item:" + << rating << "->" << fmpsRatingStr; + return true; + } else if (rating == 0) { + // Remove existing FMPS_Rating item if rating is cleared + pTag->removeItem(kItemKeyFMPSRating); + kLogger.debug() + << "Removed FMPS_Rating APE item (rating cleared)"; + return true; + } + + // Invalid rating + kLogger.warning() + << "Invalid rating value for export:" << rating; + return false; +} + bool importCoverImageFromTag(QImage* pCoverArt, const TagLib::APE::Tag& tag) { if (!pCoverArt) { return false; // nothing to do diff --git a/src/track/taglib/trackmetadata_ape.h b/src/track/taglib/trackmetadata_ape.h index 3ad230fcf2a9..520637257135 100644 --- a/src/track/taglib/trackmetadata_ape.h +++ b/src/track/taglib/trackmetadata_ape.h @@ -2,6 +2,8 @@ #include +#include + class QImage; namespace mixxx { @@ -12,6 +14,17 @@ namespace taglib { namespace ape { +/// Import rating from APE tag (FMPS_Rating item) +/// Returns std::nullopt if no rating is found, or a value 0-5 if found +std::optional importRatingFromTag(const TagLib::APE::Tag& tag); + +/// Export rating to APE tag as FMPS_Rating item +/// Rating should be 0-5, where 0 means unrated (removes existing item) +/// Returns true on success, false on invalid rating +bool exportRatingIntoTag( + TagLib::APE::Tag* pTag, + int rating); + void importTrackMetadataFromTag( TrackMetadata* pTrackMetadata, const TagLib::APE::Tag& tag, diff --git a/src/track/taglib/trackmetadata_id3v2.cpp b/src/track/taglib/trackmetadata_id3v2.cpp index 82346781e338..ced45aafda0c 100644 --- a/src/track/taglib/trackmetadata_id3v2.cpp +++ b/src/track/taglib/trackmetadata_id3v2.cpp @@ -3,10 +3,12 @@ #include #include #include +#include #include #include #include +#include #if defined(__EXTRA_METADATA__) #include #endif // __EXTRA_METADATA__ @@ -75,6 +77,10 @@ const QString kFrameDescriptionSeratoBeatGrid = QStringLiteral("Serato BeatGrid" const QString kFrameDescriptionSeratoMarkers = QStringLiteral("Serato Markers_"); const QString kFrameDescriptionSeratoMarkers2 = QStringLiteral("Serato Markers2"); +// FMPS Rating - TXXX frame description for cross-application rating compatibility +// https://www.freedesktop.org/wiki/Specifications/free-media-player-specs/ +const QString kFMPSRatingDescription = QStringLiteral("FMPS_Rating"); + // Returns the text of an ID3v2 frame as a string. inline QString frameToQString( const TagLib::ID3v2::Frame& frame) { @@ -462,6 +468,68 @@ int removeUserTextIdentificationFrames( return count; } +// Rating conversion functions +// FMPS uses 0.0-1.0 scale, Mixxx uses 0-5 (with 0 meaning unrated) +// POPM uses 0-255 scale with specific thresholds for 1-5 star ratings + +/// Convert Mixxx rating (0-5) to FMPS rating (0.0-1.0) +/// Returns 0.0 for unrated (0), maps 1-5 to 0.2, 0.4, 0.6, 0.8, 1.0 +double mixxxRatingToFMPS(int rating) { + if (rating <= 0 || rating > 5) { + return 0.0; + } + return rating / 5.0; +} + +/// Convert FMPS rating (0.0-1.0) to Mixxx rating (0-5) +/// Uses symmetric thresholds: 0.1, 0.3, 0.5, 0.7, 0.9 +int fmpsRatingToMixxx(double fmps) { + if (fmps < 0.1) { + return 0; // Unrated + } else if (fmps < 0.3) { + return 1; + } else if (fmps < 0.5) { + return 2; + } else if (fmps < 0.7) { + return 3; + } else if (fmps < 0.9) { + return 4; + } else { + return 5; + } +} + +/// Convert POPM rating (0-255) to Mixxx rating (0-5) +/// Based on common POPM thresholds used by media players +int popmRatingToMixxx(int popm) { + if (popm == 0) { + return 0; // Unrated + } else if (popm <= 31) { + return 1; // 1 star: 1-31 + } else if (popm <= 95) { + return 2; // 2 stars: 32-95 + } else if (popm <= 159) { + return 3; // 3 stars: 96-159 + } else if (popm <= 223) { + return 4; // 4 stars: 160-223 + } else { + return 5; // 5 stars: 224-255 + } +} + +/// Find the first POPM (Popularimeter) frame in the tag +TagLib::ID3v2::PopularimeterFrame* findFirstPopularimeterFrame( + const TagLib::ID3v2::Tag& tag) { + for (TagLib::ID3v2::Frame* const pFrame : tag.frameListMap()["POPM"]) { + DEBUG_ASSERT(pFrame); + auto* const pPopmFrame = downcastFrame(pFrame); + if (pPopmFrame) { + return pPopmFrame; + } + } + return nullptr; +} + void writeCommentsFrame( TagLib::ID3v2::Tag* pTag, const TagLib::String& text, @@ -599,6 +667,76 @@ inline QString formatBpmInteger( namespace id3v2 { +std::optional importRatingFromTag(const TagLib::ID3v2::Tag& tag) { + // First try FMPS_Rating TXXX frame (preferred, more precise) + const QString fmpsRatingStr = readFirstUserTextIdentificationFrame( + tag, kFMPSRatingDescription); + if (!fmpsRatingStr.isEmpty()) { + bool ok = false; + double fmpsValue = fmpsRatingStr.toDouble(&ok); + if (ok && fmpsValue >= 0.0 && fmpsValue <= 1.0) { + int rating = fmpsRatingToMixxx(fmpsValue); + kLogger.debug() + << "Imported FMPS_Rating from TXXX frame:" + << fmpsValue << "->" << rating; + return rating; + } else { + kLogger.warning() + << "Invalid FMPS_Rating value in TXXX frame:" + << fmpsRatingStr; + } + } + + // Fallback: try POPM frame + const TagLib::ID3v2::PopularimeterFrame* pPopmFrame = + findFirstPopularimeterFrame(tag); + if (pPopmFrame) { + int popmRating = pPopmFrame->rating(); + int rating = popmRatingToMixxx(popmRating); + kLogger.debug() + << "Imported rating from POPM frame:" + << popmRating << "->" << rating; + return rating; + } + + // No rating found + return std::nullopt; +} + +bool exportRatingIntoTag( + TagLib::ID3v2::Tag* pTag, + int rating) { + DEBUG_ASSERT(pTag); + + // Convert rating to FMPS format and write as TXXX frame + if (rating > 0 && rating <= 5) { + double fmpsRating = mixxxRatingToFMPS(rating); + QString fmpsRatingStr = QString::number(fmpsRating, 'f', 1); + writeUserTextIdentificationFrame( + pTag, + kFMPSRatingDescription, + fmpsRatingStr, + true); // isNumericOrURL = true + kLogger.debug() + << "Exported rating to FMPS_Rating TXXX frame:" + << rating << "->" << fmpsRatingStr; + return true; + } else if (rating == 0) { + // Remove existing FMPS_Rating frame if rating is cleared + int removed = removeUserTextIdentificationFrames(pTag, kFMPSRatingDescription); + if (removed > 0) { + kLogger.debug() + << "Removed FMPS_Rating TXXX frame (rating cleared)"; + } + return true; + } + + // Invalid rating + kLogger.warning() + << "Invalid rating value for export:" << rating; + return false; +} + bool importCoverImageFromTag( QImage* pCoverArt, const TagLib::ID3v2::Tag& tag) { diff --git a/src/track/taglib/trackmetadata_id3v2.h b/src/track/taglib/trackmetadata_id3v2.h index b58016405826..ab80f498494c 100644 --- a/src/track/taglib/trackmetadata_id3v2.h +++ b/src/track/taglib/trackmetadata_id3v2.h @@ -2,6 +2,8 @@ #include +#include + class QImage; namespace mixxx { @@ -12,6 +14,17 @@ namespace taglib { namespace id3v2 { +/// Import rating from ID3v2 tag (FMPS_Rating TXXX frame preferred, POPM fallback) +/// Returns std::nullopt if no rating is found, or a value 0-5 if found +std::optional importRatingFromTag(const TagLib::ID3v2::Tag& tag); + +/// Export rating to ID3v2 tag as FMPS_Rating TXXX frame +/// Rating should be 0-5, where 0 means unrated (removes existing frame) +/// Returns true on success, false on invalid rating +bool exportRatingIntoTag( + TagLib::ID3v2::Tag* pTag, + int rating); + void importTrackMetadataFromTag( TrackMetadata* pTrackMetadata, const TagLib::ID3v2::Tag& tag, diff --git a/src/track/taglib/trackmetadata_mp4.cpp b/src/track/taglib/trackmetadata_mp4.cpp index 993d5d0273f3..aa0f58b2cbe3 100644 --- a/src/track/taglib/trackmetadata_mp4.cpp +++ b/src/track/taglib/trackmetadata_mp4.cpp @@ -9,6 +9,8 @@ #include "track/taglib/trackmetadata_mp4.h" +#include + #include "track/taglib/trackmetadata_common.h" #include "track/tracknumbers.h" #include "util/logger.h" @@ -41,6 +43,38 @@ const TagLib::String kAtomKeySeratoBeatGrid = "----:com.serato.dj:beatgrid"; const TagLib::String kAtomKeySeratoMarkers = "----:com.serato.dj:markers"; const TagLib::String kAtomKeySeratoMarkers2 = "----:com.serato.dj:markersv2"; +// FMPS Rating - freeform atom for cross-application rating compatibility +// https://www.freedesktop.org/wiki/Specifications/free-media-player-specs/ +// Using org.freedesktop.FMPS namespace for compatibility +const TagLib::String kAtomKeyFMPSRating = "----:org.freedesktop.FMPS:FMPS_Rating"; + +// Rating conversion functions +// FMPS uses 0.0-1.0 scale, Mixxx uses 0-5 (with 0 meaning unrated) + +/// Convert Mixxx rating (0-5) to FMPS rating (0.0-1.0) +double mixxxRatingToFMPS(int rating) { + if (rating <= 0 || rating > 5) { + return 0.0; + } + return rating / 5.0; +} + +/// Convert FMPS rating (0.0-1.0) to Mixxx rating (0-5) +int fmpsRatingToMixxx(double fmps) { + if (fmps < 0.1) { + return 0; // Unrated + } else if (fmps < 0.3) { + return 1; + } else if (fmps < 0.5) { + return 2; + } else if (fmps < 0.7) { + return 3; + } else if (fmps < 0.9) { + return 4; + } else { + return 5; + } +} bool readAtom( const TagLib::MP4::Tag& tag, @@ -101,6 +135,60 @@ inline void updateAtom( namespace mp4 { +std::optional importRatingFromTag(const TagLib::MP4::Tag& tag) { + QString fmpsRatingStr; + if (readAtom(tag, kAtomKeyFMPSRating, &fmpsRatingStr) && + !fmpsRatingStr.isEmpty()) { + bool ok = false; + double fmpsValue = fmpsRatingStr.toDouble(&ok); + if (ok && fmpsValue >= 0.0 && fmpsValue <= 1.0) { + int rating = fmpsRatingToMixxx(fmpsValue); + kLogger.debug() + << "Imported FMPS_Rating from MP4 atom:" + << fmpsValue << "->" << rating; + return rating; + } else { + kLogger.warning() + << "Invalid FMPS_Rating value in MP4 atom:" + << fmpsRatingStr; + } + } + + // No rating found + return std::nullopt; +} + +bool exportRatingIntoTag( + TagLib::MP4::Tag* pTag, + int rating) { + DEBUG_ASSERT(pTag); + + // Convert rating to FMPS format and write as freeform atom + if (rating > 0 && rating <= 5) { + double fmpsRating = mixxxRatingToFMPS(rating); + QString fmpsRatingStr = QString::number(fmpsRating, 'f', 1); + writeAtom( + pTag, + kAtomKeyFMPSRating, + toTString(fmpsRatingStr)); + kLogger.debug() + << "Exported rating to FMPS_Rating MP4 atom:" + << rating << "->" << fmpsRatingStr; + return true; + } else if (rating == 0) { + // Remove existing FMPS_Rating atom if rating is cleared + pTag->removeItem(kAtomKeyFMPSRating); + kLogger.debug() + << "Removed FMPS_Rating MP4 atom (rating cleared)"; + return true; + } + + // Invalid rating + kLogger.warning() + << "Invalid rating value for export:" << rating; + return false; +} + bool importCoverImageFromTag( QImage* pCoverArt, const TagLib::MP4::Tag& tag) { diff --git a/src/track/taglib/trackmetadata_mp4.h b/src/track/taglib/trackmetadata_mp4.h index e71dca8625bd..331c0286e86c 100644 --- a/src/track/taglib/trackmetadata_mp4.h +++ b/src/track/taglib/trackmetadata_mp4.h @@ -2,6 +2,8 @@ #include +#include + class QImage; namespace mixxx { @@ -12,6 +14,17 @@ namespace taglib { namespace mp4 { +/// Import rating from MP4 tag (FMPS_Rating freeform atom) +/// Returns std::nullopt if no rating is found, or a value 0-5 if found +std::optional importRatingFromTag(const TagLib::MP4::Tag& tag); + +/// Export rating to MP4 tag as FMPS_Rating freeform atom +/// Rating should be 0-5, where 0 means unrated (removes existing atom) +/// Returns true on success, false on invalid rating +bool exportRatingIntoTag( + TagLib::MP4::Tag* pTag, + int rating); + void importTrackMetadataFromTag( TrackMetadata* pTrackMetadata, const TagLib::MP4::Tag& tag, diff --git a/src/track/taglib/trackmetadata_xiph.cpp b/src/track/taglib/trackmetadata_xiph.cpp index 654de4ad7763..e5f8e32cbe9b 100644 --- a/src/track/taglib/trackmetadata_xiph.cpp +++ b/src/track/taglib/trackmetadata_xiph.cpp @@ -12,6 +12,7 @@ #include #include +#include #include "track/taglib/trackmetadata_common.h" #include "track/tracknumbers.h" @@ -41,6 +42,40 @@ const TagLib::String kCommentFieldKeySeratoBeatGrid = "SERATO_BEATGRID"; const TagLib::String kCommentFieldKeySeratoMarkers2FLAC = "SERATO_MARKERS_V2"; const TagLib::String kCommentFieldKeySeratoMarkers2Ogg = "SERATO_MARKERS2"; +// FMPS Rating - Vorbis comment field for cross-application rating compatibility +// https://www.freedesktop.org/wiki/Specifications/free-media-player-specs/ +const TagLib::String kCommentFieldKeyFMPSRating = "FMPS_RATING"; + +// Rating conversion functions +// FMPS uses 0.0-1.0 scale, Mixxx uses 0-5 (with 0 meaning unrated) + +/// Convert Mixxx rating (0-5) to FMPS rating (0.0-1.0) +/// Returns 0.0 for unrated (0), maps 1-5 to 0.2, 0.4, 0.6, 0.8, 1.0 +double mixxxRatingToFMPS(int rating) { + if (rating <= 0 || rating > 5) { + return 0.0; + } + return rating / 5.0; +} + +/// Convert FMPS rating (0.0-1.0) to Mixxx rating (0-5) +/// Uses symmetric thresholds: 0.1, 0.3, 0.5, 0.7, 0.9 +int fmpsRatingToMixxx(double fmps) { + if (fmps < 0.1) { + return 0; // Unrated + } else if (fmps < 0.3) { + return 1; + } else if (fmps < 0.5) { + return 2; + } else if (fmps < 0.7) { + return 3; + } else if (fmps < 0.9) { + return 4; + } else { + return 5; + } +} + bool readCommentField( const TagLib::Ogg::XiphComment& tag, const TagLib::String& key, @@ -127,6 +162,60 @@ inline QImage parseBase64EncodedImage( namespace xiph { +std::optional importRatingFromTag(const TagLib::Ogg::XiphComment& tag) { + QString fmpsRatingStr; + if (readCommentField(tag, kCommentFieldKeyFMPSRating, &fmpsRatingStr) && + !fmpsRatingStr.isEmpty()) { + bool ok = false; + double fmpsValue = fmpsRatingStr.toDouble(&ok); + if (ok && fmpsValue >= 0.0 && fmpsValue <= 1.0) { + int rating = fmpsRatingToMixxx(fmpsValue); + kLogger.debug() + << "Imported FMPS_RATING from Vorbis comment:" + << fmpsValue << "->" << rating; + return rating; + } else { + kLogger.warning() + << "Invalid FMPS_RATING value in Vorbis comment:" + << fmpsRatingStr; + } + } + + // No rating found + return std::nullopt; +} + +bool exportRatingIntoTag( + TagLib::Ogg::XiphComment* pTag, + int rating) { + DEBUG_ASSERT(pTag); + + // Convert rating to FMPS format and write as comment field + if (rating > 0 && rating <= 5) { + double fmpsRating = mixxxRatingToFMPS(rating); + QString fmpsRatingStr = QString::number(fmpsRating, 'f', 1); + writeCommentField( + pTag, + kCommentFieldKeyFMPSRating, + toTString(fmpsRatingStr)); + kLogger.debug() + << "Exported rating to FMPS_RATING Vorbis comment:" + << rating << "->" << fmpsRatingStr; + return true; + } else if (rating == 0) { + // Remove existing FMPS_RATING field if rating is cleared + pTag->removeFields(kCommentFieldKeyFMPSRating); + kLogger.debug() + << "Removed FMPS_RATING Vorbis comment (rating cleared)"; + return true; + } + + // Invalid rating + kLogger.warning() + << "Invalid rating value for export:" << rating; + return false; +} + QImage importCoverImageFromPictureList( const TagLib::List& pictures) { if (pictures.isEmpty()) { diff --git a/src/track/taglib/trackmetadata_xiph.h b/src/track/taglib/trackmetadata_xiph.h index 8cbffa4fa725..25772ba373c5 100644 --- a/src/track/taglib/trackmetadata_xiph.h +++ b/src/track/taglib/trackmetadata_xiph.h @@ -2,6 +2,8 @@ #include +#include + #include "track/taglib/trackmetadata_file.h" namespace mixxx { @@ -10,6 +12,17 @@ namespace taglib { namespace xiph { +/// Import rating from Xiph/Vorbis comment (FMPS_RATING field) +/// Returns std::nullopt if no rating is found, or a value 0-5 if found +std::optional importRatingFromTag(const TagLib::Ogg::XiphComment& tag); + +/// Export rating to Xiph/Vorbis comment as FMPS_RATING field +/// Rating should be 0-5, where 0 means unrated (removes existing field) +/// Returns true on success, false on invalid rating +bool exportRatingIntoTag( + TagLib::Ogg::XiphComment* pTag, + int rating); + bool importCoverImageFromTag( QImage* pCoverArt, TagLib::Ogg::XiphComment& tag); diff --git a/src/track/track.cpp b/src/track/track.cpp index 8c0a31c9c038..29129f0dc98d 100644 --- a/src/track/track.cpp +++ b/src/track/track.cpp @@ -80,6 +80,10 @@ SyncTrackMetadataParams SyncTrackMetadataParams::readFromUserSettings( mixxx::library::prefs::kResetMissingTagMetadataOnImportConfigKey), .syncSeratoMetadata = userSettings.getValue( mixxx::library::prefs::kSyncSeratoMetadataConfigKey), + .syncRating = userSettings.getValue( + mixxx::library::prefs::kExportRatingToFileTagsConfigKey), + .importRatingFromFile = userSettings.getValue( + mixxx::library::prefs::kImportRatingFromFileTagsConfigKey), }; } @@ -1773,7 +1777,10 @@ ExportTrackMetadataResult Track::exportMetadata( // updated as expected! In these edge cases users need to explicitly // trigger the re-export of file tags or they could modify other metadata // properties. + // Check if rating needs to be exported (rating is not part of TrackMetadata) + const bool ratingNeedsExport = syncParams.syncRating && m_record.hasRating(); if (!m_bMarkedForMetadataExport && + !ratingNeedsExport && !normalizedFromRecord.anyFileTagsModified( importedFromFile, mixxx::Bpm::Comparison::Integer)) { @@ -1829,6 +1836,14 @@ ExportTrackMetadataResult Track::exportMetadata( << "Exported track metadata:" << getLocation(); } + // Export rating if enabled + if (syncParams.syncRating && m_record.hasRating()) { + if (!metadataSource.exportRating(m_record.getRating())) { + kLogger.warning() + << "Failed to export rating to file:" + << getLocation(); + } + } return ExportTrackMetadataResult::Succeeded; case mixxx::MetadataSource::ExportResult::Unsupported: return ExportTrackMetadataResult::Skipped; diff --git a/src/track/track_decl.h b/src/track/track_decl.h index c3edb26daec4..be673eb7426f 100644 --- a/src/track/track_decl.h +++ b/src/track/track_decl.h @@ -17,6 +17,8 @@ typedef QList TrackPointerList; struct SyncTrackMetadataParams { bool resetMissingTagMetadataOnImport = false; bool syncSeratoMetadata = false; + bool syncRating = false; // export rating to file tags + bool importRatingFromFile = false; // import rating from file tags static SyncTrackMetadataParams readFromUserSettings( const UserSettings& userSettings);