aede_core/analysis/
export.rs

1//! Reconstruct scoped FlacCompagnon reports from retained source entries.
2use crate::json::Json;
3use crate::model::{Catalog, EntityKind, Id};
4use std::collections::BTreeSet;
5
6const MAX_REPORT_BYTES: usize = 16 * 1024 * 1024;
7
8/// Why the selected catalog entity cannot supply a complete current report.
9#[derive(Debug, PartialEq, Eq)]
10pub enum ExportError {
11    /// Only existing tracks or releases are supported.
12    InvalidSelection,
13    /// No retained FlacCompagnon measurement exists for a selected file.
14    MissingAnalysis,
15    /// Stored measurements describe an older file state.
16    StaleAnalysis,
17    /// The store predates complete source-entry retention.
18    IncompleteAnalysis,
19    /// A retained entry uses a newer report schema.
20    UnsupportedVersion,
21    /// The file count or serialized response exceeds the export budget.
22    TooLarge,
23}
24/// Export the selected entity only; never read sidecars, re-analyze, or modify source audio.
25pub fn report(catalog: &Catalog, kind: EntityKind, id: Id) -> Result<String, ExportError> {
26    let (root, tracks) = match kind {
27        EntityKind::Track => {
28            let track = catalog.track(id).ok_or(ExportError::InvalidSelection)?;
29            let file = catalog
30                .file(track.file_id)
31                .ok_or(ExportError::InvalidSelection)?;
32            let root = std::path::Path::new(&file.path)
33                .parent()
34                .ok_or(ExportError::InvalidSelection)?;
35            (root.to_string_lossy().into_owned(), vec![id])
36        }
37        EntityKind::Release => {
38            let release = catalog.release(id).ok_or(ExportError::InvalidSelection)?;
39            (release.folder.clone(), release.track_ids.clone())
40        }
41        _ => return Err(ExportError::InvalidSelection),
42    };
43    if tracks.is_empty() {
44        return Err(ExportError::MissingAnalysis);
45    }
46    if tracks.len() > 2048 {
47        return Err(ExportError::TooLarge);
48    }
49    let mut entries = Vec::new();
50    let mut seen = BTreeSet::new();
51    let mut version = 0;
52    let mut total_bytes = 0;
53    for id in tracks {
54        let track = catalog.track(id).ok_or(ExportError::InvalidSelection)?;
55        let file = catalog
56            .file(track.file_id)
57            .ok_or(ExportError::InvalidSelection)?;
58        if !seen.insert(&file.path) {
59            continue;
60        }
61        let records: Vec<_> = catalog
62            .analyses
63            .iter()
64            .filter(|record| record.source == "flaccompagnon" && record.path == file.path)
65            .collect();
66        if records.is_empty() {
67            return Err(ExportError::MissingAnalysis);
68        }
69        let record = records
70            .into_iter()
71            .filter(|record| record.still_applies(file.size, file.mtime))
72            .max_by_key(|record| (record.result_at_ns, record.imported_at))
73            .ok_or(ExportError::StaleAnalysis)?;
74        if record.source_version > super::SUPPORTED_VERSION {
75            return Err(ExportError::UnsupportedVersion);
76        }
77        let entry = record
78            .source_data
79            .as_ref()
80            .filter(|entry| {
81                matches!(entry, Json::Obj(_)) && entry.get("path").and_then(Json::as_str).is_some()
82            })
83            .ok_or(ExportError::IncompleteAnalysis)?;
84        // Bound expansion while rendering, before an oversized retained entry
85        // can allocate an unbounded temporary JSON string.
86        total_bytes += entry
87            .to_string_pretty_bounded(MAX_REPORT_BYTES - total_bytes)
88            .map_err(|_| ExportError::TooLarge)?
89            .len();
90        // The download is a newly built report, not a new measurement. Legacy
91        // source entries need the retained date just as native report rewrites do.
92        // Clone only after the size check; the final bound also covers metadata.
93        let mut entry = entry.clone();
94        entry.set(
95            super::RESULT_DATE_FIELD,
96            record.result_at_ns.to_string().into(),
97        );
98        version = version.max(record.source_version);
99        entries.push(entry);
100    }
101    let has_flac = entries.iter().any(|entry| {
102        entry
103            .get("flac_md5")
104            .is_some_and(|value| !matches!(value, Json::Null))
105    });
106    let mut inner = Json::obj();
107    inner.set("root", Json::Str(root));
108    inner.set("files", Json::Arr(entries));
109    inner.set("has_flac", Json::Bool(has_flac));
110    let mut outer = Json::obj();
111    outer.set("format", Json::Str(super::FLACCOMPAGNON_FORMAT.into()));
112    outer.set("version", Json::Num(f64::from(version)));
113    outer.set("report", inner);
114    outer
115        .to_string_pretty_bounded(MAX_REPORT_BYTES)
116        .map_err(|_| ExportError::TooLarge)
117}
118#[cfg(test)]
119#[path = "export_tests.rs"]
120mod tests;