1use std::collections::{BTreeMap, BTreeSet};
7use std::path::{Path, PathBuf};
8use std::sync::OnceLock;
9use std::sync::atomic::{AtomicUsize, Ordering};
10
11use flaccompagnon_core::{self as fc, FileAnalysis, FolderReport, ScanOptions};
12
13use crate::model::{Catalog, Id};
14use crate::text;
15
16#[derive(Debug, Clone)]
18pub struct SavedFileAnalysis {
19 pub analysis: FileAnalysis,
21 pub result_at_ns: u64,
23 source: std::sync::Arc<serde_json::Value>,
25}
26
27pub fn saved_report(
33 text: &str,
34 report_date: u64,
35) -> Result<Vec<SavedFileAnalysis>, crate::analysis::ImportError> {
36 use crate::analysis::ImportError;
37 let report = fc::report::parse_json(text).map_err(ImportError::Json)?;
38 let dates: Vec<_> = crate::analysis::parse_report_at(text, report_date)?
39 .files
40 .into_iter()
41 .map(|file| file.result_at_ns)
42 .collect();
43 let mut value: serde_json::Value =
44 serde_json::from_str(text).map_err(|error| ImportError::Json(error.to_string()))?;
45 let files = value
46 .get_mut("report")
47 .and_then(|report| report.get_mut("files"))
48 .and_then(serde_json::Value::as_array_mut)
49 .ok_or(ImportError::Malformed("report.files must be an array"))?;
50 if report.files.len() != files.len() || dates.len() != files.len() {
51 return Err(ImportError::Malformed(
52 "report entries must have valid paths",
53 ));
54 }
55 Ok(report
56 .files
57 .into_iter()
58 .zip(dates)
59 .zip(std::mem::take(files))
60 .map(|((analysis, result_at_ns), source)| SavedFileAnalysis {
61 analysis,
62 result_at_ns,
63 source: std::sync::Arc::new(source),
64 })
65 .collect())
66}
67
68pub fn report_json_with_dates(
76 report: &FolderReport,
77 dates: &BTreeMap<String, u64>,
78 reused: &BTreeMap<String, &SavedFileAnalysis>,
79) -> Result<String, crate::analysis::ImportError> {
80 use crate::analysis::{ImportError, RESULT_DATE_FIELD};
81 let text =
82 fc::report::build_json(report).map_err(|error| ImportError::Json(error.to_string()))?;
83 let mut value: serde_json::Value =
84 serde_json::from_str(&text).map_err(|error| ImportError::Json(error.to_string()))?;
85 drop(text);
86 let files = value
87 .get_mut("report")
88 .and_then(|report| report.get_mut("files"))
89 .and_then(serde_json::Value::as_array_mut)
90 .ok_or(ImportError::Malformed("report.files must be an array"))?;
91 for file in files {
92 let path = file
93 .get("path")
94 .and_then(serde_json::Value::as_str)
95 .ok_or(ImportError::Malformed("report entries must have paths"))?;
96 let date = dates.get(path).ok_or(ImportError::Malformed(
97 "a measurement date is required for each report entry",
98 ))?;
99 if let Some(saved) = reused.get(path) {
100 if saved.analysis.path != path
101 || saved.source.get("path").and_then(serde_json::Value::as_str) != Some(path)
102 {
103 return Err(ImportError::Malformed(
104 "reused report entry has a different path",
105 ));
106 }
107 *file = saved.source.as_ref().clone();
108 }
109 let fields = file
110 .as_object_mut()
111 .ok_or(ImportError::Malformed("report entries must be objects"))?;
112 fields.insert(
113 RESULT_DATE_FIELD.into(),
114 serde_json::Value::String(date.to_string()),
115 );
116 }
117 serde_json::to_string_pretty(&value).map_err(|error| ImportError::Json(error.to_string()))
118}
119
120pub fn album_files(catalog: &Catalog, scope: &[String]) -> BTreeMap<PathBuf, Vec<PathBuf>> {
125 let mut by_file: BTreeMap<Id, PathBuf> = BTreeMap::new();
126 for release in &catalog.releases {
127 for track_id in &release.track_ids {
128 if let Some(track) = catalog.tracks.get(*track_id as usize) {
129 by_file.insert(track.file_id, PathBuf::from(&release.folder));
130 }
131 }
132 }
133
134 let mut folders: BTreeMap<PathBuf, BTreeSet<PathBuf>> = BTreeMap::new();
135 for file in &catalog.files {
136 if !scope.is_empty() && !scope.iter().any(|root| text::is_under(&file.path, root)) {
137 continue;
138 }
139 let path = PathBuf::from(&file.path);
140 let folder = by_file
141 .get(&file.id)
142 .cloned()
143 .or_else(|| path.parent().map(Path::to_path_buf));
144 if let Some(folder) = folder {
145 folders.entry(folder).or_default().insert(path);
146 }
147 }
148 folders
149 .into_iter()
150 .map(|(folder, files)| (folder, files.into_iter().collect()))
151 .collect()
152}
153
154pub fn analyze_album(
156 folder: &Path,
157 paths: &[PathBuf],
158 options: &ScanOptions,
159 threads: usize,
160) -> FolderReport {
161 analyze_album_with_progress(folder, paths, options, threads, |_, _, _| {})
162}
163
164pub fn analyze_album_with_progress<F>(
170 folder: &Path,
171 paths: &[PathBuf],
172 options: &ScanOptions,
173 threads: usize,
174 on_progress: F,
175) -> FolderReport
176where
177 F: Fn(usize, usize, &Path) + Sync,
178{
179 let next = AtomicUsize::new(0);
180 let slots: Vec<OnceLock<FileAnalysis>> = (0..paths.len()).map(|_| OnceLock::new()).collect();
181 std::thread::scope(|scope| {
182 for _ in 0..threads.max(1).min(paths.len()) {
183 scope.spawn(|| {
184 loop {
185 let index = next.fetch_add(1, Ordering::Relaxed);
186 if index >= paths.len() {
187 break;
188 }
189 on_progress(index + 1, paths.len(), &paths[index]);
190 let _ = slots[index].set(fc::analyze_file(&paths[index], options));
191 }
192 });
193 }
194 });
195 let files: Vec<FileAnalysis> = slots.into_iter().filter_map(OnceLock::into_inner).collect();
196 let has_flac = files.iter().any(|file| file.flac_md5.is_some());
197 FolderReport {
198 root: folder.to_string_lossy().to_string(),
199 files,
200 has_flac,
201 }
202}
203
204#[cfg(test)]
205#[path = "acoustic_tests.rs"]
206mod tests;