Skip to main content

suno_core/
tracks.rs

1//! Album track numbering: order a lineage album's downloaded clips and assign
2//! 1-based track numbers, with an optional per-album lead override.
3//!
4//! An album is the set of selected clips sharing a resolved lineage root (the
5//! same grouping [`album_desired`](crate::album_desired) uses for folder art).
6//! Within a group, tracks are ordered by `created_at` ascending (tie-break by
7//! id), so a track's number reflects when that version was made. A configured
8//! *lead* clip is promoted to track 1, shifting the rest down while keeping
9//! their relative order, so a later-made "main" version can still present as
10//! song 1. Pure and IO-free; the CLI resolves the lead ids and folds the result
11//! into each clip's [`LineageContext`].
12
13use std::collections::{BTreeMap, BTreeSet, HashMap};
14
15use crate::lineage::LineageContext;
16use crate::model::Clip;
17
18/// A clip's assigned position within its album: 1-based `track` of `total`.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct TrackAssignment {
21    pub track: u32,
22    pub total: u32,
23}
24
25/// The outcome of matching configured lead entries against the selected clips.
26#[derive(Debug, Clone, Default, PartialEq, Eq)]
27pub struct LeadResolution {
28    /// Full clip ids to treat as their album's lead (track 1). Deduplicated.
29    pub resolved: BTreeSet<String>,
30    /// Configured entries that matched no selected clip.
31    pub unmatched: Vec<String>,
32    /// Configured entries that matched more than one selected clip (too short a
33    /// prefix); left unresolved so an ambiguous flag never silently mis-numbers.
34    pub ambiguous: Vec<String>,
35}
36
37/// Resolve configured lead entries to concrete clip ids by unique id-prefix.
38///
39/// Each entry matches a clip when the clip id equals it or starts with it
40/// (case-insensitive), so the 8-char code from a filename (`[b320f4cf]`) or a
41/// full UUID both work. An entry matching exactly one clip resolves to that
42/// clip's id; zero or many matches are reported for the caller to warn on rather
43/// than guessing. Empty entries are ignored.
44pub fn resolve_lead_ids(clips: &[&Clip], configured: &[String]) -> LeadResolution {
45    let mut out = LeadResolution::default();
46    for entry in configured {
47        let needle = entry.trim();
48        if needle.is_empty() {
49            continue;
50        }
51        let mut matches = clips.iter().filter(|clip| id_matches(&clip.id, needle));
52        match (matches.next(), matches.next()) {
53            (Some(clip), None) => {
54                out.resolved.insert(clip.id.clone());
55            }
56            (Some(_), Some(_)) => out.ambiguous.push(needle.to_owned()),
57            (None, _) => out.unmatched.push(needle.to_owned()),
58        }
59    }
60    out
61}
62
63/// Whether `id` equals or is prefixed by `needle`, ASCII-case-insensitively.
64fn id_matches(id: &str, needle: &str) -> bool {
65    id.get(..needle.len())
66        .is_some_and(|prefix| prefix.eq_ignore_ascii_case(needle))
67}
68
69/// Assign 1-based track numbers within each lineage album.
70///
71/// Groups `clips` by the resolved root id in `contexts` (a clip absent from the
72/// map, or with an empty root, is its own album), orders each group by
73/// `(created_at, id)`, promotes the first member found in `leads` to track 1,
74/// and returns each clip's [`TrackAssignment`]. When `number_singletons` is
75/// false, a lone-track album is left out of the result (unnumbered). Clips not
76/// present in the returned map keep whatever number their context already holds.
77pub fn assign_track_numbers(
78    clips: &[&Clip],
79    contexts: &HashMap<String, LineageContext>,
80    leads: &BTreeSet<String>,
81    number_singletons: bool,
82) -> HashMap<String, TrackAssignment> {
83    let mut groups: BTreeMap<&str, Vec<&Clip>> = BTreeMap::new();
84    for clip in clips {
85        let root = contexts
86            .get(&clip.id)
87            .map(|ctx| ctx.root_id.as_str())
88            .filter(|root| !root.is_empty())
89            .unwrap_or(clip.id.as_str());
90        groups.entry(root).or_default().push(clip);
91    }
92
93    let mut out = HashMap::with_capacity(clips.len());
94    for (_root, mut members) in groups {
95        members.sort_by(|a, b| {
96            a.created_at
97                .cmp(&b.created_at)
98                .then_with(|| a.id.cmp(&b.id))
99        });
100        let total = members.len() as u32;
101        if total == 1 && !number_singletons {
102            continue;
103        }
104        if let Some(pos) = members.iter().position(|clip| leads.contains(&clip.id)) {
105            let lead = members.remove(pos);
106            members.insert(0, lead);
107        }
108        for (index, clip) in members.iter().enumerate() {
109            out.insert(
110                clip.id.clone(),
111                TrackAssignment {
112                    track: index as u32 + 1,
113                    total,
114                },
115            );
116        }
117    }
118    out
119}
120
121#[cfg(test)]
122mod tests;