add config file, blacklists, whitelists, resolution cache

master
Ondřej Hruška 6 years ago
parent a872ea2f65
commit cf52a3668b
Signed by: MightyPork
GPG Key ID: 2C5FD5035250423D
  1. 1
      .gitignore
  2. 2
      Cargo.lock
  3. 3
      Cargo.toml
  4. 18
      README.md
  5. 17
      rapblock.example.json
  6. 83
      src/brainz.rs
  7. 209
      src/main.rs

1
.gitignore vendored

@ -2,3 +2,4 @@
**/*.rs.bk **/*.rs.bk
.idea .idea
*.iml *.iml
rapblock.json

2
Cargo.lock generated

@ -925,10 +925,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"env_logger 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", "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)", "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)", "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)", "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)", "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)", "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 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)", "serde_derive 1.0.88 (registry+https://github.com/rust-lang/crates.io-index)",

@ -12,6 +12,7 @@ serde = "1.0.88"
serde_derive = "1.0.88" serde_derive = "1.0.88"
serde_json = "1.0" serde_json = "1.0"
failure = "0.1.5" failure = "0.1.5"
lazy_static = "1.2.0" #lazy_static = "1.2.0"
log = "0.4" log = "0.4"
env_logger = "0.6" env_logger = "0.6"
regex = "1.1.0"

@ -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`.

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

@ -2,6 +2,7 @@ use percent_encoding::{utf8_percent_encode, DEFAULT_ENCODE_SET};
use failure::Error; use failure::Error;
use std::io::Read; use std::io::Read;
use std::time::Duration; use std::time::Duration;
use regex::Regex;
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
struct MBArtistQueryResult { struct MBArtistQueryResult {
@ -27,15 +28,9 @@ struct MBArtist {
tags: Option<Vec<Tag>> tags: Option<Vec<Tag>>
} }
lazy_static! { const VERSION: &'static str = env!("CARGO_PKG_VERSION");
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"
];
}
pub fn artist_sucks(artist : &str) -> Result<bool, Error> { pub fn check_artist(config : &crate::Config, artist : &str) -> Result<bool, Error> {
let query = utf8_percent_encode(&format!("\"{}\"", artist), DEFAULT_ENCODE_SET).to_string(); 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); 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<bool, Error> {
let mut resp = String::new(); 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() reqwest::Client::builder()
.timeout(Duration::from_millis(2000)) .timeout(Duration::from_millis(config.api_timeout_ms))
.build()? .build()?
.get(&url) .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") .header(reqwest::header::ACCEPT, "text/json")
.send()? .send()?
.read_to_string(&mut resp)?; .read_to_string(&mut resp)?;
@ -64,22 +63,64 @@ pub fn artist_sucks(artist : &str) -> Result<bool, Error> {
warn!("No results!"); warn!("No results!");
return Err(failure::err_msg("Artist not found")); return Err(failure::err_msg("Artist not found"));
} else { } 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(); let artists = result.artists.as_ref().unwrap();
if artists[0].tags.is_some() {
let tags = artists[0].tags.as_ref().unwrap(); let mut confidence = false;
let as_vec : Vec<&String> = tags.iter().map(|t| &t.name).collect(); let mut passed = true;
debug!("First artist has tags: {:?}", tags);
for tag in as_vec { 'artists: for (an, a) in artists.iter().enumerate() {
if BAD_GENRES.contains(&tag.as_str()) { if a.score >= config.artist_min_score {
info!("Found a bad tag: {}", tag); if a.tags.is_some() {
return Ok(true); 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"); info!("All tags OK");
return Ok(false); confidence = true;
} else {
warn!("Artist #{} - \"{}\" has no tags, can't determine genre", an+1, a.name);
}
}
}
if confidence {
return Ok(passed);
} else { } else {
warn!("No tags, can't determine genre");
return Err(failure::err_msg("Artist found, but has no tags")); return Err(failure::err_msg("Artist found, but has no tags"));
} }
} }

@ -1,19 +1,140 @@
#[macro_use] extern crate serde_derive; #[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 log;
#[macro_use] extern crate failure;
mod brainz; mod brainz;
use mpris::{PlayerFinder,Event}; use mpris::{PlayerFinder,Event};
use failure::Error; use failure::Error;
use std::time::Duration; 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<String>,
pub tag_partial: Vec<String>,
pub artist: Vec<String>,
}
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<String>,
pub artist: Vec<String>,
}
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<Config, Error> {
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<P: AsRef<Path>>(path: P) -> Result<String, Error> {
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> { 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(); env_logger::Builder::from_env(env).init();
debug!("Loaded config:\n{}", serde_json::to_string_pretty(&config)?);
let mut artist_cache = HashMap::<String, bool>::new();
'main_loop: 'main_loop:
loop { loop {
let player = PlayerFinder::new() let player = PlayerFinder::new()
@ -27,7 +148,7 @@ fn main() -> Result<(), Error> {
if events.is_err() { if events.is_err() {
error!("Could not start event stream!"); error!("Could not start event stream!");
// add a delay so we don't run too hot here // 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; continue 'main_loop;
} }
@ -52,39 +173,77 @@ fn main() -> Result<(), Error> {
continue 'event_loop; continue 'event_loop;
} }
'artists_loop: let mut artists = HashSet::new();
for ar in metadata.artists().into_iter().chain(metadata.artists()) {
for a in ar {
info!("Checking artist: {}", a);
let verdict = brainz::artist_sucks(&a); if let Some(aa) = metadata.artists() {
match verdict { for a in aa { artists.insert(a); }
Ok(verdict) => {
if verdict {
if player.can_go_next().unwrap_or(false) {
info!(">>>>>> SKIP >>>>>>");
if player.next().is_err() {
break 'artists_loop;
} }
} else { if let Some(aa) = metadata.album_artists() {
info!("<><><> STOP <><><>"); for a in aa { artists.insert(a); }
if player.pause().is_err() { }
let mut skip = false;
let mut confidence = false;
'artists_loop:
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; 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;
} }
// we add a delay here to prevent going insane on rap playlists
::std::thread::sleep(Duration::from_millis(1000)); 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 { } else {
info!("Good artist, let it play"); skip = true;
break 'artists_loop;
} }
}, },
Err(e) => { Err(e) => {
error!("Something went wrong: {}", e); warn!("Something went wrong: {}", e);
info!("Letting to play"); // 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."); debug!("Event not handled.");
@ -102,7 +261,7 @@ fn main() -> Result<(), Error> {
debug!("No player found, waiting..."); debug!("No player found, waiting...");
} }
::std::thread::sleep(DELAY_FIND_PLAYER); ::std::thread::sleep(Duration::from_millis(config.player_find_interval_ms));
} }
} }

Loading…
Cancel
Save