aede_core/
acoustic.rs

1//! Acoustic analysis of catalogued albums with FlacCompagnon's Rust library.
2//!
3//! Album membership comes from Aède's catalog, including multi-disc releases.
4//! Measurements retain FlacCompagnon's provenance in Aède's analysis table.
5
6use 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/// A reusable upstream result together with its complete attributed source entry.
17#[derive(Debug, Clone)]
18pub struct SavedFileAnalysis {
19    /// Typed fields used for cache validity and human presentation.
20    pub analysis: FileAnalysis,
21    /// Original measurement date, independent of later report rewrites.
22    pub result_at_ns: u64,
23    // Shared because one artist report may be reused by several album groups.
24    source: std::sync::Arc<serde_json::Value>,
25}
26
27/// Read reusable results without discarding fields unknown to this upstream build.
28///
29/// The upstream reader validates its report contract, and the catalog reader
30/// validates the interpreted measurements and exact per-entry dates. Raw source
31/// entries remain separate from the typed projection used to select cache hits.
32pub 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
68/// Serialize an upstream report while preserving the date of every measurement.
69///
70/// Dates are decimal strings in an additive file-entry field ignored by upstream
71/// readers. Reused results retain their original date, even in a newly written
72/// report containing freshly decoded tracks. Reused entries retain all original
73/// fields, including nested unknown measurements. Fresh entries keep upstream
74/// float stabilization; integer precision is retained in both cases.
75pub 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
120/// Group catalogued files by their physical album folder.
121///
122/// Multiple releases in one directory produce one report containing both.
123/// A multi-disc release uses its shared parent folder.
124pub 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
154/// Analyze an album's selected files concurrently, preserving catalog order.
155pub 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
164/// Analyze an album while reporting each starting file to the caller.
165///
166/// The callback runs on an analysis worker before decoding its file, so
167/// a terminal client can show useful progress without changing ordering or
168/// requiring the core to write to a terminal itself.
169pub 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;