1use 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
26pub const FORMAT_VERSION: u32 = 1;
28
29pub const CATALOG_FILE: &str = "catalog.json";
31
32#[derive(Debug)]
36pub enum StoreError {
37 Io(std::io::Error),
40 Parse(json::ParseError),
43 ConclusionsParse(json::ParseError),
45 Version {
47 found: u32,
50 expected: u32,
52 },
53 ConclusionsVersion {
55 found: u32,
57 expected: u32,
59 },
60 Invalid(&'static str),
64 ConclusionsInvalid(&'static str),
66 AccountsParse(json::ParseError),
68 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
101pub 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
116pub fn catalog_path(data_dir: &Path) -> PathBuf {
121 data_dir.join(CATALOG_FILE)
122}
123
124pub const ASSETS_DIR: &str = "assets";
135
136pub fn assets_dir(data_dir: &Path) -> PathBuf {
141 data_dir.join(ASSETS_DIR)
142}
143
144pub 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
206pub 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 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
240pub 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
246pub 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
263pub 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
279pub 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
400pub 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 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 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 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
814pub 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 excluded: string_list(value.get("excluded"))?,
866 ..Default::default()
867 };
868 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 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
1114fn verify_integrity(catalog: &Catalog) -> Result<(), StoreError> {
1117 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 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
1305fn 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
1339fn 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
1453pub(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
1467pub(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;