Skip to main content

suno_core/
naming.rs

1//! Pure naming and relative path rendering for [`Clip`] values.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5use std::path::PathBuf;
6use std::str::FromStr;
7
8use serde::{Deserialize, Serialize};
9
10use crate::Clip;
11use crate::error::{Error, Result};
12use crate::lineage::LineageContext;
13use crate::pathkey::canonical_path_key;
14
15/// The default relative path template.
16///
17/// Supported placeholders are `{creator}`, `{handle}`, `{album}`, `{title}`,
18/// `{id}`, `{id8}` (first 8 characters of the clip id), `{root_id8}` (first 8 of
19/// the resolved lineage root id), `{track}` (the album track number, e.g. `7`),
20/// and `{track2}` (that number zero-padded to two digits, e.g. `07`). An empty
21/// placeholder swallows the separator run that follows it, so an unnumbered
22/// `{track2}` leaves no orphan ` - `. Empty path segments are dropped after
23/// rendering.
24///
25/// The default prefixes the file name with the two-digit track number, embeds
26/// `[{id8}]` so same-title clips never collide, and folders under `{album}`,
27/// which resolves to the lineage root's title (else the clip's own title).
28pub const DEFAULT_TEMPLATE: &str = "{creator}/{album}/{track2} - {creator}-{title} [{id8}]";
29const DEFAULT_MAX_COMPONENT_LEN: usize = 80;
30
31const MIN_BASE_CHARS_WITH_SUFFIX: usize = 1;
32
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35#[serde(rename_all = "lowercase")]
36pub enum CharacterSet {
37    #[default]
38    Unicode,
39    Ascii,
40}
41
42impl FromStr for CharacterSet {
43    type Err = Error;
44
45    // Case-sensitive to match serde (TOML) and the JSON schema, which accept
46    // lowercase only; the env tier (`SUNO_CHARACTER_SET`) parses through here.
47    fn from_str(s: &str) -> Result<Self> {
48        match s {
49            "unicode" => Ok(Self::Unicode),
50            "ascii" => Ok(Self::Ascii),
51            other => Err(Error::Config(format!(
52                "unknown character_set '{other}'; expected 'unicode' or 'ascii'"
53            ))),
54        }
55    }
56}
57
58impl fmt::Display for CharacterSet {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        match self {
61            Self::Unicode => f.write_str("unicode"),
62            Self::Ascii => f.write_str("ascii"),
63        }
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct NamingConfig {
69    pub template: String,
70    pub character_set: CharacterSet,
71    pub max_component_len: usize,
72}
73
74impl Default for NamingConfig {
75    fn default() -> Self {
76        Self {
77            template: DEFAULT_TEMPLATE.to_string(),
78            character_set: CharacterSet::Unicode,
79            max_component_len: DEFAULT_MAX_COMPONENT_LEN,
80        }
81    }
82}
83
84#[derive(Debug, Clone, Copy)]
85pub struct NamingRequest<'a> {
86    pub clip: &'a Clip,
87    pub lineage: &'a LineageContext,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct RenderedName {
92    pub relative_path: PathBuf,
93    pub base_name: String,
94}
95
96pub fn render_clip_name(request: NamingRequest<'_>, config: &NamingConfig) -> RenderedName {
97    let album = album_component(request, config);
98    render_with_album(request, config, &album)
99}
100
101pub fn render_clip_names(
102    requests: &[NamingRequest<'_>],
103    config: &NamingConfig,
104    colliding_albums: &BTreeSet<String>,
105) -> Vec<RenderedName> {
106    let albums = disambiguated_albums(requests, config, colliding_albums);
107    let mut rendered = requests
108        .iter()
109        .zip(&albums)
110        .map(|(request, album)| render_with_album(*request, config, album))
111        .collect::<Vec<_>>();
112
113    // Two passes so distinct clips never land on one path: the first keys on the
114    // exact rendered string, the second on the filesystem-canonical form (NFC +
115    // lowercase), catching paths that differ only by case or NFD/NFC and would
116    // collide on case-insensitive or NFC-normalising filesystems (Windows, macOS).
117    for apply_canonical in [false, true] {
118        let mut collisions = BTreeMap::<String, Vec<usize>>::new();
119        for (index, name) in rendered.iter().enumerate() {
120            let key = if apply_canonical {
121                canonical_path_key(&name.relative_path.to_string_lossy())
122            } else {
123                name.relative_path.to_string_lossy().into_owned()
124            };
125            collisions.entry(key).or_default().push(index);
126        }
127        for indexes in collisions.into_values().filter(|v| v.len() > 1) {
128            for index in indexes {
129                let suffix = &requests[index].clip.id;
130                rendered[index] = with_suffix(
131                    rendered[index].clone(),
132                    suffix,
133                    config.character_set,
134                    config.max_component_len,
135                );
136            }
137        }
138    }
139
140    rendered
141}
142
143/// The album path component for every request, with a clip whose root title
144/// collides across distinct roots disambiguated by `[{root_id8}]`.
145///
146/// Distinct roots must never share an album folder. `colliding_albums` is the
147/// authoritative set of such shared root titles, computed once from the whole
148/// lineage store, so the decision is stable across runs and independent of the
149/// batch. A clip whose resolved album is in that set gets its root's short id
150/// appended; every other clip keeps the bare album and groups with its
151/// same-root siblings.
152fn disambiguated_albums(
153    requests: &[NamingRequest<'_>],
154    config: &NamingConfig,
155    colliding_albums: &BTreeSet<String>,
156) -> Vec<String> {
157    requests
158        .iter()
159        .map(|request| album_for(*request, config, colliding_albums))
160        .collect()
161}
162
163/// The (possibly disambiguated) album component for one request.
164fn album_for(
165    request: NamingRequest<'_>,
166    config: &NamingConfig,
167    colliding_albums: &BTreeSet<String>,
168) -> String {
169    let raw_album = request.lineage.album(&title_name(request.clip));
170    let album = sanitise_component(&raw_album, config.character_set, config.max_component_len);
171    if colliding_albums.contains(raw_album.trim()) {
172        let suffix = truncate_chars(&request.lineage.root_id, 8);
173        append_suffix(
174            &album,
175            &suffix,
176            config.character_set,
177            config.max_component_len,
178        )
179    } else {
180        album
181    }
182}
183
184/// The sanitised album component: the resolved lineage album (root title, else
185/// the clip's own title).
186fn album_component(request: NamingRequest<'_>, config: &NamingConfig) -> String {
187    let album = request.lineage.album(&title_name(request.clip));
188    sanitise_component(&album, config.character_set, config.max_component_len)
189}
190
191/// Render one clip's path with an already-resolved album component.
192fn render_with_album(
193    request: NamingRequest<'_>,
194    config: &NamingConfig,
195    album: &str,
196) -> RenderedName {
197    let clip = request.clip;
198    let creator = sanitise_component(
199        &creator_name(clip),
200        config.character_set,
201        config.max_component_len,
202    );
203    let handle = sanitise_component(&clip.handle, config.character_set, config.max_component_len);
204    let title = sanitise_component(
205        &title_name(clip),
206        config.character_set,
207        config.max_component_len,
208    );
209    let id = sanitise_component(&clip.id, CharacterSet::Ascii, config.max_component_len);
210    let id8 = sanitise_component(
211        &truncate_chars(&clip.id, 8),
212        CharacterSet::Ascii,
213        config.max_component_len,
214    );
215    let root_id8 = sanitise_component(
216        &truncate_chars(&request.lineage.root_id, 8),
217        CharacterSet::Ascii,
218        config.max_component_len,
219    );
220    let track = request.lineage.track;
221    let track_raw = if track > 0 {
222        track.to_string()
223    } else {
224        String::new()
225    };
226    let track_pad = if track > 0 {
227        format!("{track:02}")
228    } else {
229        String::new()
230    };
231    let substitutions = SegmentSubstitutions {
232        creator: &creator,
233        handle: &handle,
234        album,
235        title: &title,
236        root_id8: &root_id8,
237        id8: &id8,
238        id: &id,
239        track: &track_raw,
240        track2: &track_pad,
241    };
242    let mut components = config
243        .template
244        .split('/')
245        .filter_map(|segment| {
246            let rendered = substitute_segment(segment, substitutions);
247            let sanitised = sanitise_segment(
248                &rendered,
249                config.character_set,
250                config.max_component_len,
251                [id8.as_str(), root_id8.as_str()],
252            );
253            (!sanitised.is_empty()).then_some(sanitised)
254        })
255        .collect::<Vec<_>>();
256
257    if components.is_empty() {
258        components.push(title.clone());
259    }
260
261    let mut base_name = components
262        .pop()
263        .filter(|value| !value.is_empty())
264        .unwrap_or_else(|| title.clone());
265    // Guarantee a non-empty file name even when every token sanitises away.
266    if base_name.is_empty() {
267        base_name = append_suffix(
268            &base_name,
269            &clip.id,
270            config.character_set,
271            config.max_component_len,
272        );
273    }
274
275    let mut relative_path = PathBuf::new();
276    for component in components {
277        relative_path.push(component);
278    }
279
280    relative_path.push(&base_name);
281    RenderedName {
282        relative_path,
283        base_name,
284    }
285}
286
287#[derive(Clone, Copy)]
288struct SegmentSubstitutions<'a> {
289    creator: &'a str,
290    handle: &'a str,
291    album: &'a str,
292    title: &'a str,
293    root_id8: &'a str,
294    id8: &'a str,
295    id: &'a str,
296    track: &'a str,
297    track2: &'a str,
298}
299
300fn substitute_segment(segment: &str, substitutions: SegmentSubstitutions<'_>) -> String {
301    let mut rendered = String::with_capacity(segment.len());
302    let mut remainder = segment;
303    while let Some(start) = remainder.find('{') {
304        rendered.push_str(&remainder[..start]);
305        remainder = &remainder[start..];
306        if let Some((token_len, value)) = placeholder_match(remainder, substitutions) {
307            rendered.push_str(value);
308            remainder = &remainder[token_len..];
309            // An empty placeholder swallows the separator run that follows it, so
310            // an optional token (e.g. an unnumbered `{track2}`) leaves no orphan
311            // separator like a leading " - ".
312            if value.is_empty() {
313                remainder = remainder.trim_start_matches([' ', '-', '_', '.']);
314            }
315        } else {
316            rendered.push('{');
317            remainder = &remainder[1..];
318        }
319    }
320    rendered.push_str(remainder);
321    rendered
322}
323
324fn placeholder_match<'a>(
325    segment: &str,
326    substitutions: SegmentSubstitutions<'a>,
327) -> Option<(usize, &'a str)> {
328    if segment.starts_with("{creator}") {
329        Some(("{creator}".len(), substitutions.creator))
330    } else if segment.starts_with("{handle}") {
331        Some(("{handle}".len(), substitutions.handle))
332    } else if segment.starts_with("{album}") {
333        Some(("{album}".len(), substitutions.album))
334    } else if segment.starts_with("{title}") {
335        Some(("{title}".len(), substitutions.title))
336    } else if segment.starts_with("{root_id8}") {
337        Some(("{root_id8}".len(), substitutions.root_id8))
338    } else if segment.starts_with("{id8}") {
339        Some(("{id8}".len(), substitutions.id8))
340    } else if segment.starts_with("{id}") {
341        Some(("{id}".len(), substitutions.id))
342    } else if segment.starts_with("{track2}") {
343        Some(("{track2}".len(), substitutions.track2))
344    } else if segment.starts_with("{track}") {
345        Some(("{track}".len(), substitutions.track))
346    } else {
347        None
348    }
349}
350
351fn with_suffix(
352    mut rendered: RenderedName,
353    suffix: &str,
354    character_set: CharacterSet,
355    max_component_len: usize,
356) -> RenderedName {
357    rendered.base_name = append_suffix(
358        &rendered.base_name,
359        suffix,
360        character_set,
361        max_component_len,
362    );
363    rendered.relative_path.set_file_name(&rendered.base_name);
364    rendered
365}
366
367fn creator_name(clip: &Clip) -> String {
368    non_blank(&clip.display_name)
369        .or_else(|| non_blank(&clip.handle))
370        .unwrap_or("Unknown Creator")
371        .to_string()
372}
373
374fn title_name(clip: &Clip) -> String {
375    let title = clip.title.trim();
376    if title.is_empty() || title.eq_ignore_ascii_case("untitled") {
377        "Untitled".to_string()
378    } else {
379        title.to_string()
380    }
381}
382
383fn append_suffix(
384    base: &str,
385    suffix: &str,
386    character_set: CharacterSet,
387    max_component_len: usize,
388) -> String {
389    let suffix_pattern = format!(" [{suffix}]");
390    if base.ends_with(&suffix_pattern) {
391        return sanitise_component(base, character_set, max_component_len);
392    }
393
394    let max_len =
395        max_component_len.max(suffix_pattern.chars().count() + MIN_BASE_CHARS_WITH_SUFFIX);
396    let allowed = max_len.saturating_sub(suffix_pattern.chars().count());
397    // Sanitise the base before measuring it. The character set can expand a
398    // character (ascii turns `ß` into `ss`), so budgeting the cut on the raw
399    // length could let the sanitised prefix grow back over the room reserved for
400    // the suffix and slice through it again (#120).
401    let base = sanitise_component(base, character_set, max_len);
402    let truncated = truncate_chars(base.trim_end(), allowed);
403    let combined = format!("{truncated}{suffix_pattern}");
404    sanitise_component(&combined, character_set, max_len)
405}
406
407/// Sanitise a rendered template segment, preserving a trailing ` [id]`
408/// disambiguator (the `[{id8}]` or `[{root_id8}]` the template embeds) when the
409/// segment would otherwise be truncated through it. Only the title portion is
410/// shortened, so two long-titled siblings keep their distinguishing id and the
411/// closing bracket is never left unbalanced (#120). A segment that does not end
412/// in a disambiguator is sanitised exactly as before.
413fn sanitise_segment(
414    rendered: &str,
415    character_set: CharacterSet,
416    max_component_len: usize,
417    disambiguators: [&str; 2],
418) -> String {
419    for suffix in disambiguators {
420        if suffix.is_empty() {
421            continue;
422        }
423        let pattern = format!(" [{suffix}]");
424        if let Some(prefix) = rendered.strip_suffix(&pattern) {
425            return append_suffix(prefix, suffix, character_set, max_component_len);
426        }
427    }
428    sanitise_component(rendered, character_set, max_component_len)
429}
430
431/// Sanitise a free-form playlist name into a single safe path component.
432///
433/// Applies the same Unicode filtering and length cap as clip path components
434/// (default [`CharacterSet::Unicode`], [`DEFAULT_MAX_COMPONENT_LEN`]), so a
435/// playlist file name obeys the same filesystem rules as the rest of the
436/// library. An empty or fully-stripped name falls back to `playlist` so the
437/// caller always has a non-empty stem to append `.m3u8` to.
438pub fn sanitise_name(name: &str) -> String {
439    let cleaned = sanitise_component(name, CharacterSet::Unicode, DEFAULT_MAX_COMPONENT_LEN);
440    if cleaned.is_empty() {
441        "playlist".to_string()
442    } else {
443        cleaned
444    }
445}
446
447/// The `.stems` sub-folder that sits beside a song's audio file.
448///
449/// `base` is the song's extensionless relative path (the same value the audio
450/// and its sidecars are built from), so the folder is `{base}.stems`. It cannot
451/// collide with the audio file (`{base}.<ext>`) or any `{base}.<sidecar>`
452/// because the `.stems` suffix is distinct, mirroring the sidecar convention.
453pub fn stems_folder(base: &str) -> String {
454    format!("{base}.stems")
455}
456
457/// The relative path of one stem file inside a song's [`stems_folder`].
458///
459/// Base+label+disambiguation rather than label-only, because Auto Split can
460/// mislabel stems and Advanced Split yields ~100 instruments, so blank or
461/// duplicate labels are expected. The file is
462/// `{song file name} - {label} [{stem id8}].{ext}`; ` - {label}` is dropped when
463/// the label sanitises to empty, and the `[{stem id8}]` disambiguator (first 8
464/// of the stable stem id) keeps blank or duplicate labels collision-free. Every
465/// component runs through the same [`sanitise_component`] filter, honouring
466/// `character_set`.
467pub fn stem_file_path(
468    base: &str,
469    label: &str,
470    stem_id: &str,
471    ext: &str,
472    character_set: CharacterSet,
473) -> String {
474    let folder = stems_folder(base);
475    // The song's own file-name stem (the last path component of `base`), reused
476    // so a stem stays identifiable even when viewed outside its `.stems` folder.
477    let song_stem = base.rsplit('/').next().unwrap_or(base);
478    let label = sanitise_component(label, character_set, DEFAULT_MAX_COMPONENT_LEN);
479    let id8 = sanitise_component(
480        &truncate_chars(stem_id, 8),
481        CharacterSet::Ascii,
482        DEFAULT_MAX_COMPONENT_LEN,
483    );
484
485    let mut name = song_stem.to_string();
486    if !label.is_empty() {
487        name.push_str(" - ");
488        name.push_str(&label);
489    }
490    if !id8.is_empty() {
491        name.push_str(" [");
492        name.push_str(&id8);
493        name.push(']');
494    }
495    // A degenerate base (empty song stem, blank label, empty id) must still
496    // yield a usable name rather than a hidden dotfile.
497    if name.trim().is_empty() {
498        name = "stem".to_string();
499    }
500    format!("{folder}/{name}.{}", sanitise_ext(ext))
501}
502
503/// Reduce a candidate extension to a safe lowercase alphanumeric token,
504/// defaulting to `mp3` when it is empty or fully stripped. The caller passes the
505/// resolved stem format's extension (`wav` or `mp3`); stems are stored RAW.
506fn sanitise_ext(ext: &str) -> String {
507    let cleaned: String = ext
508        .trim_start_matches('.')
509        .chars()
510        .filter(|c| c.is_ascii_alphanumeric())
511        .flat_map(char::to_lowercase)
512        .take(8)
513        .collect();
514    if cleaned.is_empty() {
515        "mp3".to_string()
516    } else {
517        cleaned
518    }
519}
520
521fn sanitise_component(
522    value: &str,
523    character_set: CharacterSet,
524    max_component_len: usize,
525) -> String {
526    // Single pass: map each char to its charset-safe form, collapsing runs of
527    // whitespace to one space and dropping leading/trailing whitespace.
528    let mut collapsed = String::with_capacity(value.len());
529    let mut pending_space = false;
530    let push = |out: char, buf: &mut String, pending: &mut bool| {
531        if out.is_whitespace() {
532            *pending = !buf.is_empty();
533        } else {
534            if *pending {
535                buf.push(' ');
536            }
537            *pending = false;
538            buf.push(out);
539        }
540    };
541    match character_set {
542        CharacterSet::Unicode => {
543            for ch in value.chars() {
544                push(unicode_char(ch), &mut collapsed, &mut pending_space);
545            }
546        }
547        CharacterSet::Ascii => {
548            for ch in value.chars() {
549                for out in ascii_chars(ch) {
550                    push(out, &mut collapsed, &mut pending_space);
551                }
552            }
553        }
554    }
555
556    let trimmed = collapsed.trim_matches([' ', '.']);
557    if trimmed.is_empty() {
558        return String::new();
559    }
560
561    // Keep at most `max` characters, then trim any space or dot the cut exposed.
562    // Slice at a char boundary rather than truncating a copy.
563    let max = max_component_len.max(1);
564    let end = trimmed
565        .char_indices()
566        .nth(max)
567        .map_or(trimmed.len(), |(index, _)| index);
568    let result = trimmed[..end].trim_matches([' ', '.']);
569    if result.is_empty() {
570        return String::new();
571    }
572    if result == "." || result == ".." {
573        return "item".to_string();
574    }
575    let mut result = result.to_string();
576    if is_reserved_name(&result) {
577        // Guard the stem, not the whole component: `NUL.mp3` must become
578        // `NUL_.mp3`, since `NUL.mp3_` keeps `NUL` as its dot-stem and stays a
579        // Windows device name.
580        let stem_end = result.find('.').unwrap_or(result.len());
581        result.insert(stem_end, '_');
582    }
583    result
584}
585
586fn unicode_char(ch: char) -> char {
587    if matches!(
588        ch,
589        '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0'
590    ) || ch.is_control()
591    {
592        ' '
593    } else {
594        ch
595    }
596}
597
598fn ascii_chars(ch: char) -> Vec<char> {
599    if ch.is_ascii() {
600        return vec![unicode_char(ch)];
601    }
602
603    match ch {
604        'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => vec!['A'],
605        'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => vec!['a'],
606        'Ç' => vec!['C'],
607        'ç' => vec!['c'],
608        'È' | 'É' | 'Ê' | 'Ë' => vec!['E'],
609        'è' | 'é' | 'ê' | 'ë' => vec!['e'],
610        'Ì' | 'Í' | 'Î' | 'Ï' => vec!['I'],
611        'ì' | 'í' | 'î' | 'ï' => vec!['i'],
612        'Ñ' => vec!['N'],
613        'ñ' => vec!['n'],
614        'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'Ø' => vec!['O'],
615        'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' => vec!['o'],
616        'Ù' | 'Ú' | 'Û' | 'Ü' => vec!['U'],
617        'ù' | 'ú' | 'û' | 'ü' => vec!['u'],
618        'Ý' | 'Ÿ' => vec!['Y'],
619        'ý' | 'ÿ' => vec!['y'],
620        'Æ' => vec!['A', 'E'],
621        'æ' => vec!['a', 'e'],
622        'Œ' => vec!['O', 'E'],
623        'œ' => vec!['o', 'e'],
624        'ß' => vec!['s', 's'],
625        _ => vec![' '],
626    }
627}
628
629fn truncate_chars(value: &str, max_len: usize) -> String {
630    value.chars().take(max_len).collect()
631}
632
633fn non_blank(value: &str) -> Option<&str> {
634    let trimmed = value.trim();
635    (!trimmed.is_empty()).then_some(trimmed)
636}
637
638fn is_reserved_name(value: &str) -> bool {
639    let stem = value.split('.').next().unwrap_or(value);
640    // Every reserved device name is 3 (CON/PRN/AUX/NUL) or 4 (COMx/LPTx) ASCII
641    // bytes, so anything else cannot match without allocating an uppercased copy.
642    if !matches!(stem.len(), 3 | 4) {
643        return false;
644    }
645    const RESERVED: [&str; 22] = [
646        "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
647        "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
648    ];
649    RESERVED.iter().any(|name| name.eq_ignore_ascii_case(stem))
650}
651
652#[cfg(test)]
653mod tests;