Add option to write analysis results to files, and use for future scans.

Issue #4
This commit is contained in:
CDrummond 2025-03-04 20:04:37 +00:00
parent bff4ba18b4
commit 6cc26c399e
5 changed files with 124 additions and 22 deletions

View File

@ -8,6 +8,7 @@
4. Add ability to specify LMS JSONRPC port.
5. If new files analysed and 'ignore' file exists then update DB's 'ignore'
flags.
6. Add option to write analysis results to files, and use for future scans.
0.2.3
-----

View File

@ -42,7 +42,7 @@ const MAX_ERRORS_TO_SHOW: usize = 100;
const MAX_TAG_ERRORS_TO_SHOW: usize = 50;
const VALID_EXTENSIONS: [&str; 6] = ["m4a", "mp3", "ogg", "flac", "opus", "wv"];
fn get_file_list(db: &mut db::Db, mpath: &Path, path: &Path, track_paths: &mut Vec<String>, cue_tracks:&mut Vec<cue::CueTrack>, file_count:&mut usize, max_num_files: usize) {
fn get_file_list(db: &mut db::Db, mpath: &Path, path: &Path, track_paths: &mut Vec<String>, cue_tracks:&mut Vec<cue::CueTrack>, file_count:&mut usize, max_num_files: usize, use_tags: bool, tagged_file_count:&mut usize, dry_run: bool) {
if !path.is_dir() {
return;
}
@ -51,21 +51,21 @@ fn get_file_list(db: &mut db::Db, mpath: &Path, path: &Path, track_paths: &mut V
items.sort_by_key(|dir| dir.path());
for item in items {
check_dir_entry(db, mpath, item, track_paths, cue_tracks, file_count, max_num_files);
check_dir_entry(db, mpath, item, track_paths, cue_tracks, file_count, max_num_files, use_tags, tagged_file_count, dry_run);
if max_num_files>0 && *file_count>=max_num_files {
break;
}
}
}
fn check_dir_entry(db: &mut db::Db, mpath: &Path, entry: DirEntry, track_paths: &mut Vec<String>, cue_tracks:&mut Vec<cue::CueTrack>, file_count:&mut usize, max_num_files: usize) {
fn check_dir_entry(db: &mut db::Db, mpath: &Path, entry: DirEntry, track_paths: &mut Vec<String>, cue_tracks:&mut Vec<cue::CueTrack>, file_count:&mut usize, max_num_files: usize, use_tags: bool, tagged_file_count:&mut usize, dry_run: bool) {
let pb = entry.path();
if pb.is_dir() {
let check = pb.join(DONT_ANALYSE);
if check.exists() {
log::info!("Skipping '{}', found '{}'", pb.to_string_lossy(), DONT_ANALYSE);
} else if max_num_files<=0 || *file_count<max_num_files {
get_file_list(db, mpath, &pb, track_paths, cue_tracks, file_count, max_num_files);
get_file_list(db, mpath, &pb, track_paths, cue_tracks, file_count, max_num_files, use_tags, tagged_file_count, dry_run);
}
} else if pb.is_file() && (max_num_files<=0 || *file_count<max_num_files) {
if_chain! {
@ -106,8 +106,22 @@ fn check_dir_entry(db: &mut db::Db, mpath: &Path, entry: DirEntry, track_paths:
} else {
if let Ok(id) = db.get_rowid(&sname) {
if id<=0 {
track_paths.push(String::from(pb.to_string_lossy()));
*file_count+=1;
let mut tags_used = false;
if use_tags {
let meta = tags::read(&sname, true);
if !meta.is_empty() && !meta.analysis.is_none() {
if !dry_run {
db.add_track(&sname, &meta, &meta.analysis.unwrap());
}
*tagged_file_count+=1;
tags_used = true;
}
}
if !tags_used {
track_paths.push(String::from(pb.to_string_lossy()));
*file_count+=1;
}
}
}
}
@ -116,7 +130,7 @@ fn check_dir_entry(db: &mut db::Db, mpath: &Path, entry: DirEntry, track_paths:
}
}
pub fn show_errors(failed: &mut Vec<String>, tag_error: &mut Vec<String>) {
fn show_errors(failed: &mut Vec<String>, tag_error: &mut Vec<String>) {
if !failed.is_empty() {
let total = failed.len();
failed.truncate(MAX_ERRORS_TO_SHOW);
@ -144,7 +158,7 @@ pub fn show_errors(failed: &mut Vec<String>, tag_error: &mut Vec<String>) {
}
#[cfg(feature = "libav")]
pub fn analyse_new_files(db: &db::Db, mpath: &PathBuf, track_paths: Vec<String>, max_threads: usize) -> Result<()> {
fn analyse_new_files(db: &db::Db, mpath: &PathBuf, track_paths: Vec<String>, max_threads: usize, use_tags: bool) -> Result<()> {
let total = track_paths.len();
let progress = ProgressBar::new(total.try_into().unwrap()).with_style(
ProgressStyle::default_bar()
@ -205,7 +219,7 @@ pub fn analyse_new_files(db: &db::Db, mpath: &PathBuf, track_paths: Vec<String>,
None => {
// Use lofty to read tags here, and not bliss's, so that if update
// tags is ever used they are from the same source.
let mut meta = tags::read(&cpath);
let mut meta = tags::read(&cpath, false);
if meta.is_empty() {
// Lofty failed? Try from bliss...
meta.title = track.title.unwrap_or_default().to_string();
@ -218,10 +232,13 @@ pub fn analyse_new_files(db: &db::Db, mpath: &PathBuf, track_paths: Vec<String>,
if meta.is_empty() {
tag_error.push(sname.clone());
}
if use_tags {
tags::write_analysis(&cpath, &track.analysis);
}
db.add_track(&sname, &meta, &track.analysis);
analysed += 1;
}
}
analysed += 1;
}
Err(e) => { failed.push(format!("{} - {}", sname, e)); }
};
@ -238,7 +255,7 @@ pub fn analyse_new_files(db: &db::Db, mpath: &PathBuf, track_paths: Vec<String>,
}
#[cfg(not(feature = "libav"))]
pub fn analyse_new_files(db: &db::Db, mpath: &PathBuf, track_paths: Vec<String>, max_threads: usize) -> Result<()> {
fn analyse_new_files(db: &db::Db, mpath: &PathBuf, track_paths: Vec<String>, max_threads: usize, use_tags: bool) -> Result<()> {
let total = track_paths.len();
let progress = ProgressBar::new(total.try_into().unwrap()).with_style(
ProgressStyle::default_bar()
@ -263,10 +280,13 @@ pub fn analyse_new_files(db: &db::Db, mpath: &PathBuf, track_paths: Vec<String>,
match result {
Ok(track) => {
let cpath = String::from(path.to_string_lossy());
let meta = tags::read(&cpath);
let meta = tags::read(&cpath, false);
if meta.is_empty() {
tag_error.push(sname.clone());
}
if use_tags {
tags::write_analysis(&cpath, &track.analysis);
}
db.add_track(&sname, &meta, &track.analysis);
analysed += 1;
}
@ -291,7 +311,7 @@ pub fn analyse_new_files(db: &db::Db, mpath: &PathBuf, track_paths: Vec<String>,
}
#[cfg(not(feature = "libav"))]
pub fn analyze_cue_streaming(tracks: Vec<cue::CueTrack>,) -> BlissResult<Receiver<(cue::CueTrack, BlissResult<Song>)>> {
fn analyze_cue_streaming(tracks: Vec<cue::CueTrack>,) -> BlissResult<Receiver<(cue::CueTrack, BlissResult<Song>)>> {
let num_cpus = num_cpus::get();
#[allow(clippy::type_complexity)]
@ -331,7 +351,7 @@ pub fn analyze_cue_streaming(tracks: Vec<cue::CueTrack>,) -> BlissResult<Receive
}
#[cfg(not(feature = "libav"))]
pub fn analyse_new_cue_tracks(db:&db::Db, mpath: &PathBuf, cue_tracks:Vec<cue::CueTrack>) -> Result<()> {
fn analyse_new_cue_tracks(db:&db::Db, mpath: &PathBuf, cue_tracks:Vec<cue::CueTrack>) -> Result<()> {
let total = cue_tracks.len();
let progress = ProgressBar::new(total.try_into().unwrap()).with_style(
ProgressStyle::default_bar()
@ -359,7 +379,8 @@ pub fn analyse_new_cue_tracks(db:&db::Db, mpath: &PathBuf, cue_tracks:Vec<cue::C
album_artist:track.album_artist,
album:track.album,
genre:track.genre,
duration:if track.duration>=last_track_duration { song.duration.as_secs() as u32 } else { track.duration.as_secs() as u32 }
duration:if track.duration>=last_track_duration { song.duration.as_secs() as u32 } else { track.duration.as_secs() as u32 },
analysis: None
};
db.add_track(&sname, &meta, &song.analysis);
@ -377,7 +398,7 @@ pub fn analyse_new_cue_tracks(db:&db::Db, mpath: &PathBuf, cue_tracks:Vec<cue::C
Ok(())
}
pub fn analyse_files(db_path: &str, mpaths: &Vec<PathBuf>, dry_run: bool, keep_old: bool, max_num_files: usize, max_threads: usize, ignore_path: &PathBuf) {
pub fn analyse_files(db_path: &str, mpaths: &Vec<PathBuf>, dry_run: bool, keep_old: bool, max_num_files: usize, max_threads: usize, ignore_path: &PathBuf, use_tags: bool) {
let mut db = db::Db::new(&String::from(db_path));
db.init();
@ -393,18 +414,22 @@ pub fn analyse_files(db_path: &str, mpaths: &Vec<PathBuf>, dry_run: bool, keep_o
let mut track_paths: Vec<String> = Vec::new();
let mut cue_tracks:Vec<cue::CueTrack> = Vec::new();
let mut file_count:usize = 0;
let mut tagged_file_count:usize = 0;
if mpaths.len() > 1 {
log::info!("Looking for new files in {}", mpath.to_string_lossy());
} else {
log::info!("Looking for new files");
}
get_file_list(&mut db, &mpath, &cur, &mut track_paths, &mut cue_tracks, &mut file_count, max_num_files);
get_file_list(&mut db, &mpath, &cur, &mut track_paths, &mut cue_tracks, &mut file_count, max_num_files, use_tags, &mut tagged_file_count, dry_run);
track_paths.sort();
log::info!("Num new files: {}", track_paths.len());
if !cue_tracks.is_empty() {
log::info!("Num new cue tracks: {}", cue_tracks.len());
}
if use_tags {
log::info!("Num tagged files: {}", tagged_file_count);
}
if dry_run {
if !track_paths.is_empty() || !cue_tracks.is_empty() {
@ -418,7 +443,7 @@ pub fn analyse_files(db_path: &str, mpaths: &Vec<PathBuf>, dry_run: bool, keep_o
}
} else {
if !track_paths.is_empty() {
match analyse_new_files(&db, &mpath, track_paths, max_threads) {
match analyse_new_files(&db, &mpath, track_paths, max_threads, use_tags) {
Ok(_) => { changes_made = true; }
Err(e) => { log::error!("Analysis returned error: {}", e); }
}

View File

@ -35,6 +35,7 @@ pub struct Metadata {
pub album: String,
pub genre: String,
pub duration: u32,
pub analysis: Option<Analysis>,
}
impl Metadata {
@ -275,6 +276,7 @@ impl Db {
album: dbtags.album.unwrap_or_default(),
genre: dbtags.genre.unwrap_or_default(),
duration: dbtags.duration,
analysis: None,
};
progress.set_message(format!("{}", dbtags.file));
@ -282,7 +284,7 @@ impl Db {
let track_path = mpath.join(&dbtags.file);
if track_path.exists() {
let path = String::from(track_path.to_string_lossy());
let ftags = tags::read(&path);
let ftags = tags::read(&path, false);
if ftags.is_empty() {
log::error!("Failed to read tags of '{}'", dbtags.file);
} else if ftags != dtags {

View File

@ -41,6 +41,7 @@ fn main() {
let mut max_num_files: usize = 0;
let mut music_paths: Vec<PathBuf> = Vec::new();
let mut max_threads: usize = 0;
let mut use_tags = false;
match dirs::home_dir() {
Some(path) => {
@ -74,6 +75,7 @@ fn main() {
arg_parse.refer(&mut lms_json_port).add_option(&["-J", "--json"], Store, &lms_json_port_help);
arg_parse.refer(&mut max_num_files).add_option(&["-n", "--numfiles"], Store, "Maximum number of files to analyse");
arg_parse.refer(&mut max_threads).add_option(&["-t", "--threads"], Store, "Maximum number of threads to use for analysis");
arg_parse.refer(&mut use_tags).add_option(&["-T", "--tags"], StoreTrue, "Read/write analysis results from/to source fles");
arg_parse.refer(&mut task).add_argument("task", Store, "Task to perform; analyse, tags, ignore, upload, stopmixer.");
arg_parse.parse_args_or_exit();
}
@ -200,7 +202,7 @@ fn main() {
analyse::update_ignore(&db_path, &ignore_path);
} else {
let ignore_path = PathBuf::from(&ignore_file);
analyse::analyse_files(&db_path, &music_paths, dry_run, keep_old, max_num_files, max_threads, &ignore_path);
analyse::analyse_files(&db_path, &music_paths, dry_run, keep_old, max_num_files, max_threads, &ignore_path, use_tags);
}
}
}

View File

@ -7,14 +7,44 @@
**/
use crate::db;
use lofty::{Accessor, AudioFile, ItemKey, TaggedFileExt};
use lofty::{Accessor, AudioFile, ItemKey, Tag, TagExt, TaggedFileExt};
use regex::Regex;
use std::path::Path;
use substring::Substring;
use bliss_audio::{Analysis, AnalysisIndex};
const MAX_GENRE_VAL: usize = 192;
const NUM_ANALYSIS_VALS: usize = 20;
const ANALYSIS_TAG:ItemKey = ItemKey::Script;
const ANALYSIS_TAG_START: &str = "BLISS_ANALYSIS";
const ANALYSIS_TAG_VER: u16 = 1;
pub fn read(track: &String) -> db::Metadata {
pub fn write_analysis(track: &String, analysis: &Analysis) {
let value = format!("{},{},{:.24},{:.24},{:.24},{:.24},{:.24},{:.24},{:.24},{:.24},{:.24},{:.24},{:.24},{:.24},{:.24},{:.24},{:.24},{:.24},{:.24},{:.24},{:.24},{:.24}", ANALYSIS_TAG_START, ANALYSIS_TAG_VER,
analysis[AnalysisIndex::Tempo], analysis[AnalysisIndex::Zcr], analysis[AnalysisIndex::MeanSpectralCentroid], analysis[AnalysisIndex::StdDeviationSpectralCentroid], analysis[AnalysisIndex::MeanSpectralRolloff],
analysis[AnalysisIndex::StdDeviationSpectralRolloff], analysis[AnalysisIndex::MeanSpectralFlatness], analysis[AnalysisIndex::StdDeviationSpectralFlatness], analysis[AnalysisIndex::MeanLoudness], analysis[AnalysisIndex::StdDeviationLoudness],
analysis[AnalysisIndex::Chroma1], analysis[AnalysisIndex::Chroma2], analysis[AnalysisIndex::Chroma3], analysis[AnalysisIndex::Chroma4], analysis[AnalysisIndex::Chroma5],
analysis[AnalysisIndex::Chroma6], analysis[AnalysisIndex::Chroma7], analysis[AnalysisIndex::Chroma8], analysis[AnalysisIndex::Chroma9], analysis[AnalysisIndex::Chroma10]);
if let Ok(mut file) = lofty::read_from_path(Path::new(track)) {
let tag = match file.primary_tag_mut() {
Some(primary_tag) => primary_tag,
None => {
if let Some(first_tag) = file.first_tag_mut() {
first_tag
} else {
let tag_type = file.primary_tag_type();
file.insert_tag(Tag::new(tag_type));
file.primary_tag_mut().unwrap()
}
},
};
tag.insert_text(ANALYSIS_TAG, value);
let _ = tag.save_to_path(Path::new(track));
}
}
pub fn read(track: &String, read_analysis: bool) -> db::Metadata {
let mut meta = db::Metadata {
duration: 180,
..db::Metadata::default()
@ -71,6 +101,48 @@ pub fn read(track: &String) -> db::Metadata {
}
meta.duration = file.properties().duration().as_secs() as u32;
if read_analysis {
let analysis_string = tag.get_string(&ANALYSIS_TAG).unwrap_or("");
if analysis_string.len()>(ANALYSIS_TAG_START.len()+(NUM_ANALYSIS_VALS*8)) {
let parts = analysis_string.split(",");
let mut index = 0;
let mut vals = [0.; NUM_ANALYSIS_VALS];
for part in parts {
if 0==index {
if part!=ANALYSIS_TAG_START {
break;
}
} else if 1==index {
match part.parse::<u16>() {
Ok(ver) => {
if ver!=ANALYSIS_TAG_VER {
break;
}
},
Err(_) => {
break;
}
}
} else if (index - 2) < NUM_ANALYSIS_VALS {
match part.parse::<f32>() {
Ok(val) => {
vals[index - 2] = val;
},
Err(_) => {
break;
}
}
} else {
break;
}
index += 1;
}
if index == (NUM_ANALYSIS_VALS+2) {
meta.analysis = Some(Analysis::new(vals));
}
}
}
}
meta