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;
13
14/// The default relative path template.
15///
16/// Supported placeholders are `{creator}`, `{handle}`, `{album}`, `{title}`,
17/// `{id}`, `{id8}` (first 8 characters of the clip id), and `{root_id8}`
18/// (first 8 of the resolved lineage root id). Empty path segments are dropped
19/// after rendering.
20///
21/// The default embeds `[{id8}]` in the file name so same-title clips never
22/// collide, and folders under `{album}`, which resolves to the lineage root's
23/// title (else the clip's own title).
24pub const DEFAULT_TEMPLATE: &str = "{creator}/{album}/{creator}-{title} [{id8}]";
25const DEFAULT_MAX_COMPONENT_LEN: usize = 80;
26
27const MIN_BASE_CHARS_WITH_SUFFIX: usize = 1;
28
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum CharacterSet {
32    #[default]
33    Unicode,
34    Ascii,
35}
36
37impl FromStr for CharacterSet {
38    type Err = Error;
39
40    fn from_str(s: &str) -> Result<Self> {
41        match s.to_ascii_lowercase().as_str() {
42            "unicode" => Ok(Self::Unicode),
43            "ascii" => Ok(Self::Ascii),
44            other => Err(Error::Config(format!(
45                "unknown character_set '{other}'; expected 'unicode' or 'ascii'"
46            ))),
47        }
48    }
49}
50
51impl fmt::Display for CharacterSet {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            Self::Unicode => f.write_str("unicode"),
55            Self::Ascii => f.write_str("ascii"),
56        }
57    }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct NamingConfig {
62    pub template: String,
63    pub character_set: CharacterSet,
64    pub max_component_len: usize,
65}
66
67impl Default for NamingConfig {
68    fn default() -> Self {
69        Self {
70            template: DEFAULT_TEMPLATE.to_string(),
71            character_set: CharacterSet::Unicode,
72            max_component_len: DEFAULT_MAX_COMPONENT_LEN,
73        }
74    }
75}
76
77#[derive(Debug, Clone, Copy)]
78pub struct NamingRequest<'a> {
79    pub clip: &'a Clip,
80    pub lineage: &'a LineageContext,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct RenderedName {
85    pub relative_path: PathBuf,
86    pub base_name: String,
87}
88
89pub fn render_clip_name(request: NamingRequest<'_>, config: &NamingConfig) -> RenderedName {
90    let album = album_component(request, config);
91    render_with_album(request, config, &album)
92}
93
94pub fn render_clip_names(
95    requests: &[NamingRequest<'_>],
96    config: &NamingConfig,
97    colliding_albums: &BTreeSet<String>,
98) -> Vec<RenderedName> {
99    let albums = disambiguated_albums(requests, config, colliding_albums);
100    let mut rendered = requests
101        .iter()
102        .zip(&albums)
103        .map(|(request, album)| render_with_album(*request, config, album))
104        .collect::<Vec<_>>();
105
106    // Filename fallback: any distinct clips that still render to one path (a
107    // custom template lacking `{id8}`) are separated by their full clip id.
108    let mut collisions = BTreeMap::<String, Vec<usize>>::new();
109    for (index, name) in rendered.iter().enumerate() {
110        collisions
111            .entry(name.relative_path.to_string_lossy().into_owned())
112            .or_default()
113            .push(index);
114    }
115
116    for indexes in collisions.into_values().filter(|indexes| indexes.len() > 1) {
117        for index in indexes {
118            let suffix = &requests[index].clip.id;
119            rendered[index] =
120                with_suffix(rendered[index].clone(), suffix, config.max_component_len);
121        }
122    }
123
124    rendered
125}
126
127/// The album path component for every request, with a clip whose root title
128/// collides across distinct roots disambiguated by `[{root_id8}]`.
129///
130/// Distinct roots must never share an album folder (two different upload roots
131/// titled "Break Through" exist). `colliding_albums` is the authoritative set
132/// of such shared root titles, computed once from the whole lineage store, so
133/// the decision is stable across runs and independent of which clips appear in
134/// this batch. A clip whose resolved album is in that set always gets its
135/// root's short id appended; every other clip keeps the bare album and groups
136/// with its same-root siblings.
137fn disambiguated_albums(
138    requests: &[NamingRequest<'_>],
139    config: &NamingConfig,
140    colliding_albums: &BTreeSet<String>,
141) -> Vec<String> {
142    requests
143        .iter()
144        .map(|request| album_for(*request, config, colliding_albums))
145        .collect()
146}
147
148/// The (possibly disambiguated) album component for one request.
149fn album_for(
150    request: NamingRequest<'_>,
151    config: &NamingConfig,
152    colliding_albums: &BTreeSet<String>,
153) -> String {
154    let raw_album = request.lineage.album(&title_name(request.clip));
155    let album = sanitise_component(&raw_album, config.character_set, config.max_component_len);
156    if colliding_albums.contains(raw_album.trim()) {
157        let suffix = truncate_chars(&request.lineage.root_id, 8);
158        sanitise_component(
159            &format!("{album} [{suffix}]"),
160            config.character_set,
161            config.max_component_len,
162        )
163    } else {
164        album
165    }
166}
167
168/// The sanitised album component: the resolved lineage album (root title, else
169/// the clip's own title).
170fn album_component(request: NamingRequest<'_>, config: &NamingConfig) -> String {
171    let album = request.lineage.album(&title_name(request.clip));
172    sanitise_component(&album, config.character_set, config.max_component_len)
173}
174
175/// Render one clip's path with an already-resolved album component.
176fn render_with_album(
177    request: NamingRequest<'_>,
178    config: &NamingConfig,
179    album: &str,
180) -> RenderedName {
181    let clip = request.clip;
182    let creator = sanitise_component(
183        &creator_name(clip),
184        config.character_set,
185        config.max_component_len,
186    );
187    let handle = sanitise_component(&clip.handle, config.character_set, config.max_component_len);
188    let title = sanitise_component(
189        &title_name(clip),
190        config.character_set,
191        config.max_component_len,
192    );
193    let id = sanitise_component(&clip.id, CharacterSet::Ascii, config.max_component_len);
194    let id8 = sanitise_component(
195        &truncate_chars(&clip.id, 8),
196        CharacterSet::Ascii,
197        config.max_component_len,
198    );
199    let root_id8 = sanitise_component(
200        &truncate_chars(&request.lineage.root_id, 8),
201        CharacterSet::Ascii,
202        config.max_component_len,
203    );
204    let mut components = config
205        .template
206        .split('/')
207        .filter_map(|segment| {
208            let rendered = segment
209                .replace("{creator}", &creator)
210                .replace("{handle}", &handle)
211                .replace("{album}", album)
212                .replace("{title}", &title)
213                .replace("{root_id8}", &root_id8)
214                .replace("{id8}", &id8)
215                .replace("{id}", &id);
216            let sanitised =
217                sanitise_component(&rendered, config.character_set, config.max_component_len);
218            (!sanitised.is_empty()).then_some(sanitised)
219        })
220        .collect::<Vec<_>>();
221
222    if components.is_empty() {
223        components.push(title.clone());
224    }
225
226    let mut base_name = components
227        .pop()
228        .filter(|value| !value.is_empty())
229        .unwrap_or_else(|| title.clone());
230    // Guarantee a non-empty file name even when every token sanitises away.
231    if base_name.is_empty() {
232        base_name = append_suffix(&base_name, &clip.id, config.max_component_len);
233    }
234
235    let mut relative_path = PathBuf::new();
236    for component in components {
237        relative_path.push(component);
238    }
239
240    relative_path.push(&base_name);
241    RenderedName {
242        relative_path,
243        base_name,
244    }
245}
246
247fn with_suffix(mut rendered: RenderedName, suffix: &str, max_component_len: usize) -> RenderedName {
248    rendered.base_name = append_suffix(&rendered.base_name, suffix, max_component_len);
249    rendered.relative_path.set_file_name(&rendered.base_name);
250    rendered
251}
252
253fn creator_name(clip: &Clip) -> String {
254    non_blank(&clip.display_name)
255        .or_else(|| non_blank(&clip.handle))
256        .unwrap_or("Unknown Creator")
257        .to_string()
258}
259
260fn title_name(clip: &Clip) -> String {
261    let title = clip.title.trim();
262    if title.is_empty() || title.eq_ignore_ascii_case("untitled") {
263        "Untitled".to_string()
264    } else {
265        title.to_string()
266    }
267}
268
269fn append_suffix(base: &str, suffix: &str, max_component_len: usize) -> String {
270    let suffix_pattern = format!(" [{suffix}]");
271    if base.ends_with(&suffix_pattern) {
272        return sanitise_component(base, CharacterSet::Unicode, max_component_len);
273    }
274
275    let max_len =
276        max_component_len.max(suffix_pattern.chars().count() + MIN_BASE_CHARS_WITH_SUFFIX);
277    let allowed = max_len.saturating_sub(suffix_pattern.chars().count());
278    let truncated = truncate_chars(base.trim_end(), allowed);
279    let combined = format!("{truncated}{suffix_pattern}");
280    sanitise_component(&combined, CharacterSet::Unicode, max_len)
281}
282
283/// Sanitise a free-form playlist name into a single safe path component.
284///
285/// Applies the same Unicode filtering and length cap as clip path components
286/// (default [`CharacterSet::Unicode`], [`DEFAULT_MAX_COMPONENT_LEN`]), so a
287/// playlist file name obeys the same filesystem rules as the rest of the
288/// library. An empty or fully-stripped name falls back to `playlist` so the
289/// caller always has a non-empty stem to append `.m3u8` to.
290pub fn sanitise_name(name: &str) -> String {
291    let cleaned = sanitise_component(name, CharacterSet::Unicode, DEFAULT_MAX_COMPONENT_LEN);
292    if cleaned.is_empty() {
293        "playlist".to_string()
294    } else {
295        cleaned
296    }
297}
298
299fn sanitise_component(
300    value: &str,
301    character_set: CharacterSet,
302    max_component_len: usize,
303) -> String {
304    let filtered = match character_set {
305        CharacterSet::Unicode => value.chars().map(unicode_char).collect::<String>(),
306        CharacterSet::Ascii => value.chars().flat_map(ascii_chars).collect::<String>(),
307    };
308    let collapsed = filtered.split_whitespace().collect::<Vec<_>>().join(" ");
309    let trimmed = collapsed.trim_matches([' ', '.']);
310    if trimmed.is_empty() {
311        return String::new();
312    }
313
314    let mut result = truncate_chars(trimmed, max_component_len.max(1));
315    result = result.trim_matches([' ', '.']).to_string();
316    if result.is_empty() {
317        return String::new();
318    }
319    if result == "." || result == ".." {
320        return "item".to_string();
321    }
322    if !result.ends_with('_') && is_reserved_name(&result) {
323        result.push('_');
324    }
325    result
326}
327
328fn unicode_char(ch: char) -> char {
329    if matches!(
330        ch,
331        '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0'
332    ) || ch.is_control()
333    {
334        ' '
335    } else {
336        ch
337    }
338}
339
340fn ascii_chars(ch: char) -> Vec<char> {
341    if ch.is_ascii() {
342        return vec![unicode_char(ch)];
343    }
344
345    match ch {
346        'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => vec!['A'],
347        'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => vec!['a'],
348        'Ç' => vec!['C'],
349        'ç' => vec!['c'],
350        'È' | 'É' | 'Ê' | 'Ë' => vec!['E'],
351        'è' | 'é' | 'ê' | 'ë' => vec!['e'],
352        'Ì' | 'Í' | 'Î' | 'Ï' => vec!['I'],
353        'ì' | 'í' | 'î' | 'ï' => vec!['i'],
354        'Ñ' => vec!['N'],
355        'ñ' => vec!['n'],
356        'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'Ø' => vec!['O'],
357        'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' => vec!['o'],
358        'Ù' | 'Ú' | 'Û' | 'Ü' => vec!['U'],
359        'ù' | 'ú' | 'û' | 'ü' => vec!['u'],
360        'Ý' | 'Ÿ' => vec!['Y'],
361        'ý' | 'ÿ' => vec!['y'],
362        'Æ' => vec!['A', 'E'],
363        'æ' => vec!['a', 'e'],
364        'Œ' => vec!['O', 'E'],
365        'œ' => vec!['o', 'e'],
366        'ß' => vec!['s', 's'],
367        _ => vec![' '],
368    }
369}
370
371fn truncate_chars(value: &str, max_len: usize) -> String {
372    value.chars().take(max_len).collect()
373}
374
375fn non_blank(value: &str) -> Option<&str> {
376    let trimmed = value.trim();
377    (!trimmed.is_empty()).then_some(trimmed)
378}
379
380fn is_reserved_name(value: &str) -> bool {
381    let stem = value.split('.').next().unwrap_or(value);
382    matches!(
383        stem.to_ascii_uppercase().as_str(),
384        "CON"
385            | "PRN"
386            | "AUX"
387            | "NUL"
388            | "COM1"
389            | "COM2"
390            | "COM3"
391            | "COM4"
392            | "COM5"
393            | "COM6"
394            | "COM7"
395            | "COM8"
396            | "COM9"
397            | "LPT1"
398            | "LPT2"
399            | "LPT3"
400            | "LPT4"
401            | "LPT5"
402            | "LPT6"
403            | "LPT7"
404            | "LPT8"
405            | "LPT9"
406    )
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::lineage::{EdgeType, ResolveStatus};
413    use std::collections::{BTreeMap, BTreeSet};
414
415    fn test_clip(id: &str, title: &str) -> Clip {
416        Clip {
417            id: id.to_string(),
418            title: title.to_string(),
419            display_name: "München".to_string(),
420            handle: "munchen".to_string(),
421            album_title: String::new(),
422            root_ancestor_id: String::new(),
423            ..Clip::default()
424        }
425    }
426
427    fn render_own(clip: &Clip, config: &NamingConfig) -> RenderedName {
428        let lineage = LineageContext::own_root(clip);
429        render_clip_name(
430            NamingRequest {
431                clip,
432                lineage: &lineage,
433            },
434            config,
435        )
436    }
437
438    fn render_all_own(
439        clips: &[Clip],
440        config: &NamingConfig,
441        colliding: &BTreeSet<String>,
442    ) -> Vec<RenderedName> {
443        let lineages: Vec<LineageContext> = clips.iter().map(LineageContext::own_root).collect();
444        let requests: Vec<NamingRequest> = clips
445            .iter()
446            .zip(&lineages)
447            .map(|(clip, lineage)| NamingRequest { clip, lineage })
448            .collect();
449        render_clip_names(&requests, config, colliding)
450    }
451
452    #[test]
453    fn unicode_names_are_preserved_and_ascii_falls_back() {
454        let clip = test_clip("abc12345", "Beyoncé/東京");
455
456        let unicode = render_own(&clip, &NamingConfig::default());
457        assert_eq!(
458            unicode.relative_path.to_string_lossy(),
459            "München/Beyoncé 東京/München-Beyoncé 東京 [abc12345]"
460        );
461
462        let ascii = render_own(
463            &clip,
464            &NamingConfig {
465                character_set: CharacterSet::Ascii,
466                ..NamingConfig::default()
467            },
468        );
469        assert_eq!(
470            ascii.relative_path.to_string_lossy(),
471            "Munchen/Beyonce/Munchen-Beyonce [abc12345]"
472        );
473    }
474
475    #[test]
476    fn reserved_and_hostile_names_are_sanitised() {
477        let clip = Clip {
478            id: "deadbeef".to_string(),
479            title: "CON<>:\"/\\|?*.".to_string(),
480            display_name: "AUX".to_string(),
481            ..Clip::default()
482        };
483
484        let rendered = render_own(&clip, &NamingConfig::default());
485        let path = rendered.relative_path.to_string_lossy();
486        assert!(path.starts_with("AUX_/CON_/"), "path was {path}");
487        assert!(rendered.base_name.contains("[deadbeef]"));
488    }
489
490    #[test]
491    fn default_template_always_embeds_id8() {
492        let clip = test_clip("abcdef1234567890", "Any Title");
493        let rendered = render_own(&clip, &NamingConfig::default());
494        assert!(
495            rendered.base_name.contains("[abcdef12]"),
496            "base_name was {}",
497            rendered.base_name
498        );
499    }
500
501    #[test]
502    fn blank_titles_use_a_stable_suffix() {
503        let clip = test_clip("12345678-clip", "   ");
504
505        let rendered = render_own(&clip, &NamingConfig::default());
506        assert_eq!(rendered.base_name, "München-Untitled [12345678]");
507        assert_eq!(
508            rendered.relative_path.to_string_lossy(),
509            "München/Untitled/München-Untitled [12345678]"
510        );
511    }
512
513    #[test]
514    fn very_long_titles_are_trimmed() {
515        let clip = test_clip("abcdef12", &"a".repeat(120));
516        let rendered = render_own(
517            &clip,
518            &NamingConfig {
519                max_component_len: 24,
520                ..NamingConfig::default()
521            },
522        );
523
524        for component in rendered.relative_path.components() {
525            let text = component.as_os_str().to_string_lossy();
526            assert!(
527                text.chars().count() <= 24,
528                "component {text:?} exceeds 24 chars"
529            );
530        }
531    }
532
533    #[test]
534    fn same_title_siblings_stay_distinct_via_id8() {
535        // Two clips sharing a root (same album folder) and the same title must
536        // still land on distinct files; the default template's {id8} does that.
537        let lineage = LineageContext {
538            root_id: "root-9".to_string(),
539            root_title: "Origin".to_string(),
540            parent_id: "root-9".to_string(),
541            edge_type: Some(EdgeType::Cover),
542            status: ResolveStatus::Resolved,
543        };
544        let first = test_clip("11111111-alpha", "Shared");
545        let second = test_clip("22222222-beta", "Shared");
546        let requests = [
547            NamingRequest {
548                clip: &first,
549                lineage: &lineage,
550            },
551            NamingRequest {
552                clip: &second,
553                lineage: &lineage,
554            },
555        ];
556
557        let names = render_clip_names(&requests, &NamingConfig::default(), &BTreeSet::new());
558
559        assert_eq!(
560            names[0].relative_path.to_string_lossy(),
561            "München/Origin/München-Shared [11111111]"
562        );
563        assert_eq!(
564            names[1].relative_path.to_string_lossy(),
565            "München/Origin/München-Shared [22222222]"
566        );
567    }
568
569    #[test]
570    fn id8_prefix_collision_falls_back_to_full_id() {
571        // Custom template without {id8} so identical titles collide and the
572        // filename fallback (full id) has to keep them distinct.
573        let config = NamingConfig {
574            template: "{creator}/{title}".to_string(),
575            ..NamingConfig::default()
576        };
577        let first = test_clip("abcd1234-first", "Untitled");
578        let second = test_clip("abcd1234-second", "Untitled");
579
580        let names = render_all_own(&[first.clone(), second.clone()], &config, &BTreeSet::new());
581        let swapped = render_all_own(&[second.clone(), first.clone()], &config, &BTreeSet::new());
582
583        assert_ne!(
584            names[0].relative_path.to_string_lossy(),
585            names[1].relative_path.to_string_lossy()
586        );
587
588        let ordered = |rendered: &[RenderedName], clips: &[Clip]| {
589            clips
590                .iter()
591                .zip(rendered)
592                .map(|(clip, name)| {
593                    (
594                        clip.id.clone(),
595                        name.relative_path.to_string_lossy().into_owned(),
596                    )
597                })
598                .collect::<BTreeMap<_, _>>()
599        };
600        assert_eq!(
601            ordered(&names, &[first.clone(), second.clone()]),
602            ordered(&swapped, &[second, first])
603        );
604    }
605
606    #[test]
607    fn album_is_root_title_for_a_remix() {
608        let clip = Clip {
609            id: "child".to_string(),
610            title: "Remix".to_string(),
611            display_name: "München".to_string(),
612            ..Clip::default()
613        };
614        let lineage = LineageContext {
615            root_id: "root-1".to_string(),
616            root_title: "Original".to_string(),
617            parent_id: "root-1".to_string(),
618            edge_type: Some(EdgeType::Cover),
619            status: ResolveStatus::Resolved,
620        };
621
622        let rendered = render_clip_name(
623            NamingRequest {
624                clip: &clip,
625                lineage: &lineage,
626            },
627            &NamingConfig::default(),
628        );
629        assert_eq!(
630            rendered.relative_path.to_string_lossy(),
631            "München/Original/München-Remix [child]"
632        );
633    }
634
635    #[test]
636    fn album_is_own_title_for_a_root() {
637        let clip = Clip {
638            id: "root-1".to_string(),
639            title: "Original".to_string(),
640            display_name: "München".to_string(),
641            ..Clip::default()
642        };
643
644        let rendered = render_own(&clip, &NamingConfig::default());
645        assert_eq!(
646            rendered.relative_path.to_string_lossy(),
647            "München/Original/München-Original [root-1]"
648        );
649    }
650
651    #[test]
652    fn shared_album_title_from_distinct_roots_is_disambiguated() {
653        let first = Clip {
654            id: "aaaa1111-x".to_string(),
655            title: "Break Through".to_string(),
656            display_name: "München".to_string(),
657            ..Clip::default()
658        };
659        let second = Clip {
660            id: "bbbb2222-y".to_string(),
661            title: "Break Through".to_string(),
662            display_name: "München".to_string(),
663            ..Clip::default()
664        };
665
666        // The colliding set is authoritative (store-driven), so disambiguation
667        // does not depend on both roots appearing in the same batch.
668        let colliding: BTreeSet<String> = ["Break Through".to_string()].into_iter().collect();
669        let names = render_all_own(
670            &[first.clone(), second.clone()],
671            &NamingConfig::default(),
672            &colliding,
673        );
674        let swapped = render_all_own(
675            &[second.clone(), first.clone()],
676            &NamingConfig::default(),
677            &colliding,
678        );
679
680        let album_of = |rendered: &RenderedName| {
681            rendered
682                .relative_path
683                .components()
684                .nth(1)
685                .map(|component| component.as_os_str().to_string_lossy().into_owned())
686                .unwrap_or_default()
687        };
688
689        assert_eq!(album_of(&names[0]), "Break Through [aaaa1111]");
690        assert_eq!(album_of(&names[1]), "Break Through [bbbb2222]");
691        // Deterministic regardless of input order.
692        assert_eq!(album_of(&swapped[0]), "Break Through [bbbb2222]");
693        assert_eq!(album_of(&swapped[1]), "Break Through [aaaa1111]");
694
695        // The MEDIUM fix: a narrowed run showing only one of the two roots
696        // still gets the suffixed folder, so folders never oscillate.
697        let alone = render_all_own(
698            std::slice::from_ref(&first),
699            &NamingConfig::default(),
700            &colliding,
701        );
702        assert_eq!(album_of(&alone[0]), "Break Through [aaaa1111]");
703    }
704
705    #[test]
706    fn unique_root_title_stays_a_bare_album() {
707        // A title absent from the colliding set keeps its bare folder even when
708        // the batch happens to hold a same-titled sibling of the same root.
709        let clip = Clip {
710            id: "solo-1".to_string(),
711            title: "Solo".to_string(),
712            display_name: "München".to_string(),
713            ..Clip::default()
714        };
715        let names = render_all_own(&[clip], &NamingConfig::default(), &BTreeSet::new());
716        assert_eq!(
717            names[0].relative_path.to_string_lossy(),
718            "München/Solo/München-Solo [solo-1]"
719        );
720    }
721
722    #[test]
723    fn sanitise_name_strips_separators_and_falls_back_when_empty() {
724        assert_eq!(sanitise_name("Road/Trip: 2024"), "Road Trip 2024");
725        assert_eq!(sanitise_name(""), "playlist");
726        // A name made only of illegal characters strips to nothing, so the
727        // caller still gets a usable, non-empty stem.
728        assert_eq!(sanitise_name("///"), "playlist");
729    }
730}