From 156cbfe2b0383521f540109682d98d35c88d65f8 Mon Sep 17 00:00:00 2001 From: tslil Date: Tue, 14 Jul 2026 12:20:44 +0100 Subject: don't cache album list, use compression only on json for ease of inspection --- src/albums.rs | 56 +++++++++++++++++++++++++ src/cli.rs | 7 +--- src/config.rs | 22 +++++----- src/learner.rs | 36 ++--------------- src/main.rs | 17 +------- src/state.rs | 119 ++++++++++++------------------------------------------ src/trajectory.rs | 19 ++------- 7 files changed, 103 insertions(+), 173 deletions(-) create mode 100644 src/albums.rs (limited to 'src') 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 { + 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 = 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. Suggest and play the next album" ); } @@ -39,11 +37,11 @@ pub fn parse() -> Option { 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 { 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, + pub music_root: String, pub audio_exts: Vec, 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) -> 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, @@ -87,15 +63,9 @@ pub enum Learning { MoreToSkip(Vec, String), } -impl Learner { - pub fn prune(&mut self, valid: &HashSet) { - 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 = 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, } 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 { 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 = 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 { - serde_json::to_string_pretty(&self.albums).map_err(|e| e.to_string()) - } - pub fn dump_learner(&self) -> Result { 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> for Action { } impl Trajectory { - pub fn prune(&mut self, valid: &HashSet) { - 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 { @@ -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); -- cgit v1.2.3