aede_core/
analysis.rs

1//! FlacCompagnon analyses produced in-process or imported from a saved report.
2//!
3//! Aède describes what a file *contains* by reading its structure; it does not
4//! decode. A decoder answers questions no structural read can: is this FLAC a
5//! re-encoded MP3, was it upsampled, where does its spectrum stop, how loud is
6//! it really, and — the decisive one — does the audio still match the MD5 the
7//! encoder wrote in STREAMINFO.
8//!
9//! [FlacCompagnon](https://craft-and-code.github.io/FlacCompagnon/) does that
10//! pass through `aede analyze`; its saved reports can also be imported later.
11//!
12//! **Acoustic values are never merged into Aède's own.** They sit beside them,
13//! attributed to their source, because a verdict carries the method that
14//! produced it: overwriting `effective_bit_depth` — read from the wasted-bits
15//! counts of the frames — with a figure obtained by decoding would leave the
16//! catalog unable to say where the number came from, and unable to notice that
17//! the two disagree. Noticing is the point.
18
19pub mod export;
20
21use std::collections::{BTreeMap, BTreeSet};
22use std::io::Read;
23use std::path::Path;
24
25use crate::json::{self, Json};
26use crate::model::Catalog;
27use md5::{Digest, Md5};
28
29/// The report format this reader understands.
30pub const FLACCOMPAGNON_FORMAT: &str = "flaccompagnon-report";
31
32/// Exact per-entry measurement date retained when Aède rewrites reused results.
33pub const RESULT_DATE_FIELD: &str = "aede_result_at_ns";
34
35/// One file, as FlacCompagnon measured it.
36///
37/// Every measurement is optional: a report may predate a field, omit one, or
38/// come from a tool that does not compute it. Nothing here is required for the
39/// catalog to work — this is entirely additional.
40#[derive(Debug, Clone, Default, PartialEq)]
41pub struct FileAnalysis {
42    /// Absolute path of the file this describes.
43    ///
44    /// The primary key is the **path**, not an identifier into the catalog. Two
45    /// reasons, and the second is the important one. Identifiers are positions
46    /// that a scan renumbers, so an identifier would have to be remapped after
47    /// every scan. And an analysis may perfectly well describe a file the
48    /// catalog does not hold *yet*: someone can run the analysis first and
49    /// build their library afterwards. Keying on the path lets such a record
50    /// wait, and attach itself the day the file is scanned. A whole-file MD5
51    /// can identify a renamed copy when the old path no longer exists.
52    pub path: String,
53    /// Tool that produced the measurements, `flaccompagnon` for now.
54    pub source: String,
55    /// Version of the report format, kept so an old import stays readable.
56    pub source_version: u32,
57    /// When it was imported, in seconds since the Unix epoch.
58    pub imported_at: u64,
59    /// Original report modification time, or in-process measurement completion, in
60    /// nanoseconds since the Unix epoch. Zero means an older stored result
61    /// whose date is unknown. Distinct from the audio file's own mtime.
62    pub result_at_ns: u64,
63    /// Size the analysed file had, in bytes.
64    ///
65    /// With [`FileAnalysis::modified_unix`], this makes an exact-path or legacy
66    /// name-and-size match expire when the file changes. A moved file with a
67    /// matching whole-file MD5 can keep its analysis after a timestamp change.
68    pub size_bytes: u64,
69    /// Modification date the analysed file had.
70    pub modified_unix: u64,
71    /// MD5 of the entire file, including tags and artwork, when supplied.
72    /// Used to identify a moved file without trusting its name or timestamp.
73    pub file_md5: Option<String>,
74    /// Complete file entry from the source report, including measurements that
75    /// Aède does not interpret yet. It keeps the source path when a matching
76    /// file is later moved; [`FileAnalysis::path`] is the attached path.
77    pub source_data: Option<Json>,
78
79    /// Verdict on the MD5 stored in STREAMINFO: `Match`, `Mismatch`,
80    /// `NoSignature`, `Present`, `Error`.
81    ///
82    /// A *state*, not a hash — the tool compares and reports, it does not
83    /// publish the digest. `Match` is equivalent to a successful `flac -t`.
84    pub md5_state: Option<String>,
85    /// What went wrong, when the state is an error.
86    pub md5_detail: Option<String>,
87
88    /// Bit depth actually carried, as measured by decoding.
89    pub real_bit_depth: Option<u16>,
90    /// Proportion of samples that show requantisation.
91    pub requant_rate: Option<f64>,
92    /// Both channels hold the same signal.
93    pub fake_stereo: Option<bool>,
94    /// The extension does not match the container found inside.
95    pub ext_mismatch: Option<bool>,
96
97    /// The file was made from a lower-resolution source: `none`, `suspected`,
98    /// `detected`.
99    pub transcoding: Option<String>,
100    /// A lossless file built from a lossy one.
101    pub upscaling: Option<bool>,
102    /// Sample rate raised above what the content justifies.
103    pub upsampling: Option<bool>,
104    /// One-word verdict, as the tool words it.
105    pub summary: Option<String>,
106    /// The same verdict in a sentence, worth showing as it stands.
107    pub detail: Option<String>,
108
109    /// Frequency where the spectrum stops, in hertz. A cutoff well below half
110    /// the sample rate is the signature of a lossy ancestry.
111    pub cutoff_hz: Option<f64>,
112    /// That frequency as a fraction of the Nyquist limit.
113    pub cutoff_ratio: Option<f64>,
114
115    /// Dynamic range in decibels: the lower, the more compressed the master.
116    pub dr_db: Option<f64>,
117    /// Highest sample value, in dBFS.
118    pub peak_dbfs: Option<f64>,
119    /// True peak, in dBTP — what a converter will actually have to produce.
120    pub true_peak_dbtp: Option<f64>,
121    /// FlacCompagnon's measured integrated track loudness, in LUFS.
122    pub integrated_lufs: Option<f64>,
123    /// Samples sitting at full scale.
124    pub clipped_samples: Option<u64>,
125    /// Runs of consecutive clipped samples.
126    pub clip_events: Option<u64>,
127    /// The tool's own conclusion on clipping.
128    pub clipped: Option<bool>,
129
130    /// What stopped the analysis of this file, when something did.
131    pub error: Option<String>,
132}
133
134impl FileAnalysis {
135    /// `true` when the analysis still describes the file as it is now.
136    ///
137    /// Uses the source report's unchanged size and whole-second modification
138    /// date. Unlike the scanner's native cache identity, this contract has no
139    /// subsecond precision and cannot distinguish all same-second edits.
140    pub fn still_applies(&self, size: u64, mtime: u64) -> bool {
141        self.size_bytes == size && self.modified_unix == mtime
142    }
143
144    /// `true` when the decoded audio did not match the file's own MD5.
145    pub fn md5_failed(&self) -> bool {
146        self.md5_state.as_deref() == Some("Mismatch")
147    }
148
149    /// `true` when the file is suspected of being built from a lossy source.
150    pub fn suspect_encoding(&self) -> bool {
151        matches!(
152            self.transcoding.as_deref(),
153            Some("detected") | Some("suspected")
154        ) || self.upscaling == Some(true)
155            || self.upsampling == Some(true)
156    }
157
158    /// Last component of [`FileAnalysis::path`].
159    ///
160    /// With the size, this is what identifies a file that has moved since it
161    /// was analysed — a name and a byte count together are very nearly unique
162    /// in a music library.
163    pub fn file_name(&self) -> &str {
164        crate::text::file_name(&self.path)
165    }
166}
167
168/// What a report file turned out to hold.
169#[derive(Debug)]
170pub struct Report {
171    /// Folder the tool was pointed at.
172    pub root: String,
173    /// Version declared by the report.
174    pub version: u32,
175    /// One entry per analysed file.
176    pub files: Vec<FileAnalysis>,
177}
178
179/// Waiting and stale **folders** listed by [`Attachment`]; the rest are
180/// counted only.
181///
182/// Bounded on folders rather than on files because that is the unit a reader
183/// acts on: one album of fourteen tracks is one decision, and fourteen rows
184/// saying so push the next album off the screen.
185const FOLDERS_SHOWN: usize = 10;
186
187/// What became of a batch of records handed to [`merge_into`].
188#[derive(Debug, Default, Clone, PartialEq)]
189pub struct Attachment {
190    /// Records whose path the catalog already knew.
191    pub matched: usize,
192    /// Records attached to a file that has moved since it was analysed.
193    pub moved: usize,
194    /// Records about bytes that have changed since, and therefore dropped.
195    pub stale: usize,
196    /// Records kept although no file matches them yet.
197    pub waiting: usize,
198    /// Incoming records superseded by a more recent result for the same file
199    /// and source. Re-reading an old report must not undo a newer analysis.
200    pub older: usize,
201    /// The first few waiting **folders**, each with how many wait in it, for a
202    /// report that names rather than counts.
203    ///
204    /// Folders, not paths, and for the reason a reader would give: what one
205    /// does with a waiting record is scan the folder it names, or decide that
206    /// folder is gone. The file name inside it settles nothing, and listing
207    /// every file of an album spends ten rows to say one thing. It also
208    /// survives the column width — a path cut to fit is cut at the *tail*,
209    /// where the file name is, leaving the head that identifies the folder
210    /// exactly where it cannot be read.
211    pub waiting_folders: BTreeMap<String, usize>,
212    /// The first few **folders** holding a record dropped as stale, each with
213    /// how many, for the same reason `waiting_folders` names rather than
214    /// counts: "some files changed" answers how many, not which, and which is
215    /// the one thing a reader needs to know where to point FlacCompagnon again.
216    pub stale_folders: BTreeMap<String, usize>,
217}
218
219impl Attachment {
220    /// Records that found their file, one way or the other.
221    pub fn attached(&self) -> usize {
222        self.matched + self.moved
223    }
224}
225
226/// Counts one more record against the folder it sits in, capping how many
227/// distinct folders are kept rather than how many records count against them
228/// — a folder already listed keeps counting past the cap, only a new one is
229/// refused room.
230fn note_folder(folders: &mut BTreeMap<String, usize>, path: &str) {
231    let folder = crate::text::folder(path);
232    if let Some(count) = folders.get_mut(folder) {
233        *count += 1;
234    } else if folders.len() < FOLDERS_SHOWN {
235        folders.insert(folder.to_string(), 1);
236    }
237}
238
239/// Stores records in the catalog, attaching each to the file it describes.
240///
241/// Three outcomes make the order of analysis and scanning irrelevant:
242///
243/// - the path is one the catalog holds — the usual case;
244/// - the path is unknown, but one file has the same size and whole-file MD5;
245///   older reports without a digest can use a unique name and size instead;
246///   the record is refiled under its current location;
247/// - nothing matches, and the record is kept as it is. The folder it names has
248///   most likely not been scanned yet.
249///
250/// Exact-path and legacy name-and-size matches also require the old size and
251/// modification date. A digest match proves the bytes agree, so a changed
252/// timestamp alone is harmless. Duplicate digests remain unresolved.
253pub fn merge_into(catalog: &mut Catalog, records: Vec<FileAnalysis>, now: u64) -> Attachment {
254    let mut out = Attachment::default();
255    let mut resolved: Vec<FileAnalysis> = Vec::with_capacity(records.len());
256
257    {
258        let known: BTreeMap<&str, (u64, u64)> = catalog
259            .files
260            .iter()
261            .map(|f| (f.path.as_str(), (f.size, f.mtime)))
262            .collect();
263        let by_name_size = name_size_index(catalog);
264        let wanted_sizes = records
265            .iter()
266            .filter(|record| !known.contains_key(record.path.as_str()))
267            .filter(|record| record.file_md5.is_some())
268            .map(|record| record.size_bytes)
269            .collect();
270        let by_digest = file_md5_index(catalog, &wanted_sizes);
271
272        for mut record in records {
273            match known.get(record.path.as_str()) {
274                Some(&(size, mtime)) => {
275                    if !record.still_applies(size, mtime) {
276                        out.stale += 1;
277                        note_folder(&mut out.stale_folders, &record.path);
278                        continue;
279                    }
280                    out.matched += 1;
281                }
282                None => {
283                    if let Some(digest) = &record.file_md5 {
284                        if let Some(&(path, mtime)) =
285                            by_digest.get(&(record.size_bytes, digest.clone()))
286                        {
287                            record.path = path.to_string();
288                            record.modified_unix = mtime;
289                            out.moved += 1;
290                        } else {
291                            out.waiting += 1;
292                            note_folder(&mut out.waiting_folders, &record.path);
293                        }
294                    } else {
295                        match by_name_size.get(&(record.file_name(), record.size_bytes)) {
296                            Some(&(path, mtime)) => {
297                                if !record.still_applies(record.size_bytes, mtime) {
298                                    out.stale += 1;
299                                    note_folder(&mut out.stale_folders, path);
300                                    continue;
301                                }
302                                record.path = path.to_string();
303                                out.moved += 1;
304                            }
305                            None => {
306                                out.waiting += 1;
307                                note_folder(&mut out.waiting_folders, &record.path);
308                            }
309                        }
310                    }
311                }
312            }
313            record.imported_at = now;
314            resolved.push(record);
315        }
316    }
317
318    for record in resolved {
319        if !store_one(catalog, record) {
320            out.older += 1;
321        }
322    }
323    sort_analyses(catalog);
324    out
325}
326
327/// Attaches the records that were still waiting for their file.
328///
329/// Called after a scan. A record waits because nothing matched it at import
330/// time, and the usual reason is that its folder had not been scanned yet — but
331/// also that the path it names is not the path the catalog settled on. Watched
332/// folders are stored canonical, so a report written against a symbolic link,
333/// or against `/var` where the system says `/private/var`, names the same file
334/// by another route. A whole-file MD5 resolves the path when available; older
335/// reports still use a unique name, size and modification date.
336///
337/// Returns how many were attached.
338pub fn reconcile(catalog: &mut Catalog) -> usize {
339    let mut moves: Vec<(usize, String, u64)> = Vec::new();
340    let known: BTreeMap<&str, (u64, u64)> = catalog
341        .files
342        .iter()
343        .map(|file| (file.path.as_str(), (file.size, file.mtime)))
344        .collect();
345    {
346        let by_name_size = name_size_index(catalog);
347        let wanted_sizes = catalog
348            .analyses
349            .iter()
350            .filter(|record| !known.contains_key(record.path.as_str()))
351            .filter(|record| record.file_md5.is_some())
352            .map(|record| record.size_bytes)
353            .collect();
354        let by_digest = file_md5_index(catalog, &wanted_sizes);
355        let taken: BTreeSet<(&str, &str)> = catalog
356            .analyses
357            .iter()
358            .filter(|record| {
359                known
360                    .get(record.path.as_str())
361                    .is_some_and(|&(size, mtime)| record.still_applies(size, mtime))
362            })
363            .map(|a| (a.path.as_str(), a.source.as_str()))
364            .collect();
365
366        for (index, record) in catalog.analyses.iter().enumerate() {
367            if known.contains_key(record.path.as_str()) {
368                continue;
369            }
370            let candidate = if let Some(digest) = &record.file_md5 {
371                by_digest.get(&(record.size_bytes, digest.clone())).copied()
372            } else {
373                by_name_size
374                    .get(&(record.file_name(), record.size_bytes))
375                    .filter(|(_, mtime)| record.still_applies(record.size_bytes, *mtime))
376                    .copied()
377            };
378            let Some((path, mtime)) = candidate else {
379                continue;
380            };
381            // Current evidence already filed under the real path wins. A
382            // stale record cannot reserve a file whose bytes it no longer describes.
383            if taken.contains(&(path, record.source.as_str())) {
384                continue;
385            }
386            moves.push((index, path.to_string(), mtime));
387        }
388    }
389    let attached = moves.len();
390    for (index, path, mtime) in moves {
391        catalog.analyses[index].path = path;
392        catalog.analyses[index].modified_unix = mtime;
393    }
394    if attached > 0 {
395        // Refiling two records onto the same path is possible when a report
396        // named the same file by two routes. Apply the import date rule here
397        // too, retaining the existing order for equal or unknown dates.
398        let mut by_file: BTreeMap<(String, String), FileAnalysis> = BTreeMap::new();
399        for record in std::mem::take(&mut catalog.analyses) {
400            let key = (record.path.clone(), record.source.clone());
401            if by_file.get(&key).is_some_and(|held| {
402                let preserve_current =
403                    known
404                        .get(record.path.as_str())
405                        .is_some_and(|&(size, mtime)| {
406                            held.still_applies(size, mtime) && !record.still_applies(size, mtime)
407                        });
408                preserve_current || is_older_result(&record, held)
409            }) {
410                continue;
411            }
412            by_file.insert(key, record);
413        }
414        catalog.analyses = by_file.into_values().collect();
415    }
416    attached
417}
418
419/// Files indexed by name and size, with ambiguous keys excluded.
420///
421/// If two albums contain the same name and byte count, guessing the first
422/// path would attach a measurement to the wrong music. The report can wait
423/// until an exact path is available instead.
424fn name_size_index(catalog: &Catalog) -> BTreeMap<(&str, u64), (&str, u64)> {
425    let mut index: BTreeMap<(&str, u64), (&str, u64)> = BTreeMap::new();
426    let mut ambiguous = BTreeSet::new();
427    for file in &catalog.files {
428        let key = (file.file_name(), file.size);
429        if ambiguous.contains(&key) {
430            continue;
431        }
432        if index
433            .insert(key, (file.path.as_str(), file.mtime))
434            .is_some()
435        {
436            index.remove(&key);
437            ambiguous.insert(key);
438        }
439    }
440    index
441}
442
443/// Find byte-identical files for reports whose old paths no longer exist.
444/// Duplicate hashes stay unresolved: identical copies still have distinct
445/// album placements, and the report cannot say which one it described.
446fn file_md5_index<'a>(
447    catalog: &'a Catalog,
448    wanted_sizes: &BTreeSet<u64>,
449) -> BTreeMap<(u64, String), (&'a str, u64)> {
450    let mut index = BTreeMap::new();
451    let mut ambiguous = BTreeSet::new();
452    for file in &catalog.files {
453        if !wanted_sizes.contains(&file.size) {
454            continue;
455        }
456        let Ok(digest) = file_md5(Path::new(&file.path)) else {
457            continue;
458        };
459        let key = (file.size, digest);
460        if ambiguous.contains(&key) {
461            continue;
462        }
463        if index
464            .insert(key.clone(), (file.path.as_str(), file.mtime))
465            .is_some()
466        {
467            index.remove(&key);
468            ambiguous.insert(key);
469        }
470    }
471    index
472}
473
474fn file_md5(path: &Path) -> std::io::Result<String> {
475    // Refuse static special paths before opening them. Rechecking the
476    // descriptor preserves this rule but does not close path-replacement races.
477    if !std::fs::metadata(path)?.is_file() {
478        return Err(std::io::Error::new(
479            std::io::ErrorKind::InvalidInput,
480            "analysis hash source must be a regular file",
481        ));
482    }
483    let mut file = std::fs::File::open(path)?;
484    if !file.metadata()?.is_file() {
485        return Err(std::io::Error::new(
486            std::io::ErrorKind::InvalidInput,
487            "analysis hash source must be a regular file",
488        ));
489    }
490    let mut digest = Md5::new();
491    let mut buffer = [0u8; 64 * 1024];
492    loop {
493        let count = file.read(&mut buffer)?;
494        if count == 0 {
495            break;
496        }
497        digest.update(&buffer[..count]);
498    }
499    Ok(format!("{:x}", digest.finalize()))
500}
501
502/// Stores the latest dated result for one path and source. Equal or unknown
503/// dates retain the legacy import-order rule; records never accumulate.
504fn store_one(catalog: &mut Catalog, record: FileAnalysis) -> bool {
505    if catalog.analyses.iter().any(|held| {
506        held.path == record.path && held.source == record.source && is_older_result(&record, held)
507    }) {
508        return false;
509    }
510    catalog
511        .analyses
512        .retain(|a| !(a.path == record.path && a.source == record.source));
513    catalog.analyses.push(record);
514    true
515}
516
517/// Whether an incoming result for the same path and source must keep the
518/// held measurement of those bytes. Unknown dates retain the legacy order.
519fn is_older_result(record: &FileAnalysis, held: &FileAnalysis) -> bool {
520    record.result_at_ns != 0
521        && held.still_applies(record.size_bytes, record.modified_unix)
522        && held.result_at_ns > record.result_at_ns
523}
524
525fn sort_analyses(catalog: &mut Catalog) {
526    catalog
527        .analyses
528        .sort_by(|a, b| (&a.path, &a.source).cmp(&(&b.path, &b.source)));
529}
530
531/// Anything that can stop a report from being read.
532#[derive(Debug)]
533pub enum ImportError {
534    /// The file could not be opened or read.
535    Io(std::io::Error),
536    /// The text is not valid JSON.
537    Json(String),
538    /// A recognized report has an invalid envelope, file list or known measurement.
539    Malformed(&'static str),
540    /// Valid JSON, but not a report this reader knows.
541    NotAReport {
542        /// What the file claimed to be.
543        found: String,
544    },
545    /// A report of a version written after this build.
546    Version {
547        /// Version the file declares.
548        found: u32,
549    },
550}
551
552impl std::fmt::Display for ImportError {
553    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
554        match self {
555            ImportError::Io(e) => write!(f, "input/output error: {e}"),
556            ImportError::Json(e) => write!(f, "not valid JSON: {e}"),
557            ImportError::Malformed(detail) => write!(f, "malformed FlacCompagnon report: {detail}"),
558            ImportError::NotAReport { found } => write!(
559                f,
560                "not a FlacCompagnon report (format is \"{found}\", expected \"{FLACCOMPAGNON_FORMAT}\")"
561            ),
562            ImportError::Version { found } => write!(
563                f,
564                "report version {found} was written by a later FlacCompagnon; upgrade Aède"
565            ),
566        }
567    }
568}
569
570impl std::error::Error for ImportError {}
571
572impl From<std::io::Error> for ImportError {
573    fn from(e: std::io::Error) -> Self {
574        ImportError::Io(e)
575    }
576}
577
578/// Highest report version this reader understands.
579const SUPPORTED_VERSION: u32 = 1;
580
581/// Reads a FlacCompagnon report.
582///
583/// Unknown fields are ignored rather than refused: the tool will grow new
584/// measurements, and a report carrying one must still import the rest.
585pub fn read_report(path: &Path) -> Result<Report, ImportError> {
586    let text = std::fs::read_to_string(path)?;
587    let result_at_ns = crate::clock::mtime_nanoseconds(&std::fs::metadata(path)?);
588    parse_report_at(&text, result_at_ns)
589}
590
591/// Parse a report already held in memory, including a newly produced analysis.
592pub fn parse_report(text: &str) -> Result<Report, ImportError> {
593    parse_report_at(text, 0)
594}
595
596/// Parse a report with its file date as fallback for entries without an exact date.
597///
598/// Rewriting cached measurements must not promote their date to the rewrite time.
599/// A present [`RESULT_DATE_FIELD`] is a decimal `u64` string, including zero for
600/// an explicitly undated result; ordinary upstream reports use the fallback.
601pub fn parse_report_at(text: &str, report_date: u64) -> Result<Report, ImportError> {
602    let value = json::parse(text).map_err(|e| ImportError::Json(e.to_string()))?;
603
604    let format = value.field_str("format").unwrap_or_default();
605    if format != FLACCOMPAGNON_FORMAT {
606        return Err(ImportError::NotAReport { found: format });
607    }
608    let version = match value.get("version") {
609        // Preserve the reader's legacy absent-version fallback.
610        None => 0,
611        Some(value) => value.as_u32().ok_or(ImportError::Malformed(
612            "version must be an unsigned 32-bit integer",
613        ))?,
614    };
615    if version > SUPPORTED_VERSION {
616        return Err(ImportError::Version { found: version });
617    }
618
619    let report = value
620        .get("report")
621        .filter(|report| matches!(report, Json::Obj(_)))
622        .ok_or(ImportError::Malformed("report must be an object"))?;
623    let root = report.field_str("root").unwrap_or_default();
624    let entries = report
625        .get("files")
626        .and_then(|f| f.as_arr())
627        .ok_or(ImportError::Malformed("report.files must be an array"))?;
628
629    // An entry without a path cannot be attached to anything, ever: it is
630    // dropped here rather than stored as a record about no file in particular.
631    let files = entries
632        .iter()
633        .filter(|item| item.field_str("path").is_some())
634        .map(|item| from_json(item, version, report_date))
635        .collect::<Result<_, _>>()?;
636
637    Ok(Report {
638        root,
639        version,
640        files,
641    })
642}
643
644/// Bytes read from a file to decide whether it is worth parsing.
645const SNIFF_BYTES: usize = 512;
646
647/// `true` when a file announces itself as a report, on a glance at its head.
648///
649/// A scan walks past every file in the library and must not parse each `.json`
650/// it meets — a cover-art sidecar, a player's own database, anything. The
651/// format marker sits in the first object of the file, so half a kilobyte
652/// settles it without reading a report that may hold thousands of entries.
653pub fn looks_like_a_report(path: &Path) -> bool {
654    use std::io::Read;
655    let Ok(mut file) = std::fs::File::open(path) else {
656        return false;
657    };
658    let mut head = [0u8; SNIFF_BYTES];
659    let Ok(read) = file.read(&mut head) else {
660        return false;
661    };
662    // Lossy on purpose: the read may well have cut a multi-byte character in
663    // half, and the marker being looked for is plain ASCII.
664    String::from_utf8_lossy(&head[..read]).contains(FLACCOMPAGNON_FORMAT)
665}
666
667/// Maps one entry of a report onto [`FileAnalysis`].
668fn from_json(item: &Json, version: u32, report_date: u64) -> Result<FileAnalysis, ImportError> {
669    let detections = item.get("detections");
670    let clipping = item.get("clipping");
671    let md5 = item.get("flac_md5");
672
673    let real_bit_depth = match item.get("real_bit_depth") {
674        None | Some(Json::Null) => None,
675        Some(value) => Some(
676            value
677                .as_u32()
678                .and_then(|value| u16::try_from(value).ok())
679                .ok_or(ImportError::Malformed(
680                    "real_bit_depth must fit an unsigned 16-bit integer",
681                ))?,
682        ),
683    };
684    let result_at_ns = match item.get(RESULT_DATE_FIELD) {
685        None => report_date,
686        Some(value) => value
687            .as_str()
688            .filter(|value| !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()))
689            .and_then(|value| value.parse::<u64>().ok())
690            .ok_or(ImportError::Malformed(
691                "aede_result_at_ns must be a decimal u64 string",
692            ))?,
693    };
694
695    Ok(FileAnalysis {
696        path: item.field_str("path").unwrap_or_default(),
697        source: "flaccompagnon".to_string(),
698        source_version: version,
699        imported_at: 0,
700        result_at_ns,
701        size_bytes: item.field_u64("size_bytes").unwrap_or(0),
702        modified_unix: item.field_u64("modified_unix").unwrap_or(0),
703        file_md5: item.field_str("file_md5"),
704
705        md5_state: md5.and_then(|m| m.field_str("state")),
706        md5_detail: md5.and_then(|m| m.field_str("detail")),
707
708        real_bit_depth,
709        requant_rate: item.field_f64("requant_rate"),
710        fake_stereo: item.field_optional_bool("fake_stereo"),
711        ext_mismatch: item.field_optional_bool("ext_mismatch"),
712
713        transcoding: detections
714            .and_then(|d| d.get("transcoding"))
715            .and_then(|value| match value {
716                Json::Bool(true) => Some("detected".to_string()),
717                Json::Bool(false) => Some("none".to_string()),
718                Json::Str(value) => Some(value.clone()),
719                _ => None,
720            }),
721        upscaling: detections.and_then(|d| d.field_optional_bool("upscaling")),
722        upsampling: detections.and_then(|d| d.field_optional_bool("upsampling")),
723        summary: detections.and_then(|d| d.field_str("summary")),
724        detail: detections.and_then(|d| d.field_str("detail")),
725
726        cutoff_hz: item.field_f64("cutoff_hz"),
727        cutoff_ratio: item.field_f64("cutoff_ratio"),
728
729        dr_db: item.field_f64("dr_db"),
730        peak_dbfs: clipping.and_then(|c| c.field_f64("peak_dbfs")),
731        true_peak_dbtp: clipping.and_then(|c| c.field_f64("true_peak_dbtp")),
732        integrated_lufs: item.field_f64("integrated_lufs"),
733        clipped_samples: clipping.and_then(|c| c.field_u64("clipped_samples")),
734        clip_events: clipping.and_then(|c| c.field_u64("clip_events")),
735        clipped: clipping.and_then(|c| c.field_optional_bool("clipped")),
736
737        error: item.field_str("error"),
738        source_data: Some(item.clone()),
739    })
740}
741
742#[cfg(test)]
743#[path = "analysis_tests.rs"]
744mod tests;