1use 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
13pub const FORMAT_VERSION: u32 = 1;
15const LOUDNESS_METHOD_VERSION: u32 = 5;
19pub const CONCLUSIONS_FILE: &str = "conclusions.json";
21
22#[derive(Debug, Clone, Default)]
24pub struct Conclusions {
25 pub files: BTreeMap<String, FileConclusion>,
27 pub analyses: Vec<FileAnalysis>,
29 pub loudness_tracks: BTreeMap<String, CachedTrack>,
31 pub loudness_programmes: Vec<CachedProgramme>,
33}
34
35#[derive(Debug, Clone)]
37pub struct FileConclusion {
38 pub size: u64,
40 pub mtime: u64,
42 pub mtime_subseconds: Option<u32>,
45 pub integrity: Option<IntegrityRecord>,
47 pub fingerprint: Option<Fingerprint>,
49}
50
51pub fn conclusions_path(data_dir: &Path) -> PathBuf {
53 data_dir.join(CONCLUSIONS_FILE)
54}
55
56impl Conclusions {
57 pub fn from_catalog(catalog: &Catalog) -> Self {
59 let mut result = Self::default();
60 result.update_from_catalog(catalog);
61 result
62 }
63
64 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 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 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 && (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
115pub 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
259pub 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
401pub 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
407pub 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;