aede_core/
conclusions.rs

1//! Costly conclusions about audio bytes, independent of the rebuildable catalog.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use crate::analysis::FileAnalysis;
7use crate::fingerprint::Fingerprint;
8use crate::json::{self, Json};
9use crate::model::{Catalog, IntegrityRecord};
10use crate::playback::loudness::{CachedProgramme, CachedTrack, Measurement, ProgrammeFile};
11use crate::store::{self, StoreError};
12
13/// Independent on-disk format version for the conclusions store.
14pub const FORMAT_VERSION: u32 = 1;
15// Version 5 requires precise file evidence for derived playback measurements.
16// Older values cannot detect same-size changes within one second. Imported
17// analyses retain their independent source attribution and legacy evidence.
18const LOUDNESS_METHOD_VERSION: u32 = 5;
19/// File name inside Aède's data directory.
20pub const CONCLUSIONS_FILE: &str = "conclusions.json";
21
22/// Conclusions keyed by stable paths rather than scan-local identifiers.
23#[derive(Debug, Clone, Default)]
24pub struct Conclusions {
25    /// Path-keyed results, including results waiting for a file to return.
26    pub files: BTreeMap<String, FileConclusion>,
27    /// Imported measurements, including those waiting for a file to appear.
28    pub analyses: Vec<FileAnalysis>,
29    /// Playback loudness measurements keyed by file path and byte identity.
30    pub loudness_tracks: BTreeMap<String, CachedTrack>,
31    /// Album programme measurements keyed by their ordered file identities.
32    pub loudness_programmes: Vec<CachedProgramme>,
33}
34
35/// Results tied to a particular version of a file's bytes.
36#[derive(Debug, Clone)]
37pub struct FileConclusion {
38    /// File size when these results were produced.
39    pub size: u64,
40    /// Modification time when these results were produced.
41    pub mtime: u64,
42    /// Nanosecond fraction when known. Legacy results without precision only
43    /// match legacy catalog rows; a fresh read must not guess their identity.
44    pub mtime_subseconds: Option<u32>,
45    /// The most recent integrity check.
46    pub integrity: Option<IntegrityRecord>,
47    /// The computed acoustic fingerprint.
48    pub fingerprint: Option<Fingerprint>,
49}
50
51/// Returns the conclusions file inside a data directory.
52pub fn conclusions_path(data_dir: &Path) -> PathBuf {
53    data_dir.join(CONCLUSIONS_FILE)
54}
55
56impl Conclusions {
57    /// Extracts the non-derivable portion of a legacy catalog or a backup.
58    pub fn from_catalog(catalog: &Catalog) -> Self {
59        let mut result = Self::default();
60        result.update_from_catalog(catalog);
61        result
62    }
63
64    /// Current files are authoritative; absent paths are kept for later scans.
65    pub fn update_from_catalog(&mut self, catalog: &Catalog) {
66        for file in &catalog.files {
67            self.files.remove(&file.path);
68            if file.integrity.is_some() || file.fingerprint.is_some() {
69                self.files.insert(
70                    file.path.clone(),
71                    FileConclusion {
72                        size: file.size,
73                        mtime: file.mtime,
74                        mtime_subseconds: catalog.file_mtime_subseconds.get(&file.path).copied(),
75                        integrity: file.integrity.clone(),
76                        fingerprint: file.fingerprint.clone(),
77                    },
78                );
79            }
80        }
81        self.analyses = catalog.analyses.clone();
82    }
83
84    /// Imports legacy fields only when no independent store exists yet.
85    pub fn merge_legacy(&mut self, catalog: &Catalog) -> bool {
86        let legacy = Self::from_catalog(catalog);
87        let changed = !legacy.files.is_empty() || !legacy.analyses.is_empty();
88        if changed {
89            *self = legacy;
90        }
91        changed
92    }
93
94    /// Matches results to bytes, never to scan-local numeric identifiers.
95    pub fn attach(&self, catalog: &mut Catalog) {
96        for file in &mut catalog.files {
97            file.integrity = None;
98            file.fingerprint = None;
99            if let Some(record) = self.files.get(&file.path)
100                // A fallback zero without precision says nothing about bytes.
101                // A real timestamp at the epoch still has explicit precision.
102                && (file.mtime > 0 || catalog.file_mtime_subseconds.contains_key(&file.path))
103                && record.size == file.size
104                && record.mtime == file.mtime
105                && record.mtime_subseconds == catalog.file_mtime_subseconds.get(&file.path).copied()
106            {
107                file.integrity = record.integrity.clone();
108                file.fingerprint = record.fingerprint.clone();
109            }
110        }
111        catalog.analyses = self.analyses.clone();
112    }
113}
114
115/// Serializes the independent store.
116pub fn to_json(conclusions: &Conclusions) -> Json {
117    let mut root = Json::obj();
118    root.set("format_version", FORMAT_VERSION.into());
119    root.set("loudness_method_version", LOUDNESS_METHOD_VERSION.into());
120    let mut files = Vec::with_capacity(conclusions.files.len());
121    for (path, record) in &conclusions.files {
122        let mut row = Json::obj();
123        row.set("path", path.clone().into());
124        row.set("size", record.size.into());
125        row.set("mtime", record.mtime.into());
126        if let Some(fraction) = record.mtime_subseconds {
127            row.set("mtime_subseconds", fraction.into());
128        }
129        if let Some(verdict) = &record.integrity {
130            let mut value = Json::obj();
131            value.set("state", verdict.verdict.key().into());
132            value.set("method", verdict.method.clone().into());
133            value.set("checked_at", verdict.checked_at.into());
134            if let crate::audit::integrity::Verdict::Damaged { detail } = &verdict.verdict {
135                value.set("detail", detail.clone().into());
136            }
137            row.set("integrity", value);
138        }
139        if let Some(print) = &record.fingerprint {
140            let mut value = Json::obj();
141            value.set("data", print.data.clone().into());
142            value.set("seconds", u64::from(print.seconds).into());
143            row.set("fingerprint", value);
144        }
145        files.push(row);
146    }
147    root.set("file", Json::Arr(files));
148    root.set(
149        "analysis",
150        Json::Arr(
151            conclusions
152                .analyses
153                .iter()
154                .map(store::analysis_to_json)
155                .collect(),
156        ),
157    );
158    root.set(
159        "loudness_track",
160        Json::Arr(
161            conclusions
162                .loudness_tracks
163                .iter()
164                .map(|(path, cached)| {
165                    let mut row = Json::obj();
166                    row.set("path", path.clone().into());
167                    row.set("size", cached.size.into());
168                    row.set("mtime", cached.mtime.into());
169                    if let Some(fraction) = cached.mtime_subseconds {
170                        row.set("mtime_subseconds", fraction.into());
171                    }
172                    row.set("measurement", measurement_json(cached.measurement));
173                    row
174                })
175                .collect(),
176        ),
177    );
178    root.set(
179        "loudness_programme",
180        Json::Arr(
181            conclusions
182                .loudness_programmes
183                .iter()
184                .map(|cached| {
185                    let mut row = Json::obj();
186                    row.set(
187                        "file",
188                        Json::Arr(
189                            cached
190                                .files
191                                .iter()
192                                .map(|file| {
193                                    let mut value = Json::obj();
194                                    value.set("path", file.path.clone().into());
195                                    value.set("size", file.size.into());
196                                    value.set("mtime", file.mtime.into());
197                                    if let Some(fraction) = file.mtime_subseconds {
198                                        value.set("mtime_subseconds", fraction.into());
199                                    }
200                                    value
201                                })
202                                .collect(),
203                        ),
204                    );
205                    row.set("measurement", measurement_json(cached.measurement));
206                    row
207                })
208                .collect(),
209        ),
210    );
211    root
212}
213
214fn measurement_json(measurement: Option<Measurement>) -> Json {
215    let Some(measurement) = measurement else {
216        return Json::Null;
217    };
218    let mut value = Json::obj();
219    value.set(
220        "integrated_lufs",
221        (measurement.integrated_lufs as f64).into(),
222    );
223    if let Some(peak) = measurement.true_peak {
224        value.set("true_peak", (peak as f64).into());
225    }
226    value
227}
228
229fn measurement_from_json(value: Option<&Json>) -> Result<Option<Measurement>, StoreError> {
230    let Some(value) = value else {
231        return Err(StoreError::ConclusionsInvalid(
232            "missing loudness measurement",
233        ));
234    };
235    if *value == Json::Null {
236        return Ok(None);
237    }
238    let loudness = value
239        .field_f64("integrated_lufs")
240        .ok_or(StoreError::ConclusionsInvalid("invalid loudness"))?;
241    let peak = value.field_f64("true_peak");
242    if value.get("true_peak").is_some() && peak.is_none() {
243        return Err(StoreError::ConclusionsInvalid("invalid true peak"));
244    }
245    if !loudness.is_finite()
246        || peak.is_some_and(|peak| !peak.is_finite() || peak < 0.0 || peak > f32::MAX as f64)
247        || !(-100.0..=20.0).contains(&loudness)
248    {
249        return Err(StoreError::ConclusionsInvalid(
250            "out-of-range loudness measurement",
251        ));
252    }
253    Ok(Some(Measurement {
254        integrated_lufs: loudness as f32,
255        true_peak: peak.map(|peak| peak as f32),
256    }))
257}
258
259/// Parses the independent store, refusing an incompatible version.
260pub fn from_json(value: &Json) -> Result<Conclusions, StoreError> {
261    let found = value.field_u32("format_version").unwrap_or(0);
262    if found != FORMAT_VERSION {
263        return Err(StoreError::ConclusionsVersion {
264            found,
265            expected: FORMAT_VERSION,
266        });
267    }
268    for table in ["file", "analysis", "loudness_track", "loudness_programme"] {
269        if value.get(table).is_some_and(|rows| rows.as_arr().is_none()) {
270            return Err(StoreError::ConclusionsInvalid(
271                "conclusion table must be an array",
272            ));
273        }
274    }
275    let mut result = Conclusions::default();
276    for row in value.get("file").and_then(Json::as_arr).unwrap_or(&[]) {
277        let path = row
278            .field_str("path")
279            .ok_or(StoreError::ConclusionsInvalid("conclusion without path"))?;
280        let size = row
281            .field_u64("size")
282            .ok_or(StoreError::ConclusionsInvalid("conclusion without size"))?;
283        let mtime = row
284            .field_u64("mtime")
285            .ok_or(StoreError::ConclusionsInvalid("conclusion without mtime"))?;
286        let integrity = store::integrity_from_json(row.get("integrity"));
287        let fingerprint = store::fingerprint_from_json(row.get("fingerprint"));
288        if row
289            .get("integrity")
290            .is_some_and(|value| *value != Json::Null)
291            && integrity.is_none()
292        {
293            return Err(StoreError::ConclusionsInvalid(
294                "unreadable integrity verdict",
295            ));
296        }
297        if row
298            .get("fingerprint")
299            .is_some_and(|value| *value != Json::Null)
300            && fingerprint.is_none()
301        {
302            return Err(StoreError::ConclusionsInvalid("unreadable fingerprint"));
303        }
304        let record = FileConclusion {
305            size,
306            mtime,
307            mtime_subseconds: modification_fraction(row)?,
308            integrity,
309            fingerprint,
310        };
311        if result.files.insert(path, record).is_some() {
312            return Err(StoreError::ConclusionsInvalid("duplicate conclusion path"));
313        }
314    }
315    for row in value.get("analysis").and_then(Json::as_arr).unwrap_or(&[]) {
316        result.analyses.push(store::analysis_from_json(row)?);
317    }
318    if value.field_u32("loudness_method_version") == Some(LOUDNESS_METHOD_VERSION) {
319        for row in value
320            .get("loudness_track")
321            .and_then(Json::as_arr)
322            .unwrap_or(&[])
323        {
324            let path = row
325                .field_str("path")
326                .ok_or(StoreError::ConclusionsInvalid(
327                    "loudness track without path",
328                ))?
329                .to_owned();
330            let cached = CachedTrack {
331                size: row.field_u64("size").ok_or(StoreError::ConclusionsInvalid(
332                    "loudness track without size",
333                ))?,
334                mtime: row
335                    .field_u64("mtime")
336                    .ok_or(StoreError::ConclusionsInvalid(
337                        "loudness track without mtime",
338                    ))?,
339                mtime_subseconds: modification_fraction(row)?,
340                measurement: measurement_from_json(row.get("measurement"))?,
341            };
342            if result.loudness_tracks.insert(path, cached).is_some() {
343                return Err(StoreError::ConclusionsInvalid("duplicate loudness track"));
344            }
345        }
346        for row in value
347            .get("loudness_programme")
348            .and_then(Json::as_arr)
349            .unwrap_or(&[])
350        {
351            let files =
352                row.get("file")
353                    .and_then(Json::as_arr)
354                    .ok_or(StoreError::ConclusionsInvalid("programme without files"))?
355                    .iter()
356                    .map(|file| {
357                        Ok(ProgrammeFile {
358                            path: file
359                                .field_str("path")
360                                .ok_or(StoreError::ConclusionsInvalid(
361                                    "programme file without path",
362                                ))?
363                                .to_owned(),
364                            size: file
365                                .field_u64("size")
366                                .ok_or(StoreError::ConclusionsInvalid(
367                                    "programme file without size",
368                                ))?,
369                            mtime: file.field_u64("mtime").ok_or(
370                                StoreError::ConclusionsInvalid("programme file without mtime"),
371                            )?,
372                            mtime_subseconds: modification_fraction(file)?,
373                        })
374                    })
375                    .collect::<Result<Vec<_>, StoreError>>()?;
376            if files.is_empty() {
377                return Err(StoreError::ConclusionsInvalid("empty loudness programme"));
378            }
379            result.loudness_programmes.push(CachedProgramme {
380                files,
381                measurement: measurement_from_json(row.get("measurement"))?,
382            });
383        }
384    }
385    Ok(result)
386}
387
388fn modification_fraction(row: &Json) -> Result<Option<u32>, StoreError> {
389    row.get("mtime_subseconds")
390        .map(|value| {
391            value
392                .as_u32()
393                .filter(|fraction| *fraction < 1_000_000_000)
394                .ok_or(StoreError::ConclusionsInvalid(
395                    "invalid file modification fraction",
396                ))
397        })
398        .transpose()
399}
400
401/// Atomically saves the independent store.
402pub fn save(conclusions: &Conclusions, path: &Path) -> Result<(), StoreError> {
403    crate::atomic_file::write(path, to_json(conclusions).to_string_compact().as_bytes())?;
404    Ok(())
405}
406
407/// Loads the independent store, or returns `None` when it does not exist.
408pub fn load(path: &Path) -> Result<Option<Conclusions>, StoreError> {
409    let content = match std::fs::read_to_string(path) {
410        Ok(content) => content,
411        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
412        Err(error) => return Err(error.into()),
413    };
414    let value = json::parse(&content).map_err(StoreError::ConclusionsParse)?;
415    from_json(&value).map(Some)
416}
417
418#[cfg(test)]
419#[path = "conclusions_tests.rs"]
420mod tests;