Rust daemon that skips rap songs in (not only) Spotify radios via MPRIS and MusicBrainz
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
rapblock/src/main.rs

315 lines
13 KiB

#[macro_use] extern crate serde_derive;
//#[macro_use] extern crate lazy_static;
#[macro_use] extern crate log;
//#[macro_use] extern crate failure;
use std::env;
use crate::config_file::ConfigFile;
use std::collections::HashMap;
use mpris::PlayerFinder;
use std::time::Duration;
use mpris::Event;
use std::collections::HashSet;
use failure::Error;
use dbus::arg::Variant;
use dbus::arg::RefArg;
mod brainz;
mod config_file;
#[derive(Serialize, Deserialize, Debug)]
#[serde(default)]
pub struct BlacklistConf {
/// Literal tags to reject
pub tag: Vec<String>,
/// Tag sub-strings to reject (must be a whole word)
pub tag_partial: Vec<String>,
/// Artists to reject
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 {
/// Tags to allow despite e.g. a substring match
pub tag: Vec<String>,
/// Artists to allow
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,
/// Players to handle (empty = all)
pub allowed_players: Vec<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(),
allowed_players: vec![],
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 main() -> Result<(), Error> {
// let config = match load_config() {
// Ok(c) => c,
// Err(e) => {
// eprintln!("Could not load config from \"{}\": {}", CONFIG_FILE, e);
// Config::default()
// }
// };
let config_file_path = env::current_dir()?.join(CONFIG_FILE);
let mut cfg: ConfigFile<Config> = config_file::ConfigFile::new(config_file_path)?;
let env = env_logger::Env::default().default_filter_or(&cfg.borrow().logging);
env_logger::Builder::from_env(env).init();
let mut artist_cache = HashMap::<String, bool>::new();
info!("Waiting for players...");
'main_loop: loop {
// XXX this picks the first player, which isn't always ideal - see mpris/src/find.rs
let player = PlayerFinder::new()
.expect("Could not connect to D-Bus")
.find_active();
let _ = cfg.update_if_needed(false);
if let Ok(player) = player {
let config = cfg.borrow();
let player_name = player.bus_name().to_string();
if !config.allowed_players.is_empty() && !config.allowed_players.contains(&player_name)
{
debug!("Ignoring player {}", player_name);
::std::thread::sleep(Duration::from_millis(config.player_find_interval_ms));
continue 'main_loop;
}
info!(
"Connected to player: {}{}",
player_name,
player.unique_name()
);
let events = player.events();
if events.is_err() {
error!("Could not start event stream!");
// add a delay so we don't run too hot here
::std::thread::sleep(Duration::from_millis(config.player_find_interval_ms));
continue 'main_loop;
}
'event_loop: for event in events.unwrap() {
let _ = cfg.update_if_needed(false);
let config = cfg.borrow();
match event {
Ok(event) => {
debug!("MPRIS event: {:#?}", event);
match event {
Event::PlayerShutDown => {
info!("Player shut down");
break 'event_loop;
}
Event::TrackChanged(mut metadata) => {
// we can't use the event metadata - it's incomplete
::std::thread::sleep(Duration::from_millis(250));
metadata = player.get_metadata().unwrap_or(metadata);
let title = metadata.title().unwrap_or("");
info!("--- new track : {} ---", title);
if title.is_empty() {
warn!("Empty metadata! Wait for next track...");
continue 'event_loop;
}
let mut skip = !config.allow_by_default;
let mut confidence = false;
debug!("MPRIS meta: {:#?}", metadata);
// try to get genre from the 'rest' map in the metadata struct
#[allow(deprecated)] // 2.0.0 will provide some nicer API, hopefully
let rest = metadata.rest();
let meta_genre : Option<&Variant<Box<RefArg>>> = rest.get("xesam:genre");
match meta_genre {
Some(variant) => {
let b : &RefArg = variant.0.as_ref();
if let Some(s) = b.as_str() {
info!("Using genre from MPRIS metadata: {}", s);
skip = !brainz::check_genre(&config, &s.to_string().to_lowercase());
confidence = true;
} else if let Some(list) = b.as_iter() {
debug!("MPRIS contains array of genres");
for item in list {
if let Some(s) = item.as_str() {
info!("Using genre from MPRIS metadata: {}", s);
skip = !brainz::check_genre(&config, &s.to_string().to_lowercase());
confidence = true;
}
}
}
},
None => {
debug!("No MPRIS genre");
}
};
if !confidence {
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 artists_e = artists
.iter()
.take(config.max_artists_per_track as usize)
.enumerate();
'artists_loop: for (an, a) in artists_e {
info!("Checking artist #{}: {}", an + 1, a);
if let Some(resolution) = artist_cache.get(*a) {
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.");
}
}
}
Err(err) => {
error!("D-Bus error: {}. Aborting.", err);
break 'event_loop;
}
}
}
info!("Event stream ended - player likely shut down");
} else {
debug!("No player found, waiting...");
}
::std::thread::sleep(Duration::from_millis(cfg.borrow().player_find_interval_ms));
}
}