From 67073417c345cc7399808ffec03a7e34eb350b91 Mon Sep 17 00:00:00 2001 From: Jesse Luehrs Date: Sat, 10 Nov 2018 04:52:09 -0500 Subject: add a command to recommend things to listen to --- src/cmd/mod.rs | 3 ++ src/cmd/recommend.rs | 89 +++++++++++++++++++++++++++++++++++++++ src/db.rs | 116 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 src/cmd/recommend.rs diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 2c4873f..1cbe362 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,5 +1,6 @@ mod sql; mod sync; +mod recommend; const _DUMMY_DEPENDENCY: &'static str = include_str!("../../Cargo.toml"); @@ -15,11 +16,13 @@ fn get_command() -> failure::Fallible> { let matches = app_from_crate!() .subcommand(sync::subcommand()) .subcommand(sql::subcommand()) + .subcommand(recommend::subcommand()) .get_matches(); let command: Box = match matches.subcommand() { ("sync", Some(matches)) => Box::new(sync::Command::new(matches)), ("sql", Some(matches)) => Box::new(sql::Command::new(matches)), + ("recommend", Some(matches)) => Box::new(recommend::Command::new(matches)?), (name, Some(_)) => bail!("unknown subcommand: {}", name), (_, None) => bail!("no subcommand given"), diff --git a/src/cmd/recommend.rs b/src/cmd/recommend.rs new file mode 100644 index 0000000..6788e0c --- /dev/null +++ b/src/cmd/recommend.rs @@ -0,0 +1,89 @@ +use db; +use util; + +use clap; + +pub struct Command { + count: u64, + random: bool, + album: bool, + include: db::TimeWindow, + exclude: db::TimeWindow, + + db: db::DB, +} + +pub fn subcommand<'a, 'b>() -> clap::App<'a, 'b> { + clap::SubCommand::with_name("recommend") + .about("Recommends an artist or album to listen to") + .arg( + clap::Arg::with_name("count") + .default_value("20") + .help("number of results to return") + ) + .arg( + clap::Arg::with_name("random") + .long("random") + .help("pick a random artist instead of by weight") + ) + .arg( + clap::Arg::with_name("album") + .long("album") + .help("also choose a random album by the chosen artists") + ) + .arg( + clap::Arg::with_name("include") + .long("include") + .default_value("yearly") + .possible_values(&["all", "yearly", "monthly", "weekly"]) + ) + .arg( + clap::Arg::with_name("exclude") + .long("exclude") + .default_value("weekly") + .possible_values(&["all", "yearly", "monthly", "weekly", "none"]) + ) +} + +impl Command { + pub fn new<'a>(matches: &clap::ArgMatches<'a>) -> failure::Fallible { + Ok(Command { + count: matches.value_of("count").unwrap().parse()?, + random: matches.is_present("random"), + album: matches.is_present("album"), + include: db::parse_timewindow(matches.value_of("include").unwrap()), + exclude: db::parse_timewindow(matches.value_of("exclude").unwrap()), + + db: db::DB::new(&util::db_path()?)?, + }) + } +} + +impl super::Command for Command { + fn run(&self) -> failure::Fallible<()> { + let mut artists = self.db.recommend_artists( + self.count, + self.random, + self.include, + self.exclude + )?; + if self.album { + artists = artists.iter().map(|artist| { + Ok(format!( + "{} - {}", + artist, + self.db.recommend_album( + &artist, + self.random, + self.include, + self.exclude + )? + )) + }).collect::>>()?; + } + for line in artists { + println!("{}", line); + } + Ok(()) + } +} diff --git a/src/db.rs b/src/db.rs index efc1a88..63caa87 100644 --- a/src/db.rs +++ b/src/db.rs @@ -23,6 +23,15 @@ const SCHEMA: &'static str = " WHERE strftime('%s') - timestamp < 60*60*24*7; "; +#[derive(Eq, PartialEq, Copy, Clone)] +pub enum TimeWindow { + All, + Yearly, + Monthly, + Weekly, + None, +} + pub struct DB { conn: rusqlite::Connection, } @@ -128,4 +137,111 @@ impl DB { errs.map(|_| cols) } + + pub fn recommend_artists( + &self, + count: u64, + random: bool, + include: TimeWindow, + exclude: TimeWindow, + ) -> failure::Fallible> { + let exclude = if exclude != TimeWindow::None { + format!(" + WHERE artist NOT IN ( + SELECT DISTINCT(artist) + FROM {} + ) + ", timewindow_table(&exclude)) + } + else { + "".to_string() + }; + let order = if random { + "ORDER BY random()" + } + else { + "ORDER BY count(artist) * (strftime('%s') - max(timestamp)) DESC" + }; + + let sql = format!(" + SELECT artist + FROM {} + {} + GROUP BY artist + {} + LIMIT {} + ", timewindow_table(&include), exclude, order, count); + let mut sth = self.conn.prepare(&sql)?; + let artists = sth.query_and_then(rusqlite::NO_PARAMS, |row| { + Ok(row.get_checked(0)?) + })?.collect::>>()?; + + Ok(artists) + } + + pub fn recommend_album( + &self, + artist: &str, + random: bool, + include: TimeWindow, + exclude: TimeWindow, + ) -> failure::Fallible { + let mut params = vec![artist]; + let exclude = if exclude != TimeWindow::None { + params.push(artist); + format!(" + AND album NOT IN ( + SELECT DISTINCT(album) + FROM {} + WHERE artist = ? + ) + ", timewindow_table(&exclude)) + } + else { + "".to_string() + }; + let order = if random { + "ORDER BY random()" + } + else { + "ORDER BY count(album) * (strftime('%s') - max(timestamp)) DESC" + }; + + let sql = format!(" + SELECT album + FROM {} + WHERE artist = ? + {} + GROUP BY album + {} + LIMIT 1 + ", timewindow_table(&include), exclude, order); + let mut sth = self.conn.prepare(&sql)?; + let artists = sth.query_row::, _, _>(¶ms, |row| { + Ok(row.get_checked(0)?) + })??; + + Ok(artists) + } +} + +pub fn parse_timewindow(s: &str) -> TimeWindow { + match s { + "all" => TimeWindow::All, + "yearly" => TimeWindow::Yearly, + "monthly" => TimeWindow::Monthly, + "weekly" => TimeWindow::Weekly, + "none" => TimeWindow::None, + _ => unreachable!(), + } +} + +fn timewindow_table(tw: &TimeWindow) -> String { + match tw { + TimeWindow::All => "tracks".to_string(), + TimeWindow::Yearly => "yearly_tracks".to_string(), + TimeWindow::Monthly => "monthly_tracks".to_string(), + TimeWindow::Weekly => "weekly_tracks".to_string(), + _ => unreachable!(), + } } -- cgit v1.2.3