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;