Skip to main content

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    /// Build a [`LineageContext`] for `clip` from the durable store.
224    ///
225    /// The source of truth for every file-affecting lineage decision (album
226    /// folder, embedded tags, change hash), so a dropped resolution call never
227    /// rewrites the library (HARDENING H3). The root comes from the monotonic
228    /// cache (the clip's own id when the store has no better answer), its title
229    /// and date from that root's archived node, so a transient miss keeps the
230    /// last-known-good album (even a since-purged ancestor) and the Year tag
231    /// anchors on the root's year. The parent edge is read structurally.
232    pub fn context_for(&self, clip: &Clip) -> LineageContext {
233        let cached = self.get_root(&clip.id);
234        let root_id = cached
235            .map(|entry| entry.root_id.clone())
236            .filter(|id| !id.is_empty())
237            .unwrap_or_else(|| clip.id.clone());
238        let root_title = self
239            .node(&root_id)
240            .map(|node| node.title.clone())
241            .unwrap_or_else(|| clip.title.clone());
242        let root_title = self.effective_root_title(&root_id, root_title);
243        let root_date = self
244            .node(&root_id)
245            .map(|node| node.created_at.clone())
246            .unwrap_or_else(|| clip.created_at.clone());
247        let (parent_id, edge_type) = match immediate_parent(clip) {
248            Some((id, edge)) => (id, Some(edge)),
249            None => (String::new(), None),
250        };
251        let status = cached
252            .map(|entry| entry.status)
253            .unwrap_or(ResolveStatus::Resolved);
254        LineageContext {
255            root_id,
256            root_title,
257            root_date,
258            parent_id,
259            edge_type,
260            status,
261            track: 0,
262            track_total: 0,
263        }
264    }
265
266    /// The canonical logical album title for a clip identified only by `id`.
267    ///
268    /// The store-side counterpart of `context_for(clip).album(clip.title)` when
269    /// no live [`Clip`] is on hand. Title and root come from the archived nodes
270    /// and the monotonic cache, then the [`LineageContext::album`] rule decides
271    /// whether the clip folders under its root's album or its own title. A clip
272    /// absent from the store folds to a self-root with an empty title.
273    pub fn album_for_id(&self, id: &str) -> String {
274        let own = self.node(id);
275        let own_title = own.map(|node| node.title.clone()).unwrap_or_default();
276        let own_created_at = own.map(|node| node.created_at.clone()).unwrap_or_default();
277        let root_id = self
278            .get_root(id)
279            .map(|entry| entry.root_id.clone())
280            .filter(|root| !root.is_empty())
281            .unwrap_or_else(|| id.to_owned());
282        let root_title = self
283            .node(&root_id)
284            .map(|node| node.title.clone())
285            .unwrap_or_else(|| own_title.clone());
286        let root_title = self.effective_root_title(&root_id, root_title);
287        let root_date = self
288            .node(&root_id)
289            .map(|node| node.created_at.clone())
290            .unwrap_or(own_created_at);
291        let context = LineageContext {
292            root_id,
293            root_title,
294            root_date,
295            parent_id: String::new(),
296            edge_type: None,
297            status: ResolveStatus::Resolved,
298            track: 0,
299            track_total: 0,
300        };
301        context.album(&own_title)
302    }
303
304    /// The set of *effective* album titles shared by more than one distinct root.
305    ///
306    /// Two distinct roots must never share an album folder, so naming appends the
307    /// short root id to the album of any clip whose album is in this set.
308    /// Computed from the whole archive — every eligible root paired with its
309    /// effective title (override when configured, else the node title) — so the
310    /// decision is stable across runs and independent of the current batch: a
311    /// `--since`/`--limit` slice showing only one of two same-titled roots still
312    /// disambiguates rather than oscillating.
313    ///
314    /// It iterates the same eligible-root set that
315    /// [`effective_root_title`](Self::effective_root_title) gates overrides on,
316    /// so the two can never disagree. A root with no node and no override has an
317    /// empty effective title and is skipped.
318    pub fn colliding_root_titles(&self) -> BTreeSet<String> {
319        let mut roots_by_title: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
320        for root_id in &self.eligible_root_ids {
321            let node_title = self
322                .nodes
323                .get(root_id)
324                .map(|node| node.title.clone())
325                .unwrap_or_default();
326            let effective = self.effective_root_title(root_id, node_title);
327            let title = effective.trim();
328            if title.is_empty() {
329                continue;
330            }
331            roots_by_title
332                .entry(title.to_owned())
333                .or_default()
334                .insert(root_id.clone());
335        }
336        roots_by_title
337            .into_iter()
338            .filter(|(_, roots)| roots.len() > 1)
339            .map(|(title, _)| title)
340            .collect()
341    }
342
343    /// Full clip ids whose 8-char id prefix (`{id8}`) is shared by more than one
344    /// distinct clip anywhere in the archive.
345    ///
346    /// The file-name counterpart of [`colliding_root_titles`]: distinct clips
347    /// must never share a path, so naming appends the full clip id to any clip in
348    /// this set. Computed from the whole store — every clip ever seen, including
349    /// trashed and since-purged ancestors ([`nodes`](Self::nodes)) — so the
350    /// decision is stable across runs and independent of the current batch: a
351    /// `--limit`/`--since` slice or a trashed twin never flips the suffix on or
352    /// off, which is the churn #356 is about.
353    ///
354    /// [`colliding_root_titles`]: LineageStore::colliding_root_titles
355    pub fn colliding_clip_ids(&self) -> BTreeSet<String> {
356        let mut ids_by_id8: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
357        for id in self.nodes.keys() {
358            // The `{id8}` group key. Must stay in lock-step with how the path
359            // renders `{id8}`: the first 8 scalar values (mirrors
360            // `truncate_chars(id, 8)` in `naming.rs`) then ASCII-fold.
361            // `to_ascii_lowercase` future-proofs against a non-lowercase id
362            // diverging from the ASCII-sanitised `{id8}` in the path; today's
363            // Suno UUIDs are already lowercase, so it is a no-op.
364            let id8 = id.chars().take(8).collect::<String>().to_ascii_lowercase();
365            ids_by_id8.entry(id8).or_default().insert(id.clone());
366        }
367        ids_by_id8
368            .into_values()
369            .filter(|ids| ids.len() > 1)
370            .flatten()
371            .collect()
372    }
373
374    /// Number of nodes in the graph.
375    pub fn len(&self) -> usize {
376        self.nodes.len()
377    }
378
379    /// True when the graph holds no nodes.
380    pub fn is_empty(&self) -> bool {
381        self.nodes.is_empty()
382    }
383
384    /// Iterate nodes in clip-id order.
385    pub fn iter(&self) -> Iter<'_, String, Node> {
386        self.nodes.iter()
387    }
388
389    /// Fold this run's clips and their [`Resolution`] into the store.
390    ///
391    /// Pure: it takes `now` (an ISO timestamp) from the caller rather than
392    /// reading a clock. Upserts a node for every clip *and* every gap-filled
393    /// ancestor (so trashed ancestors are archived), upserts an edge for every
394    /// [`lineage_edges`] link, and refreshes the monotonic resolution cache.
395    /// `edges` is left sorted so the serialised form is deterministic.
396    pub fn update(&mut self, clips: &[Clip], resolution: &Resolution, now: &str) {
397        self.rebuild_edge_index();
398
399        for clip in clips {
400            self.upsert_node(clip, now);
401        }
402        // Gap-filled ancestors are not download candidates, but their lineage
403        // must be archived before Suno purges them, so they become nodes too.
404        for clip in &resolution.gap_filled {
405            self.upsert_node(clip, now);
406        }
407
408        // Persist edges for the input clips AND the gap-filled ancestors: an
409        // ancestor's own parent pointer keeps the stored graph connected and
410        // lets a later run resolve through it without re-fetching, even after
411        // Suno purges it. Parent-endpoint bridges have no clip, so they are
412        // persisted directly below to keep that hop durable too.
413        for clip in clips.iter().chain(resolution.gap_filled.iter()) {
414            for edge in lineage_edges(clip) {
415                self.upsert_edge(&clip.id, &edge, now);
416            }
417        }
418        for (child_id, parent_id) in &resolution.bridges {
419            let edge = Edge {
420                parent_id: parent_id.clone(),
421                edge_type: EdgeType::Derived,
422                role: EdgeRole::Primary,
423                ordinal: 0,
424                source_field: "parent_endpoint",
425            };
426            self.upsert_edge(child_id, &edge, now);
427        }
428        // Attribution edges from `clip_roots` are additive and informational,
429        // never structural: role Secondary with the open slug, so
430        // `archived_parents` (Primary, ordinal 0) never reads them and root
431        // resolution stays untouched.
432        for clip in clips.iter().chain(resolution.gap_filled.iter()) {
433            for edge in attribution_edges(clip) {
434                self.upsert_attribution_edge(&clip.id, &edge, now);
435            }
436        }
437        self.edges.sort_by(|a, b| {
438            a.child_id
439                .cmp(&b.child_id)
440                .then(a.ordinal.cmp(&b.ordinal))
441                .then(a.parent_id.cmp(&b.parent_id))
442                .then(a.edge_type.cmp(&b.edge_type))
443                .then(a.role.cmp(&b.role))
444        });
445        self.rebuild_edge_index();
446
447        for (child_id, info) in &resolution.roots {
448            self.upsert_cache(child_id, info, now);
449        }
450        self.refresh_eligible_roots();
451    }
452
453    /// The persisted `child_id -> parent_id` map from the active primary edges
454    /// (each clip's ordinal-0 lineage parent), for seeding
455    /// [`resolve_roots`](crate::resolve_roots).
456    ///
457    /// This lets a resolution walk hop through an ancestor whose clip is absent
458    /// this run (an intermediate remix, or one Suno has purged) using the link
459    /// captured on an earlier run, instead of self-rooting. It is resolution
460    /// input only: these ids are never download candidates.
461    pub fn archived_parents(&self) -> HashMap<String, String> {
462        self.edges
463            .iter()
464            .filter(|edge| {
465                edge.role == EdgeRole::Primary
466                    && edge.ordinal == 0
467                    && edge.status == EdgeStatus::Active
468            })
469            .map(|edge| (edge.child_id.clone(), edge.parent_id.clone()))
470            .collect()
471    }
472
473    /// Insert or refresh the node for `clip`. `first_seen_at` and `status` are
474    /// set once on insert; everything else is refreshed to the latest sighting.
475    fn upsert_node(&mut self, clip: &Clip, now: &str) {
476        let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
477            first_seen_at: now.to_owned(),
478            ..Node::default()
479        });
480        node.title = clip.title.clone();
481        node.created_at = clip.created_at.clone();
482        node.clip_type = clip.clip_type.clone();
483        node.task = clip.task.clone();
484        node.is_remix = clip.is_remix;
485        node.is_trashed = clip.is_trashed;
486        node.last_seen_at = now.to_owned();
487    }
488
489    /// Insert or refresh the edge from `child_id` to `edge.parent_id`, keyed by
490    /// `(child_id, parent_id, edge_type, role, ordinal)`.
491    fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
492        let edge_type = edge_type_slug(edge.edge_type);
493        let key = EdgeKey::new(
494            child_id,
495            &edge.parent_id,
496            edge_type,
497            edge.role,
498            edge.ordinal,
499        );
500        if let Some(&index) = self.edge_index.get(&key) {
501            let existing = &mut self.edges[index];
502            existing.source_field = edge.source_field.to_owned();
503            existing.status = EdgeStatus::Active;
504            existing.last_seen_at = now.to_owned();
505        } else {
506            self.edges.push(StoredEdge {
507                child_id: child_id.to_owned(),
508                parent_id: edge.parent_id.clone(),
509                edge_type: edge_type.to_owned(),
510                role: edge.role,
511                source_field: edge.source_field.to_owned(),
512                ordinal: edge.ordinal,
513                status: EdgeStatus::Active,
514                first_seen_at: now.to_owned(),
515                last_seen_at: now.to_owned(),
516            });
517            self.edge_index.insert(key, self.edges.len() - 1);
518        }
519    }
520
521    /// Insert or refresh an attribution edge from `clip_roots`, keyed like any
522    /// edge by `(child_id, parent_id, edge_type, role, ordinal)`.
523    ///
524    /// The open attribution slug (normalised) is written DIRECTLY to
525    /// `edge_type`, bypassing the closed-[`EdgeType`] slug path, so an unknown
526    /// `clip_attribution_type` is preserved verbatim rather than forced into the
527    /// structural enum.
528    fn upsert_attribution_edge(&mut self, child_id: &str, edge: &AttributionEdge, now: &str) {
529        let edge_type = normalise_slug(&edge.edge_slug);
530        let key = EdgeKey::new(
531            child_id,
532            &edge.parent_id,
533            &edge_type,
534            edge.role,
535            edge.ordinal,
536        );
537        if let Some(&index) = self.edge_index.get(&key) {
538            let existing = &mut self.edges[index];
539            existing.source_field = edge.source_field.to_owned();
540            existing.status = EdgeStatus::Active;
541            existing.last_seen_at = now.to_owned();
542        } else {
543            self.edges.push(StoredEdge {
544                child_id: child_id.to_owned(),
545                parent_id: edge.parent_id.clone(),
546                edge_type,
547                role: edge.role,
548                source_field: edge.source_field.to_owned(),
549                ordinal: edge.ordinal,
550                status: EdgeStatus::Active,
551                first_seen_at: now.to_owned(),
552                last_seen_at: now.to_owned(),
553            });
554            self.edge_index.insert(key, self.edges.len() - 1);
555        }
556    }
557
558    fn rebuild_edge_index(&mut self) {
559        self.edge_index.clear();
560        for (index, edge) in self.edges.iter().enumerate() {
561            self.edge_index
562                .entry(EdgeKey::from_stored(edge))
563                .or_insert(index);
564        }
565    }
566
567    /// Fold one clip's root resolution into the cache, monotonically.
568    ///
569    /// A [`Resolved`](ResolveStatus::Resolved) root always wins. A non-resolved
570    /// outcome (external, unresolved, cycle) never overwrites an existing
571    /// resolved root — a transient gap-fill miss must not downgrade a good
572    /// album. Otherwise the last-known non-resolved status is recorded.
573    fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
574        if info.status != ResolveStatus::Resolved
575            && self
576                .resolution_cache
577                .get(child_id)
578                .is_some_and(|entry| entry.status == ResolveStatus::Resolved)
579        {
580            return;
581        }
582        self.resolution_cache.insert(
583            child_id.to_owned(),
584            CacheEntry {
585                root_id: info.root_id.clone(),
586                status: info.status,
587                algorithm_version: 1,
588                computed_at: now.to_owned(),
589            },
590        );
591    }
592}
593
594/// The stable on-disk slug for an [`EdgeType`].
595fn edge_type_slug(edge_type: EdgeType) -> &'static str {
596    match edge_type {
597        EdgeType::Cover => "cover",
598        EdgeType::Remaster => "remaster",
599        EdgeType::SpeedEdit => "speed_edit",
600        EdgeType::Edit => "edit",
601        EdgeType::Extend => "extend",
602        EdgeType::SectionReplace => "section_replace",
603        EdgeType::Stitch => "stitch",
604        EdgeType::Derived => "derived",
605        EdgeType::Uploaded => "uploaded",
606    }
607}
608
609/// Normalise an open attribution slug to a stable lowercase, underscore-joined
610/// form, e.g. `"Remix Cover"` -> `"remix_cover"`. An empty (or whitespace-only)
611/// slug maps to `"attribution"` so an edge always carries a non-empty type.
612pub(super) fn normalise_slug(slug: &str) -> String {
613    let normalised = slug
614        .split_whitespace()
615        .collect::<Vec<_>>()
616        .join("_")
617        .to_lowercase();
618    if normalised.is_empty() {
619        "attribution".to_owned()
620    } else {
621        normalised
622    }
623}