summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/cmd/mod.rs3
-rw-r--r--src/cmd/recommend.rs89
-rw-r--r--src/db.rs116
3 files changed, 208 insertions, 0 deletions
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<Box<Command>> {
let matches = app_from_crate!()
.subcommand(sync::subcommand())
.subcommand(sql::subcommand())
+ .subcommand(recommend::subcommand())
.get_matches();
let command: Box<Command> = 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<Command> {
+ 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::<failure::Fallible<Vec<String>>>()?;
+ }
+ 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<Vec<String>> {
+ 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::<failure::Fallible<Vec<String>>>()?;
+
+ Ok(artists)
+ }
+
+ pub fn recommend_album(
+ &self,
+ artist: &str,
+ random: bool,
+ include: TimeWindow,
+ exclude: TimeWindow,
+ ) -> failure::Fallible<String> {
+ 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::<failure::Fallible<String>, _, _>(&params, |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!(),
+ }
}