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