1use std::collections::BTreeMap;
11
12use crate::model::{Catalog, CreditAttribute, EntityKind};
13use crate::sources::{
14 Confidence, ReviewDecision, Side, SourcedCreditLink, SourcedEditionCreditLink, Sources,
15};
16use crate::user::EntityRef;
17
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
24pub struct RelationRef {
25 pub source: EntityRef,
27 pub kind: String,
29 pub target: EntityRef,
31 pub provenance: String,
33 pub source_id: Option<String>,
35}
36
37impl RelationRef {
38 pub fn id(&self) -> String {
43 let mut hash = 0xcbf29ce484222325u64;
44 for part in [
45 self.source.to_token(),
46 self.kind.clone(),
47 self.target.to_token(),
48 self.provenance.clone(),
49 self.source_id.clone().unwrap_or_default(),
50 ] {
51 for byte in part.bytes().chain(std::iter::once(0)) {
52 hash ^= u64::from(byte);
53 hash = hash.wrapping_mul(0x100000001b3);
54 }
55 }
56 format!("{hash:016x}")
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct GraphEdge {
63 pub reference: RelationRef,
65 pub source_name: String,
67 pub target_name: String,
69 pub weight: u32,
71 pub trusted: bool,
73 pub excluded: bool,
75 pub confidence: Option<Confidence>,
77 pub review: Option<ReviewDecision>,
79 pub fetched_at: Option<u64>,
81 pub relationship_type_id: Option<String>,
83 pub direction: Option<String>,
85 pub credited_as: Option<String>,
87 pub attributes: Vec<CreditAttribute>,
89 pub began: Option<String>,
91 pub ended: Option<String>,
93 pub over: Option<bool>,
95 pub order: Option<u32>,
97}
98
99pub fn edges(catalog: &Catalog, sources: &Sources) -> Vec<GraphEdge> {
105 let mut edges = local_edges(catalog);
106 edges.extend(work_edges(catalog, sources));
107 edges.extend(work_parent_edges(catalog, sources));
108 edges.extend(credit_edges(catalog, sources));
109 edges.extend(edition_credit_edges(catalog, sources));
110 edges.extend(membership_edges(catalog, sources));
111 let mut by_reference: BTreeMap<RelationRef, GraphEdge> = BTreeMap::new();
112 for edge in edges {
113 match by_reference.entry(edge.reference.clone()) {
114 std::collections::btree_map::Entry::Vacant(entry) => {
115 entry.insert(edge);
116 }
117 std::collections::btree_map::Entry::Occupied(mut entry) => {
118 let previous = entry.get_mut();
119 let weight = previous.weight.saturating_add(edge.weight);
120 let priority = (edge.trusted, edge.fetched_at);
121 let previous_priority = (previous.trusted, previous.fetched_at);
122 if priority > previous_priority
123 || (priority == previous_priority
124 && (&edge.source_name, &edge.target_name)
125 < (&previous.source_name, &previous.target_name))
126 {
127 *previous = edge;
128 }
129 previous.weight = weight;
130 }
131 }
132 }
133 by_reference.into_values().collect()
134}
135
136fn local_edges(catalog: &Catalog) -> Vec<GraphEdge> {
137 catalog
138 .relations
139 .iter()
140 .filter_map(|relation| {
141 let source = EntityRef::of(catalog, relation.source_kind, relation.source_id)?;
142 let target = EntityRef::of(catalog, relation.target_kind, relation.target_id)?;
143 Some(GraphEdge {
144 source_name: source.display_name(catalog),
145 target_name: target.display_name(catalog),
146 reference: RelationRef {
147 source,
148 kind: relation.kind.clone(),
149 target,
150 provenance: relation.source.clone(),
151 source_id: None,
152 },
153 weight: relation.weight,
154 trusted: true,
155 excluded: false,
156 confidence: None,
157 review: None,
158 fetched_at: None,
159 relationship_type_id: None,
160 direction: None,
161 credited_as: None,
162 attributes: Vec::new(),
163 began: None,
164 ended: None,
165 over: None,
166 order: None,
167 })
168 })
169 .collect()
170}
171
172fn work_edges(catalog: &Catalog, sources: &Sources) -> Vec<GraphEdge> {
173 sources
174 .work_links(catalog)
175 .into_iter()
176 .filter_map(|link| {
177 let source = EntityRef::of(catalog, EntityKind::Recording, link.recording_id)?;
178 let source_name = source.display_name(catalog);
179 let target = EntityRef::new(EntityKind::Work, link.work.mbid.clone());
180 let target_name = if link.work.title.is_empty() {
181 link.work.mbid.clone()
182 } else {
183 link.work.title.clone()
184 };
185 Some(GraphEdge {
186 reference: RelationRef {
187 source,
188 kind: link
189 .work
190 .relation_type
191 .clone()
192 .unwrap_or_else(|| "performance_of".into()),
193 target,
194 provenance: link.source,
195 source_id: link.work.relation_id.clone(),
196 },
197 source_name,
198 target_name,
199 weight: 1,
200 trusted: link.trusted,
201 excluded: false,
202 confidence: Some(link.confidence),
203 review: link.review,
204 fetched_at: Some(link.fetched_at),
205 relationship_type_id: link.work.relation_type_id.clone(),
206 direction: link.work.direction.clone(),
207 credited_as: None,
208 attributes: link.work.attributes,
209 began: None,
210 ended: None,
211 over: None,
212 order: None,
213 })
214 })
215 .collect()
216}
217
218fn work_parent_edges(catalog: &Catalog, sources: &Sources) -> Vec<GraphEdge> {
219 sources
220 .work_parent_links(catalog)
221 .into_iter()
222 .map(|link| GraphEdge {
223 reference: RelationRef {
224 source: EntityRef::new(EntityKind::Work, link.child_mbid),
225 kind: "part_of_work".into(),
226 target: EntityRef::new(EntityKind::Work, link.parent.mbid.clone()),
227 provenance: link.source,
228 source_id: link.parent.relation_id.clone(),
229 },
230 source_name: link.child_title,
231 target_name: link.parent.title,
232 weight: 1,
233 trusted: link.trusted,
234 excluded: false,
235 confidence: Some(link.confidence),
236 review: link.review,
237 fetched_at: Some(link.fetched_at),
238 relationship_type_id: link.parent.relation_type_id,
239 direction: link.parent.direction,
240 credited_as: None,
241 attributes: link.parent.attributes,
242 began: None,
243 ended: None,
244 over: None,
245 order: link.parent.order,
246 })
247 .collect()
248}
249
250fn credit_edges(catalog: &Catalog, sources: &Sources) -> Vec<GraphEdge> {
251 sources
252 .credit_links(catalog)
253 .into_iter()
254 .filter_map(|link| {
255 let (source, source_name) =
256 artist_endpoint(catalog, &link.credit.artist_mbid, &link.credit.artist_name);
257 let (target, target_name) = match &link.work {
258 Some(work) => (
259 EntityRef::new(EntityKind::Work, work.mbid.clone()),
260 if work.title.is_empty() {
261 work.mbid.clone()
262 } else {
263 work.title.clone()
264 },
265 ),
266 None => {
267 let target = EntityRef::of(catalog, EntityKind::Recording, link.recording_id)?;
268 let name = target.display_name(catalog);
269 (target, name)
270 }
271 };
272 Some(GraphEdge {
273 reference: RelationRef {
274 source,
275 kind: format!("credit:{}", link.credit.role),
276 target,
277 provenance: link.source,
278 source_id: link.credit.relation_id.clone(),
279 },
280 source_name,
281 target_name,
282 weight: 1,
283 trusted: link.trusted,
284 excluded: link.excluded,
285 confidence: Some(link.confidence),
286 review: link.review,
287 fetched_at: Some(link.fetched_at),
288 relationship_type_id: link.credit.role_id.clone(),
289 direction: link.credit.direction.clone(),
290 credited_as: link.credit.credited_as.clone(),
291 attributes: link.credit.attributes.clone(),
292 began: link.credit.began.clone(),
293 ended: link.credit.ended.clone(),
294 over: link.credit.over,
295 order: link.credit.order,
296 })
297 })
298 .collect()
299}
300
301fn edition_credit_edges(catalog: &Catalog, sources: &Sources) -> Vec<GraphEdge> {
302 sources
303 .edition_credit_links(catalog)
304 .into_iter()
305 .filter_map(|link| {
306 let (source, source_name) =
307 artist_endpoint(catalog, &link.credit.artist_mbid, &link.credit.artist_name);
308 let target = EntityRef::of(catalog, EntityKind::Release, link.release_id)?;
309 let target_name = target.display_name(catalog);
310 Some(GraphEdge {
311 reference: RelationRef {
312 source,
313 kind: format!("credit:{}", link.credit.role),
314 target,
315 provenance: link.source,
316 source_id: link.credit.relation_id,
317 },
318 source_name,
319 target_name,
320 weight: 1,
321 trusted: link.trusted,
322 excluded: link.excluded,
323 confidence: Some(link.confidence),
324 review: link.review,
325 fetched_at: Some(link.fetched_at),
326 relationship_type_id: link.credit.role_id,
327 direction: link.credit.direction,
328 credited_as: link.credit.credited_as,
329 attributes: link.credit.attributes,
330 began: link.credit.began,
331 ended: link.credit.ended,
332 over: link.credit.over,
333 order: link.credit.order,
334 })
335 })
336 .collect()
337}
338
339fn membership_edges(catalog: &Catalog, sources: &Sources) -> Vec<GraphEdge> {
340 sources
341 .membership_links(catalog)
342 .into_iter()
343 .filter_map(|link| {
344 let local = EntityRef::of(catalog, EntityKind::Artist, link.artist_id)?;
345 let local_name = local.display_name(catalog);
346 let (related, related_name) = link
347 .related_artist_id
348 .and_then(|id| {
349 let reference = EntityRef::of(catalog, EntityKind::Artist, id)?;
350 let name = reference.display_name(catalog);
351 Some((reference, name))
352 })
353 .unwrap_or_else(|| {
354 (
355 EntityRef::new(
356 EntityKind::Artist,
357 format!("mbid:{}", link.membership.mbid),
358 ),
359 link.membership.name.clone(),
360 )
361 });
362 let (source, source_name, target, target_name) = match link.membership.side {
363 Side::Player => (related, related_name, local, local_name),
364 Side::Group => (local, local_name, related, related_name),
365 };
366 Some(GraphEdge {
367 reference: RelationRef {
368 source,
369 kind: format!("membership:{}", link.membership.kind),
370 target,
371 provenance: link.source,
372 source_id: link.membership.relation_id.clone(),
373 },
374 source_name,
375 target_name,
376 weight: 1,
377 trusted: link.trusted,
378 excluded: false,
379 confidence: Some(link.confidence),
380 review: link.review,
381 fetched_at: Some(link.fetched_at),
382 relationship_type_id: link.membership.role_id.clone(),
383 direction: link.membership.direction.clone(),
384 credited_as: link.membership.credited_as.clone(),
385 attributes: link
386 .membership
387 .attributes
388 .iter()
389 .map(|name| CreditAttribute {
390 id: None,
391 name: name.clone(),
392 value: None,
393 credited_as: None,
394 })
395 .collect(),
396 began: link.membership.began.clone(),
397 ended: link.membership.ended.clone(),
398 over: link.membership.over,
399 order: None,
400 })
401 })
402 .collect()
403}
404
405pub(crate) fn credit_reference(catalog: &Catalog, link: &SourcedCreditLink) -> Option<RelationRef> {
407 let (source, _) = artist_endpoint(catalog, &link.credit.artist_mbid, &link.credit.artist_name);
408 let target = match &link.work {
409 Some(work) => EntityRef::new(EntityKind::Work, work.mbid.clone()),
410 None => EntityRef::of(catalog, EntityKind::Recording, link.recording_id)?,
411 };
412 Some(RelationRef {
413 source,
414 kind: format!("credit:{}", link.credit.role),
415 target,
416 provenance: link.source.clone(),
417 source_id: link.credit.relation_id.clone(),
418 })
419}
420
421pub(crate) fn edition_credit_reference(
423 catalog: &Catalog,
424 link: &SourcedEditionCreditLink,
425) -> Option<RelationRef> {
426 let (source, _) = artist_endpoint(catalog, &link.credit.artist_mbid, &link.credit.artist_name);
427 Some(RelationRef {
428 source,
429 kind: format!("credit:{}", link.credit.role),
430 target: EntityRef::of(catalog, EntityKind::Release, link.release_id)?,
431 provenance: link.source.clone(),
432 source_id: link.credit.relation_id.clone(),
433 })
434}
435
436pub(crate) fn same_credit_relation(
437 catalog: &Catalog,
438 held: &RelationRef,
439 current: &RelationRef,
440) -> bool {
441 let same_endpoint = |left: &EntityRef, right: &EntityRef| {
442 left == right
443 || (left.kind == right.kind
444 && left
445 .resolve(catalog)
446 .is_some_and(|id| right.resolve(catalog) == Some(id)))
447 };
448 held.kind == current.kind
449 && held.provenance == current.provenance
450 && held.source_id == current.source_id
451 && same_endpoint(&held.source, ¤t.source)
452 && same_endpoint(&held.target, ¤t.target)
453}
454
455pub(crate) fn canonical_credit_relation(
456 catalog: &Catalog,
457 mut relation: RelationRef,
458) -> RelationRef {
459 if relation.source.kind == EntityKind::Artist
460 && let Some(artist_id) = relation.source.resolve(catalog)
461 && let Some(mbid) = catalog
462 .artist(artist_id)
463 .and_then(|artist| artist.mbid.as_deref())
464 .filter(|mbid| !mbid.trim().is_empty())
465 {
466 let canonical = EntityRef::new(EntityKind::Artist, format!("mbid:{mbid}"));
467 if canonical.resolve(catalog) == Some(artist_id) {
469 relation.source = canonical;
470 }
471 }
472 relation
473}
474
475fn artist_endpoint(catalog: &Catalog, mbid: &str, name: &str) -> (EntityRef, String) {
476 if mbid.is_empty() {
477 if let Some(artist) = catalog
478 .artists
479 .iter()
480 .find(|artist| artist.key == crate::text::normalize(name))
481 && let Some(reference) = EntityRef::of(catalog, EntityKind::Artist, artist.id)
482 {
483 return (reference, artist.name.clone());
484 }
485 return (
486 EntityRef::new(
487 EntityKind::Artist,
488 format!("manual:{}", crate::text::normalize(name)),
489 ),
490 name.to_string(),
491 );
492 }
493 if let Some(artist) = catalog
494 .artists
495 .iter()
496 .find(|artist| artist.mbid.as_deref() == Some(mbid))
497 && let Some(reference) = EntityRef::of(catalog, EntityKind::Artist, artist.id)
498 {
499 return (reference, artist.name.clone());
500 }
501 (
502 EntityRef::new(EntityKind::Artist, format!("mbid:{mbid}")),
503 name.to_string(),
504 )
505}
506
507pub fn select(edges: &[GraphEdge], selector: &str) -> Result<GraphEdge, String> {
509 let selector = selector.trim().to_ascii_lowercase();
510 if selector.is_empty() {
511 return Err("a relation ID is required; run aede relations to list them".into());
512 }
513 let mut found = edges
514 .iter()
515 .filter(|edge| edge.reference.id().starts_with(&selector));
516 let Some(edge) = found.next() else {
517 return Err(format!(
518 "no relation starts with \"{selector}\"; run aede relations for the current list"
519 ));
520 };
521 if found.next().is_some() {
522 return Err(format!(
523 "relation ID \"{selector}\" is ambiguous; copy more characters from aede relations"
524 ));
525 }
526 Ok(edge.clone())
527}
528
529#[cfg(test)]
530#[path = "graph_tests.rs"]
531mod tests;