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);