Compare commits

...

4 commits

7 changed files with 148 additions and 55 deletions

View file

@ -453,6 +453,23 @@ def package(out_dir: Path):
with open(util.TRANSLATION_FILE_EN) as f: with open(util.TRANSLATION_FILE_EN) as f:
tl_en = json.load(f) tl_en = json.load(f)
# Remove redundant tags
def remove_redundant_tags(gid: str, parent: str):
genre = metadata[gid]
pg = metadata[parent]
if pg.language and pg.language == genre.language:
metadata[gid].language = None
if pg.country and pg.country == genre.country:
metadata[gid].country = None
if pg.region and pg.region == genre.region:
metadata[gid].region = None
if pg.parent:
remove_redundant_tags(gid, pg.parent)
for genre_id, genre in metadata.items():
if genre.parent:
remove_redundant_tags(genre_id, genre.parent)
# Genre database # Genre database
db = { db = {
g_id: model.GenreMetadataDB.conv(genre, tl_en.get(g_id)) g_id: model.GenreMetadataDB.conv(genre, tl_en.get(g_id))

View file

@ -2,6 +2,10 @@
name = "spotify-genrebase" name = "spotify-genrebase"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
authors = ["ThetaDev <thetadev@magenta.de>"]
license = "MIT"
description = "Lookup Spotify genre metadata"
repository = "https://code.thetadev.de/Tiraya/spotify-genres"
[features] [features]
playlists = [] playlists = []
@ -15,4 +19,5 @@ serde_with = { version = "3.0", default-features = false, features = [
"macros", "macros",
] } ] }
serde_json = "1.0" serde_json = "1.0"
serde_plain = "1.0"
thiserror = "1.0" thiserror = "1.0"

View file

@ -6,7 +6,7 @@ use path_macro::path;
use model::{GenreEntry, GenreMeta}; use model::{GenreEntry, GenreMeta};
pub use model::Genre; pub use model::{Genre, Region};
type Translation = HashMap<String, String>; type Translation = HashMap<String, String>;
@ -55,6 +55,11 @@ impl GenreDb {
}) })
} }
/// Return a list of supported languages
pub fn languages(&self) -> Vec<&str> {
self.translations.keys().map(String::as_str).collect()
}
/// Select a supported language from a list of given languages (for example from the /// Select a supported language from a list of given languages (for example from the
/// Accept-Language http headere). /// Accept-Language http headere).
pub fn select_lang<'a, S: AsRef<str>>(&self, langs: &'a [S]) -> Option<&'a str> { pub fn select_lang<'a, S: AsRef<str>>(&self, langs: &'a [S]) -> Option<&'a str> {
@ -125,22 +130,23 @@ impl GenreDb {
GenreEntry::Alias { alias } => self.get_localized(alias, lang), GenreEntry::Alias { alias } => self.get_localized(alias, lang),
GenreEntry::Meta(genre) => { GenreEntry::Meta(genre) => {
if let Some(lang) = lang { if let Some(lang) = lang {
Some(conv_genre( Some(
self.conv_genre(
id, id,
self.get_translated_name(id, lang) self.get_translated_name(id, lang)
.unwrap_or_else(|| genre.name.to_owned()), .unwrap_or_else(|| genre.name.to_owned()),
genre, genre,
)) ),
)
} else { } else {
Some(conv_genre(id, genre.name.to_owned(), genre)) Some(self.conv_genre(id, genre.name.to_owned(), genre))
}
} }
} }
} }
} }
fn conv_genre<'a>(id: &str, name: String, gm: &GenreMeta) -> Genre { fn conv_genre<'a>(&self, id: &str, name: String, gm: &GenreMeta) -> Genre {
Genre { let mut genre = Genre {
id: id.to_owned(), id: id.to_owned(),
name: name, name: name,
parent: gm.parent.to_owned(), parent: gm.parent.to_owned(),
@ -152,6 +158,36 @@ fn conv_genre<'a>(id: &str, name: String, gm: &GenreMeta) -> Genre {
metagenre: gm.metagenre, metagenre: gm.metagenre,
#[cfg(feature = "playlists")] #[cfg(feature = "playlists")]
playlists: gm.playlists.clone(), playlists: gm.playlists.clone(),
};
self.inherit_parent(&mut genre, gm.parent.as_deref());
genre
}
fn inherit_parent(&self, genre: &mut Genre, parent: Option<&str>) {
if let Some(GenreEntry::Meta(g2)) = parent.and_then(|p| self.genres.get(p)) {
merge_genre_data(genre, g2);
self.inherit_parent(genre, g2.parent.as_deref());
}
}
/// Get the path of the genre tree
pub fn tree_path(&self, lang: Option<&str>) -> PathBuf {
match lang.filter(|l| self.translations.contains_key(*l)) {
Some(lang) => path!(self.path / "tree" / format!("tree.{lang}.json")),
None => path!(self.path / "tree" / "tree.en.json"),
}
}
}
fn merge_genre_data(genre: &mut Genre, g2: &GenreMeta) {
if genre.language.is_none() {
genre.language = g2.language.clone();
}
if genre.country.is_none() {
genre.country = g2.country.clone();
}
if genre.region.is_none() {
genre.region = g2.region.clone();
} }
} }
@ -177,14 +213,25 @@ mod tests {
let genre = db.get_localized(id, lang).unwrap(); let genre = db.get_localized(id, lang).unwrap();
assert_eq!(genre.id, id); assert_eq!(genre.id, id);
assert_eq!(genre.name, expect); assert_eq!(genre.name, expect);
assert_eq!(genre.language.unwrap(), "sqi");
assert_eq!(genre.country.unwrap(), "AL");
let localized_name = db.get_localized_name(id, lang).unwrap(); let localized_name = db.get_localized_name(id, lang).unwrap();
assert_eq!(localized_name, expect) assert_eq!(localized_name, expect)
} }
#[test]
/// Genres should inherit parent parameters
fn get_subgenre() {
let db = GenreDb::new(genre_db_path()).unwrap();
let genre = db.get("k-pop girl group").unwrap();
assert_eq!(genre.language.unwrap(), "kor");
assert_eq!(genre.country.unwrap(), "KR");
}
#[test] #[test]
fn select_lang() { fn select_lang() {
let languages = vec!["es".to_owned(), "de".to_owned(), "en-GB".to_owned()]; let languages = vec!["xy".to_owned(), "de".to_owned(), "en-GB".to_owned()];
let db = GenreDb::new(genre_db_path()).unwrap(); let db = GenreDb::new(genre_db_path()).unwrap();
let lang = db.select_lang(&languages); let lang = db.select_lang(&languages);
@ -199,4 +246,18 @@ mod tests {
let lang = db.select_lang(&languages); let lang = db.select_lang(&languages);
assert_eq!(lang, None); assert_eq!(lang, None);
} }
#[test]
fn tree_path() {
let db_path = genre_db_path();
let db = GenreDb::new(&db_path).unwrap();
let expect_en = path!(&db_path / "tree" / "tree.en.json");
let expect_de = path!(&db_path / "tree" / "tree.de.json");
assert_eq!(db.tree_path(None), expect_en);
assert_eq!(db.tree_path(Some("en")), expect_en);
assert_eq!(db.tree_path(Some("xy")), expect_en);
assert_eq!(db.tree_path(Some("de")), expect_de);
}
} }

View file

@ -4,8 +4,9 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DefaultOnError}; use serde_with::{serde_as, DefaultOnError};
/// Region for describing genres from multiple countries /// Region for describing genres from multiple countrie
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Region { pub enum Region {
/// Africa /// Africa
AF, AF,
@ -57,28 +58,7 @@ pub enum Region {
WF, WF,
} }
#[cfg(feature = "playlists")] serde_plain::derive_display_from_serialize!(Region);
#[serde_as]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PlaylistKind {
Sound,
Intro,
Pulse,
Edge,
#[serde(rename = "2018")]
Y2018,
#[serde(rename = "2019")]
Y2019,
#[serde(rename = "2020")]
Y2020,
#[serde(rename = "2021")]
Y2021,
#[serde(rename = "2022")]
Y2022,
#[serde(rename = "2023")]
Y2023,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)] #[serde(untagged)]
@ -101,7 +81,7 @@ pub struct GenreMeta {
pub rank: Option<u32>, pub rank: Option<u32>,
#[cfg(feature = "playlists")] #[cfg(feature = "playlists")]
#[serde(default)] #[serde(default)]
pub playlists: HashMap<PlaylistKind, String>, pub playlists: HashMap<String, String>,
#[serde(default)] #[serde(default)]
pub deprecated: bool, pub deprecated: bool,
#[serde(default)] #[serde(default)]
@ -110,21 +90,51 @@ pub struct GenreMeta {
#[serde_as] #[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Genre { pub struct Genre {
/// Spotify genre ID
pub id: String, pub id: String,
/// Localized genre name
pub name: String, pub name: String,
/// ID of the parent genre if this is a subgenre
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>, pub parent: Option<String>,
/// ISO-639-3 language code if the genre implies lyrics in a specific language
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>, pub language: Option<String>,
/// ISO-3166-1 country code if the genre is dominant in a specific country
#[serde(skip_serializing_if = "Option::is_none")]
pub country: Option<String>, pub country: Option<String>,
#[serde(default)] /// Region code if the genre is dominant in a specific region
///
/// Region codes are not standardized, refer to the documentation for a definition.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde_as(deserialize_as = "DefaultOnError")] #[serde_as(deserialize_as = "DefaultOnError")]
pub region: Option<Region>, pub region: Option<Region>,
/// Position in the popularity ranking (1: most popular)
#[serde(skip_serializing_if = "Option::is_none")]
pub rank: Option<u32>, pub rank: Option<u32>,
/// Spotify playlist IDs
#[cfg(feature = "playlists")] #[cfg(feature = "playlists")]
#[serde(default)] #[serde(skip_serializing_if = "Option::is_none")]
pub playlists: HashMap<PlaylistKind, String>, pub playlists: Option<HashMap<String, String>>,
#[serde(default)] /// True if the genre is no longer part of Spotify's catalog
#[serde(default, skip_serializing_if = "is_default")]
pub deprecated: bool, pub deprecated: bool,
#[serde(default)] /// True if the genre is a metagenre
///
/// Metagenres do not exist at Spotify but are used to group other genres together.
#[serde(default, skip_serializing_if = "is_default")]
pub metagenre: bool, pub metagenre: bool,
} }
impl PartialEq for Genre {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for Genre {}
fn is_default<T: Default + PartialEq>(t: &T) -> bool {
t == &T::default()
}

View file

@ -34,7 +34,7 @@
"pattern": "^[A-Z]{2}$" "pattern": "^[A-Z]{2}$"
}, },
"region": { "region": {
"description": "Region code if the genre is dominant in a specific region", "description": "Region code if the genre is dominant in a specific region\nRegion codes are not standardized, refer to the documentation for a definition.",
"type": "string", "type": "string",
"enum": [ "enum": [
"AF", "AF",

View file

@ -36,7 +36,7 @@
"pattern": "^[A-Z]{2}$" "pattern": "^[A-Z]{2}$"
}, },
"region": { "region": {
"description": "Region code if the genre is dominant in a specific region", "description": "Region code if the genre is dominant in a specific region\nRegion codes are not standardized, refer to the documentation for a definition.",
"type": "string", "type": "string",
"enum": [ "enum": [
"AF", "AF",
@ -112,7 +112,7 @@
"pattern": "^[a-z0-9\\-+: '&]+$" "pattern": "^[a-z0-9\\-+: '&]+$"
}, },
"deprecated": { "deprecated": {
"description": "True if the genre is no longer part of Spotify's catalogue", "description": "True if the genre is no longer part of Spotify's catalog",
"type": "boolean" "type": "boolean"
}, },
"metagenre": { "metagenre": {

View file

@ -32,7 +32,7 @@
"pattern": "^[A-Z]{2}$" "pattern": "^[A-Z]{2}$"
}, },
"region": { "region": {
"description": "Region code if the genre is dominant in a specific region", "description": "Region code if the genre is dominant in a specific region\nRegion codes are not standardized, refer to the documentation for a definition.",
"type": "string", "type": "string",
"enum": [ "enum": [
"AF", "AF",