aede_core/
store.rs

1//! Catalog persistence.
2//!
3//! The format is a JSON document whose every key is a "table": it is the
4//! mirror image of the relational schema described in `schema.sql`. Costly
5//! conclusions are saved independently, so a catalog-format change does not
6//! require recomputing them. SQLite remains deferred.
7//!
8//! Writing is atomic (temporary file then rename): an interruption during
9//! saving cannot leave a half-written catalog behind.
10
11use std::collections::BTreeMap;
12use std::io::{self, Write};
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15
16use crate::analysis::FileAnalysis;
17use crate::audit;
18use crate::conclusions;
19use crate::json::{self, Json};
20use crate::model::{
21    self, Artist, AudioFile, Catalog, Credit, EntityKind, Genre, GenreLink, Id, IntegrityRecord,
22    Label, Recording, Relation, Release, ReleaseGroup, Track, Work,
23};
24use crate::tags::AudioProperties;
25
26/// On-disk format version. Every incompatible change increments it.
27pub const FORMAT_VERSION: u32 = 1;
28
29/// Name of the catalog file inside the data folder.
30pub const CATALOG_FILE: &str = "catalog.json";
31
32/// What can go wrong when saving or loading a catalog.
33///
34/// A missing file is not an error: [`load`] reports it as `Ok(None)`.
35#[derive(Debug)]
36pub enum StoreError {
37    /// The catalog file, its parent folder or the temporary file used for the
38    /// atomic rename could not be accessed.
39    Io(std::io::Error),
40    /// The file exists but is not valid JSON, typically after being edited by
41    /// hand or truncated by a full disk.
42    Parse(json::ParseError),
43    /// The independent conclusions document is not valid JSON.
44    ConclusionsParse(json::ParseError),
45    /// The file was written by an incompatible version.
46    Version {
47        /// Version stamped in the file; `0` when the field is missing
48        /// altogether, which is how a pre-versioning catalog shows up.
49        found: u32,
50        /// Version this build understands, that is [`FORMAT_VERSION`].
51        expected: u32,
52    },
53    /// The conclusions store uses a version this build cannot read.
54    ConclusionsVersion {
55        /// Version stamped in `conclusions.json`.
56        found: u32,
57        /// Version this build understands.
58        expected: u32,
59    },
60    /// The JSON parsed but the catalog does not hold together: a row without
61    /// an identifier, a track pointing at no file, non-contiguous identifiers.
62    /// The payload names the specific breach.
63    Invalid(&'static str),
64    /// A malformed row in the conclusions store.
65    ConclusionsInvalid(&'static str),
66    /// Private credential document could not be parsed.
67    AccountsParse(json::ParseError),
68    /// Account metadata or credential policy is inconsistent.
69    AccountsInvalid(&'static str),
70}
71
72impl std::fmt::Display for StoreError {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            StoreError::Io(e) => write!(f, "input/output error: {e}"),
76            StoreError::Parse(e) => write!(f, "unreadable catalog: {e}"),
77            StoreError::ConclusionsParse(e) => write!(f, "unreadable conclusions: {e}"),
78            StoreError::Version { found, expected } => write!(
79                f,
80                "catalog in version {found}, expected {expected} — run a full scan again"
81            ),
82            StoreError::Invalid(what) => write!(f, "inconsistent catalog: {what}"),
83            StoreError::ConclusionsVersion { found, expected } => {
84                write!(f, "conclusions in version {found}, expected {expected}")
85            }
86            StoreError::ConclusionsInvalid(what) => write!(f, "inconsistent conclusions: {what}"),
87            StoreError::AccountsParse(what) => write!(f, "unreadable accounts: {what}"),
88            StoreError::AccountsInvalid(what) => write!(f, "inconsistent accounts: {what}"),
89        }
90    }
91}
92
93impl std::error::Error for StoreError {}
94
95impl From<std::io::Error> for StoreError {
96    fn from(e: std::io::Error) -> Self {
97        StoreError::Io(e)
98    }
99}
100
101/// Default data location: `~/.local/share/aede`, or `$AEDE_HOME` if the
102/// variable is set.
103pub fn default_data_dir() -> PathBuf {
104    if let Ok(custom) = std::env::var("AEDE_HOME") {
105        return PathBuf::from(custom);
106    }
107    if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
108        return PathBuf::from(xdg).join("aede");
109    }
110    match std::env::var("HOME") {
111        Ok(home) => PathBuf::from(home).join(".local/share/aede"),
112        Err(_) => PathBuf::from(".aede"),
113    }
114}
115
116/// Builds the catalog path inside a data folder.
117///
118/// Nothing is created or checked here; the folder is only made to exist when
119/// [`save`] runs.
120pub fn catalog_path(data_dir: &Path) -> PathBuf {
121    data_dir.join(CATALOG_FILE)
122}
123
124/// Where a picture goes when no single folder on disk can hold it.
125///
126/// `fetch --portraits` prefers the folder shared by every album of an artist,
127/// the same place `cover.jpg` sits relative to a release — nothing here is
128/// involved and nothing needs backing up beyond the music library itself. This
129/// is the fallback for the artist that has no such folder: credited only on
130/// compilations, or with a discography spread across more than one watched
131/// root. Inside the data folder rather than beside anything on disk, because
132/// there is no "beside" to put it — the same reasoning that puts `sources.json`
133/// here and not in the library.
134pub const ASSETS_DIR: &str = "assets";
135
136/// The folder pictures without a home on disk are written into.
137///
138/// Nothing is created or checked here, the same as [`catalog_path`]: it is
139/// made to exist only when something is about to be written into it.
140pub fn assets_dir(data_dir: &Path) -> PathBuf {
141    data_dir.join(ASSETS_DIR)
142}
143
144/// Writes a new sidecar through a temporary file, never replacing an existing one.
145///
146/// An interrupted download may leave a hidden temporary file, but never a
147/// truncated image or lyric at its final path. Publication uses a hard link
148/// on the same filesystem and fails closed if the filesystem cannot provide
149/// it: an existence check followed by rename could replace another writer's file.
150pub fn write_new_atomic(path: &Path, bytes: &[u8]) -> io::Result<bool> {
151    static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
152
153    if path.exists() {
154        return Ok(false);
155    }
156    let parent = path.parent().unwrap_or(Path::new("."));
157    let name = path
158        .file_name()
159        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "sidecar has no file name"))?;
160    let mut temporary_name = std::ffi::OsString::from(".");
161    temporary_name.push(name);
162    let nonce = std::time::SystemTime::now()
163        .duration_since(std::time::SystemTime::UNIX_EPOCH)
164        .unwrap_or_default()
165        .as_nanos();
166    temporary_name.push(format!(
167        ".aede-tmp-{}-{nonce}-{}",
168        std::process::id(),
169        NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
170    ));
171    let temporary = parent.join(temporary_name);
172    let mut file = std::fs::OpenOptions::new()
173        .write(true)
174        .create_new(true)
175        .open(&temporary)?;
176    if let Err(error) = file.write_all(bytes) {
177        drop(file);
178        let _ = std::fs::remove_file(&temporary);
179        return Err(error);
180    }
181    drop(file);
182    publish_new(&temporary, path)
183}
184
185fn publish_new(temporary: &Path, path: &Path) -> io::Result<bool> {
186    let published = std::fs::hard_link(temporary, path);
187    let cleanup = std::fs::remove_file(temporary);
188    match published {
189        Ok(()) => {
190            cleanup?;
191            Ok(true)
192        }
193        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
194            cleanup?;
195            Ok(false)
196        }
197        Err(error) => Err(io::Error::new(
198            error.kind(),
199            format!(
200                "cannot safely publish a new sidecar (filesystem hard links required): {error}"
201            ),
202        )),
203    }
204}
205
206/// Saves the catalog atomically, after preserving its independent conclusions.
207///
208/// The caller must hold the data-directory writer lock for the whole
209/// read/modify/save operation. Legacy conclusions are migrated here, not by
210/// readers; the legacy catalog stays untouched if the migration fails.
211pub fn save(catalog: &Catalog, path: &Path) -> Result<(), StoreError> {
212    let conclusions_path = conclusions::conclusions_path(path.parent().unwrap_or(Path::new(".")));
213    let existing = conclusions::load(&conclusions_path)?;
214    let had_store = existing.is_some();
215    let mut gathered = existing.unwrap_or_default();
216    if !had_store {
217        // Keep conclusions for files absent from the new scan, too. An
218        // incompatible catalog remains rebuildable; other read errors must
219        // not hide data that this write would otherwise discard.
220        match load_catalog_only(path) {
221            Ok(Some(legacy)) => {
222                gathered.merge_legacy(&legacy);
223            }
224            Ok(None) | Err(StoreError::Version { .. }) => {}
225            Err(error) => return Err(error),
226        }
227    }
228    gathered.update_from_catalog(catalog);
229    if had_store
230        || !gathered.files.is_empty()
231        || !gathered.analyses.is_empty()
232        || !gathered.loudness_tracks.is_empty()
233        || !gathered.loudness_programmes.is_empty()
234    {
235        conclusions::save(&gathered, &conclusions_path)?;
236    }
237    save_catalog_only(catalog, path)
238}
239
240/// Writes only the rebuildable catalog, for a restore that handles each store independently.
241pub fn save_catalog_only(catalog: &Catalog, path: &Path) -> Result<(), StoreError> {
242    crate::atomic_file::write(path, to_json(catalog).to_string_compact().as_bytes())?;
243    Ok(())
244}
245
246/// Loads a catalog without writing anything, including during legacy migration.
247/// Returns `Ok(None)` if the file does not exist yet.
248pub fn load(path: &Path) -> Result<Option<Catalog>, StoreError> {
249    let Some(mut catalog) = load_catalog_only(path)? else {
250        return Ok(None);
251    };
252    let conclusions_path = conclusions::conclusions_path(path.parent().unwrap_or(Path::new(".")));
253    let existing = conclusions::load(&conclusions_path)?;
254    let missing = existing.is_none();
255    let mut gathered = existing.unwrap_or_default();
256    if missing {
257        gathered.merge_legacy(&catalog);
258    }
259    gathered.attach(&mut catalog);
260    Ok(Some(catalog))
261}
262
263/// Reads the catalog snapshot without loading independent conclusions.
264///
265/// Backups handle each store separately: an unreadable conclusions document
266/// must not hide an otherwise readable catalog. Legacy inline conclusions stay
267/// available to the caller for migration. Ordinary catalog consumers should use
268/// [`load`] to attach the current independent results.
269pub fn load_catalog_only(path: &Path) -> Result<Option<Catalog>, StoreError> {
270    let text = match std::fs::read_to_string(path) {
271        Ok(text) => text,
272        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
273        Err(error) => return Err(error.into()),
274    };
275    let value = json::parse(&text).map_err(StoreError::Parse)?;
276    from_json(&value).map(Some)
277}
278
279// --------------------------------------------------------------------------
280// Serialisation
281// --------------------------------------------------------------------------
282
283/// Turns a catalog into its JSON document, one array per table.
284///
285/// Exposed apart from [`save`] so callers can inspect or divert the document
286/// without writing it. The result carries the current [`FORMAT_VERSION`], so a
287/// round trip through [`from_json`] is always accepted.
288pub fn to_json(catalog: &Catalog) -> Json {
289    let mut root = Json::obj();
290    root.set("format_version", FORMAT_VERSION.into());
291    root.set("relation_rules", model::RELATION_RULES.into());
292    root.set("scanned_at", catalog.scanned_at.into());
293    root.set(
294        "excluded",
295        Json::Arr(
296            catalog
297                .excluded
298                .iter()
299                .map(|f| Json::from(f.as_str()))
300                .collect(),
301        ),
302    );
303    root.set(
304        "roots",
305        Json::Arr(catalog.roots.iter().map(|r| Json::Str(r.clone())).collect()),
306    );
307
308    root.set(
309        "file",
310        Json::Arr(
311            catalog
312                .files
313                .iter()
314                .map(|file| {
315                    let mut row = file_to_json(file);
316                    if let Some(fraction) = catalog.file_mtime_subseconds.get(&file.path) {
317                        row.set("mtime_subseconds", (*fraction).into());
318                    }
319                    if let Some(added) = catalog.file_added_at.get(&file.path) {
320                        row.set("added_at", (*added).into());
321                    }
322                    row
323                })
324                .collect(),
325        ),
326    );
327    root.set("artist", array(&catalog.artists, artist_to_json));
328    root.set("release", array(&catalog.releases, release_to_json));
329    root.set(
330        "release_group",
331        array(&catalog.release_groups, release_group_to_json),
332    );
333    root.set("track", array(&catalog.tracks, track_to_json));
334    root.set("recording", array(&catalog.recordings, recording_to_json));
335    root.set("work", array(&catalog.works, work_to_json));
336    root.set("label", array(&catalog.labels, label_to_json));
337    root.set("genre", array(&catalog.genres, genre_to_json));
338    root.set("credit", array(&catalog.credits, credit_to_json));
339    root.set("relation", array(&catalog.relations, relation_to_json));
340    root.set(
341        "genre_link",
342        array(&catalog.genre_links, genre_link_to_json),
343    );
344    root
345}
346
347fn array<T>(items: &[T], f: impl Fn(&T) -> Json) -> Json {
348    Json::Arr(items.iter().map(f).collect())
349}
350
351fn opt_str(value: &Option<String>) -> Json {
352    match value {
353        Some(s) => Json::Str(s.clone()),
354        None => Json::Null,
355    }
356}
357
358fn opt_num<T: Into<Json> + Copy>(value: &Option<T>) -> Json {
359    match value {
360        Some(v) => (*v).into(),
361        None => Json::Null,
362    }
363}
364
365fn file_to_json(file: &AudioFile) -> Json {
366    let mut o = Json::obj();
367    o.set("id", file.id.into());
368    o.set("path", file.path.clone().into());
369    o.set("size", file.size.into());
370    o.set("mtime", file.mtime.into());
371    o.set("codec", file.properties.codec.clone().into());
372    o.set("container", file.properties.container.clone().into());
373    o.set("sample_rate", opt_num(&file.properties.sample_rate));
374    o.set(
375        "bit_depth",
376        opt_num(&file.properties.bit_depth.map(u32::from)),
377    );
378    o.set(
379        "channels",
380        opt_num(&file.properties.channels.map(u32::from)),
381    );
382    o.set("duration_ms", opt_num(&file.properties.duration_ms));
383    o.set("bitrate_kbps", opt_num(&file.properties.bitrate_kbps));
384    o.set("lossless", file.properties.lossless.into());
385    o.set("has_embedded_art", file.has_embedded_art.into());
386    o.set("lyrics_path", opt_str(&file.lyrics_path));
387
388    let mut tags = Json::obj();
389    for (key, values) in &file.tags {
390        tags.set(
391            key,
392            Json::Arr(values.iter().map(|v| Json::Str(v.clone())).collect()),
393        );
394    }
395    o.set("tags", tags);
396
397    o
398}
399
400/// An imported analysis, field for field.
401///
402/// Absent measurements are written as `null` rather than left out: the reader
403/// then distinguishes "not measured" from "measured as zero", which for a peak
404/// or a dynamic range is the whole difference.
405/// Serializes an attributed analysis without dropping unknown source data.
406///
407/// Dated rows retain the legacy numeric `result_at_ns` and an additive decimal
408/// `result_at_ns_exact` for precision beyond the JSON model's `f64` range.
409pub fn analysis_to_json(a: &FileAnalysis) -> Json {
410    let mut o = Json::obj();
411    o.set("path", a.path.clone().into());
412    o.set("source", a.source.clone().into());
413    o.set("source_version", a.source_version.into());
414    o.set("imported_at", a.imported_at.into());
415    o.set("result_at_ns", a.result_at_ns.into());
416    if a.result_at_ns != 0 {
417        // Keep the legacy numeric field for older readers. Nanosecond dates
418        // exceed the JSON model's exact f64 integer range.
419        o.set("result_at_ns_exact", a.result_at_ns.to_string().into());
420    }
421    o.set("size_bytes", a.size_bytes.into());
422    o.set("modified_unix", a.modified_unix.into());
423    o.set("file_md5", opt_str(&a.file_md5));
424    o.set("source_data", a.source_data.clone().unwrap_or(Json::Null));
425    o.set("md5_state", opt_str(&a.md5_state));
426    o.set("md5_detail", opt_str(&a.md5_detail));
427    o.set("real_bit_depth", opt_num(&a.real_bit_depth.map(u32::from)));
428    o.set("requant_rate", opt_float(&a.requant_rate));
429    o.set("fake_stereo", opt_bool(&a.fake_stereo));
430    o.set("ext_mismatch", opt_bool(&a.ext_mismatch));
431    o.set("transcoding", opt_str(&a.transcoding));
432    o.set("upscaling", opt_bool(&a.upscaling));
433    o.set("upsampling", opt_bool(&a.upsampling));
434    o.set("summary", opt_str(&a.summary));
435    o.set("detail", opt_str(&a.detail));
436    o.set("cutoff_hz", opt_float(&a.cutoff_hz));
437    o.set("cutoff_ratio", opt_float(&a.cutoff_ratio));
438    o.set("dr_db", opt_float(&a.dr_db));
439    o.set("peak_dbfs", opt_float(&a.peak_dbfs));
440    o.set("true_peak_dbtp", opt_float(&a.true_peak_dbtp));
441    o.set("integrated_lufs", opt_float(&a.integrated_lufs));
442    o.set("clipped_samples", opt_num(&a.clipped_samples));
443    o.set("clip_events", opt_num(&a.clip_events));
444    o.set("clipped", opt_bool(&a.clipped));
445    o.set("error", opt_str(&a.error));
446    o
447}
448
449pub(crate) fn analysis_from_json(item: &Json) -> Result<FileAnalysis, StoreError> {
450    let path = item
451        .field_str("path")
452        .filter(|path| !path.is_empty())
453        .ok_or(StoreError::Invalid("analysis without path"))?;
454    // Missing/null optional measurements are legacy-compatible. A present
455    // value of the wrong type must not be lost on the next protected save.
456    stored_fields(
457        item,
458        &[
459            "source",
460            "file_md5",
461            "md5_state",
462            "md5_detail",
463            "transcoding",
464            "summary",
465            "detail",
466            "error",
467        ],
468        |value| value.as_str().is_some(),
469        "invalid stored analysis field",
470    )?;
471    stored_fields(
472        item,
473        &[
474            "imported_at",
475            "size_bytes",
476            "modified_unix",
477            "clipped_samples",
478            "clip_events",
479        ],
480        |value| value.as_u64().is_some(),
481        "invalid stored analysis field",
482    )?;
483    stored_fields(
484        item,
485        &["source_version"],
486        |value| value.as_u32().is_some(),
487        "invalid stored analysis field",
488    )?;
489    stored_fields(
490        item,
491        &["real_bit_depth"],
492        |value| {
493            value
494                .as_u32()
495                .is_some_and(|depth| u16::try_from(depth).is_ok())
496        },
497        "invalid stored analysis field",
498    )?;
499    stored_fields(
500        item,
501        &[
502            "requant_rate",
503            "cutoff_hz",
504            "cutoff_ratio",
505            "dr_db",
506            "peak_dbfs",
507            "true_peak_dbtp",
508            "integrated_lufs",
509        ],
510        |value| value.as_f64().is_some_and(f64::is_finite),
511        "invalid stored analysis field",
512    )?;
513    stored_fields(
514        item,
515        &[
516            "fake_stereo",
517            "ext_mismatch",
518            "upscaling",
519            "upsampling",
520            "clipped",
521        ],
522        |value| value.as_bool().is_some(),
523        "invalid stored analysis field",
524    )?;
525    stored_fields(
526        item,
527        &["source_data"],
528        |value| matches!(value, Json::Obj(_)),
529        "invalid stored analysis field",
530    )?;
531    Ok(FileAnalysis {
532        path,
533        source: item.field_str("source").unwrap_or_default(),
534        source_version: item.field_u32("source_version").unwrap_or(0),
535        imported_at: item.field_u64("imported_at").unwrap_or(0),
536        result_at_ns: analysis_result_date(item)?,
537        size_bytes: item.field_u64("size_bytes").unwrap_or(0),
538        modified_unix: item.field_u64("modified_unix").unwrap_or(0),
539        file_md5: item.field_str("file_md5"),
540        source_data: item
541            .get("source_data")
542            .filter(|value| matches!(value, Json::Obj(_)))
543            .cloned(),
544        md5_state: item.field_str("md5_state"),
545        md5_detail: item.field_str("md5_detail"),
546        real_bit_depth: item
547            .field_u32("real_bit_depth")
548            .and_then(|v| u16::try_from(v).ok()),
549        requant_rate: item.field_f64("requant_rate"),
550        fake_stereo: item.field_optional_bool("fake_stereo"),
551        ext_mismatch: item.field_optional_bool("ext_mismatch"),
552        transcoding: item.field_str("transcoding"),
553        upscaling: item.field_optional_bool("upscaling"),
554        upsampling: item.field_optional_bool("upsampling"),
555        summary: item.field_str("summary"),
556        detail: item.field_str("detail"),
557        cutoff_hz: item.field_f64("cutoff_hz"),
558        cutoff_ratio: item.field_f64("cutoff_ratio"),
559        dr_db: item.field_f64("dr_db"),
560        peak_dbfs: item.field_f64("peak_dbfs"),
561        true_peak_dbtp: item.field_f64("true_peak_dbtp"),
562        integrated_lufs: item.field_f64("integrated_lufs"),
563        clipped_samples: item.field_u64("clipped_samples"),
564        clip_events: item.field_u64("clip_events"),
565        clipped: item.field_optional_bool("clipped"),
566        error: item.field_str("error"),
567    })
568}
569
570fn stored_fields(
571    item: &Json,
572    fields: &[&str],
573    valid: impl Fn(&Json) -> bool,
574    message: &'static str,
575) -> Result<(), StoreError> {
576    if fields.iter().any(|field| {
577        item.get(field)
578            .is_some_and(|value| *value != Json::Null && !valid(value))
579    }) {
580        return Err(StoreError::Invalid(message));
581    }
582    Ok(())
583}
584
585fn analysis_result_date(item: &Json) -> Result<u64, StoreError> {
586    let invalid = || StoreError::Invalid("invalid analysis result date");
587    if let Some(exact) = item.get("result_at_ns_exact") {
588        let date = exact
589            .as_str()
590            .filter(|text| !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit()))
591            .and_then(|text| text.parse::<u64>().ok())
592            .ok_or_else(invalid)?;
593        if item.field_f64("result_at_ns") != Some(date as f64) {
594            return Err(invalid());
595        }
596        return Ok(date);
597    }
598    match item.get("result_at_ns") {
599        None | Some(Json::Null) => Ok(0),
600        Some(value) => value.as_u64().ok_or_else(invalid),
601    }
602}
603
604fn opt_bool(value: &Option<bool>) -> Json {
605    match value {
606        Some(v) => (*v).into(),
607        None => Json::Null,
608    }
609}
610
611fn opt_float(value: &Option<f64>) -> Json {
612    match value {
613        Some(v) => (*v).into(),
614        None => Json::Null,
615    }
616}
617
618fn artist_to_json(a: &Artist) -> Json {
619    let mut o = Json::obj();
620    o.set("id", a.id.into());
621    o.set("name", a.name.clone().into());
622    o.set("sort_name", a.sort_name.clone().into());
623    o.set("key", a.key.clone().into());
624    o.set("mbid", opt_str(&a.mbid));
625    // Written only when there are any: an empty array on every artist of a
626    // library that has never been merged is a quarter of a megabyte of `[]`.
627    if !a.aliases.is_empty() {
628        o.set(
629            "aliases",
630            Json::Arr(a.aliases.iter().map(|s| s.clone().into()).collect()),
631        );
632    }
633    o
634}
635
636fn release_to_json(r: &Release) -> Json {
637    let mut o = Json::obj();
638    o.set("id", r.id.into());
639    o.set("title", r.title.clone().into());
640    o.set("key", r.key.clone().into());
641    o.set("album_artist_id", opt_num(&r.album_artist_id));
642    o.set("date", opt_str(&r.date));
643    o.set("year", opt_num(&r.year));
644    o.set(
645        "label_ids",
646        Json::Arr(r.label_ids.iter().map(|&id| Json::Num(id as f64)).collect()),
647    );
648    o.set("catalog_number", opt_str(&r.catalog_number));
649    o.set("barcode", opt_str(&r.barcode));
650    o.set("media", opt_str(&r.media));
651    o.set("mbid", opt_str(&r.mbid));
652    o.set("release_group_mbid", opt_str(&r.release_group_mbid));
653    o.set("release_group_id", opt_num(&r.release_group_id));
654    o.set("is_compilation", r.is_compilation.into());
655    o.set("folder", r.folder.clone().into());
656    o.set("cover_path", opt_str(&r.cover_path));
657    o.set(
658        "track_ids",
659        Json::Arr(r.track_ids.iter().map(|&id| Json::Num(id as f64)).collect()),
660    );
661    o
662}
663
664fn work_to_json(work: &Work) -> Json {
665    let mut o = Json::obj();
666    o.set("id", work.id.into());
667    o.set("title", work.title.clone().into());
668    o.set("key", work.key.clone().into());
669    o.set("mbid", work.mbid.clone().into());
670    o.set(
671        "recording_ids",
672        Json::Arr(
673            work.recording_ids
674                .iter()
675                .map(|&id| Json::Num(id as f64))
676                .collect(),
677        ),
678    );
679    o
680}
681
682fn release_group_to_json(group: &ReleaseGroup) -> Json {
683    let mut o = Json::obj();
684    o.set("id", group.id.into());
685    o.set("title", group.title.clone().into());
686    o.set("key", group.key.clone().into());
687    o.set("mbid", group.mbid.clone().into());
688    o.set(
689        "release_ids",
690        Json::Arr(
691            group
692                .release_ids
693                .iter()
694                .map(|&id| Json::Num(id as f64))
695                .collect(),
696        ),
697    );
698    o
699}
700
701fn track_to_json(t: &Track) -> Json {
702    let mut o = Json::obj();
703    o.set("id", t.id.into());
704    o.set("file_id", t.file_id.into());
705    o.set("release_id", opt_num(&t.release_id));
706    o.set("recording_id", t.recording_id.into());
707    o.set("title", t.title.clone().into());
708    o.set("disc_no", opt_num(&t.disc_no));
709    o.set("track_no", opt_num(&t.track_no));
710    o.set("duration_ms", opt_num(&t.duration_ms));
711    o.set("isrc", opt_str(&t.isrc));
712    o.set("mbid", opt_str(&t.mbid));
713    o
714}
715
716fn recording_to_json(recording: &Recording) -> Json {
717    let mut o = Json::obj();
718    o.set("id", recording.id.into());
719    o.set("title", recording.title.clone().into());
720    o.set("key", recording.key.clone().into());
721    o.set("isrc", opt_str(&recording.isrc));
722    o.set("mbid", opt_str(&recording.mbid));
723    o.set(
724        "track_ids",
725        Json::Arr(
726            recording
727                .track_ids
728                .iter()
729                .map(|&id| Json::Num(id as f64))
730                .collect(),
731        ),
732    );
733    o.set(
734        "work_ids",
735        Json::Arr(
736            recording
737                .work_ids
738                .iter()
739                .map(|&id| Json::Num(id as f64))
740                .collect(),
741        ),
742    );
743    o
744}
745
746fn label_to_json(l: &Label) -> Json {
747    let mut o = Json::obj();
748    o.set("id", l.id.into());
749    o.set("name", l.name.clone().into());
750    o.set("key", l.key.clone().into());
751    o.set("mbid", opt_str(&l.mbid));
752    o
753}
754
755fn genre_to_json(g: &Genre) -> Json {
756    let mut o = Json::obj();
757    o.set("id", g.id.into());
758    o.set("name", g.name.clone().into());
759    o.set("key", g.key.clone().into());
760    o
761}
762
763fn credit_to_json(c: &Credit) -> Json {
764    let mut o = Json::obj();
765    o.set("artist_id", c.artist_id.into());
766    o.set("entity_kind", c.entity_kind.as_str().into());
767    o.set("entity_id", c.entity_id.into());
768    o.set("role", c.role.clone().into());
769    o.set("credited_as", opt_str(&c.credited_as));
770    o.set(
771        "attributes",
772        Json::Arr(
773            c.attributes
774                .iter()
775                .map(|attribute| {
776                    let mut row = Json::obj();
777                    row.set("id", opt_str(&attribute.id));
778                    row.set("name", attribute.name.clone().into());
779                    row.set("value", opt_str(&attribute.value));
780                    row.set("credited_as", opt_str(&attribute.credited_as));
781                    row
782                })
783                .collect(),
784        ),
785    );
786    o.set("began", opt_str(&c.began));
787    o.set("ended", opt_str(&c.ended));
788    o.set("order", opt_num(&c.order));
789    o.set("source", c.source.clone().into());
790    o.set("source_id", opt_str(&c.source_id));
791    o
792}
793
794fn relation_to_json(r: &Relation) -> Json {
795    let mut o = Json::obj();
796    o.set("source_kind", r.source_kind.as_str().into());
797    o.set("source_id", r.source_id.into());
798    o.set("target_kind", r.target_kind.as_str().into());
799    o.set("target_id", r.target_id.into());
800    o.set("kind", r.kind.clone().into());
801    o.set("weight", r.weight.into());
802    o.set("source", r.source.clone().into());
803    o
804}
805
806fn genre_link_to_json(g: &GenreLink) -> Json {
807    let mut o = Json::obj();
808    o.set("genre_id", g.genre_id.into());
809    o.set("entity_kind", g.entity_kind.as_str().into());
810    o.set("entity_id", g.entity_id.into());
811    o
812}
813
814// --------------------------------------------------------------------------
815// Deserialisation
816// --------------------------------------------------------------------------
817
818/// Rebuilds a catalog from its JSON document.
819///
820/// The version is checked before anything else, so an old file is rejected
821/// rather than half-decoded. Identifiers are then verified to be contiguous:
822/// the model indexes entities by position, and a gap would silently shift
823/// every relation. Present tables, lists and polymorphic references must be
824/// well formed; absent optional legacy fields retain their migration defaults.
825/// Optional audio precision/channel counts accept absence or `null`, but
826/// present values must be integral `u16` values rather than truncated numbers.
827/// Known file scalars and tag tables must have their declared JSON types;
828/// malformed values cannot be rewritten as empty/default file metadata.
829pub fn from_json(value: &Json) -> Result<Catalog, StoreError> {
830    let version = value.field_u32("format_version").unwrap_or(0);
831    if version != FORMAT_VERSION {
832        return Err(StoreError::Version {
833            found: version,
834            expected: FORMAT_VERSION,
835        });
836    }
837
838    for table in [
839        "roots",
840        "excluded",
841        "file",
842        "artist",
843        "release",
844        "release_group",
845        "track",
846        "recording",
847        "work",
848        "label",
849        "genre",
850        "credit",
851        "relation",
852        "genre_link",
853        "analysis",
854    ] {
855        if value.get(table).is_some_and(|rows| rows.as_arr().is_none()) {
856            return Err(StoreError::Invalid("catalog tables must be arrays"));
857        }
858    }
859
860    let mut catalog = Catalog {
861        scanned_at: value.field_u64("scanned_at").unwrap_or(0),
862        roots: string_list(value.get("roots"))?,
863        // Absent from a file written before exclusions existed, which reads as
864        // "nothing excluded" — the behaviour that file was saved with.
865        excluded: string_list(value.get("excluded"))?,
866        ..Default::default()
867    };
868    // Relations inferred under older rules are stale, not wrong: they are
869    // recomputed below rather than refused, so that upgrading Aède never costs
870    // a rescan — nor the integrity verdicts a rescan would keep but a rebuilt
871    // catalog would not.
872    let stale_relations = value.field_u32("relation_rules").unwrap_or(0) != model::RELATION_RULES;
873
874    for item in rows(value, "file") {
875        let file = file_from_json(item)?;
876        if let Some(fraction) = item.field_u32("mtime_subseconds") {
877            if fraction >= 1_000_000_000 {
878                return Err(StoreError::Invalid(
879                    "file modification fraction out of range",
880                ));
881            }
882            catalog
883                .file_mtime_subseconds
884                .insert(file.path.clone(), fraction);
885        } else if item.get("mtime_subseconds").is_some() {
886            return Err(StoreError::Invalid("invalid file modification fraction"));
887        }
888        if let Some(added) = item.field_u64("added_at") {
889            catalog.file_added_at.insert(file.path.clone(), added);
890        } else if item.get("added_at").is_some() {
891            return Err(StoreError::Invalid("invalid file catalog date"));
892        }
893        catalog.files.push(file);
894    }
895    for item in rows(value, "artist") {
896        catalog.artists.push(Artist {
897            id: item
898                .field_u32("id")
899                .ok_or(StoreError::Invalid("artist without identifier"))?,
900            name: item.field_str("name").unwrap_or_default(),
901            sort_name: item.field_str("sort_name").unwrap_or_default(),
902            key: item.field_str("key").unwrap_or_default(),
903            mbid: item.field_str("mbid"),
904            aliases: string_list(item.get("aliases"))?,
905        });
906    }
907    for item in rows(value, "release") {
908        catalog.releases.push(Release {
909            id: item
910                .field_u32("id")
911                .ok_or(StoreError::Invalid("release without identifier"))?,
912            title: item.field_str("title").unwrap_or_default(),
913            key: item.field_str("key").unwrap_or_default(),
914            album_artist_id: item.field_u32("album_artist_id"),
915            date: item.field_str("date"),
916            year: item.field_u32("year"),
917            label_ids: id_list(item.get("label_ids"))?,
918            catalog_number: item.field_str("catalog_number"),
919            barcode: item.field_str("barcode"),
920            media: item.field_str("media"),
921            mbid: item.field_str("mbid"),
922            release_group_mbid: item.field_str("release_group_mbid"),
923            release_group_id: item.field_u32("release_group_id"),
924            is_compilation: item.field_bool("is_compilation"),
925            folder: item.field_str("folder").unwrap_or_default(),
926            cover_path: item.field_str("cover_path"),
927            track_ids: id_list(item.get("track_ids"))?,
928        });
929    }
930    for item in rows(value, "release_group") {
931        catalog.release_groups.push(ReleaseGroup {
932            id: item
933                .field_u32("id")
934                .ok_or(StoreError::Invalid("release group without identifier"))?,
935            title: item.field_str("title").unwrap_or_default(),
936            key: item.field_str("key").unwrap_or_default(),
937            mbid: item.field_str("mbid").unwrap_or_default(),
938            release_ids: id_list(item.get("release_ids"))?,
939        });
940    }
941    if catalog.release_groups.is_empty() {
942        rebuild_legacy_release_groups(&mut catalog);
943    }
944    for item in rows(value, "track") {
945        catalog.tracks.push(Track {
946            id: item
947                .field_u32("id")
948                .ok_or(StoreError::Invalid("track without identifier"))?,
949            file_id: item
950                .field_u32("file_id")
951                .ok_or(StoreError::Invalid("track without file"))?,
952            release_id: item.field_u32("release_id"),
953            // Catalogs written before recordings existed gain their canonical
954            // rows below. Their track ids were dense already, so each one is
955            // a valid temporary local recording id during that migration.
956            recording_id: item
957                .field_u32("recording_id")
958                .unwrap_or_else(|| item.field_u32("id").unwrap_or(0)),
959            title: item.field_str("title").unwrap_or_default(),
960            disc_no: item.field_u32("disc_no"),
961            track_no: item.field_u32("track_no"),
962            duration_ms: item.field_u64("duration_ms"),
963            isrc: item.field_str("isrc"),
964            mbid: item.field_str("mbid"),
965        });
966    }
967    for item in rows(value, "recording") {
968        catalog.recordings.push(Recording {
969            id: item
970                .field_u32("id")
971                .ok_or(StoreError::Invalid("recording without identifier"))?,
972            title: item.field_str("title").unwrap_or_default(),
973            key: item.field_str("key").unwrap_or_default(),
974            isrc: item.field_str("isrc"),
975            mbid: item.field_str("mbid"),
976            track_ids: id_list(item.get("track_ids"))?,
977            work_ids: id_list(item.get("work_ids"))?,
978        });
979    }
980    if catalog.recordings.is_empty() && !catalog.tracks.is_empty() {
981        rebuild_legacy_recordings(&mut catalog);
982    }
983    for item in rows(value, "work") {
984        catalog.works.push(Work {
985            id: item
986                .field_u32("id")
987                .ok_or(StoreError::Invalid("work without identifier"))?,
988            title: item.field_str("title").unwrap_or_default(),
989            key: item.field_str("key").unwrap_or_default(),
990            mbid: item
991                .field_str("mbid")
992                .ok_or(StoreError::Invalid("work without MusicBrainz identifier"))?,
993            recording_ids: id_list(item.get("recording_ids"))?,
994        });
995    }
996    for item in rows(value, "label") {
997        catalog.labels.push(Label {
998            id: item
999                .field_u32("id")
1000                .ok_or(StoreError::Invalid("label without identifier"))?,
1001            name: item.field_str("name").unwrap_or_default(),
1002            key: item.field_str("key").unwrap_or_default(),
1003            mbid: item.field_str("mbid"),
1004        });
1005    }
1006    for item in rows(value, "genre") {
1007        catalog.genres.push(Genre {
1008            id: item
1009                .field_u32("id")
1010                .ok_or(StoreError::Invalid("genre without identifier"))?,
1011            name: item.field_str("name").unwrap_or_default(),
1012            key: item.field_str("key").unwrap_or_default(),
1013        });
1014    }
1015    for item in rows(value, "credit") {
1016        let kind = item
1017            .field_str("entity_kind")
1018            .and_then(|s| EntityKind::parse_kind(&s))
1019            .ok_or(StoreError::Invalid("credit without a valid entity kind"))?;
1020        catalog.credits.push(Credit {
1021            artist_id: item
1022                .field_u32("artist_id")
1023                .ok_or(StoreError::Invalid("credit without artist identifier"))?,
1024            entity_kind: kind,
1025            entity_id: item
1026                .field_u32("entity_id")
1027                .ok_or(StoreError::Invalid("credit without entity identifier"))?,
1028            role: item.field_str("role").unwrap_or_default(),
1029            credited_as: item.field_str("credited_as"),
1030            attributes: item
1031                .get("attributes")
1032                .and_then(Json::as_arr)
1033                .map(|rows| {
1034                    rows.iter()
1035                        .filter_map(|row| {
1036                            Some(model::CreditAttribute {
1037                                id: row.field_str("id"),
1038                                name: row.field_str("name")?,
1039                                value: row.field_str("value"),
1040                                credited_as: row.field_str("credited_as"),
1041                            })
1042                        })
1043                        .collect()
1044                })
1045                .unwrap_or_default(),
1046            began: item.field_str("began"),
1047            ended: item.field_str("ended"),
1048            order: item.field_u32("order"),
1049            source: item
1050                .field_str("source")
1051                .unwrap_or_else(|| "tags".to_string()),
1052            source_id: item.field_str("source_id"),
1053        });
1054    }
1055    for item in rows(value, "relation") {
1056        let source_kind = item
1057            .field_str("source_kind")
1058            .and_then(|s| EntityKind::parse_kind(&s))
1059            .ok_or(StoreError::Invalid("relation without a valid source kind"))?;
1060        let target_kind = item
1061            .field_str("target_kind")
1062            .and_then(|s| EntityKind::parse_kind(&s))
1063            .ok_or(StoreError::Invalid("relation without a valid target kind"))?;
1064        catalog.relations.push(Relation {
1065            source_kind,
1066            source_id: item
1067                .field_u32("source_id")
1068                .ok_or(StoreError::Invalid("relation without source identifier"))?,
1069            target_kind,
1070            target_id: item
1071                .field_u32("target_id")
1072                .ok_or(StoreError::Invalid("relation without target identifier"))?,
1073            kind: item.field_str("kind").unwrap_or_default(),
1074            weight: item.field_u32("weight").unwrap_or(1),
1075            source: item.field_str("source").unwrap_or_default(),
1076        });
1077    }
1078    for item in rows(value, "genre_link") {
1079        let kind = item
1080            .field_str("entity_kind")
1081            .and_then(|s| EntityKind::parse_kind(&s))
1082            .ok_or(StoreError::Invalid(
1083                "genre link without a valid entity kind",
1084            ))?;
1085        catalog.genre_links.push(GenreLink {
1086            genre_id: item
1087                .field_u32("genre_id")
1088                .ok_or(StoreError::Invalid("genre link without genre identifier"))?,
1089            entity_kind: kind,
1090            entity_id: item
1091                .field_u32("entity_id")
1092                .ok_or(StoreError::Invalid("genre link without entity identifier"))?,
1093        });
1094    }
1095
1096    for item in rows(value, "analysis") {
1097        catalog.analyses.push(analysis_from_json(item)?);
1098    }
1099
1100    verify_integrity(&catalog)?;
1101    if stale_relations {
1102        model::rebuild_relations(&mut catalog);
1103    }
1104    for relation in &catalog.relations {
1105        if !entity_exists(&catalog, relation.source_kind, relation.source_id)
1106            || !entity_exists(&catalog, relation.target_kind, relation.target_id)
1107        {
1108            return Err(StoreError::Invalid("relation attached to a missing entity"));
1109        }
1110    }
1111    Ok(catalog)
1112}
1113
1114/// The `Vec`s are indexed by identifier: we check that the file read back
1115/// respects that invariant before handing it to the rest of the program.
1116fn verify_integrity(catalog: &Catalog) -> Result<(), StoreError> {
1117    // Validated reverse entries prove their forward membership in linear time,
1118    // including albums or recordings with many placements.
1119    let mut tracks_in_releases = vec![false; catalog.tracks.len()];
1120    let mut tracks_in_recordings = vec![false; catalog.tracks.len()];
1121    let mut releases_in_groups = vec![false; catalog.releases.len()];
1122    // A frequently performed work can link thousands of recordings. Walking
1123    // that full list for each reverse reference would make loading quadratic.
1124    let work_recordings: Vec<std::collections::HashSet<&Id>> = catalog
1125        .works
1126        .iter()
1127        .map(|work| work.recording_ids.iter().collect())
1128        .collect();
1129    let mut file_paths = std::collections::HashSet::new();
1130    for (index, file) in catalog.files.iter().enumerate() {
1131        if file.id as usize != index {
1132            return Err(StoreError::Invalid("non-contiguous file identifiers"));
1133        }
1134        if !file_paths.insert(file.path.as_str()) {
1135            return Err(StoreError::Invalid("duplicate file path"));
1136        }
1137    }
1138    for (index, artist) in catalog.artists.iter().enumerate() {
1139        if artist.id as usize != index {
1140            return Err(StoreError::Invalid("non-contiguous artist identifiers"));
1141        }
1142    }
1143    for (index, release) in catalog.releases.iter().enumerate() {
1144        if release.id as usize != index {
1145            return Err(StoreError::Invalid("non-contiguous release identifiers"));
1146        }
1147        if release
1148            .album_artist_id
1149            .is_some_and(|id| id as usize >= catalog.artists.len())
1150        {
1151            return Err(StoreError::Invalid("release attached to a missing artist"));
1152        }
1153        if release
1154            .label_ids
1155            .iter()
1156            .any(|&id| id as usize >= catalog.labels.len())
1157        {
1158            return Err(StoreError::Invalid("release attached to a missing label"));
1159        }
1160        for &id in &release.track_ids {
1161            if catalog
1162                .track(id)
1163                .is_none_or(|track| track.release_id != Some(release.id))
1164            {
1165                return Err(StoreError::Invalid("release attached to a missing track"));
1166            }
1167            tracks_in_releases[id as usize] = true;
1168        }
1169        if release
1170            .release_group_id
1171            .is_some_and(|id| id as usize >= catalog.release_groups.len())
1172        {
1173            return Err(StoreError::Invalid(
1174                "release attached to a missing release group",
1175            ));
1176        }
1177    }
1178    for (index, group) in catalog.release_groups.iter().enumerate() {
1179        if group.id as usize != index {
1180            return Err(StoreError::Invalid(
1181                "non-contiguous release group identifiers",
1182            ));
1183        }
1184        for &id in &group.release_ids {
1185            if catalog
1186                .releases
1187                .get(id as usize)
1188                .is_none_or(|release| release.release_group_id != Some(group.id))
1189            {
1190                return Err(StoreError::Invalid(
1191                    "release group attached to a missing release",
1192                ));
1193            }
1194            releases_in_groups[id as usize] = true;
1195        }
1196    }
1197    if catalog
1198        .releases
1199        .iter()
1200        .enumerate()
1201        .any(|(index, release)| release.release_group_id.is_some() && !releases_in_groups[index])
1202    {
1203        return Err(StoreError::Invalid("release absent from its release group"));
1204    }
1205    for (index, track) in catalog.tracks.iter().enumerate() {
1206        if track.id as usize != index {
1207            return Err(StoreError::Invalid("non-contiguous track identifiers"));
1208        }
1209        if track.file_id as usize >= catalog.files.len() {
1210            return Err(StoreError::Invalid("track attached to a missing file"));
1211        }
1212        if track.recording_id as usize >= catalog.recordings.len() {
1213            return Err(StoreError::Invalid("track attached to a missing recording"));
1214        }
1215        if track
1216            .release_id
1217            .is_some_and(|id| id as usize >= catalog.releases.len())
1218        {
1219            return Err(StoreError::Invalid("track attached to a missing release"));
1220        }
1221        if track.release_id.is_some() && !tracks_in_releases[index] {
1222            return Err(StoreError::Invalid("track absent from its release"));
1223        }
1224    }
1225    for (index, recording) in catalog.recordings.iter().enumerate() {
1226        if recording.id as usize != index {
1227            return Err(StoreError::Invalid("non-contiguous recording identifiers"));
1228        }
1229        for &id in &recording.track_ids {
1230            if catalog
1231                .track(id)
1232                .is_none_or(|track| track.recording_id != recording.id)
1233            {
1234                return Err(StoreError::Invalid("recording attached to a missing track"));
1235            }
1236            tracks_in_recordings[id as usize] = true;
1237        }
1238        if recording.work_ids.iter().any(|&id| {
1239            work_recordings
1240                .get(id as usize)
1241                .is_none_or(|recordings| !recordings.contains(&recording.id))
1242        }) {
1243            return Err(StoreError::Invalid("recording attached to a missing work"));
1244        }
1245    }
1246    if tracks_in_recordings.iter().any(|present| !present) {
1247        return Err(StoreError::Invalid("track absent from its recording"));
1248    }
1249    for (index, work) in catalog.works.iter().enumerate() {
1250        if work.id as usize != index {
1251            return Err(StoreError::Invalid("non-contiguous work identifiers"));
1252        }
1253        if work.mbid.trim().is_empty() {
1254            return Err(StoreError::Invalid("work without MusicBrainz identifier"));
1255        }
1256        if work.recording_ids.iter().any(|&id| {
1257            catalog
1258                .recording(id)
1259                .is_none_or(|recording| !recording.work_ids.contains(&work.id))
1260        }) {
1261            return Err(StoreError::Invalid("work attached to a missing recording"));
1262        }
1263    }
1264    for (index, label) in catalog.labels.iter().enumerate() {
1265        if label.id as usize != index {
1266            return Err(StoreError::Invalid("non-contiguous label identifiers"));
1267        }
1268    }
1269    for (index, genre) in catalog.genres.iter().enumerate() {
1270        if genre.id as usize != index {
1271            return Err(StoreError::Invalid("non-contiguous genre identifiers"));
1272        }
1273    }
1274    for credit in &catalog.credits {
1275        if credit.artist_id as usize >= catalog.artists.len()
1276            || !entity_exists(catalog, credit.entity_kind, credit.entity_id)
1277        {
1278            return Err(StoreError::Invalid("credit attached to a missing entity"));
1279        }
1280    }
1281    for link in &catalog.genre_links {
1282        if link.genre_id as usize >= catalog.genres.len()
1283            || !entity_exists(catalog, link.entity_kind, link.entity_id)
1284        {
1285            return Err(StoreError::Invalid("genre attached to a missing entity"));
1286        }
1287    }
1288    Ok(())
1289}
1290
1291fn entity_exists(catalog: &Catalog, kind: EntityKind, id: Id) -> bool {
1292    let length = match kind {
1293        EntityKind::Artist => catalog.artists.len(),
1294        EntityKind::Release => catalog.releases.len(),
1295        EntityKind::ReleaseGroup => catalog.release_groups.len(),
1296        EntityKind::Track => catalog.tracks.len(),
1297        EntityKind::Recording => catalog.recordings.len(),
1298        EntityKind::Work => catalog.works.len(),
1299        EntityKind::Label => catalog.labels.len(),
1300        EntityKind::Genre => catalog.genres.len(),
1301    };
1302    (id as usize) < length
1303}
1304
1305/// Adds groups to a pre-canonical catalog from explicit release-group MBIDs.
1306fn rebuild_legacy_release_groups(catalog: &mut Catalog) {
1307    let mut known: BTreeMap<String, Id> = BTreeMap::new();
1308    for index in 0..catalog.releases.len() {
1309        let Some(mbid) = catalog.releases[index].release_group_mbid.clone() else {
1310            continue;
1311        };
1312        if mbid.trim().is_empty() {
1313            continue;
1314        }
1315        let group_id = match known.get(&mbid) {
1316            Some(&id) => id,
1317            None => {
1318                let id = catalog.release_groups.len() as Id;
1319                let release = &catalog.releases[index];
1320                catalog.release_groups.push(ReleaseGroup {
1321                    id,
1322                    title: release.title.clone(),
1323                    key: release.key.clone(),
1324                    mbid: mbid.clone(),
1325                    release_ids: Vec::new(),
1326                });
1327                known.insert(mbid, id);
1328                id
1329            }
1330        };
1331        let release_id = catalog.releases[index].id;
1332        catalog.releases[index].release_group_id = Some(group_id);
1333        catalog.release_groups[group_id as usize]
1334            .release_ids
1335            .push(release_id);
1336    }
1337}
1338
1339/// Builds recordings for a pre-canonical catalog without title-based claims.
1340///
1341/// A recording MBID is explicit enough to join placements. Without it, one
1342/// legacy placement becomes one local recording and can be joined later only
1343/// by sourced evidence.
1344fn rebuild_legacy_recordings(catalog: &mut Catalog) {
1345    let mut known: BTreeMap<String, Id> = BTreeMap::new();
1346    for index in 0..catalog.tracks.len() {
1347        let track = &catalog.tracks[index];
1348        let key = track
1349            .mbid
1350            .as_ref()
1351            .map(|mbid| format!("mbid:{mbid}"))
1352            .unwrap_or_else(|| format!("local:{}", track.file_id));
1353        let recording_id = match known.get(&key) {
1354            Some(&id) => id,
1355            None => {
1356                let id = catalog.recordings.len() as Id;
1357                catalog.recordings.push(Recording {
1358                    id,
1359                    title: track.title.clone(),
1360                    key: crate::text::normalize(&track.title),
1361                    isrc: track.isrc.clone(),
1362                    mbid: track.mbid.clone(),
1363                    track_ids: Vec::new(),
1364                    work_ids: Vec::new(),
1365                });
1366                known.insert(key, id);
1367                id
1368            }
1369        };
1370        let track_id = catalog.tracks[index].id;
1371        catalog.tracks[index].recording_id = recording_id;
1372        catalog.recordings[recording_id as usize]
1373            .track_ids
1374            .push(track_id);
1375    }
1376}
1377
1378fn file_optional_u16(item: &Json, field: &str) -> Result<Option<u16>, StoreError> {
1379    match item.get(field) {
1380        None | Some(Json::Null) => Ok(None),
1381        Some(value) => value
1382            .as_u32()
1383            .and_then(|value| u16::try_from(value).ok())
1384            .map(Some)
1385            .ok_or(StoreError::Invalid("invalid stored audio property")),
1386    }
1387}
1388
1389fn file_from_json(item: &Json) -> Result<AudioFile, StoreError> {
1390    stored_fields(
1391        item,
1392        &["codec", "container", "lyrics_path"],
1393        |value| value.as_str().is_some(),
1394        "invalid stored file field",
1395    )?;
1396    stored_fields(
1397        item,
1398        &["size", "mtime", "duration_ms"],
1399        |value| value.as_u64().is_some(),
1400        "invalid stored file field",
1401    )?;
1402    stored_fields(
1403        item,
1404        &["sample_rate", "bitrate_kbps"],
1405        |value| value.as_u32().is_some(),
1406        "invalid stored file field",
1407    )?;
1408    stored_fields(
1409        item,
1410        &["lossless", "has_embedded_art"],
1411        |value| value.as_bool().is_some(),
1412        "invalid stored file field",
1413    )?;
1414    stored_fields(
1415        item,
1416        &["tags"],
1417        |value| matches!(value, Json::Obj(_)),
1418        "file tags must be an object",
1419    )?;
1420    let mut tags: BTreeMap<String, Vec<String>> = BTreeMap::new();
1421    if let Some(Json::Obj(map)) = item.get("tags") {
1422        for (key, values) in map {
1423            tags.insert(key.clone(), string_list(Some(values))?);
1424        }
1425    }
1426    Ok(AudioFile {
1427        lyrics_path: item.field_str("lyrics_path"),
1428        id: item
1429            .field_u32("id")
1430            .ok_or(StoreError::Invalid("file without identifier"))?,
1431        path: item
1432            .field_str("path")
1433            .ok_or(StoreError::Invalid("file without path"))?,
1434        size: item.field_u64("size").unwrap_or(0),
1435        mtime: item.field_u64("mtime").unwrap_or(0),
1436        properties: AudioProperties {
1437            codec: item.field_str("codec").unwrap_or_default(),
1438            container: item.field_str("container").unwrap_or_default(),
1439            sample_rate: item.field_u32("sample_rate"),
1440            bit_depth: file_optional_u16(item, "bit_depth")?,
1441            channels: file_optional_u16(item, "channels")?,
1442            duration_ms: item.field_u64("duration_ms"),
1443            bitrate_kbps: item.field_u32("bitrate_kbps"),
1444            lossless: item.field_bool("lossless"),
1445        },
1446        has_embedded_art: item.field_bool("has_embedded_art"),
1447        tags,
1448        integrity: integrity_from_json(item.get("integrity")),
1449        fingerprint: fingerprint_from_json(item.get("fingerprint")),
1450    })
1451}
1452
1453/// Reads back a stored fingerprint, or nothing.
1454///
1455/// A row missing either half is read as no fingerprint at all rather than as
1456/// half of one: the pair is what a lookup needs, and a fingerprint with no
1457/// length would be sent out as a duration of zero.
1458pub(crate) fn fingerprint_from_json(
1459    value: Option<&Json>,
1460) -> Option<crate::fingerprint::Fingerprint> {
1461    let value = value?;
1462    let data = value.field_str("data").filter(|d| !d.is_empty())?;
1463    let seconds = value.field_u32("seconds").filter(|s| *s > 0)?;
1464    Some(crate::fingerprint::Fingerprint { data, seconds })
1465}
1466
1467/// Reads back a stored verdict; an unknown state is treated as no verdict at
1468/// all, so a catalog written by a later version degrades instead of failing.
1469pub(crate) fn integrity_from_json(value: Option<&Json>) -> Option<IntegrityRecord> {
1470    let value = value?;
1471    let verdict = match value.field_str("state")?.as_str() {
1472        "intact" => audit::integrity::Verdict::Intact,
1473        "nothing_to_check" => audit::integrity::Verdict::NothingToCheck,
1474        "damaged" => audit::integrity::Verdict::Damaged {
1475            detail: value.field_str("detail").unwrap_or_default(),
1476        },
1477        _ => return None,
1478    };
1479    Some(IntegrityRecord {
1480        verdict,
1481        method: value.field_str("method").unwrap_or_default(),
1482        checked_at: value.field_u64("checked_at").unwrap_or(0),
1483    })
1484}
1485
1486fn rows<'a>(value: &'a Json, key: &str) -> &'a [Json] {
1487    value.get(key).and_then(|v| v.as_arr()).unwrap_or(&[])
1488}
1489
1490fn string_list(value: Option<&Json>) -> Result<Vec<String>, StoreError> {
1491    let Some(value) = value else {
1492        return Ok(Vec::new());
1493    };
1494    value
1495        .as_arr()
1496        .ok_or(StoreError::Invalid("string list must be an array"))?
1497        .iter()
1498        .map(|item| {
1499            item.as_string()
1500                .ok_or(StoreError::Invalid("string list contains a non-string"))
1501        })
1502        .collect()
1503}
1504
1505fn id_list(value: Option<&Json>) -> Result<Vec<Id>, StoreError> {
1506    let Some(value) = value else {
1507        return Ok(Vec::new());
1508    };
1509    value
1510        .as_arr()
1511        .ok_or(StoreError::Invalid("identifier list must be an array"))?
1512        .iter()
1513        .map(|item| {
1514            item.as_u32().ok_or(StoreError::Invalid(
1515                "identifier list contains an invalid identifier",
1516            ))
1517        })
1518        .collect()
1519}
1520
1521#[cfg(test)]
1522#[path = "store_tests.rs"]
1523mod tests;