aede_core/model/mod.rs
1//! The data model: a graph of linked entities, not a list of files.
2//!
3//! This is the central architectural decision of the project. We do not model
4//! "an album belongs to an artist" but "entities carry roles with respect to
5//! one another". That is what will later make it possible to click on a
6//! drummer and see their forty appearances, without redoing anything.
7//!
8//! Each `Vec` is indexed by identifier: `catalog.artists[id]` is artist `id`.
9//! The persisted graph mirrors the tables described in `schema.sql`. A future
10//! database would preserve this vocabulary; adopting SQLite depends on measured
11//! storage and deployment budgets, rather than a mandatory milestone migration.
12//!
13//! The module is divided by what the code *does* with the graph, which is the
14//! only division that holds up. This file is the vocabulary — the entities and
15//! the tables they live in — and beside it sit the three verbs:
16//!
17//! | Module | What it does |
18//! |---|---|
19//! | [`query`] | Reads the graph: every question a command asks of it |
20//! | [`builder`] | Turns scanned files into entities, deterministically |
21//! | [`relations`] | Derives the typed links between entities |
22//!
23//! The division is not cosmetic. [`query`] takes `&self` throughout, so a
24//! lookup that tried to change something would not compile; [`builder`] is the
25//! only place identifiers are handed out; [`relations`] holds everything that
26//! is *inferred* rather than read, which is why it carries a version of its own.
27//!
28//! What the rest of the program calls is re-exported here, so `model::build`,
29//! `model::ScannedFile` and `model::rebuild_relations` keep naming the same
30//! things they always did — the split is internal, and no caller had to change.
31
32use std::collections::BTreeMap;
33
34use crate::audit;
35use crate::fingerprint;
36use crate::tags::AudioProperties;
37use crate::text;
38
39pub mod builder;
40mod composers;
41pub mod identity;
42pub mod query;
43pub mod relations;
44
45pub use builder::{ScannedFile, build};
46pub use query::{SearchHit, TitleMatch};
47pub use relations::{
48 ALBUM_BY, APPEARS_ON, COMPILATION_APPEARANCE, CONTRIBUTED_TO, DISCOGRAPHY, DUPLICATE,
49 EDITION_OF, HAS_EDITION, HAS_RECORDING, HAS_TRACK, OTHER_EDITION, PART_OF_RELEASE,
50 PERFORMANCE_OF, PLACED_AS, PLACEMENT_OF, RELATION_RULES, RELEASED, RELEASED_BY,
51 rebuild_relations,
52};
53
54/// Dense index into the catalog's vectors: `catalog.artists[id]` is artist `id`.
55pub type Id = u32;
56
57/// Entity kind, for the polymorphic tables (credits, relations, genres).
58#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
59pub enum EntityKind {
60 /// A person or a group.
61 Artist,
62 /// An album, in the release sense.
63 Release,
64 /// A local placement of one recording on one release.
65 Track,
66 /// A recorded performance, independent of its local placements.
67 Recording,
68 /// A musical composition realized by recordings.
69 Work,
70 /// The album identity shared by several release editions.
71 ReleaseGroup,
72 /// A record label.
73 Label,
74 /// A genre. Never carried by a credit or a relation — a genre performs
75 /// nothing — but an entity all the same, and one a user can have an
76 /// opinion about.
77 Genre,
78}
79
80impl EntityKind {
81 /// Lowercase spelling used on disk and in the polymorphic tables.
82 pub fn as_str(self) -> &'static str {
83 match self {
84 EntityKind::Artist => "artist",
85 EntityKind::Release => "release",
86 EntityKind::Track => "track",
87 EntityKind::Recording => "recording",
88 EntityKind::Work => "work",
89 EntityKind::ReleaseGroup => "release_group",
90 EntityKind::Label => "label",
91 EntityKind::Genre => "genre",
92 }
93 }
94
95 /// Named `parse_kind` rather than `from_str`: this is not the standard
96 /// trait, and the homonymy would be confusing.
97 pub fn parse_kind(s: &str) -> Option<EntityKind> {
98 Some(match s {
99 "artist" => EntityKind::Artist,
100 "release" => EntityKind::Release,
101 "track" => EntityKind::Track,
102 "recording" => EntityKind::Recording,
103 "work" => EntityKind::Work,
104 "release_group" => EntityKind::ReleaseGroup,
105 "label" => EntityKind::Label,
106 "genre" => EntityKind::Genre,
107 _ => return None,
108 })
109 }
110}
111
112/// A file on disk, with its technical properties and its raw tags.
113///
114/// The raw tags are kept: they allow the whole graph to be rebuilt without
115/// rereading the files, which is what makes incremental scanning possible.
116#[derive(Debug, Clone, Default)]
117pub struct AudioFile {
118 /// Position in [`Catalog::files`]; this is what a track points back to.
119 pub id: Id,
120 /// Absolute path in the scanner's native spelling, including Windows
121 /// verbatim prefixes. Use `text` path helpers for catalog string operations.
122 pub path: String,
123 /// Size in bytes; together with [`AudioFile::mtime`] and the catalog's
124 /// [`Catalog::file_mtime_subseconds`] it decides whether a later scan can
125 /// reuse the stored tags.
126 pub size: u64,
127 /// Modification date, in seconds since the Unix epoch.
128 pub mtime: u64,
129 /// Description of the stream itself: codec, sample rate, duration.
130 pub properties: AudioProperties,
131 /// Whether cover art sits inside the file; when it does not, the release
132 /// may still carry an image found beside it.
133 pub has_embedded_art: bool,
134 /// Tags as read, lowercase name to values, kept so that the whole graph
135 /// can be rebuilt without touching the disk again.
136 pub tags: BTreeMap<String, Vec<String>>,
137 /// A `.lrc` sitting beside the file, when there is one.
138 ///
139 /// The **path**, not the text. Lyrics carried by a tag are already in the
140 /// catalog — raw tags are kept per file — but a sidecar is not in the file,
141 /// and storing its contents as though it were a tag would make the catalog
142 /// lie about what the file says. It is one small text file next to the
143 /// music; reading it when somebody asks costs nothing and is always
144 /// current.
145 pub lyrics_path: Option<String>,
146 /// What the last integrity check concluded, if one was ever run.
147 ///
148 /// `None` means "not checked", which is deliberately distinct from
149 /// [`audit::integrity::Verdict::NothingToCheck`]: the first can change, the
150 /// second cannot. The verdict travels with the file entry, so a scan that
151 /// reuses the entry keeps it and a scan that re-reads the file drops it —
152 /// a modified file has to be checked again.
153 pub integrity: Option<IntegrityRecord>,
154 /// The acoustic fingerprint of this file, once one has been computed.
155 ///
156 /// Beside the integrity verdict and for the same reason: both are Aède's
157 /// own conclusions about the **bytes**, worked out rather than fetched,
158 /// and both are expensive enough that recomputing them on every scan would
159 /// be unthinkable. So both travel with the file entry — a scan that reuses
160 /// the entry keeps them, a scan that re-reads the file drops them, because
161 /// a modified file is different audio and its old fingerprint describes
162 /// something that is no longer there.
163 ///
164 /// What AcoustID *says* when shown one is a different kind of thing —
165 /// somebody else's claim — and lives in `sources.json` with every other
166 /// source's.
167 pub fingerprint: Option<fingerprint::Fingerprint>,
168}
169
170/// An integrity verdict, with what produced it.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct IntegrityRecord {
173 /// What the check concluded.
174 pub verdict: audit::integrity::Verdict,
175 /// How it was reached: `flac-frame-crc`, `ogg-page-crc`, or `none`.
176 ///
177 /// Stored because verdicts of different strengths will coexist: a frame
178 /// checksum proves the container intact, an MD5 of the decoded audio will
179 /// prove the music itself intact.
180 pub method: String,
181 /// When the check ran, in seconds since the Unix epoch.
182 pub checked_at: u64,
183}
184
185impl AudioFile {
186 /// Containing directory, the strongest grouping clue after the tags.
187 pub fn folder(&self) -> &str {
188 text::folder(&self.path)
189 }
190
191 /// Last path component, extension included.
192 pub fn file_name(&self) -> &str {
193 text::file_name(&self.path)
194 }
195
196 /// First value of a tag, for the many fields where repetition carries no
197 /// meaning and only the leading value is wanted.
198 pub fn first_tag(&self, key: &str) -> Option<&str> {
199 self.tags
200 .get(key)
201 .and_then(|v| v.first())
202 .map(|s| s.as_str())
203 }
204}
205
206/// Person or group. The person/group distinction will come from MusicBrainz.
207#[derive(Debug, Clone, Default)]
208pub struct Artist {
209 /// Position in [`Catalog::artists`]; credits and relations refer to it.
210 pub id: Id,
211 /// Name exactly as written in the tags, before normalization.
212 pub name: String,
213 /// Filing form, with a leading article moved to the end: `Beatles, The`.
214 pub sort_name: String,
215 /// Normalized matching key (see [`crate::text::normalize`]).
216 pub key: String,
217 /// MusicBrainz identifier, when one of the files carried it.
218 pub mbid: Option<String>,
219 /// Other spellings of this name, merged in because the files carrying them
220 /// carried the same MusicBrainz identifier.
221 ///
222 /// **Kept so the merge can be seen.** Two rows becoming one is a decision,
223 /// and a decision the reader cannot see is one they cannot check: a shelf
224 /// that lists `Ozzy Osbourne` and no longer lists `O. Osbourne` leaves them
225 /// unable to tell a merge that happened from a folder that was never
226 /// scanned. Normalised, since that is the form the merge was decided on,
227 /// and sorted so two runs over one library answer alike.
228 pub aliases: Vec<String>,
229}
230
231/// An album, in the sense of a "release": what MusicBrainz calls a *release*.
232#[derive(Debug, Clone, Default)]
233pub struct Release {
234 /// Position in [`Catalog::releases`]; tracks point back to it.
235 pub id: Id,
236 /// Album title as written in the tags.
237 pub title: String,
238 /// Normalized title, which is what makes two spellings meet.
239 pub key: String,
240 /// Album artist; absent for multi-artist compilations.
241 pub album_artist_id: Option<Id>,
242 /// Earliest date announced by the tracks, kept in its tagged form.
243 pub date: Option<String>,
244 /// Year read out of `date`, for sorting and for filtering by decade.
245 pub year: Option<u32>,
246 /// Labels that issued this edition, in the order the tags named them.
247 pub label_ids: Vec<Id>,
248 /// Reference printed on the sleeve, which tells two pressings apart.
249 pub catalog_number: Option<String>,
250 /// Commercial barcode of the physical edition.
251 pub barcode: Option<String>,
252 /// Medium the edition was issued on: `CD`, `Vinyl`, `Digital Media`…
253 pub media: Option<String>,
254 /// MusicBrainz identifier of this precise edition.
255 pub mbid: Option<String>,
256 /// MusicBrainz identifier of the work every edition of it shares.
257 pub release_group_mbid: Option<String>,
258 /// Canonical release group this edition belongs to, when tags identify it.
259 pub release_group_id: Option<Id>,
260 /// Set when the tags declare a compilation or name a placeholder album
261 /// artist; the same condition is what leaves `album_artist_id` empty.
262 pub is_compilation: bool,
263 /// Source folder: the most reliable grouping clue after the tags.
264 pub folder: String,
265 /// Cover art found next to the files, when none is embedded.
266 pub cover_path: Option<String>,
267 /// Its tracks, ordered by disc then by position once the build is done.
268 pub track_ids: Vec<Id>,
269}
270
271/// A work's release identity shared by its editions.
272///
273/// This is MusicBrainz's release group, not a title-based folder grouping:
274/// original pressings, remasters and regional editions meet here only when an
275/// explicit release-group identifier says they are the same musical release.
276#[derive(Debug, Clone, Default)]
277pub struct ReleaseGroup {
278 /// Position in [`Catalog::release_groups`].
279 pub id: Id,
280 /// Title from the first local edition carrying the identifier.
281 pub title: String,
282 /// Normalized title for display, never identity on its own.
283 pub key: String,
284 /// MusicBrainz release-group identifier.
285 pub mbid: String,
286 /// Local editions belonging to this group.
287 pub release_ids: Vec<Id>,
288}
289
290/// A local placement of a recording: one file at one position within a release.
291#[derive(Debug, Clone, Default)]
292pub struct Track {
293 /// Position in [`Catalog::tracks`].
294 pub id: Id,
295 /// File this track was read from; there is exactly one per track.
296 pub file_id: Id,
297 /// Release it belongs to; absent when the file carried no `album` tag.
298 pub release_id: Option<Id>,
299 /// Recording heard at this position.
300 ///
301 /// Every local placement names exactly one recording. Several placements
302 /// share it only when the files carry the same MusicBrainz recording ID;
303 /// a matching title alone is never evidence that two performances are one.
304 pub recording_id: Id,
305 /// Title as tagged, or rebuilt from the file name when the tag is missing.
306 pub title: String,
307 /// Disc within a multi-disc set; a missing value is ordered as the first.
308 pub disc_no: Option<u32>,
309 /// Position on the disc, taken from the tags or, failing that, inferred
310 /// from the digits opening the file name.
311 pub track_no: Option<u32>,
312 /// Playing time measured on the stream, not read from a tag.
313 pub duration_ms: Option<u64>,
314 /// Industry code identifying the recording across releases.
315 pub isrc: Option<String>,
316 /// MusicBrainz identifier of the recording.
317 pub mbid: Option<String>,
318}
319
320/// One recorded performance, independent of the local files and editions
321/// carrying it.
322///
323/// A recording without a MusicBrainz identifier starts as local to one track
324/// placement. It is deliberately not merged by title, duration or ISRC alone:
325/// the studio take, a live take and a cover often share some of those clues.
326#[derive(Debug, Clone, Default)]
327pub struct Recording {
328 /// Position in [`Catalog::recordings`].
329 pub id: Id,
330 /// Display title from the first local placement that identified it.
331 pub title: String,
332 /// Normalized title, useful for display searches but never identity alone.
333 pub key: String,
334 /// Industry code when the local placement carried one.
335 pub isrc: Option<String>,
336 /// MusicBrainz recording identifier, when local metadata supplied one.
337 pub mbid: Option<String>,
338 /// Every local placement carrying this recording.
339 pub track_ids: Vec<Id>,
340 /// Compositions this performance realizes, when explicitly identified.
341 pub work_ids: Vec<Id>,
342}
343
344/// A musical composition, distinct from every recording that realizes it.
345///
346/// An œuvre is created only from an explicit MusicBrainz work identifier. A
347/// title is descriptive, not identity: a cover, a live performance and an
348/// unrelated song may all share it.
349#[derive(Debug, Clone, Default)]
350pub struct Work {
351 /// Position in [`Catalog::works`].
352 pub id: Id,
353 /// Title supplied by the local work tag, or the recording title as a
354 /// display fallback when the identifier is known but no title is tagged.
355 pub title: String,
356 /// Normalized display/search key; never used to merge works.
357 pub key: String,
358 /// MusicBrainz work identifier.
359 pub mbid: String,
360 /// Recordings known to realize this composition.
361 pub recording_ids: Vec<Id>,
362}
363
364/// A record company, as named by the `label` tag.
365#[derive(Debug, Clone, Default)]
366pub struct Label {
367 /// Position in [`Catalog::labels`].
368 pub id: Id,
369 /// Name as written in the tags.
370 pub name: String,
371 /// Normalized key, so that spelling variants land on one label.
372 pub key: String,
373 /// MusicBrainz label identifier when local metadata explicitly carries it.
374 /// A fetched search result remains in the attributed source layer until a
375 /// later review accepts it; a name is never promoted into this field.
376 pub mbid: Option<String>,
377}
378
379/// A genre, interned once however many entities carry it.
380#[derive(Debug, Clone, Default)]
381pub struct Genre {
382 /// Position in [`Catalog::genres`].
383 pub id: Id,
384 /// Name as written in the tags.
385 pub name: String,
386 /// Normalized key, so that case and accent variants land on one genre.
387 pub key: String,
388}
389
390/// One attribute modifying a credit relationship.
391#[derive(Debug, Clone, PartialEq, Eq)]
392pub struct CreditAttribute {
393 /// Stable source identifier of the attribute type, when one exists.
394 pub id: Option<String>,
395 /// Stable attribute name, such as `guitar`, `additional` or `guest`.
396 pub name: String,
397 /// Structured value when the attribute type accepts one.
398 pub value: Option<String>,
399 /// Exact wording printed in the original credit.
400 pub credited_as: Option<String>,
401}
402
403/// "Who does what, on what". The heart of the graph.
404#[derive(Debug, Clone)]
405pub struct Credit {
406 /// The artist being credited.
407 pub artist_id: Id,
408 /// Which table `entity_id` indexes.
409 pub entity_kind: EntityKind,
410 /// The track or release the artist is credited on.
411 pub entity_id: Id,
412 /// `main`, `featured`, `composer`, `conductor`, `remixer`, `lyricist`…
413 ///
414 /// [`is_performing_role`] splits these in two: heard on the recording, or
415 /// merely behind it.
416 pub role: String,
417 /// Exact name used by the credit when it differs from the artist entity.
418 pub credited_as: Option<String>,
419 /// Instruments and qualifiers carried by the relationship itself.
420 pub attributes: Vec<CreditAttribute>,
421 /// Optional beginning of the credit's date span.
422 pub began: Option<String>,
423 /// Optional end of the credit's date span.
424 pub ended: Option<String>,
425 /// Explicit relationship order, when supplied.
426 pub order: Option<u32>,
427 /// Provenance, normally `tags` for catalog credits.
428 pub source: String,
429 /// Identifier of the source relationship, when one exists.
430 pub source_id: Option<String>,
431}
432
433/// A typed link between two canonical entities.
434///
435/// Structural links and summaries inferred from local tags live here. Rich
436/// dated external relationships stay in the attributed source layer, where
437/// their source identity and every relationship detail remain intact.
438#[derive(Debug, Clone)]
439pub struct Relation {
440 /// Which table `source_id` indexes.
441 pub source_kind: EntityKind,
442 /// Entity the link starts from.
443 pub source_id: Id,
444 /// Which table `target_id` indexes.
445 pub target_kind: EntityKind,
446 /// Entity the link reaches.
447 pub target_id: Id,
448 /// Nature of the link: `placement_of`, `performance_of`, `edition_of`,
449 /// `released_by`, `discography`, `credit:<role>`, `collaborated`…
450 pub kind: String,
451 /// Number of observed occurrences: used to rank links by strength.
452 pub weight: u32,
453 /// Provenance: `tags`, `musicbrainz`, `manual`.
454 pub source: String,
455}
456
457/// Attachment of a genre to an entity.
458#[derive(Debug, Clone)]
459pub struct GenreLink {
460 /// The genre being attached.
461 pub genre_id: Id,
462 /// Which table `entity_id` indexes.
463 pub entity_kind: EntityKind,
464 /// The entity the genre is attached to.
465 pub entity_id: Id,
466}
467
468/// Roles that contribute to the performance, as opposed to composing or
469/// producing it. A conductor shapes a performance without being audible.
470///
471/// The distinction drives the artist page: singing one guest verse on somebody
472/// else's album must not put that album in your discography.
473pub const PERFORMING_ROLES: &[&str] = &[
474 "main",
475 "album",
476 "featured",
477 "performer",
478 // MusicBrainz names many performance relationships by their
479 // relationship type rather than by the generic local `performer` tag.
480 "instrument",
481 "vocal",
482 "performing orchestra",
483 "orchestra",
484 "choir",
485 "ensemble",
486 "soloist",
487 "conductor",
488 "remixer",
489];
490
491/// `true` when the role belongs to the performance side of the credit graph.
492pub fn is_performing_role(role: &str) -> bool {
493 PERFORMING_ROLES.contains(&role)
494}
495
496/// The complete catalog.
497#[derive(Debug, Clone, Default)]
498pub struct Catalog {
499 /// Scanned root folders.
500 pub roots: Vec<String>,
501 /// Folders never read, however deep under a root they sit.
502 ///
503 /// Kept beside the roots rather than passed to each scan, because a plain
504 /// `aede scan` re-reads every root: an exclusion that had to be retyped
505 /// would be forgotten precisely when it mattered. Canonical, like the
506 /// roots, so the comparison against a walked path is the same string
507 /// comparison everywhere else in the program makes.
508 pub excluded: Vec<String>,
509 /// Date of the last scan (Unix epoch, seconds).
510 pub scanned_at: u64,
511 /// The files that were read, in the order the scan settled on.
512 pub files: Vec<AudioFile>,
513 /// Nanosecond fractions of file modification dates, keyed by native path.
514 ///
515 /// Stored as an optional `mtime_subseconds` field on each file row. Legacy
516 /// rows without that field need one fresh read before they can be reused;
517 /// guessing zero would miss real same-second changes. Keeping the fraction
518 /// apart from epoch seconds avoids losing precision in JSON numbers.
519 pub file_mtime_subseconds: BTreeMap<String, u32>,
520 /// First successful catalog observation by path, in Unix seconds.
521 /// Absent legacy dates remain unknown; file modification dates are not substitutes.
522 pub file_added_at: BTreeMap<String, u64>,
523 /// Deep analyses imported from another tool, one row per path and source.
524 ///
525 /// Optional by nature: a catalog without a single one works exactly the
526 /// same. They are kept apart from the files rather than merged into them,
527 /// so that a measurement always answers "who says so".
528 ///
529 /// Keyed by path, which means some of them may describe files the catalog
530 /// does not hold — a library analysed before it was ever scanned. Those
531 /// wait here until a scan brings the file in.
532 pub analyses: Vec<crate::analysis::FileAnalysis>,
533 /// Artists, one entry per normalized key.
534 pub artists: Vec<Artist>,
535 /// Releases, one entry per title, owner and folder.
536 pub releases: Vec<Release>,
537 /// Release groups shared by explicitly identified local editions.
538 pub release_groups: Vec<ReleaseGroup>,
539 /// Tracks, one entry per file that carried readable tags.
540 pub tracks: Vec<Track>,
541 /// Recorded performances, distinct from their local track placements.
542 pub recordings: Vec<Recording>,
543 /// Musical compositions explicitly linked to local recordings.
544 pub works: Vec<Work>,
545 /// Labels, one entry per normalized key.
546 pub labels: Vec<Label>,
547 /// Genres, one entry per normalized key.
548 pub genres: Vec<Genre>,
549 /// Every "who did what, on what" observation, without repeats.
550 pub credits: Vec<Credit>,
551 /// Links between entities; symmetric ones are stored in both directions.
552 pub relations: Vec<Relation>,
553 /// Attachments of genres to tracks and to releases.
554 pub genre_links: Vec<GenreLink>,
555}
556
557/// Fixtures shared by the tests of every sibling module.
558///
559/// One library described once: a catalog built by hand in four files would
560/// drift, and a test asserting on a different library than the one it names is
561/// worse than no test.
562#[cfg(test)]
563#[path = "mod_tests.rs"]
564pub(crate) mod tests;