aede_core/model/
relations.rs

1//! Deriving the typed links between entities.
2//!
3//! Relations are **inferred**, never read from a file: they follow from the
4//! credits and from the track lists. That is what [`RELATION_RULES`] versions,
5//! and what lets a stored catalog rebuild them on load without touching the
6//! disk — the reason the raw tags are kept per file in the first place.
7//!
8//! Structural links are derived alongside the two original inferred families:
9//! placements, recordings, works, editions, release groups, labels and album
10//! artists can all be traversed in either direction. Artist participation is
11//! derived from credits without copying credit details into the relation row.
12//! Collaborations and duplicate/other-edition links remain weighted summaries.
13
14use std::collections::{BTreeMap, BTreeSet, HashMap};
15
16use crate::text;
17
18use super::{Catalog, EntityKind, Id, Relation, is_performing_role};
19
20/// Version of the rules that derive the `relation` table.
21///
22/// The relations are **inferred**, not read: they follow from the credits and
23/// from the track lists. Changing how they are inferred makes a stored catalog
24/// out of date without making it invalid, which is why this is not
25/// `store::FORMAT_VERSION` — refusing to load would be out of proportion, and
26/// would throw away integrity verdicts that cost hours to obtain. Bump this
27/// instead, and every catalog rebuilds its relations on the next load.
28pub const RELATION_RULES: u32 = 5;
29
30/// Recomputes every inferred relation from the entities already in place.
31///
32/// Needs no disk access: the credits and the tracks hold everything. That is
33/// exactly what keeping the raw tags per file was for.
34pub fn rebuild_relations(catalog: &mut Catalog) {
35    catalog.relations.clear();
36    build_entity_graph(catalog);
37    build_collaboration_graph(catalog);
38    build_release_relations(catalog);
39}
40
41/// Names of the two relations that tie one album to another copy of itself.
42pub const DUPLICATE: &str = "duplicate";
43/// Same album, encoded differently: a deliberate second copy.
44pub const OTHER_EDITION: &str = "other_edition";
45/// A local track placement points to the abstract recording it carries.
46pub const PLACEMENT_OF: &str = "placement_of";
47/// Reverse of [`PLACEMENT_OF`].
48pub const PLACED_AS: &str = "placed_as";
49/// A local track belongs to one edition.
50pub const PART_OF_RELEASE: &str = "part_of_release";
51/// Reverse of [`PART_OF_RELEASE`].
52pub const HAS_TRACK: &str = "has_track";
53/// A recording realizes a musical work.
54pub const PERFORMANCE_OF: &str = "performance_of";
55/// Reverse of [`PERFORMANCE_OF`].
56pub const HAS_RECORDING: &str = "has_recording";
57/// A release is one edition of a release group.
58pub const EDITION_OF: &str = "edition_of";
59/// Reverse of [`EDITION_OF`].
60pub const HAS_EDITION: &str = "has_edition";
61/// A release was issued by a label.
62pub const RELEASED_BY: &str = "released_by";
63/// Reverse of [`RELEASED_BY`].
64pub const RELEASED: &str = "released";
65/// A release is primarily credited to an album artist.
66pub const ALBUM_BY: &str = "album_by";
67/// Reverse of [`ALBUM_BY`].
68pub const DISCOGRAPHY: &str = "discography";
69/// A guest performer appears on another artist's release.
70pub const APPEARS_ON: &str = "appears_on";
71/// A performer appears on a compilation.
72pub const COMPILATION_APPEARANCE: &str = "compilation_appearance";
73/// A non-performing credit contributes to a release.
74pub const CONTRIBUTED_TO: &str = "contributed_to";
75
76/// Adds the structural and credit-derived edges of the local graph.
77fn build_entity_graph(catalog: &mut Catalog) {
78    let mut links: Vec<(EntityKind, Id, EntityKind, Id, String, u32)> = Vec::new();
79    let mut pair = |a_kind, a, forward: &str, b_kind, b, backward: &str| {
80        links.push((a_kind, a, b_kind, b, forward.to_string(), 1));
81        links.push((b_kind, b, a_kind, a, backward.to_string(), 1));
82    };
83
84    for track in &catalog.tracks {
85        pair(
86            EntityKind::Track,
87            track.id,
88            PLACEMENT_OF,
89            EntityKind::Recording,
90            track.recording_id,
91            PLACED_AS,
92        );
93        if let Some(release_id) = track.release_id {
94            pair(
95                EntityKind::Track,
96                track.id,
97                PART_OF_RELEASE,
98                EntityKind::Release,
99                release_id,
100                HAS_TRACK,
101            );
102        }
103    }
104    for recording in &catalog.recordings {
105        for &work_id in &recording.work_ids {
106            pair(
107                EntityKind::Recording,
108                recording.id,
109                PERFORMANCE_OF,
110                EntityKind::Work,
111                work_id,
112                HAS_RECORDING,
113            );
114        }
115    }
116    for release in &catalog.releases {
117        if let Some(group_id) = release.release_group_id {
118            pair(
119                EntityKind::Release,
120                release.id,
121                EDITION_OF,
122                EntityKind::ReleaseGroup,
123                group_id,
124                HAS_EDITION,
125            );
126        }
127        for &label_id in &release.label_ids {
128            pair(
129                EntityKind::Release,
130                release.id,
131                RELEASED_BY,
132                EntityKind::Label,
133                label_id,
134                RELEASED,
135            );
136        }
137        if let Some(artist_id) = release.album_artist_id {
138            pair(
139                EntityKind::Release,
140                release.id,
141                ALBUM_BY,
142                EntityKind::Artist,
143                artist_id,
144                DISCOGRAPHY,
145            );
146        }
147    }
148
149    let mut recording_credits: BTreeMap<(Id, Id, String), u32> = BTreeMap::new();
150    let mut release_participations: BTreeMap<(Id, Id, &'static str), BTreeSet<Id>> =
151        BTreeMap::new();
152    for credit in &catalog.credits {
153        if credit.entity_kind != EntityKind::Track {
154            continue;
155        }
156        let Some(track) = catalog.track(credit.entity_id) else {
157            continue;
158        };
159        *recording_credits
160            .entry((credit.artist_id, track.recording_id, credit.role.clone()))
161            .or_insert(0) += 1;
162        let Some(release_id) = track.release_id else {
163            continue;
164        };
165        let Some(release) = catalog.release(release_id) else {
166            continue;
167        };
168        let kind = if is_performing_role(&credit.role) {
169            if release.album_artist_id == Some(credit.artist_id) {
170                continue;
171            }
172            if release.is_compilation {
173                COMPILATION_APPEARANCE
174            } else {
175                APPEARS_ON
176            }
177        } else {
178            CONTRIBUTED_TO
179        };
180        release_participations
181            .entry((credit.artist_id, release_id, kind))
182            .or_default()
183            .insert(track.id);
184    }
185    for ((artist_id, recording_id, role), weight) in recording_credits {
186        let kind = format!("credit:{role}");
187        links.push((
188            EntityKind::Artist,
189            artist_id,
190            EntityKind::Recording,
191            recording_id,
192            kind.clone(),
193            weight,
194        ));
195        links.push((
196            EntityKind::Recording,
197            recording_id,
198            EntityKind::Artist,
199            artist_id,
200            kind,
201            weight,
202        ));
203    }
204    for ((artist_id, release_id, kind), tracks) in release_participations {
205        let weight = tracks.len() as u32;
206        links.push((
207            EntityKind::Artist,
208            artist_id,
209            EntityKind::Release,
210            release_id,
211            kind.to_string(),
212            weight,
213        ));
214        links.push((
215            EntityKind::Release,
216            release_id,
217            EntityKind::Artist,
218            artist_id,
219            kind.to_string(),
220            weight,
221        ));
222    }
223
224    catalog.relations.extend(links.into_iter().map(
225        |(source_kind, source_id, target_kind, target_id, kind, weight)| Relation {
226            source_kind,
227            source_id,
228            target_kind,
229            target_id,
230            kind,
231            weight,
232            source: "tags".into(),
233        },
234    ));
235}
236
237/// Links the releases that are the same album twice.
238///
239/// The same album legitimately appears twice in a library — a hi-res copy
240/// beside the CD rip, a FLAC beside the MP3 for the car — and illegitimately
241/// too, when a folder was copied and forgotten. The model keeps them as two
242/// releases either way, because they *are* two sets of files in two folders and
243/// that is what one needs to act on. What was missing is the link between them,
244/// and the reason for it.
245///
246/// The two are told apart by their audio, not by their folder: same album
247/// artist, same title, same track list, and then
248///
249/// - the same quality for each corresponding track — nothing distinguishes the
250///   copies, and one of them is wasted space;
251/// - a different quality — the second copy is there on purpose.
252///
253/// Different disc positions, track positions or titles leave the releases
254/// unlinked. Recognizing a deluxe edition with bonus tracks needs stronger
255/// evidence than the local tags currently supply.
256fn build_release_relations(catalog: &mut Catalog) {
257    let mut groups: BTreeMap<(Option<Id>, String), Vec<Id>> = BTreeMap::new();
258    for release in &catalog.releases {
259        groups
260            .entry((release.album_artist_id, release.key.clone()))
261            .or_default()
262            .push(release.id);
263    }
264
265    let mut links: Vec<(Id, Id, &'static str)> = Vec::new();
266    for ids in groups.values().filter(|ids| ids.len() > 1) {
267        for (i, &left) in ids.iter().enumerate() {
268            for &right in &ids[i + 1..] {
269                // Two albums sharing a name are not necessarily the same
270                // album: without a matching track list there is nothing
271                // reliable to say, and MusicBrainz will settle it at M1.
272                if !same_track_list(catalog, left, right) {
273                    continue;
274                }
275                let kind =
276                    if quality_fingerprint(catalog, left) == quality_fingerprint(catalog, right) {
277                        DUPLICATE
278                    } else {
279                        OTHER_EDITION
280                    };
281                links.push((left, right, kind));
282            }
283        }
284    }
285
286    for (left, right, kind) in links {
287        // Symmetric, like the collaboration graph: navigation works from
288        // either side.
289        for (source, target) in [(left, right), (right, left)] {
290            catalog.relations.push(Relation {
291                source_kind: EntityKind::Release,
292                source_id: source,
293                target_kind: EntityKind::Release,
294                target_id: target,
295                kind: kind.to_string(),
296                weight: 1,
297                source: "tags".into(),
298            });
299        }
300    }
301}
302
303/// `true` when two releases hold the same tracks.
304///
305/// Disc and track positions and normalised titles have to match exactly;
306/// positive, known durations only have to be **close**. Two rips of one disc differ by a few
307/// hundred milliseconds, and a transcode to a lossy format shifts the end of a
308/// track further still — but a
309/// live rendition of the same song differs by minutes. Three seconds is the
310/// tolerance the duplicate-track check uses, for the same reason.
311fn same_track_list(catalog: &Catalog, left: Id, right: Id) -> bool {
312    let (left, right) = (track_list(catalog, left), track_list(catalog, right));
313    left.len() == right.len() && left.iter().zip(right.iter()).all(|(a, b)| {
314        a.0 == b.0
315            && a.1 == b.1
316            && a.2 == b.2
317            && matches!((a.3, b.3), (Some(a), Some(b)) if a > 0 && b > 0 && a.abs_diff(b) <= 3_000)
318    })
319}
320
321/// Disc and track positions, titles and durations of a release, in a comparable
322/// order.
323fn track_list(catalog: &Catalog, release_id: Id) -> Vec<(u32, u32, String, Option<u64>)> {
324    let Some(release) = catalog.release(release_id) else {
325        return Vec::new();
326    };
327    let mut out: Vec<_> = release
328        .track_ids
329        .iter()
330        .filter_map(|&id| catalog.track(id))
331        .map(|t| {
332            (
333                t.disc_no.unwrap_or(1),
334                t.track_no.unwrap_or(0),
335                text::normalize(&t.title),
336                t.duration_ms,
337            )
338        })
339        .collect();
340    out.sort();
341    out
342}
343
344/// How each corresponding track is encoded, which is what separates a wasted
345/// copy from a second one kept on purpose. Keeping positions and multiplicity
346/// avoids equating two releases merely because they contain the same set of
347/// encodings, attached to different tracks.
348/// The display label omits channels, so compare that source property separately.
349fn quality_fingerprint(
350    catalog: &Catalog,
351    release_id: Id,
352) -> Vec<(u32, u32, String, String, Option<u16>)> {
353    let Some(release) = catalog.release(release_id) else {
354        return Vec::new();
355    };
356    let mut qualities: Vec<_> = release
357        .track_ids
358        .iter()
359        .filter_map(|&id| catalog.track(id))
360        .filter_map(|track| catalog.file(track.file_id).map(|file| (track, file)))
361        .map(|(track, file)| {
362            (
363                track.disc_no.unwrap_or(1),
364                track.track_no.unwrap_or(0),
365                text::normalize(&track.title),
366                file.properties.quality_label(),
367                file.properties.channels,
368            )
369        })
370        .collect();
371    qualities.sort();
372    qualities
373}
374
375/// Two artists credited on the same track are considered to have
376/// collaborated. The weight counts the shared tracks: that is what allows
377/// ranking by "played the most with".
378fn build_collaboration_graph(catalog: &mut Catalog) {
379    let mut per_track: HashMap<Id, BTreeSet<Id>> = HashMap::new();
380    for credit in &catalog.credits {
381        // Only performers: sharing a composer does not mean two artists ever
382        // met, let alone played together.
383        if credit.entity_kind == EntityKind::Track && is_performing_role(&credit.role) {
384            per_track
385                .entry(credit.entity_id)
386                .or_default()
387                .insert(credit.artist_id);
388        }
389    }
390
391    let mut weights: BTreeMap<(Id, Id), u32> = BTreeMap::new();
392    for artists in per_track.values() {
393        let list: Vec<Id> = artists.iter().copied().collect();
394        for (i, &a) in list.iter().enumerate() {
395            for &b in &list[i + 1..] {
396                *weights.entry((a, b)).or_insert(0) += 1;
397            }
398        }
399    }
400
401    for ((a, b), weight) in weights {
402        // The relation is symmetric: it is stored in both directions so that
403        // navigation is direct from either side.
404        catalog.relations.push(Relation {
405            source_kind: EntityKind::Artist,
406            source_id: a,
407            target_kind: EntityKind::Artist,
408            target_id: b,
409            kind: "collaborated".into(),
410            weight,
411            source: "tags".into(),
412        });
413        catalog.relations.push(Relation {
414            source_kind: EntityKind::Artist,
415            source_id: b,
416            target_kind: EntityKind::Artist,
417            target_id: a,
418            kind: "collaborated".into(),
419            weight,
420            source: "tags".into(),
421        });
422    }
423}
424
425#[cfg(test)]
426#[path = "relations_tests.rs"]
427mod tests;