diff --git a/.gitignore b/.gitignore index 4a7cfbd..37e06ac 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ **/*.rs.bk .idea *.iml +rapblock.json \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ad56de0..a26ac99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -925,10 +925,10 @@ version = "0.1.0" dependencies = [ "env_logger 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", "failure 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)", - "lazy_static 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)", "mpris 1.1.1 (registry+https://github.com/rust-lang/crates.io-index)", "percent-encoding 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "regex 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "reqwest 0.9.10 (registry+https://github.com/rust-lang/crates.io-index)", "serde 1.0.88 (registry+https://github.com/rust-lang/crates.io-index)", "serde_derive 1.0.88 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/Cargo.toml b/Cargo.toml index f65d752..a881bd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ serde = "1.0.88" serde_derive = "1.0.88" serde_json = "1.0" failure = "0.1.5" -lazy_static = "1.2.0" +#lazy_static = "1.2.0" log = "0.4" env_logger = "0.6" +regex = "1.1.0" diff --git a/README.md b/README.md new file mode 100644 index 0000000..f4536c5 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +RapBlock +======== + +This is a daemon that auto-skips rap and hip-hop songs on Spotify via MPRIS. + +Music genre is determined using the MusicBrainz JSON API, as that is not reported via MPRIS. + +Compatibility +------------- + +This daemon should work with any MPRIS compatible player, so long as it reports artist names +in the TrackChanged event metadata. + +Customization +------------- + +You can easily modify this to skip e.g. folk, country or any other genre. Just change the +`BAD_GENRES` list in `src/brainz.rs`. \ No newline at end of file diff --git a/rapblock.example.json b/rapblock.example.json new file mode 100644 index 0000000..8d22139 --- /dev/null +++ b/rapblock.example.json @@ -0,0 +1,17 @@ +{ + "logging": "info", + "blacklist": { + "tag": [], + "tag_partial": ["hip-hop", "hip hop", "rap"], + "artist": ["Higher Brothers"] + }, + "whitelist": { + "tag": [], + "artist": [] + }, + "artist_min_score": 95, + "max_artists_per_track": 3, + "player_find_interval_ms": 2500, + "cooldown_ms": 500, + "api_timeout_ms": 2000 +} diff --git a/src/brainz.rs b/src/brainz.rs index 5cf822c..1edef54 100644 --- a/src/brainz.rs +++ b/src/brainz.rs @@ -2,6 +2,7 @@ use percent_encoding::{utf8_percent_encode, DEFAULT_ENCODE_SET}; use failure::Error; use std::io::Read; use std::time::Duration; +use regex::Regex; #[derive(Serialize, Deserialize, Debug)] struct MBArtistQueryResult { @@ -27,15 +28,9 @@ struct MBArtist { tags: Option> } -lazy_static! { - static ref BAD_GENRES : Vec<&'static str> = vec![ - "hip hop", "hip-hop", "hiphop", "rnb", "rap", "rapper", - "hardcore hip hop", "new york hip hop", "east coast hip hop", - "pop rap" - ]; -} +const VERSION: &'static str = env!("CARGO_PKG_VERSION"); -pub fn artist_sucks(artist : &str) -> Result { +pub fn check_artist(config : &crate::Config, artist : &str) -> Result { let query = utf8_percent_encode(&format!("\"{}\"", artist), DEFAULT_ENCODE_SET).to_string(); let url = format!("https://musicbrainz.org/ws/2/artist?query={}&fmt=json&inc=tags", query); @@ -43,11 +38,15 @@ pub fn artist_sucks(artist : &str) -> Result { let mut resp = String::new(); + let ua = format!("Bad Song Skipper / {v} ( https://git.ondrovo.com/MightyPork/rapblock )", v=VERSION); + + debug!("Using UA: {}", ua); + reqwest::Client::builder() - .timeout(Duration::from_millis(2000)) + .timeout(Duration::from_millis(config.api_timeout_ms)) .build()? .get(&url) - .header(reqwest::header::USER_AGENT, "Bad Spotify Song Skipper/0.0.1 ( ondra@ondrovo.com )") + .header(reqwest::header::USER_AGENT, ua.as_str()) .header(reqwest::header::ACCEPT, "text/json") .send()? .read_to_string(&mut resp)?; @@ -64,22 +63,64 @@ pub fn artist_sucks(artist : &str) -> Result { warn!("No results!"); return Err(failure::err_msg("Artist not found")); } else { - info!("Got {} results", result.count); + info!("Got {} results, checking where score >= {}", result.count, config.artist_min_score); let artists = result.artists.as_ref().unwrap(); - if artists[0].tags.is_some() { - let tags = artists[0].tags.as_ref().unwrap(); - let as_vec : Vec<&String> = tags.iter().map(|t| &t.name).collect(); - debug!("First artist has tags: {:?}", tags); - for tag in as_vec { - if BAD_GENRES.contains(&tag.as_str()) { - info!("Found a bad tag: {}", tag); - return Ok(true); + + let mut confidence = false; + let mut passed = true; + + 'artists: for (an, a) in artists.iter().enumerate() { + if a.score >= config.artist_min_score { + if a.tags.is_some() { + let tags = a.tags.as_ref().unwrap(); + let as_vec: Vec<&String> = tags.iter().map(|t| &t.name).collect(); + + info!("Artist #{} - \"{}\" has tags: {:?}", an+1, a.name, as_vec); + 'tags: for tag in as_vec { + if config.whitelist.tag.contains(tag) { + info!("+ Whitelisted tag \"{}\"", tag); + continue 'tags; + } + + let mut blacklisted = false; + if config.blacklist.tag.contains(&tag) { + info!("- Blacklisted tag \"{}\"", tag); + blacklisted = true; + } else { + 'blacklist: + for t in config.blacklist.tag_partial.iter() { + let re = Regex::new(&format!(r"\b{}\b", regex::escape(t))); + + let matches = match re { + Ok(re) => re.is_match(tag), + Err(_) => tag.contains(t) + }; + + if matches { + info!("- Blacklisted tag \"{}\" - due to substring \"{}\"", tag, t); + blacklisted = true; + break 'blacklist; + } + } + } + + if blacklisted { + confidence = true; + passed = false; + break 'artists; + } + } + info!("All tags OK"); + confidence = true; + } else { + warn!("Artist #{} - \"{}\" has no tags, can't determine genre", an+1, a.name); } } - info!("All tags OK"); - return Ok(false); + } + + if confidence { + return Ok(passed); } else { - warn!("No tags, can't determine genre"); return Err(failure::err_msg("Artist found, but has no tags")); } } diff --git a/src/main.rs b/src/main.rs index a47af59..26dc17c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,140 @@ #[macro_use] extern crate serde_derive; -#[macro_use] extern crate lazy_static; +//#[macro_use] extern crate lazy_static; #[macro_use] extern crate log; +#[macro_use] extern crate failure; mod brainz; use mpris::{PlayerFinder,Event}; use failure::Error; use std::time::Duration; +use std::collections::HashSet; +use std::env; +use std::path::Path; +use std::fs::File; +use std::io::Read; +use std::collections::HashMap; -const DELAY_FIND_PLAYER : Duration = Duration::from_millis(1000); +#[derive(Serialize, Deserialize, Debug)] +#[serde(default)] +pub struct BlacklistConf { + pub tag: Vec, + pub tag_partial: Vec, + pub artist: Vec, +} + +impl Default for BlacklistConf { + fn default() -> Self { + Self { + tag: vec![], + artist: vec![], + tag_partial: vec![ + "hip-hop".to_owned(), + "hip hop".to_owned(), + "hiphop".to_owned(), + "rap".to_owned() + ], + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(default)] +pub struct WhiteList { + pub tag: Vec, + pub artist: Vec, +} + +impl Default for WhiteList { + fn default() -> Self { + Self { + tag: vec![], + artist: vec![] + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(default)] +pub struct Config { + /// Logging - trace, debug, (info), warning, error + pub logging: String, + /// Blacklists + pub blacklist: BlacklistConf, + /// Whitelist (overrides blacklist) + pub whitelist: WhiteList, + /// Min MusicBrainz search score for artist look-up + pub artist_min_score : i32, + /// Max nr of artists to check per track + pub max_artists_per_track : u64, + /// Interval in which the daemon probes DBUS for open MPRIS channels + pub player_find_interval_ms: u64, + /// Delay after a skip or allow, e.g. to prevent infinite skip chain when someone starts a rap playlist + pub cooldown_ms: u64, + /// Music Brainz API access timeout + pub api_timeout_ms: u64, + /// Allow playing songs from artists we couldn't verify + pub allow_by_default: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + logging: "info".to_owned(), + blacklist: BlacklistConf::default(), + whitelist: WhiteList::default(), + artist_min_score: 95, + max_artists_per_track: 3, + player_find_interval_ms: 2500, + cooldown_ms: 1000, + api_timeout_ms: 2000, + allow_by_default: true + } + } +} + +const CONFIG_FILE: &'static str = "rapblock.json"; + +fn load_config() -> Result { + let config_file_path = env::current_dir()?.join(CONFIG_FILE); + let buf = read_file(config_file_path)?; + + let config : Config = serde_json::from_str(&buf)?; + + // Validations + match config.logging.as_ref() { + "info" | "debug" | "trace" | "warning" | "error" => (), + _ => bail!("Invalid value for \"logging\""), + }; + + Ok(config) +} + +pub fn read_file>(path: P) -> Result { + let path = path.as_ref(); + let mut file = File::open(path)?; + + let mut buf = String::new(); + file.read_to_string(&mut buf)?; + Ok(buf) +} fn main() -> Result<(), Error> { - let env = env_logger::Env::default().default_filter_or("info"); + let config = match load_config() { + Ok(c) => c, + Err(e) => { + eprintln!("Could not load config from \"{}\": {}", CONFIG_FILE, e); + Config::default() + } + }; + + let env = env_logger::Env::default().default_filter_or(&config.logging); env_logger::Builder::from_env(env).init(); + debug!("Loaded config:\n{}", serde_json::to_string_pretty(&config)?); + + let mut artist_cache = HashMap::::new(); + 'main_loop: loop { let player = PlayerFinder::new() @@ -27,7 +148,7 @@ fn main() -> Result<(), Error> { if events.is_err() { error!("Could not start event stream!"); // add a delay so we don't run too hot here - ::std::thread::sleep(DELAY_FIND_PLAYER); + ::std::thread::sleep(Duration::from_millis(config.player_find_interval_ms)); continue 'main_loop; } @@ -52,39 +173,77 @@ fn main() -> Result<(), Error> { continue 'event_loop; } + let mut artists = HashSet::new(); + + if let Some(aa) = metadata.artists() { + for a in aa { artists.insert(a); } + } + if let Some(aa) = metadata.album_artists() { + for a in aa { artists.insert(a); } + } + + let mut skip = false; + let mut confidence = false; 'artists_loop: - for ar in metadata.artists().into_iter().chain(metadata.artists()) { - for a in ar { - info!("Checking artist: {}", a); - - let verdict = brainz::artist_sucks(&a); - match verdict { - Ok(verdict) => { - if verdict { - if player.can_go_next().unwrap_or(false) { - info!(">>>>>> SKIP >>>>>>"); - if player.next().is_err() { - break 'artists_loop; - } - } else { - info!("<><><> STOP <><><>"); - if player.pause().is_err() { - break 'artists_loop; - } - } - // we add a delay here to prevent going insane on rap playlists - ::std::thread::sleep(Duration::from_millis(1000)); - } else { - info!("Good artist, let it play"); - } - }, - Err(e) => { - error!("Something went wrong: {}", e); - info!("Letting to play"); + for (an, a) in artists.iter().take(config.max_artists_per_track as usize).enumerate() { + info!("Checking artist #{}: {}", an+1, a); + if let Some(resolution) = artist_cache.get(a.as_str()) { + confidence = true; + if !resolution { + info!("~ result cached: BAD"); + skip = true; + break 'artists_loop; + } + info!("~ result cached: GOOD"); + continue 'artists_loop; + } + + if config.whitelist.artist.contains(a) { + info!("+ Whitelisted artist!"); + // there may be other co-artists that spoil the song -> don't break yet + artist_cache.insert(a.to_string(), true); + confidence = true; + continue 'artists_loop; + } + + if config.blacklist.artist.contains(a) { + info!("- Blacklisted artist!"); + skip = true; + artist_cache.insert(a.to_string(), false); + confidence = true; + break 'artists_loop; + } + + let verdict = brainz::check_artist(&config, &a); + match verdict { + Ok(allow) => { + confidence = true; + artist_cache.insert(a.to_string(), allow); + if allow { + info!("Artist passed"); + } else { + skip = true; + break 'artists_loop; } + }, + Err(e) => { + warn!("Something went wrong: {}", e); + // probably no tags, or not found - use the default action + artist_cache.insert(a.to_string(), config.allow_by_default); } } } + + if skip || (!confidence && !config.allow_by_default) { + info!(">>>>>> SKIP : {} >>>>>>\n", title); + if player.next().is_err() { + break 'event_loop; + } + } else { + info!("Let it play...\n"); + } + + ::std::thread::sleep(Duration::from_millis(config.cooldown_ms)); }, _ => { debug!("Event not handled."); @@ -102,7 +261,7 @@ fn main() -> Result<(), Error> { debug!("No player found, waiting..."); } - ::std::thread::sleep(DELAY_FIND_PLAYER); + ::std::thread::sleep(Duration::from_millis(config.player_find_interval_ms)); } }