add config file, blacklists, whitelists, resolution cache
This commit is contained in:
@@ -2,3 +2,4 @@
|
||||
**/*.rs.bk
|
||||
.idea
|
||||
*.iml
|
||||
rapblock.json
|
||||
Generated
+1
-1
@@ -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)",
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+63
-22
@@ -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<Vec<Tag>>
|
||||
}
|
||||
|
||||
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<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 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 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<bool, Error> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
+192
-33
@@ -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<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> {
|
||||
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::<String, bool>::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;
|
||||
}
|
||||
|
||||
'artists_loop:
|
||||
for ar in metadata.artists().into_iter().chain(metadata.artists()) {
|
||||
for a in ar {
|
||||
info!("Checking artist: {}", a);
|
||||
let mut artists = HashSet::new();
|
||||
|
||||
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");
|
||||
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 (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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user