Compare commits
4 commits
bf4ab15a6f
...
4560a5ba11
Author | SHA1 | Date | |
---|---|---|---|
4560a5ba11 | |||
e1937268d8 | |||
c35f03c3c9 | |||
d678ca17f3 |
7 changed files with 148 additions and 55 deletions
|
@ -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))
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
Loading…
Reference in a new issue