aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/albums.rs56
-rw-r--r--src/cli.rs7
-rw-r--r--src/config.rs22
-rw-r--r--src/learner.rs36
-rw-r--r--src/main.rs17
-rw-r--r--src/state.rs119
-rw-r--r--src/trajectory.rs19
7 files changed, 103 insertions, 173 deletions
diff --git a/src/albums.rs b/src/albums.rs
new file mode 100644
index 0000000..17966ff
--- /dev/null
+++ b/src/albums.rs
@@ -0,0 +1,56 @@
+use std::collections::HashSet;
+use std::fs;
+
+use expanduser::expanduser;
+use walkdir::WalkDir;
+
+use crate::config::Config;
+
+pub fn load(config: &Config) -> HashSet<String> {
+ let root = expanduser(&config.music_root).ok().filter(|p| p.is_dir());
+ let Some(root_path) = root else {
+ return HashSet::new();
+ };
+
+ let mut new_albums: HashSet<String> = HashSet::new();
+
+ for entry in WalkDir::new(&root_path) {
+ let entry = match entry {
+ Ok(e) if e.path().is_dir() => e,
+ _ => continue,
+ };
+
+ if !contains_audio(&entry, config) {
+ continue;
+ }
+
+ if let Some(rel_str) = entry
+ .path()
+ .strip_prefix(&root_path)
+ .ok()
+ .and_then(|p| p.to_str())
+ {
+ new_albums.insert(rel_str.to_string());
+ }
+ }
+
+ new_albums
+}
+
+fn contains_audio(entry: &walkdir::DirEntry, config: &Config) -> bool {
+ fs::read_dir(entry.path()).ok().is_some_and(|mut entries| {
+ entries.any(|e| {
+ e.as_ref()
+ .is_ok_and(|entry| is_audio_file(&entry.path(), config))
+ })
+ })
+}
+
+fn is_audio_file(path: &std::path::Path, config: &Config) -> bool {
+ !path.is_dir()
+ && path
+ .extension()
+ .and_then(|ext| ext.to_str())
+ .map(str::to_lowercase)
+ .is_some_and(|ext| config.audio_exts.contains(&format!(".{ext}")))
+}
diff --git a/src/cli.rs b/src/cli.rs
index f1563b3..7be3fd4 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -6,7 +6,6 @@ pub enum Action {
NewState,
DumpTrajectory,
DumpLearner,
- DumpAlbums,
Run,
}
@@ -27,7 +26,6 @@ Options:
--new-state Write a default state file and exit.
--dump-trajectory Dump the play trajectory as JSON to stdout.
--dump-learner Dump learner weights as JSON to stdout.
- --dump-albums Dump album list from state as JSON to stdout.
<no option> Suggest and play the next album"
);
}
@@ -39,11 +37,11 @@ pub fn parse() -> Option<Action> {
match args.next().as_deref() {
None => Some(Action::Run),
- Some("--help") | Some("-h") => {
+ Some("--help" | "-h") => {
print_help();
None
}
- Some("--version") | Some("-V") | Some("-v") => {
+ Some("--version" | "-V" | "-v") => {
println!("{NAME} {VERSION}");
None
}
@@ -51,7 +49,6 @@ pub fn parse() -> Option<Action> {
Some("--new-state") => Some(Action::NewState),
Some("--dump-trajectory") => Some(Action::DumpTrajectory),
Some("--dump-learner") => Some(Action::DumpLearner),
- Some("--dump-albums") => Some(Action::DumpAlbums),
Some(unknown) => {
eprintln!("Unknown option: {unknown}");
std::process::exit(1);
diff --git a/src/config.rs b/src/config.rs
index f38b571..15b449a 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -6,7 +6,7 @@ use expanduser::expanduser;
#[derive(Serialize, Deserialize)]
pub struct Config {
- pub music_roots: Vec<String>,
+ pub music_root: String,
pub audio_exts: Vec<String>,
pub skip_window_secs: u64,
pub temperature: f32,
@@ -19,7 +19,7 @@ pub struct Config {
fn default_config_path() -> String {
let name = "mood.json";
ProjectDirs::from("qualifier", "organisation", "mood")
- .and_then(|pd| pd.config_dir().join(name).to_str().map(|x| x.to_string()))
+ .and_then(|pd| pd.config_dir().join(name).to_str().map(std::string::ToString::to_string))
.unwrap_or(format!("~/.config/mood/{name}"))
}
@@ -35,8 +35,8 @@ impl Config {
let path = expanduser(default_config_path()).map_err(|e| e.to_string())?;
_ = path.parent().map(std::fs::create_dir_all);
let serialised = serde_json::to_string_pretty(self)
- .map_err(|e| format!("Failed to serialize config: {}", e))?;
- fs::write(&path, serialised).map_err(|e| format!("Failed to write config file: {}", e))?;
+ .map_err(|e| format!("Failed to serialize config: {e}"))?;
+ fs::write(&path, serialised).map_err(|e| format!("Failed to write config file: {e}"))?;
Ok(())
}
}
@@ -44,14 +44,12 @@ impl Config {
impl Default for Config {
fn default() -> Config {
Config {
- music_roots: vec![
- UserDirs::new()
- .and_then(|u| {
- u.audio_dir()
- .and_then(|p| p.to_str().map(|x| x.to_string()))
- })
- .unwrap_or("~/Music".to_string()),
- ],
+ music_root: UserDirs::new()
+ .and_then(|u| {
+ u.audio_dir()
+ .and_then(|p| p.to_str().map(std::string::ToString::to_string))
+ })
+ .unwrap_or("~/Music".to_string()),
audio_exts: [".mp3", ".flac", ".wav", ".m4a", ".ogg", ".vorbis"]
.into_iter()
.map(String::from)
diff --git a/src/learner.rs b/src/learner.rs
index 59db567..942d52e 100644
--- a/src/learner.rs
+++ b/src/learner.rs
@@ -53,30 +53,6 @@ enum Episode {
},
}
-impl Episode {
- fn prune_mut(&mut self, valid: &HashSet<String>) -> bool {
- match self {
- Episode::Continue { group, avoid, .. } => {
- if let Some(a) = avoid
- && valid.contains(a)
- {
- } else {
- *avoid = None;
- };
- group.retain(|g| valid.contains(g));
- !group.is_empty()
- }
- Episode::Escape { from, to, .. } => {
- if !valid.contains(to) {
- return false;
- };
- from.retain(|f| valid.contains(f));
- !from.is_empty()
- }
- }
- }
-}
-
#[derive(Serialize, Deserialize, Default)]
pub struct Learner {
history: Vec<Episode>,
@@ -88,14 +64,8 @@ pub enum Learning {
}
impl Learner {
- pub fn prune(&mut self, valid: &HashSet<String>) {
- self.history.retain_mut(|e| e.prune_mut(valid));
- }
-}
-
-impl Learner {
pub fn learn(&mut self, learning: &Learning) {
- let stamp = chrono::Utc::now().format("U%Y%m%d-%H%M%S").to_string();
+ let stamp = chrono::Utc::now().format("%+").to_string();
match learning {
Learning::SkipToMore(trajectory, new) => {
self.history.push(Episode::Escape {
@@ -111,7 +81,7 @@ impl Learner {
committed: stamp,
});
}
- };
+ }
}
pub fn sample(
@@ -135,7 +105,7 @@ impl Learner {
let mut items: HashMap<String, f32> =
candidates.into_iter().map(|c| (c.clone(), 0.0)).collect();
- for episode in self.history.iter() {
+ for episode in &self.history {
match (episode, action) {
(Episode::Escape { from, to, .. }, Action::Skip) => {
let w = compute_weight(from, &trajectory);
diff --git a/src/main.rs b/src/main.rs
index 86c6341..d16b50f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,6 @@
use std::{process::Command, process::exit};
+mod albums;
mod cli;
mod config;
mod learner;
@@ -38,22 +39,11 @@ fn main() {
let action = parse();
if action.is_none() {
return;
- };
+ }
let action = action.unwrap();
match action {
- Action::DumpAlbums => {
- let state = load_state_no_default();
- println!(
- "{}",
- state.dump_albums().unwrap_or_else(|e| {
- eprintln!("Failed to serialize state (dump-albums): {e}");
- exit(1);
- })
- );
- }
-
Action::DumpLearner => {
let state = load_state_no_default();
println!(
@@ -115,9 +105,6 @@ fn main() {
}
};
- let ur = state.update_albums(&config);
- println!("Update result: {ur}");
-
if let Some((next_album, prob)) = state.next(&config) {
let text = format!("Now playing: {next_album} ({:.2}%)", prob * 100.0);
println!("{text}");
diff --git a/src/state.rs b/src/state.rs
index 425e5d7..195d5cf 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -1,16 +1,15 @@
-use std::{collections::HashSet, fmt, fs};
+use std::fs;
+
use directories::ProjectDirs;
use expanduser::expanduser;
use serde::{Deserialize, Serialize};
-use walkdir::WalkDir;
-use zstd;
-use crate::{config::Config, learner::Learner, trajectory::Trajectory};
+use crate::{albums, config::Config, learner::Learner, trajectory::Trajectory};
fn default_state_path() -> String {
- let name = "mood.bin.zstd";
+ let name = "mood.json.zstd";
ProjectDirs::from("qualifier", "organisation", "mood")
- .and_then(|pd| pd.config_dir().join(name).to_str().map(|x| x.to_string()))
+ .and_then(|pd| pd.config_dir().join(name).to_str().map(std::string::ToString::to_string))
.unwrap_or(format!("~/.config/mood/{name}"))
}
@@ -18,11 +17,19 @@ fn default_state_path() -> String {
pub struct State {
trajectory: Trajectory,
learner: Learner,
- albums: HashSet<String>,
}
impl State {
pub fn next(&mut self, config: &Config) -> Option<(String, f32)> {
+ let albums = albums::load(config);
+ if albums.is_empty() {
+ println!("Found no albums in {}", config.music_root);
+ return None;
+ } else {
+ let l = albums.len();
+ println!("Found {} album{}", l, if l > 1 { "s" } else { "" });
+ }
+
let learning = self.trajectory.step(config.skip_window_secs);
if let Some(ref learning) = learning {
self.learner.learn(learning);
@@ -30,7 +37,7 @@ impl State {
if let Some((new_album, prob)) = self.learner.sample(
self.trajectory.slice(),
&learning.as_ref().into(),
- &self.albums,
+ &albums,
config,
) {
self.trajectory.log(&new_album);
@@ -41,109 +48,35 @@ impl State {
}
}
-pub struct UpdateResult {
- added: usize,
- removed: usize,
- new_total: usize,
-}
-
-impl fmt::Display for UpdateResult {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "+{}/-{}, total {}", self.added, self.removed, self.new_total)
- }
-}
-
impl State {
pub fn try_load() -> Result<State, String> {
expanduser(default_state_path())
.map_err(|e| e.to_string())
.and_then(|p| fs::read(p).map_err(|e| e.to_string()))
.and_then(|contents| zstd::decode_all(&contents[..]).map_err(|e| e.to_string()))
- .and_then(|decompressed| bincode::deserialize(&decompressed).map_err(|e| e.to_string()))
+ .and_then(|decompressed| {
+ std::str::from_utf8(&decompressed)
+ .map(std::string::ToString::to_string)
+ .map_err(|e| e.to_string())
+ })
+ .and_then(|utf8| serde_json::from_str(&utf8).map_err(|e| e.to_string()))
}
pub fn try_save(&self) -> Result<(), String> {
let str_path = default_state_path();
let path = expanduser(&str_path).map_err(|e| e.to_string())?;
_ = path.parent().map(std::fs::create_dir_all);
- let serialised =
- bincode::serialize(self).map_err(|e| format!("Failed to serialize state: {}", e))?;
- let compressed = zstd::encode_all(serialised.as_slice(), 3)
- .map_err(|e| format!("Failed to compress state: {}", e))?;
+ let serialised = serde_json::to_string_pretty(self)
+ .map_err(|e| format!("Failed to serialize state: {e}"))?;
+ let compressed = zstd::encode_all(serialised.into_bytes().as_slice(), 3)
+ .map_err(|e| format!("Failed to compress state: {e}"))?;
fs::write(&path, compressed)
- .map_err(|e| format!("Failed to write state file at {}: {}", str_path, e))?;
+ .map_err(|e| format!("Failed to write state file at {str_path}: {e}"))?;
Ok(())
}
-
- pub fn update_albums(&mut self, config: &Config) -> UpdateResult {
- let roots: Vec<_> = config
- .music_roots
- .iter()
- .filter_map(|x| expanduser(x).ok().filter(|p| p.is_dir()))
- .collect();
-
- let mut new_albums: HashSet<String> = HashSet::new();
-
- for root_path in &roots {
- for entry in WalkDir::new(&root_path) {
- let entry = match entry {
- Ok(e) if e.path().is_dir() => e,
- _ => continue,
- };
-
- if !Self::contains_audio(&entry, config) {
- continue;
- }
-
- if let Some(rel_str) = entry
- .path()
- .strip_prefix(&root_path)
- .ok()
- .and_then(|p| p.to_str())
- {
- new_albums.insert(rel_str.to_string());
- }
- }
- }
-
- let added = new_albums.difference(&self.albums).count();
- let removed = self.albums.difference(&new_albums).count();
- self.albums.clone_from(&new_albums);
-
- self.trajectory.prune(&self.albums);
- self.learner.prune(&self.albums);
-
- UpdateResult {
- added,
- removed,
- new_total: self.albums.len(),
- }
- }
-
- fn contains_audio(entry: &walkdir::DirEntry, config: &Config) -> bool {
- fs::read_dir(entry.path()).ok().is_some_and(|mut entries| {
- entries.any(|e| {
- e.as_ref()
- .is_ok_and(|entry| Self::is_audio_file(&entry.path(), config))
- })
- })
- }
-
- fn is_audio_file(path: &std::path::Path, config: &Config) -> bool {
- !path.is_dir()
- && path
- .extension()
- .and_then(|ext| ext.to_str())
- .map(|s| s.to_lowercase())
- .is_some_and(|ext| config.audio_exts.contains(&format!(".{ext}")))
- }
}
impl State {
- pub fn dump_albums(&self) -> Result<String, String> {
- serde_json::to_string_pretty(&self.albums).map_err(|e| e.to_string())
- }
-
pub fn dump_learner(&self) -> Result<String, String> {
serde_json::to_string_pretty(&self.learner).map_err(|e| e.to_string())
}
diff --git a/src/trajectory.rs b/src/trajectory.rs
index cbb0661..7f41a65 100644
--- a/src/trajectory.rs
+++ b/src/trajectory.rs
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
-use std::{collections::HashSet, time::SystemTime};
+use std::time::SystemTime;
use crate::learner::Learning;
@@ -36,16 +36,6 @@ impl From<Option<&Learning>> for Action {
}
impl Trajectory {
- pub fn prune(&mut self, valid: &HashSet<String>) {
- self.history.retain_mut(|x| valid.contains(x));
-
- if let Some(ref last_data) = self.last_data
- && !valid.contains(&last_data.album)
- {
- self.last_data = None
- }
- }
-
pub fn slice(&self) -> &[String] {
&self.history
}
@@ -54,7 +44,7 @@ impl Trajectory {
self.last_data = Some(LastData {
timestamp: SystemTime::now(),
album: new_album.to_string(),
- })
+ });
}
pub fn step(&mut self, skip_window_secs: u64) -> Option<Learning> {
@@ -74,8 +64,7 @@ impl Trajectory {
let learnt_nothing = self
.streak_kind
.as_ref()
- .map(|kind| *kind == action)
- .unwrap_or(false);
+ .is_some_and(|kind| *kind == action);
let learning = match (learnt_nothing, &action) {
(false, Action::Skip) => {
@@ -89,7 +78,7 @@ impl Trajectory {
if !learnt_nothing {
self.history.clear();
- };
+ }
self.history.push(last_album);
self.streak_kind = Some(action);