diff --git a/id3/id3v2frame.cpp b/id3/id3v2frame.cpp index d3c407e..b8a34b6 100644 --- a/id3/id3v2frame.cpp +++ b/id3/id3v2frame.cpp @@ -719,7 +719,7 @@ Id3v2FrameMaker::Id3v2FrameMaker(Id3v2Frame &frame, std::uint8_t version, Diagno // make popularimeter frame auto popularity = Popularity(); try { - popularity = values.front()->toPopularity(); + popularity = values.front()->toScaledPopularity(TagType::Id3v2Tag); } catch (const ConversionException &) { diag.emplace_back(DiagLevel::Warning, argsToString( diff --git a/matroska/matroskatagfield.cpp b/matroska/matroskatagfield.cpp index 01b48f7..900a77b 100644 --- a/matroska/matroskatagfield.cpp +++ b/matroska/matroskatagfield.cpp @@ -1,6 +1,7 @@ #include "./matroskatagfield.h" #include "./ebmlelement.h" #include "./matroskacontainer.h" +#include "./matroskatagid.h" #include "../exceptions.h" @@ -136,6 +137,15 @@ void MatroskaTagField::reparse(EbmlElement &simpleTagElement, Diagnostics &diag, "\"SimpleTag\"-element contains unknown element ", child->idToString(), " at ", child->startOffset(), ". It will be ignored."), context); } + + // set rating as Popularity to preserve the scale information + if (id() == MatroskaTagIds::rating()) { + try { + value().assignPopularity(Popularity{ .rating = stringToNumber(value().toString()), .scale = TagType::MatroskaTag }); + } catch (const ConversionException &) { + diag.emplace_back(DiagLevel::Warning, argsToString("The rating is not a number."), context); + } + } } } @@ -207,7 +217,11 @@ MatroskaTagFieldMaker::MatroskaTagFieldMaker(MatroskaTagField &field, Diagnostic , m_isBinary(false) { try { - m_stringValue = m_field.value().toString(); + if (m_field.value().type() == TagDataType::Popularity) { + m_stringValue = m_field.value().toScaledPopularity(TagType::MatroskaTag).toString(); + } else { + m_stringValue = m_field.value().toString(); + } } catch (const ConversionException &) { diag.emplace_back(DiagLevel::Warning, "The assigned tag value can not be converted to a string and is treated as binary value (which is likely not what you want since " diff --git a/tagvalue.cpp b/tagvalue.cpp index 56f16a7..28faec6 100644 --- a/tagvalue.cpp +++ b/tagvalue.cpp @@ -92,6 +92,14 @@ pair encodingParameter(TagTextEncoding tagTextEncoding) * To ensure that, the functions Tag::canEncodingBeUsed(), Tag::proposedTextEncoding() and * Tag::ensureTextValuesAreProperlyEncoded() can be used. * + * Values of the type TagDataType::Popularity might use different rating scales depending on the tag + * format. + * - You can assign a Popularity object of any scale. Tag implementations will convert it accordingly. + * - You can use TagValue::toScaledPopularity() to retrieve a Popularity object of the desired scale. + * - When just working with text data (via TagValue::toString() and TagValue::assignText()), no scaling + * of internally assigned Popularity objects is done; so you're working with raw rating values in this + * case. + * * Values of the type TagDataType::Text are not supposed to contain Byte-Order-Marks. Before assigning text * which might be prepended by a Byte-Order-Mark the helper function TagValue::stripBom() can be used. */ @@ -624,6 +632,14 @@ DateTime TagValue::toDateTime() const * \brief Converts the value of the current TagValue object to its equivalent * Popularity representation. * \throws Throws ConversionException on failure. + * \remarks + * - If text is assigned, the returned popularity's scale will always be TagType::Unspecified + * as the text representation does not preserve the scale. Assign the correct scale if needed + * manually. Note that tag field implementations provided by this library take care to assign a + * popularity (and not just text) when parsing the popularity/rating fields to preserve the + * scale information. + * - Use TagValue::toScaledPopularity() if you want to convert the rating to a certain scale (to + * use that scale consistently without having to deal with multiple scales yourself). */ Popularity TagValue::toPopularity() const { @@ -662,6 +678,36 @@ Popularity TagValue::toPopularity() const return popularity; } +/*! + * \brief Converts the value of the current TagValue object to its equivalent + * Popularity representation using the specified \a scale. + * \throws Throws ConversionException on failure, e.g. when Popularity::scaleTo() fails. + * \remarks + * 1. See Popularity::scaleTo() for details about scaling. + * 2. If text is assigned, it is converted like with TagValue::toPopularity(). However, + * the specified \a a scale is *assigned* as the popularity's scale assuming that the + * text representation already contains a rating with the desired \a scale. That means + * if you assign text to a TagValue, the tag implementations (which use this function + * internally) will use that text as-is when serializing the popularity/rating field. + * 3. Since TagValue::toString() also does not do any scaling the previous point means that + * if you only ever use TagValue::assignText() (or equivalent c'tors) and TagValue::toString() + * you will always work with raw rating values consistently. + * 4. Since tag implementations provided by this library always take care to assign the + * popularity/rating as such (and not just as text) you do not need to care about point 2. if + * you want to use a certain scale consistently. Just call this function with the desired scal + * when reading and assign a popularity object with that scale before saving changes. + */ +Popularity TagValue::toScaledPopularity(TagType scale) const +{ + auto popularity = toPopularity(); + if (m_type == TagDataType::Text) { + popularity.scale = scale; + } else if (!popularity.scaleTo(scale)) { + throw ConversionException(argsToString("Assigned popularity cannot be scaled accordingly")); + } + return popularity; +} + /*! * \brief Converts the currently assigned text value to the specified \a encoding. * \throws Throws CppUtilities::ConversionException() if the conversion fails. @@ -770,7 +816,8 @@ void TagValue::convertDescriptionEncoding(TagTextEncoding encoding) * \remarks * - Not all types can be converted to a string, eg. TagDataType::Picture, TagDataType::Binary and * TagDataType::Unspecified will always fail to convert. - * - If UTF-16 is the desired output \a encoding, it makes sense to use the toWString() method instead. + * - If UTF-16 is the desired output \a encoding, it makes sense to use TagValue::toWString() instead. + * - If a popularity is assigned, its string representation is returned without further scaling. * \throws Throws ConversionException on failure. */ void TagValue::toString(string &result, TagTextEncoding encoding) const @@ -1181,6 +1228,67 @@ const TagValue &TagValue::empty() return emptyTagValue; } +/*! + * \brief Scales the rating from the current scale to \a targetScale. + * \returns + * Returns whether a conversion from the current scale to \a targetScale was possible. If no, the object stays unchanged. + * Note that it is not validated whether the currently assigned rating is a valid value in the currently assigned scale. + * \remarks + * - Providing TagType::Unspecified as \a targetScale will convert to a *generic* scale where the rating is number is between + * 1 and 5 with decimal values possible where 5 is the best possible rating and 1 the lowest. The value 0 means there's no + * rating. + * - If the currently assigned scale is TagType::Unspecified than the currently assigned rating is assumed to use the *generic* + * scale described in the previous point. + */ +bool Popularity::scaleTo(TagType targetScale) +{ + if (scale == targetScale) { + return true; + } + + // convert to generic scale first + double genericRating; + switch (scale) { + case TagType::Unspecified: + genericRating = rating; + break; + case TagType::MatroskaTag: + genericRating = rating / (5.0 / 4.0) + 1.0; + break; + case TagType::Id3v2Tag: + genericRating = rating < 1.0 ? 0.0 : ((rating - 1.0) / (254.0 / 4.0) + 1.0); + break; + case TagType::VorbisComment: + case TagType::OggVorbisComment: + genericRating = rating / 20.0; + break; + default: + return false; + } + + // convert from the generic scale to the target scale + switch (targetScale) { + case TagType::Unspecified: + rating = genericRating; + break; + case TagType::MatroskaTag: + rating = (genericRating - 1.0) * (5.0 / 4.0); + break; + case TagType::Id3v2Tag: + rating = genericRating < 1.0 ? 0.0 : ((genericRating - 1.0) * (254.0 / 4.0) + 1.0); + break; + case TagType::VorbisComment: + case TagType::OggVorbisComment: + rating = genericRating * 20.0; + break; + default: + return false; + } + + scale = targetScale; + return true; +} + /*! * \brief Returns the popularity as string in the format "rating" if only a rating is present * or in the format "user|rating|play-counter" or an empty string if the popularity isEmpty(). @@ -1192,13 +1300,26 @@ std::string Popularity::toString() const } /*! - * \brief Parses the popularity from \a str assuming the same format as toString() produces. + * \brief Parses the popularity from \a str assuming the same format as toString() produces and + * sets TagType::Unspecified as scale. So \a str is expected to contain a rating within + * the range of 1.0 and 5.0 or 0.0 to denote there's no rating. * \throws Throws ConversionException() if the format is invalid. */ Popularity Popularity::fromString(std::string_view str) +{ + return fromString(str, TagType::Unspecified); +} + +/*! + * \brief Parses the popularity from \a str assuming the same format as toString() produces and assigns the + * specified \a scale. So \a str is expected to contain a rating according to the specifications of + * the tag format passed via \a scale. + * \throws Throws ConversionException() if the format is invalid. + */ +TagParser::Popularity TagParser::Popularity::fromString(std::string_view str, TagType scale) { const auto parts = splitStringSimple>(str, "|"); - auto res = Popularity(); + auto res = Popularity({ .scale = scale }); if (parts.empty()) { return res; } else if (parts.size() > 3) { diff --git a/tagvalue.h b/tagvalue.h index 2b08d15..d5f49d9 100644 --- a/tagvalue.h +++ b/tagvalue.h @@ -77,13 +77,15 @@ struct TAG_PARSER_EXPORT Popularity { /// \brief Play counter specific to the user. std::uint64_t playCounter = 0; /// \brief Specifies the scale used for \a rating by the tag defining that scale. - /// \remarks The value TagType::Unspecified is preserved to denote a generic scale is used (no - /// conversions to/from a generic scale to tag format specific scales have been implemented at - /// this point). + /// \remarks The value TagType::Unspecified is used to denote a *generic* scale from 1.0 to + /// 5.0 where 5.0 is the best and the special value 0.0 stands for "not rated". TagType scale = TagType::Unspecified; + bool scaleTo(TagType targetScale); + Popularity scaled(TagType targetScale) const; std::string toString() const; static Popularity fromString(std::string_view str); + static Popularity fromString(std::string_view str, TagType scale); /// \brief Returns whether the Popularity is empty. The \a scale and zero-values don't count. bool isEmpty() const @@ -101,6 +103,16 @@ struct TAG_PARSER_EXPORT Popularity { } }; +/*! + * \brief Same as Popularity::scaleTo() but returns a new object. + */ +inline Popularity Popularity::scaled(TagType targetScale) const +{ + auto copy = *this; + copy.scaleTo(targetScale); + return copy; +} + /*! * \brief Specifies the data type. */ @@ -179,6 +191,7 @@ public: CppUtilities::TimeSpan toTimeSpan() const; CppUtilities::DateTime toDateTime() const; Popularity toPopularity() const; + Popularity toScaledPopularity(TagType scale = TagType::Unspecified) const; std::size_t dataSize() const; char *dataPointer(); const char *dataPointer() const; diff --git a/tests/tagvalue.cpp b/tests/tagvalue.cpp index 7de1782..cf4465c 100644 --- a/tests/tagvalue.cpp +++ b/tests/tagvalue.cpp @@ -31,6 +31,7 @@ class TagValueTests : public TestFixture { CPPUNIT_TEST(testPopularity); CPPUNIT_TEST(testString); CPPUNIT_TEST(testEqualityOperator); + CPPUNIT_TEST(testPopularityScaling); CPPUNIT_TEST_SUITE_END(); public: @@ -47,6 +48,7 @@ public: void testPopularity(); void testString(); void testEqualityOperator(); + void testPopularityScaling(); }; CPPUNIT_TEST_SUITE_REGISTRATION(TagValueTests); @@ -179,18 +181,22 @@ void TagValueTests::testDateTime() void TagValueTests::testPopularity() { - const auto tagValue = TagValue(Popularity{ .user = "foo", .rating = 42, .playCounter = 123, .scale = TagType::VorbisComment }); + const auto tagValue = TagValue(Popularity{ .user = "foo", .rating = 40.0, .playCounter = 123, .scale = TagType::VorbisComment }); const auto popularity = tagValue.toPopularity(); CPPUNIT_ASSERT_EQUAL_MESSAGE("conversion to popularity (user)", "foo"s, popularity.user); - CPPUNIT_ASSERT_EQUAL_MESSAGE("conversion to popularity (rating)", 42.0, popularity.rating); + CPPUNIT_ASSERT_EQUAL_MESSAGE("conversion to popularity (rating)", 40.0, popularity.rating); CPPUNIT_ASSERT_EQUAL_MESSAGE("conversion to popularity (play counter)", std::uint64_t(123), popularity.playCounter); CPPUNIT_ASSERT_EQUAL_MESSAGE("conversion to popularity (scale)", TagType::VorbisComment, popularity.scale); - CPPUNIT_ASSERT_EQUAL_MESSAGE("conversion to string", "foo|42|123"s, tagValue.toString()); + CPPUNIT_ASSERT_EQUAL_MESSAGE("conversion to string", "foo|40|123"s, tagValue.toString()); CPPUNIT_ASSERT_EQUAL_MESSAGE("conversion to string (only rating)", "43"s, TagValue(Popularity{ .rating = 43 }).toString()); - CPPUNIT_ASSERT_EQUAL_MESSAGE("conversion to integer", 42, tagValue.toInteger()); - CPPUNIT_ASSERT_EQUAL_MESSAGE("conversion to unsigned integer", static_cast(42), tagValue.toUnsignedInteger()); + CPPUNIT_ASSERT_EQUAL_MESSAGE("conversion to integer", 40, tagValue.toInteger()); + CPPUNIT_ASSERT_EQUAL_MESSAGE("conversion to unsigned integer", static_cast(40), tagValue.toUnsignedInteger()); CPPUNIT_ASSERT_THROW_MESSAGE( "failing conversion to other type", TagValue("foo|bar"sv, TagTextEncoding::Latin1).toPopularity(), ConversionException); + const auto scaledPopularity = tagValue.toScaledPopularity(); + CPPUNIT_ASSERT_EQUAL_MESSAGE("rating scaled to generic scale", 2.0, scaledPopularity.rating); + CPPUNIT_ASSERT_THROW_MESSAGE( + "failed to scale if no scaling for specified format defined", tagValue.toScaledPopularity(TagType::Mp4Tag), ConversionException); } void TagValueTests::testString() @@ -285,3 +291,38 @@ void TagValueTests::testEqualityOperator() CPPUNIT_ASSERT_MESSAGE("meta-data case must match by default"s, withDescription != withDescription2); CPPUNIT_ASSERT_MESSAGE("meta-data case ignored"s, withDescription.compareTo(withDescription2, TagValueComparisionFlags::CaseInsensitive)); } + +void TagValueTests::testPopularityScaling() +{ + const auto genericZero = Popularity{ .rating = 0.0, .scale = TagType::Unspecified }; + const auto genericMin = Popularity{ .rating = 1.0, .scale = TagType::Unspecified }; + const auto genericMax = Popularity{ .rating = 5.0, .scale = TagType::Unspecified }; + const auto genericMiddle = Popularity{ .rating = 3.0, .scale = TagType::Unspecified }; + const auto id3zero = Popularity{ .rating = 0.0, .scale = TagType::Id3v2Tag }; + const auto id3min = Popularity{ .rating = 1.0, .scale = TagType::Id3v2Tag }; + const auto id3max = Popularity{ .rating = 255.0, .scale = TagType::Id3v2Tag }; + const auto id3middle = Popularity{ .rating = 128.0, .scale = TagType::Id3v2Tag }; + const auto vorbisZero = Popularity{ .rating = 0.0, .scale = TagType::VorbisComment }; + const auto vorbisMin = Popularity{ .rating = 20.0, .scale = TagType::VorbisComment }; + const auto vorbisMax = Popularity{ .rating = 100.0, .scale = TagType::OggVorbisComment }; + const auto vorbisMiddle = Popularity{ .rating = 60.0, .scale = TagType::OggVorbisComment }; + const auto mkvMin = Popularity{ .rating = 0.0, .scale = TagType::MatroskaTag }; + const auto mkvMax = Popularity{ .rating = 5.0, .scale = TagType::MatroskaTag }; + const auto mkvMiddle = Popularity{ .rating = 2.5, .scale = TagType::MatroskaTag }; + for (const auto &rawZero : { id3zero, vorbisZero }) { + CPPUNIT_ASSERT_EQUAL_MESSAGE("zero: raw to generic", genericZero.rating, rawZero.scaled(TagType::Unspecified).rating); + CPPUNIT_ASSERT_EQUAL_MESSAGE("zero: generic to raw ", rawZero.rating, genericZero.scaled(rawZero.scale).rating); + } + for (const auto &rawMin : { id3min, vorbisMin, mkvMin }) { + CPPUNIT_ASSERT_EQUAL_MESSAGE("min: raw to generic", genericMin.rating, rawMin.scaled(TagType::Unspecified).rating); + CPPUNIT_ASSERT_EQUAL_MESSAGE("min: generic to raw ", rawMin.rating, genericMin.scaled(rawMin.scale).rating); + } + for (const auto &rawMax : { id3max, vorbisMax, mkvMax }) { + CPPUNIT_ASSERT_EQUAL_MESSAGE("max: raw to generic", genericMax.rating, rawMax.scaled(TagType::Unspecified).rating); + CPPUNIT_ASSERT_EQUAL_MESSAGE("max: generic to raw ", rawMax.rating, genericMax.scaled(rawMax.scale).rating); + } + for (const auto &rawMiddle : { id3middle, vorbisMiddle, mkvMiddle }) { + CPPUNIT_ASSERT_EQUAL_MESSAGE("middle: raw to generic", genericMiddle.rating, rawMiddle.scaled(TagType::Unspecified).rating); + CPPUNIT_ASSERT_EQUAL_MESSAGE("middle: generic to raw ", rawMiddle.rating, genericMiddle.scaled(rawMiddle.scale).rating); + } +} diff --git a/vorbis/vorbiscommentfield.cpp b/vorbis/vorbiscommentfield.cpp index ea5ef99..c47ddab 100644 --- a/vorbis/vorbiscommentfield.cpp +++ b/vorbis/vorbiscommentfield.cpp @@ -104,6 +104,7 @@ template void VorbisCommentField::internalParse(StreamType &s } catch (const ConversionException &) { // fallback to text value().assignText(str, TagTextEncoding::Utf8); + diag.emplace_back(DiagLevel::Warning, argsToString("The rating is not a number."), context); } } else { // extract other values (as string) @@ -210,6 +211,8 @@ bool VorbisCommentField::make(BinaryWriter &writer, VorbisCommentFlags flags, Di argsToString("An IO error occurred when writing the METADATA_BLOCK_PICTURE struct: ", failure.what()), context); throw Failure(); } + } else if (value().type() == TagDataType::Popularity) { + valueString = value().toScaledPopularity(TagType::VorbisComment).toString(); } else { // make normal string value valueString = value().toString();