1use 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
15pub 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> {
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 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
143fn 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
163fn 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
184fn 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
191fn 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 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 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 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
407fn 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
431pub 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
447pub fn stems_folder(base: &str) -> String {
454 format!("{base}.stems")
455}
456
457pub 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 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 if name.trim().is_empty() {
498 name = "stem".to_string();
499 }
500 format!("{folder}/{name}.{}", sanitise_ext(ext))
501}
502
503fn 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 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 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 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 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;