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;