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