suno_core/graph/store.rs
1//! The [`LineageStore`] container: the relational archive plus its query and
2//! ingest logic. [`LineageStore::update`] is the sole mutator; the store
3//! takes the wall clock as a `now` string so it stays free of IO.
4
5use std::collections::btree_map::Iter;
6use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
7
8use serde::{Deserialize, Serialize};
9
10use crate::album_art::{AlbumArt, PlaylistState};
11use crate::identity::Owner;
12use crate::lineage::{
13 AttributionEdge, Edge, EdgeRole, EdgeType, LineageContext, Resolution, ResolveStatus, RootInfo,
14 attribution_edges, immediate_parent, lineage_edges,
15};
16use crate::model::Clip;
17
18use super::node::{CacheEntry, EdgeStatus, Node, StoredEdge};
19
20/// The whole lineage graph, kept relational for a clean SQLite migration.
21///
22/// `nodes` and `resolution_cache` are [`BTreeMap`]s and `edges` is sorted after
23/// every [`update`](LineageStore::update), so serialisation is deterministic.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(default)]
26pub struct LineageStore {
27 /// On-disk schema version, so a future migration can branch on it.
28 pub schema_version: u32,
29 /// Every clip ever seen (including trashed ancestors), keyed by clip id.
30 pub(crate) nodes: BTreeMap<String, Node>,
31 /// Every observed parent link, as a flat relational list.
32 pub(crate) edges: Vec<StoredEdge>,
33 /// The last resolved (or last-known) root per clip, keyed by clip id.
34 pub(crate) resolution_cache: BTreeMap<String, CacheEntry>,
35 /// The reconciled folder-art state per album, keyed by the album's stable
36 /// root id (HARDENING H2). Additive: absent in older stores. `pub` so the
37 /// CLI executor and reconcile can borrow it across the crate boundary.
38 pub albums: BTreeMap<String, AlbumArt>,
39 /// The reconciled `.m3u8` state per playlist, keyed by the playlist's Suno
40 /// id (the synthetic `"liked"` id for the liked feed). Additive: absent in
41 /// older stores. `pub` for the same cross-crate borrow as `albums`.
42 pub playlists: BTreeMap<String, PlaylistState>,
43 /// The Suno account this library is pinned to (trust-on-first-use). Absent
44 /// in older stores and in a fresh library until the first run adopts it.
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub(crate) owner: Option<Owner>,
47 /// Manual album-name overrides, keyed by lineage root id, layered from
48 /// config each run (see [`set_album_overrides`]). Runtime-only (never
49 /// serialised), so it can never persist into the durable graph or outlive
50 /// its config entry.
51 ///
52 /// [`set_album_overrides`]: LineageStore::set_album_overrides
53 #[serde(skip)]
54 pub(crate) album_overrides: BTreeMap<String, String>,
55 /// Root ids eligible for an album name (override or collision suffix): every
56 /// non-empty `root_id` appearing as a *value* in
57 /// [`resolution_cache`](Self::resolution_cache). Both override-application
58 /// ([`effective_root_title`]) and collision detection
59 /// ([`colliding_root_titles`]) draw from this one set, so they can never
60 /// disagree. Runtime-only, refreshed by [`refresh_eligible_roots`].
61 ///
62 /// [`effective_root_title`]: LineageStore::effective_root_title
63 /// [`colliding_root_titles`]: LineageStore::colliding_root_titles
64 /// [`refresh_eligible_roots`]: LineageStore::refresh_eligible_roots
65 #[serde(skip)]
66 eligible_root_ids: HashSet<String>,
67 /// Runtime index from edge identity to its row in `edges`, rebuilt from the
68 /// vector and kept in sync so upserts are O(1) without changing on-disk
69 /// shape.
70 #[serde(skip)]
71 edge_index: HashMap<EdgeKey, usize>,
72}
73
74impl Default for LineageStore {
75 fn default() -> Self {
76 Self {
77 schema_version: 1,
78 nodes: BTreeMap::new(),
79 edges: Vec::new(),
80 resolution_cache: BTreeMap::new(),
81 albums: BTreeMap::new(),
82 playlists: BTreeMap::new(),
83 owner: None,
84 album_overrides: BTreeMap::new(),
85 eligible_root_ids: HashSet::new(),
86 edge_index: HashMap::new(),
87 }
88 }
89}
90
91/// Equality over the durable graph only.
92///
93/// `album_overrides` and `eligible_root_ids` are runtime-only overlays
94/// (`#[serde(skip)]`): the first is layered from config each run, the second is
95/// a cache derived from `resolution_cache`. Neither is part of the persisted
96/// relational shape, so two stores are equal when their durable content is,
97/// regardless of the overrides in force or whether the derived set has been
98/// refreshed after a load.
99impl PartialEq for LineageStore {
100 fn eq(&self, other: &Self) -> bool {
101 self.schema_version == other.schema_version
102 && self.nodes == other.nodes
103 && self.edges == other.edges
104 && self.resolution_cache == other.resolution_cache
105 && self.albums == other.albums
106 && self.playlists == other.playlists
107 && self.owner == other.owner
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Hash)]
112struct EdgeKey {
113 child_id: String,
114 parent_id: String,
115 edge_type: String,
116 role: EdgeRole,
117 ordinal: u32,
118}
119
120impl EdgeKey {
121 fn new(child_id: &str, parent_id: &str, edge_type: &str, role: EdgeRole, ordinal: u32) -> Self {
122 Self {
123 child_id: child_id.to_owned(),
124 parent_id: parent_id.to_owned(),
125 edge_type: edge_type.to_owned(),
126 role,
127 ordinal,
128 }
129 }
130
131 fn from_stored(edge: &StoredEdge) -> Self {
132 Self::new(
133 &edge.child_id,
134 &edge.parent_id,
135 &edge.edge_type,
136 edge.role,
137 edge.ordinal,
138 )
139 }
140}
141
142impl LineageStore {
143 /// Create an empty store at the current schema version.
144 pub fn new() -> Self {
145 Self::default()
146 }
147
148 /// Layer this run's manual album-name overrides onto the store.
149 ///
150 /// Keyed by lineage root id, from config each run and never persisted
151 /// (`#[serde(skip)]`). Applied wherever the album title is resolved
152 /// ([`context_for`], [`album_for_id`], [`colliding_root_titles`]), so one
153 /// call makes the path, `ALBUM` tag, change hash, index, and disambiguation
154 /// all reflect the preferred name from one source.
155 ///
156 /// [`context_for`]: LineageStore::context_for
157 /// [`album_for_id`]: LineageStore::album_for_id
158 /// [`colliding_root_titles`]: LineageStore::colliding_root_titles
159 pub fn set_album_overrides(&mut self, overrides: BTreeMap<String, String>) {
160 self.album_overrides = overrides;
161 }
162
163 /// The effective album title for a lineage root: the manual override when one
164 /// is configured for `root_id` AND that root is eligible (see
165 /// [`eligible_root_ids`]), otherwise the derived `root_title`. The single
166 /// point where an override supplants the derived name.
167 ///
168 /// Gating on the eligible set — exactly the roots [`colliding_root_titles`]
169 /// groups over — means an override is never applied to a root that collision
170 /// detection cannot suffix, which would otherwise let two distinct roots
171 /// share one album folder. An uncached fallback self-root on a
172 /// resolution-failed run is not overridden; it folders under its own derived
173 /// title and converges once the root resolves. Safe degraded behaviour: a
174 /// transient miss can never collapse two albums onto one path.
175 ///
176 /// [`eligible_root_ids`]: Self::eligible_root_ids
177 /// [`colliding_root_titles`]: LineageStore::colliding_root_titles
178 fn effective_root_title(&self, root_id: &str, root_title: String) -> String {
179 if !self.eligible_root_ids.contains(root_id) {
180 return root_title;
181 }
182 match self.album_overrides.get(root_id) {
183 Some(name) if !name.trim().is_empty() => name.clone(),
184 _ => root_title,
185 }
186 }
187
188 /// Recompute the eligible-root set from the resolution cache.
189 ///
190 /// The non-empty `root_id`s across the cache's values — exactly what
191 /// [`colliding_root_titles`] groups over. Called at the end of [`update`] and
192 /// after a load (the field is not serialised).
193 ///
194 /// [`colliding_root_titles`]: LineageStore::colliding_root_titles
195 /// [`update`]: LineageStore::update
196 pub fn refresh_eligible_roots(&mut self) {
197 self.eligible_root_ids = self
198 .resolution_cache
199 .values()
200 .map(|entry| entry.root_id.as_str())
201 .filter(|root_id| !root_id.is_empty())
202 .map(str::to_owned)
203 .collect();
204 }
205
206 /// The eligible-root set, for tests that assert override-application and
207 /// collision detection share one domain.
208 #[cfg(test)]
209 pub(crate) fn eligible_root_ids_for_test(&self) -> &HashSet<String> {
210 &self.eligible_root_ids
211 }
212
213 /// The node for `id`, if present.
214 pub fn node(&self, id: &str) -> Option<&Node> {
215 self.nodes.get(id)
216 }
217
218 /// The cached root resolution for `id`, if present.
219 pub fn get_root(&self, id: &str) -> Option<&CacheEntry> {
220 self.resolution_cache.get(id)
221 }
222
223 /// Resolve a clip's lineage root anchor — `(root_id, root_title, root_date)`
224 /// — from the durable store, falling back to the supplied own identity when
225 /// the cache or the archived root node has no better answer.
226 ///
227 /// The single source the live-clip [`context_for`](Self::context_for) and the
228 /// clip-less [`album_for_id`](Self::album_for_id) share, so the two can never
229 /// drift on how a root is anchored. Each caller passes its own fallback
230 /// identity (a live [`Clip`]'s fields, or the archived node's stored values),
231 /// keeping that difference explicit rather than folded away.
232 fn root_anchor(
233 &self,
234 own_id: &str,
235 own_title: &str,
236 own_date: &str,
237 ) -> (String, String, String) {
238 let root_id = self
239 .get_root(own_id)
240 .map(|entry| entry.root_id.clone())
241 .filter(|id| !id.is_empty())
242 .unwrap_or_else(|| own_id.to_owned());
243 let root_title = self
244 .node(&root_id)
245 .map(|node| node.title.clone())
246 .unwrap_or_else(|| own_title.to_owned());
247 let root_title = self.effective_root_title(&root_id, root_title);
248 let root_date = self
249 .node(&root_id)
250 .map(|node| node.created_at.clone())
251 .unwrap_or_else(|| own_date.to_owned());
252 (root_id, root_title, root_date)
253 }
254
255 /// Build a [`LineageContext`] for `clip` from the durable store.
256 ///
257 /// The source of truth for every file-affecting lineage decision (album
258 /// folder, embedded tags, change hash), so a dropped resolution call never
259 /// rewrites the library (HARDENING H3). The root comes from the monotonic
260 /// cache (the clip's own id when the store has no better answer), its title
261 /// and date from that root's archived node, so a transient miss keeps the
262 /// last-known-good album (even a since-purged ancestor) and the Year tag
263 /// anchors on the root's year. The parent edge is read structurally.
264 pub fn context_for(&self, clip: &Clip) -> LineageContext {
265 let (root_id, root_title, root_date) =
266 self.root_anchor(&clip.id, &clip.title, &clip.created_at);
267 let (parent_id, edge_type) = match immediate_parent(clip) {
268 Some((id, edge)) => (id, Some(edge)),
269 None => (String::new(), None),
270 };
271 let status = self
272 .get_root(&clip.id)
273 .map(|entry| entry.status)
274 .unwrap_or(ResolveStatus::Resolved);
275 LineageContext {
276 root_id,
277 root_title,
278 root_date,
279 parent_id,
280 edge_type,
281 status,
282 track: 0,
283 track_total: 0,
284 }
285 }
286
287 /// The canonical logical album title for a clip identified only by `id`.
288 ///
289 /// The store-side counterpart of `context_for(clip).album(clip.title)` when
290 /// no live [`Clip`] is on hand. Title and root come from the archived nodes
291 /// and the monotonic cache, then the [`LineageContext::album`] rule decides
292 /// whether the clip folders under its root's album or its own title. A clip
293 /// absent from the store folds to a self-root with an empty title.
294 pub fn album_for_id(&self, id: &str) -> String {
295 let own = self.node(id);
296 let own_title = own.map(|node| node.title.clone()).unwrap_or_default();
297 let own_created_at = own.map(|node| node.created_at.clone()).unwrap_or_default();
298 let (root_id, root_title, root_date) = self.root_anchor(id, &own_title, &own_created_at);
299 let context = LineageContext {
300 root_id,
301 root_title,
302 root_date,
303 parent_id: String::new(),
304 edge_type: None,
305 status: ResolveStatus::Resolved,
306 track: 0,
307 track_total: 0,
308 };
309 context.album(&own_title)
310 }
311
312 /// The set of *effective* album titles shared by more than one distinct root.
313 ///
314 /// Two distinct roots must never share an album folder, so naming appends the
315 /// short root id to the album of any clip whose album is in this set.
316 /// Computed from the whole archive — every eligible root paired with its
317 /// effective title (override when configured, else the node title) — so the
318 /// decision is stable across runs and independent of the current batch: a
319 /// `--since`/`--limit` slice showing only one of two same-titled roots still
320 /// disambiguates rather than oscillating.
321 ///
322 /// It iterates the same eligible-root set that
323 /// `effective_root_title` gates overrides on,
324 /// so the two can never disagree. A root with no node and no override has an
325 /// empty effective title and is skipped.
326 pub fn colliding_root_titles(&self) -> BTreeSet<String> {
327 let mut roots_by_title: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
328 for root_id in &self.eligible_root_ids {
329 let node_title = self
330 .nodes
331 .get(root_id)
332 .map(|node| node.title.clone())
333 .unwrap_or_default();
334 let effective = self.effective_root_title(root_id, node_title);
335 let title = effective.trim();
336 if title.is_empty() {
337 continue;
338 }
339 roots_by_title
340 .entry(title.to_owned())
341 .or_default()
342 .insert(root_id.clone());
343 }
344 roots_by_title
345 .into_iter()
346 .filter(|(_, roots)| roots.len() > 1)
347 .map(|(title, _)| title)
348 .collect()
349 }
350
351 /// Full clip ids whose 8-char id prefix (`{id8}`) is shared by more than one
352 /// distinct clip anywhere in the archive.
353 ///
354 /// The file-name counterpart of [`colliding_root_titles`]: distinct clips
355 /// must never share a path, so naming appends the full clip id to any clip in
356 /// this set. Computed from the whole store — every clip ever seen, including
357 /// trashed and since-purged ancestors (`nodes`) — so the
358 /// decision is stable across runs and independent of the current batch: a
359 /// `--limit`/`--since` slice or a trashed twin never flips the suffix on or
360 /// off, which is the churn #356 is about.
361 ///
362 /// [`colliding_root_titles`]: LineageStore::colliding_root_titles
363 pub fn colliding_clip_ids(&self) -> BTreeSet<String> {
364 let mut ids_by_id8: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
365 for id in self.nodes.keys() {
366 // The `{id8}` group key. Must stay in lock-step with how the path
367 // renders `{id8}`: the first 8 scalar values (mirrors
368 // `truncate_chars(id, 8)` in `naming.rs`) then ASCII-fold.
369 // `to_ascii_lowercase` future-proofs against a non-lowercase id
370 // diverging from the ASCII-sanitised `{id8}` in the path; today's
371 // Suno UUIDs are already lowercase, so it is a no-op.
372 let id8 = id.chars().take(8).collect::<String>().to_ascii_lowercase();
373 ids_by_id8.entry(id8).or_default().insert(id.clone());
374 }
375 ids_by_id8
376 .into_values()
377 .filter(|ids| ids.len() > 1)
378 .flatten()
379 .collect()
380 }
381
382 /// Number of nodes in the graph.
383 pub fn len(&self) -> usize {
384 self.nodes.len()
385 }
386
387 /// True when the graph holds no nodes.
388 pub fn is_empty(&self) -> bool {
389 self.nodes.is_empty()
390 }
391
392 /// Iterate nodes in clip-id order.
393 pub fn iter(&self) -> Iter<'_, String, Node> {
394 self.nodes.iter()
395 }
396
397 /// Fold this run's clips and their [`Resolution`] into the store.
398 ///
399 /// Pure: it takes `now` (an ISO timestamp) from the caller rather than
400 /// reading a clock. Upserts a node for every clip *and* every gap-filled
401 /// ancestor (so trashed ancestors are archived), upserts an edge for every
402 /// [`lineage_edges`] link, and refreshes the monotonic resolution cache.
403 /// `edges` is left sorted so the serialised form is deterministic.
404 pub fn update(&mut self, clips: &[Clip], resolution: &Resolution, now: &str) {
405 self.rebuild_edge_index();
406
407 for clip in clips {
408 self.upsert_node(clip, now);
409 }
410 // Gap-filled ancestors are not download candidates, but their lineage
411 // must be archived before Suno purges them, so they become nodes too.
412 for clip in &resolution.gap_filled {
413 self.upsert_node(clip, now);
414 }
415
416 // Persist edges for the input clips AND the gap-filled ancestors: an
417 // ancestor's own parent pointer keeps the stored graph connected and
418 // lets a later run resolve through it without re-fetching, even after
419 // Suno purges it. Parent-endpoint bridges have no clip, so they are
420 // persisted directly below to keep that hop durable too.
421 for clip in clips.iter().chain(resolution.gap_filled.iter()) {
422 for edge in lineage_edges(clip) {
423 self.upsert_edge(&clip.id, &edge, now);
424 }
425 }
426 for (child_id, parent_id) in &resolution.bridges {
427 let edge = Edge {
428 parent_id: parent_id.clone(),
429 edge_type: EdgeType::Derived,
430 role: EdgeRole::Primary,
431 ordinal: 0,
432 source_field: "parent_endpoint",
433 };
434 self.upsert_edge(child_id, &edge, now);
435 }
436 // Attribution edges from `clip_roots` are additive and informational,
437 // never structural: role Secondary with the open slug, so
438 // `archived_parents` (Primary, ordinal 0) never reads them and root
439 // resolution stays untouched.
440 for clip in clips.iter().chain(resolution.gap_filled.iter()) {
441 for edge in attribution_edges(clip) {
442 self.upsert_attribution_edge(&clip.id, &edge, now);
443 }
444 }
445 self.edges.sort_by(|a, b| {
446 a.child_id
447 .cmp(&b.child_id)
448 .then(a.ordinal.cmp(&b.ordinal))
449 .then(a.parent_id.cmp(&b.parent_id))
450 .then(a.edge_type.cmp(&b.edge_type))
451 .then(a.role.cmp(&b.role))
452 });
453 self.rebuild_edge_index();
454
455 for (child_id, info) in &resolution.roots {
456 self.upsert_cache(child_id, info, now);
457 }
458 self.refresh_eligible_roots();
459 }
460
461 /// The persisted `child_id -> parent_id` map from the active primary edges
462 /// (each clip's ordinal-0 lineage parent), for seeding
463 /// [`resolve_roots`](crate::resolve_roots).
464 ///
465 /// This lets a resolution walk hop through an ancestor whose clip is absent
466 /// this run (an intermediate remix, or one Suno has purged) using the link
467 /// captured on an earlier run, instead of self-rooting. It is resolution
468 /// input only: these ids are never download candidates.
469 pub fn archived_parents(&self) -> HashMap<String, String> {
470 self.edges
471 .iter()
472 .filter(|edge| {
473 edge.role == EdgeRole::Primary
474 && edge.ordinal == 0
475 && edge.status == EdgeStatus::Active
476 })
477 .map(|edge| (edge.child_id.clone(), edge.parent_id.clone()))
478 .collect()
479 }
480
481 /// Insert or refresh the node for `clip`. `first_seen_at` and `status` are
482 /// set once on insert; everything else is refreshed to the latest sighting.
483 fn upsert_node(&mut self, clip: &Clip, now: &str) {
484 let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
485 first_seen_at: now.to_owned(),
486 ..Node::default()
487 });
488 node.title = clip.title.clone();
489 node.created_at = clip.created_at.clone();
490 node.clip_type = clip.clip_type.clone();
491 node.task = clip.task.clone();
492 node.is_remix = clip.is_remix;
493 node.is_trashed = clip.is_trashed;
494 node.last_seen_at = now.to_owned();
495 }
496
497 /// Insert or refresh the edge from `child_id` to `edge.parent_id`, keyed by
498 /// `(child_id, parent_id, edge_type, role, ordinal)`.
499 fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
500 let edge_type = edge_type_slug(edge.edge_type);
501 let key = EdgeKey::new(
502 child_id,
503 &edge.parent_id,
504 edge_type,
505 edge.role,
506 edge.ordinal,
507 );
508 if let Some(&index) = self.edge_index.get(&key) {
509 let existing = &mut self.edges[index];
510 existing.source_field = edge.source_field.to_owned();
511 existing.status = EdgeStatus::Active;
512 existing.last_seen_at = now.to_owned();
513 } else {
514 self.edges.push(StoredEdge {
515 child_id: child_id.to_owned(),
516 parent_id: edge.parent_id.clone(),
517 edge_type: edge_type.to_owned(),
518 role: edge.role,
519 source_field: edge.source_field.to_owned(),
520 ordinal: edge.ordinal,
521 status: EdgeStatus::Active,
522 first_seen_at: now.to_owned(),
523 last_seen_at: now.to_owned(),
524 });
525 self.edge_index.insert(key, self.edges.len() - 1);
526 }
527 }
528
529 /// Insert or refresh an attribution edge from `clip_roots`, keyed like any
530 /// edge by `(child_id, parent_id, edge_type, role, ordinal)`.
531 ///
532 /// The open attribution slug (normalised) is written DIRECTLY to
533 /// `edge_type`, bypassing the closed-[`EdgeType`] slug path, so an unknown
534 /// `clip_attribution_type` is preserved verbatim rather than forced into the
535 /// structural enum.
536 fn upsert_attribution_edge(&mut self, child_id: &str, edge: &AttributionEdge, now: &str) {
537 let edge_type = normalise_slug(&edge.edge_slug);
538 let key = EdgeKey::new(
539 child_id,
540 &edge.parent_id,
541 &edge_type,
542 edge.role,
543 edge.ordinal,
544 );
545 if let Some(&index) = self.edge_index.get(&key) {
546 let existing = &mut self.edges[index];
547 existing.source_field = edge.source_field.to_owned();
548 existing.status = EdgeStatus::Active;
549 existing.last_seen_at = now.to_owned();
550 } else {
551 self.edges.push(StoredEdge {
552 child_id: child_id.to_owned(),
553 parent_id: edge.parent_id.clone(),
554 edge_type,
555 role: edge.role,
556 source_field: edge.source_field.to_owned(),
557 ordinal: edge.ordinal,
558 status: EdgeStatus::Active,
559 first_seen_at: now.to_owned(),
560 last_seen_at: now.to_owned(),
561 });
562 self.edge_index.insert(key, self.edges.len() - 1);
563 }
564 }
565
566 fn rebuild_edge_index(&mut self) {
567 self.edge_index.clear();
568 for (index, edge) in self.edges.iter().enumerate() {
569 self.edge_index
570 .entry(EdgeKey::from_stored(edge))
571 .or_insert(index);
572 }
573 }
574
575 /// Fold one clip's root resolution into the cache, monotonically.
576 ///
577 /// A [`Resolved`](ResolveStatus::Resolved) root always wins. A non-resolved
578 /// outcome (external, unresolved, cycle) never overwrites an existing
579 /// resolved root — a transient gap-fill miss must not downgrade a good
580 /// album. Otherwise the last-known non-resolved status is recorded.
581 fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
582 if info.status != ResolveStatus::Resolved
583 && self
584 .resolution_cache
585 .get(child_id)
586 .is_some_and(|entry| entry.status == ResolveStatus::Resolved)
587 {
588 return;
589 }
590 self.resolution_cache.insert(
591 child_id.to_owned(),
592 CacheEntry {
593 root_id: info.root_id.clone(),
594 status: info.status,
595 algorithm_version: 1,
596 computed_at: now.to_owned(),
597 },
598 );
599 }
600}
601
602/// The stable on-disk slug for an [`EdgeType`].
603fn edge_type_slug(edge_type: EdgeType) -> &'static str {
604 match edge_type {
605 EdgeType::Cover => "cover",
606 EdgeType::Remaster => "remaster",
607 EdgeType::SpeedEdit => "speed_edit",
608 EdgeType::Edit => "edit",
609 EdgeType::Extend => "extend",
610 EdgeType::SectionReplace => "section_replace",
611 EdgeType::Stitch => "stitch",
612 EdgeType::Derived => "derived",
613 EdgeType::Uploaded => "uploaded",
614 }
615}
616
617/// Normalise an open attribution slug to a stable lowercase, underscore-joined
618/// form, e.g. `"Remix Cover"` -> `"remix_cover"`. An empty (or whitespace-only)
619/// slug maps to `"attribution"` so an edge always carries a non-empty type.
620pub(super) fn normalise_slug(slug: &str) -> String {
621 let normalised = slug
622 .split_whitespace()
623 .collect::<Vec<_>>()
624 .join("_")
625 .to_lowercase();
626 if normalised.is_empty() {
627 "attribution".to_owned()
628 } else {
629 normalised
630 }
631}