use crate::tag::TagType;
use std::borrow::Cow;
use std::fmt::{Debug, Display, Formatter};
use std::sync::OnceLock;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StarRating {
One = 1,
Two = 2,
Three = 3,
Four = 4,
Five = 5,
}
pub trait RatingProvider: Send + Sync {
fn supports_email(&self, email: &str) -> bool;
fn default_email(&self) -> &'static str {
"" }
fn rate(&self, tag_type: TagType, rating: StarRating) -> u8;
fn convert_raw(&self, tag_type: TagType, rating: u8) -> StarRating;
}
static CUSTOM_PROVIDER: OnceLock<&'static dyn RatingProvider> = OnceLock::new();
pub fn set_custom_rating_provider<T>(provider: T)
where
T: RatingProvider + 'static,
{
assert!(
CUSTOM_PROVIDER.set(Box::leak(Box::new(provider))).is_ok(),
"Multiple calls to `set_custom_rating_provider()`"
);
}
fn custom_provider() -> &'static dyn RatingProvider {
CUSTOM_PROVIDER.get().map_or(DEFAULT_PROVIDER, |p| *p)
}
pub struct DefaultRatingProvider;
impl RatingProvider for DefaultRatingProvider {
fn supports_email(&self, _: &str) -> bool {
true
}
fn default_email(&self) -> &'static str {
MUSICBEE_PROVIDER.default_email()
}
fn rate(&self, tag_type: TagType, rating: StarRating) -> u8 {
MUSICBEE_PROVIDER.rate(tag_type, rating)
}
fn convert_raw(&self, tag_type: TagType, rating: u8) -> StarRating {
MUSICBEE_PROVIDER.convert_raw(tag_type, rating)
}
}
static DEFAULT_PROVIDER: &'static dyn RatingProvider = &DefaultRatingProvider;
static MUSICBEE_PROVIDER: &'static dyn RatingProvider = &MusicBeeProvider;
static WMP_PROVIDER: &'static dyn RatingProvider = &WindowsMediaPlayerProvider;
static PICARD_PROVIDER: &'static dyn RatingProvider = &PicardProvider;
pub struct MusicBeeProvider;
impl RatingProvider for MusicBeeProvider {
fn supports_email(&self, email: &str) -> bool {
email == Popularimeter::MUSICBEE_EMAIL
}
fn default_email(&self) -> &'static str {
Popularimeter::MUSICBEE_EMAIL
}
fn rate(&self, tag_type: TagType, rating: StarRating) -> u8 {
match tag_type {
TagType::Id3v2 => match rating {
StarRating::One => 1,
StarRating::Two => 64,
StarRating::Three => 128,
StarRating::Four => 196,
StarRating::Five => 255,
},
_ => {
let stars = rating as u8;
stars.saturating_mul(20)
},
}
}
#[allow(clippy::match_overlapping_arm)]
fn convert_raw(&self, tag_type: TagType, rating: u8) -> StarRating {
match tag_type {
TagType::Id3v2 => match rating {
..=1 => StarRating::One,
..=64 => StarRating::Two,
..=128 => StarRating::Three,
..=196 => StarRating::Four,
..=255 => StarRating::Five,
},
_ => match rating {
..=20 => StarRating::One,
..=40 => StarRating::Two,
..=60 => StarRating::Three,
..=80 => StarRating::Four,
_ => StarRating::Five,
},
}
}
}
pub struct WindowsMediaPlayerProvider;
impl RatingProvider for WindowsMediaPlayerProvider {
fn supports_email(&self, email: &str) -> bool {
email == Popularimeter::WMP_EMAIL
}
fn default_email(&self) -> &'static str {
Popularimeter::WMP_EMAIL
}
fn rate(&self, _: TagType, rating: StarRating) -> u8 {
MusicBeeProvider.rate(TagType::Id3v2, rating)
}
fn convert_raw(&self, _: TagType, rating: u8) -> StarRating {
MusicBeeProvider.convert_raw(TagType::Id3v2, rating)
}
}
pub struct PicardProvider;
impl RatingProvider for PicardProvider {
fn supports_email(&self, email: &str) -> bool {
email == Popularimeter::PICARD_EMAIL
}
fn default_email(&self) -> &'static str {
Popularimeter::PICARD_EMAIL
}
fn rate(&self, tag_type: TagType, rating: StarRating) -> u8 {
match tag_type {
TagType::Id3v2 => {
let stars = rating as u8;
stars.saturating_mul(51)
},
_ => {
let stars = rating as u8;
stars.saturating_mul(5)
},
}
}
#[allow(clippy::match_overlapping_arm)]
fn convert_raw(&self, tag_type: TagType, rating: u8) -> StarRating {
match tag_type {
TagType::Id3v2 => match rating {
..=51 => StarRating::One,
..=102 => StarRating::Two,
..=153 => StarRating::Three,
..=204 => StarRating::Four,
..=255 => StarRating::Five,
},
_ => match rating {
..=5 => StarRating::One,
..=10 => StarRating::Two,
..=15 => StarRating::Three,
..=20 => StarRating::Four,
_ => StarRating::Five,
},
}
}
}
#[derive(Clone)]
pub struct Popularimeter<'a> {
pub(crate) email: Option<Cow<'a, str>>,
pub(crate) rating_provider: &'static dyn RatingProvider,
pub rating: StarRating,
pub play_counter: u64,
}
impl Debug for Popularimeter<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Popularimeter")
.field("email", &self.email)
.field("rating", &self.rating)
.field("play_counter", &self.play_counter)
.finish()
}
}
impl<'a> Popularimeter<'a> {
const WMP_EMAIL: &'static str = "Windows Media Player 9 Series";
const MUSICBEE_EMAIL: &'static str = "MusicBee";
const PICARD_EMAIL: &'static str = "users@musicbrainz.org";
pub fn custom(email: impl Into<Cow<'a, str>>, rating: StarRating, play_counter: u64) -> Self {
Self {
email: Some(email.into()),
rating_provider: custom_provider(),
rating,
play_counter,
}
}
pub(crate) fn mapped(
email: impl Into<Cow<'a, str>>,
tag_type: TagType,
rate: u8,
play_counter: u64,
) -> Option<Self> {
let mut email = email.into();
let rating_provider;
match &*email {
Popularimeter::WMP_EMAIL => rating_provider = WMP_PROVIDER,
Popularimeter::MUSICBEE_EMAIL => rating_provider = MUSICBEE_PROVIDER,
Popularimeter::PICARD_EMAIL => rating_provider = PICARD_PROVIDER,
_ => {
rating_provider = custom_provider();
if email.is_empty() {
email = rating_provider.default_email().into();
}
if !rating_provider.supports_email(&email) {
return None;
}
},
}
let star_rating = rating_provider.convert_raw(tag_type, rate);
Some(Self {
email: (!email.is_empty()).then_some(email),
rating_provider,
rating: star_rating,
play_counter,
})
}
pub fn email(&self) -> &str {
self.email
.as_deref()
.unwrap_or_else(|| self.rating_provider.default_email())
}
pub fn rating(&self) -> StarRating {
self.rating
}
pub(crate) fn mapped_value(&self, tag_type: TagType) -> u8 {
self.rating_provider.rate(tag_type, self.rating)
}
pub(crate) fn from_str(s: &str) -> Result<Self, ()> {
let mut parts = s.splitn(3, '|');
let email = parts.next().ok_or(())?;
let star_rating = parts.next().ok_or(())?;
let play_counter = parts
.next()
.ok_or(())
.and_then(|p| p.parse::<u64>().map_err(|_| ()))?;
let star_rating = match star_rating.parse().map_err(|_| ())? {
1 => StarRating::One,
2 => StarRating::Two,
3 => StarRating::Three,
4 => StarRating::Four,
5 => StarRating::Five,
_ => return Err(()),
};
let rating_provider;
match email {
Popularimeter::WMP_EMAIL => rating_provider = WMP_PROVIDER,
Popularimeter::MUSICBEE_EMAIL => rating_provider = MUSICBEE_PROVIDER,
Popularimeter::PICARD_EMAIL => rating_provider = PICARD_PROVIDER,
_ => {
rating_provider = custom_provider();
if !rating_provider.supports_email(email) {
return Err(());
}
},
}
Ok(Popularimeter {
email: (!email.is_empty()).then(|| Cow::Owned(email.to_owned())),
rating_provider,
rating: star_rating,
play_counter,
})
}
}
impl Popularimeter<'static> {
pub fn windows_media_player(rating: StarRating, play_counter: u64) -> Self {
Self {
email: Some(Cow::Borrowed(Self::WMP_EMAIL)),
rating_provider: WMP_PROVIDER,
rating,
play_counter,
}
}
pub fn musicbee(rating: StarRating, play_counter: u64) -> Self {
Self {
email: Some(Cow::Borrowed(Self::MUSICBEE_EMAIL)),
rating_provider: MUSICBEE_PROVIDER,
rating,
play_counter,
}
}
pub fn picard(rating: StarRating, play_counter: u64) -> Self {
Self {
email: Some(Cow::Borrowed(Self::PICARD_EMAIL)),
rating_provider: PICARD_PROVIDER,
rating,
play_counter,
}
}
}
impl Display for Popularimeter<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let email = self.email.as_deref().unwrap_or("");
write!(f, "{email}|{}|{}", self.rating as u8, self.play_counter)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::id3::v2::{Frame, Id3v2Tag, PopularimeterFrame};
use crate::iff::wav::RiffInfoList;
use crate::mp4::Ilst;
use crate::ogg::tag::VorbisComments;
use crate::prelude::ItemKey;
use crate::tag::{Tag, TagType};
use rusty_fork::rusty_fork_test;
#[test_log::test]
fn popm_conversions() {
let expected = Popularimeter::musicbee(StarRating::Three, 5);
for tag_type in [
TagType::Id3v2,
TagType::VorbisComments,
TagType::Mp4Ilst,
TagType::RiffInfo,
] {
let mut tag = Tag::new(tag_type);
tag.insert_text(ItemKey::Popularimeter, expected.to_string());
let mut check_play_counter = true;
match tag_type {
TagType::Id3v2 => {
let t: Id3v2Tag = tag.into();
tag = t.into();
},
TagType::Mp4Ilst => {
let t: Ilst = tag.into();
tag = t.into();
check_play_counter = false;
},
TagType::VorbisComments => {
let t: VorbisComments = tag.into();
tag = t.into();
check_play_counter = false;
},
TagType::RiffInfo => {
let t: RiffInfoList = tag.into();
tag = t.into();
check_play_counter = false;
},
_ => unreachable!(),
}
let mut ratings = tag.ratings();
let popm = ratings.next().unwrap();
assert!(ratings.next().is_none());
assert_eq!(popm.email(), expected.email());
assert_eq!(popm.rating(), expected.rating());
if check_play_counter {
assert_eq!(popm.play_counter, expected.play_counter);
}
}
}
rusty_fork_test! {
#[test_log::test]
fn popm_custom_provider() {
let frame = PopularimeterFrame::new("foo@example.com", 128, 40);
let mut tag = Id3v2Tag::new();
tag.insert(Frame::Popularimeter(frame.clone()));
let tag: Tag = tag.into();
let popm = tag.ratings().next().unwrap();
assert_eq!(popm.email(), "foo@example.com");
assert_eq!(popm.rating(), StarRating::Three);
assert_eq!(popm.play_counter, 40);
struct CustomRatingProvider;
impl RatingProvider for CustomRatingProvider {
fn supports_email(&self, email: &str) -> bool {
email.starts_with("foo")
}
fn rate(&self, tag_type: TagType, rating: StarRating) -> u8 {
match tag_type {
TagType::Id3v2 => match rating {
StarRating::One => 128,
_ => unreachable!(),
},
_ => unreachable!(),
}
}
fn convert_raw(&self, tag_type: TagType, rating: u8) -> StarRating {
match tag_type {
TagType::Id3v2 => match rating {
128 => StarRating::One,
_ => unreachable!(),
},
_ => unreachable!(),
}
}
}
set_custom_rating_provider(CustomRatingProvider);
let mut tag = Id3v2Tag::new();
tag.insert(Frame::Popularimeter(frame.clone()));
tag.insert(Frame::Popularimeter(PopularimeterFrame::new(
"bar@example.com",
128,
40,
)));
let tag: Tag = tag.into();
let mut ratings = tag.ratings();
let popm = ratings.next().unwrap();
assert!(ratings.next().is_none());
assert_eq!(popm.email(), "foo@example.com");
assert_eq!(popm.rating(), StarRating::One);
assert_eq!(popm.play_counter, 40);
}
}
}