Merge pull request #24 from Martchus/rating

Scale rating automatically when serializing popularity/rating field
This commit is contained in:
Martchus 2022-07-20 23:26:52 +02:00 committed by GitHub
commit 1a5dff611c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 205 additions and 13 deletions

View File

@ -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(

View File

@ -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<double>(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 "

View File

@ -92,6 +92,14 @@ pair<const char *, float> 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<std::vector<std::string_view>>(str, "|");
auto res = Popularity();
auto res = Popularity({ .scale = scale });
if (parts.empty()) {
return res;
} else if (parts.size() > 3) {

View File

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

View File

@ -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<std::uint64_t>(42), tagValue.toUnsignedInteger());
CPPUNIT_ASSERT_EQUAL_MESSAGE("conversion to integer", 40, tagValue.toInteger());
CPPUNIT_ASSERT_EQUAL_MESSAGE("conversion to unsigned integer", static_cast<std::uint64_t>(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);
}
}

View File

@ -104,6 +104,7 @@ template <class StreamType> 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();