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;