1use std::collections::{BTreeMap, BTreeSet, HashMap};
15
16use crate::text;
17
18use super::{Catalog, EntityKind, Id, Relation, is_performing_role};
19
20pub const RELATION_RULES: u32 = 5;
29
30pub 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
41pub const DUPLICATE: &str = "duplicate";
43pub const OTHER_EDITION: &str = "other_edition";
45pub const PLACEMENT_OF: &str = "placement_of";
47pub const PLACED_AS: &str = "placed_as";
49pub const PART_OF_RELEASE: &str = "part_of_release";
51pub const HAS_TRACK: &str = "has_track";
53pub const PERFORMANCE_OF: &str = "performance_of";
55pub const HAS_RECORDING: &str = "has_recording";
57pub const EDITION_OF: &str = "edition_of";
59pub const HAS_EDITION: &str = "has_edition";
61pub const RELEASED_BY: &str = "released_by";
63pub const RELEASED: &str = "released";
65pub const ALBUM_BY: &str = "album_by";
67pub const DISCOGRAPHY: &str = "discography";
69pub const APPEARS_ON: &str = "appears_on";
71pub const COMPILATION_APPEARANCE: &str = "compilation_appearance";
73pub const CONTRIBUTED_TO: &str = "contributed_to";
75
76fn 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
237fn 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 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 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
303fn 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
321fn 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
344fn 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
375fn build_collaboration_graph(catalog: &mut Catalog) {
379 let mut per_track: HashMap<Id, BTreeSet<Id>> = HashMap::new();
380 for credit in &catalog.credits {
381 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 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;