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 {
105 let album = album_component(request, config);
106 render_with_album(request, config, &album)
107}
108
109pub fn render_clip_names(
121 requests: &[NamingRequest<'_>],
122 config: &NamingConfig,
123 colliding_albums: &BTreeSet<String>,
124 colliding_ids: &BTreeSet<String>,
125) -> Vec<RenderedName> {
126 let albums = disambiguated_albums(requests, config, colliding_albums);
127 let mut rendered = requests
128 .iter()
129 .zip(&albums)
130 .map(|(request, album)| render_with_album(*request, config, album))
131 .collect::<Vec<_>>();
132
133 for (index, request) in requests.iter().enumerate() {
138 if colliding_ids.contains(&request.clip.id) {
139 rendered[index] = with_suffix(
140 rendered[index].clone(),
141 &request.clip.id,
142 config.character_set,
143 config.max_component_len,
144 );
145 }
146 }
147
148 for apply_canonical in [false, true] {
153 let mut collisions = BTreeMap::<String, Vec<usize>>::new();
154 for (index, name) in rendered.iter().enumerate() {
155 let key = if apply_canonical {
156 canonical_path_key(&name.relative_path.to_string_lossy())
157 } else {
158 name.relative_path.to_string_lossy().into_owned()
159 };
160 collisions.entry(key).or_default().push(index);
161 }
162 for indexes in collisions.into_values().filter(|v| v.len() > 1) {
163 for index in indexes {
164 let suffix = &requests[index].clip.id;
165 rendered[index] = with_suffix(
166 rendered[index].clone(),
167 suffix,
168 config.character_set,
169 config.max_component_len,
170 );
171 }
172 }
173 }
174
175 rendered
176}
177
178fn disambiguated_albums(
188 requests: &[NamingRequest<'_>],
189 config: &NamingConfig,
190 colliding_albums: &BTreeSet<String>,
191) -> Vec<String> {
192 requests
193 .iter()
194 .map(|request| album_for(*request, config, colliding_albums))
195 .collect()
196}
197
198fn album_for(
200 request: NamingRequest<'_>,
201 config: &NamingConfig,
202 colliding_albums: &BTreeSet<String>,
203) -> String {
204 let raw_album = request.lineage.album(&title_name(request.clip));
205 let album = sanitise_component(&raw_album, config.character_set, config.max_component_len);
206 if colliding_albums.contains(raw_album.trim()) {
207 let suffix = truncate_chars(&request.lineage.root_id, 8);
208 append_suffix(
209 &album,
210 &suffix,
211 config.character_set,
212 config.max_component_len,
213 )
214 } else {
215 album
216 }
217}
218
219fn album_component(request: NamingRequest<'_>, config: &NamingConfig) -> String {
222 let album = request.lineage.album(&title_name(request.clip));
223 sanitise_component(&album, config.character_set, config.max_component_len)
224}
225
226fn render_with_album(
228 request: NamingRequest<'_>,
229 config: &NamingConfig,
230 album: &str,
231) -> RenderedName {
232 let clip = request.clip;
233 let creator = sanitise_component(
234 &creator_name(clip),
235 config.character_set,
236 config.max_component_len,
237 );
238 let handle = sanitise_component(&clip.handle, config.character_set, config.max_component_len);
239 let title = sanitise_component(
240 &title_name(clip),
241 config.character_set,
242 config.max_component_len,
243 );
244 let id = sanitise_component(&clip.id, CharacterSet::Ascii, config.max_component_len);
245 let id8 = sanitise_component(
246 &truncate_chars(&clip.id, 8),
247 CharacterSet::Ascii,
248 config.max_component_len,
249 );
250 let root_id8 = sanitise_component(
251 &truncate_chars(&request.lineage.root_id, 8),
252 CharacterSet::Ascii,
253 config.max_component_len,
254 );
255 let track = request.lineage.track;
256 let track_raw = if track > 0 {
257 track.to_string()
258 } else {
259 String::new()
260 };
261 let track_pad = if track > 0 {
262 format!("{track:02}")
263 } else {
264 String::new()
265 };
266 let substitutions = SegmentSubstitutions {
267 creator: &creator,
268 handle: &handle,
269 album,
270 title: &title,
271 root_id8: &root_id8,
272 id8: &id8,
273 id: &id,
274 track: &track_raw,
275 track2: &track_pad,
276 };
277 let mut components = config
278 .template
279 .split('/')
280 .filter_map(|segment| {
281 let rendered = substitute_segment(segment, substitutions);
282 let sanitised = sanitise_segment(
283 &rendered,
284 config.character_set,
285 config.max_component_len,
286 [id8.as_str(), root_id8.as_str()],
287 );
288 (!sanitised.is_empty()).then_some(sanitised)
289 })
290 .collect::<Vec<_>>();
291
292 if components.is_empty() {
293 components.push(title.clone());
294 }
295
296 let mut base_name = components
297 .pop()
298 .filter(|value| !value.is_empty())
299 .unwrap_or_else(|| title.clone());
300 if base_name.is_empty() {
302 base_name = append_suffix(
303 &base_name,
304 &clip.id,
305 config.character_set,
306 config.max_component_len,
307 );
308 }
309
310 let mut relative_path = PathBuf::new();
311 for component in components {
312 relative_path.push(component);
313 }
314
315 relative_path.push(&base_name);
316 RenderedName {
317 relative_path,
318 base_name,
319 }
320}
321
322#[derive(Clone, Copy)]
323struct SegmentSubstitutions<'a> {
324 creator: &'a str,
325 handle: &'a str,
326 album: &'a str,
327 title: &'a str,
328 root_id8: &'a str,
329 id8: &'a str,
330 id: &'a str,
331 track: &'a str,
332 track2: &'a str,
333}
334
335fn substitute_segment(segment: &str, substitutions: SegmentSubstitutions<'_>) -> String {
336 let mut rendered = String::with_capacity(segment.len());
337 let mut remainder = segment;
338 while let Some(start) = remainder.find('{') {
339 rendered.push_str(&remainder[..start]);
340 remainder = &remainder[start..];
341 if let Some((token_len, value)) = placeholder_match(remainder, substitutions) {
342 rendered.push_str(value);
343 remainder = &remainder[token_len..];
344 if value.is_empty() {
348 remainder = remainder.trim_start_matches([' ', '-', '_', '.']);
349 }
350 } else {
351 rendered.push('{');
352 remainder = &remainder[1..];
353 }
354 }
355 rendered.push_str(remainder);
356 rendered
357}
358
359fn placeholder_match<'a>(
360 segment: &str,
361 substitutions: SegmentSubstitutions<'a>,
362) -> Option<(usize, &'a str)> {
363 if segment.starts_with("{creator}") {
364 Some(("{creator}".len(), substitutions.creator))
365 } else if segment.starts_with("{handle}") {
366 Some(("{handle}".len(), substitutions.handle))
367 } else if segment.starts_with("{album}") {
368 Some(("{album}".len(), substitutions.album))
369 } else if segment.starts_with("{title}") {
370 Some(("{title}".len(), substitutions.title))
371 } else if segment.starts_with("{root_id8}") {
372 Some(("{root_id8}".len(), substitutions.root_id8))
373 } else if segment.starts_with("{id8}") {
374 Some(("{id8}".len(), substitutions.id8))
375 } else if segment.starts_with("{id}") {
376 Some(("{id}".len(), substitutions.id))
377 } else if segment.starts_with("{track2}") {
378 Some(("{track2}".len(), substitutions.track2))
379 } else if segment.starts_with("{track}") {
380 Some(("{track}".len(), substitutions.track))
381 } else {
382 None
383 }
384}
385
386fn with_suffix(
387 mut rendered: RenderedName,
388 suffix: &str,
389 character_set: CharacterSet,
390 max_component_len: usize,
391) -> RenderedName {
392 rendered.base_name = append_suffix(
393 &rendered.base_name,
394 suffix,
395 character_set,
396 max_component_len,
397 );
398 rendered.relative_path.set_file_name(&rendered.base_name);
399 rendered
400}
401
402fn creator_name(clip: &Clip) -> String {
403 non_blank(&clip.display_name)
404 .or_else(|| non_blank(&clip.handle))
405 .unwrap_or("Unknown Creator")
406 .to_string()
407}
408
409fn title_name(clip: &Clip) -> String {
410 let title = clip.title.trim();
411 if title.is_empty() || title.eq_ignore_ascii_case("untitled") {
412 "Untitled".to_string()
413 } else {
414 title.to_string()
415 }
416}
417
418fn append_suffix(
419 base: &str,
420 suffix: &str,
421 character_set: CharacterSet,
422 max_component_len: usize,
423) -> String {
424 let suffix_pattern = format!(" [{suffix}]");
425 if base.ends_with(&suffix_pattern) {
426 return sanitise_component(base, character_set, max_component_len);
427 }
428
429 let max_len =
430 max_component_len.max(suffix_pattern.chars().count() + MIN_BASE_CHARS_WITH_SUFFIX);
431 let allowed = max_len.saturating_sub(suffix_pattern.chars().count());
432 let base = sanitise_component(base, character_set, max_len);
437 let truncated = truncate_chars(base.trim_end(), allowed);
438 let combined = format!("{truncated}{suffix_pattern}");
439 sanitise_component(&combined, character_set, max_len)
440}
441
442fn sanitise_segment(
449 rendered: &str,
450 character_set: CharacterSet,
451 max_component_len: usize,
452 disambiguators: [&str; 2],
453) -> String {
454 for suffix in disambiguators {
455 if suffix.is_empty() {
456 continue;
457 }
458 let pattern = format!(" [{suffix}]");
459 if let Some(prefix) = rendered.strip_suffix(&pattern) {
460 return append_suffix(prefix, suffix, character_set, max_component_len);
461 }
462 }
463 sanitise_component(rendered, character_set, max_component_len)
464}
465
466pub fn sanitise_name(name: &str) -> String {
474 let cleaned = sanitise_component(name, CharacterSet::Unicode, DEFAULT_MAX_COMPONENT_LEN);
475 if cleaned.is_empty() {
476 "playlist".to_string()
477 } else {
478 cleaned
479 }
480}
481
482pub fn stems_folder(base: &str) -> String {
489 format!("{base}.stems")
490}
491
492pub fn stem_file_path(
503 base: &str,
504 label: &str,
505 stem_id: &str,
506 ext: &str,
507 character_set: CharacterSet,
508) -> String {
509 let folder = stems_folder(base);
510 let song_stem = base.rsplit('/').next().unwrap_or(base);
513 let label = sanitise_component(label, character_set, DEFAULT_MAX_COMPONENT_LEN);
514 let id8 = sanitise_component(
515 &truncate_chars(stem_id, 8),
516 CharacterSet::Ascii,
517 DEFAULT_MAX_COMPONENT_LEN,
518 );
519
520 let mut name = song_stem.to_string();
521 if !label.is_empty() {
522 name.push_str(" - ");
523 name.push_str(&label);
524 }
525 if !id8.is_empty() {
526 name.push_str(" [");
527 name.push_str(&id8);
528 name.push(']');
529 }
530 if name.trim().is_empty() {
533 name = "stem".to_string();
534 }
535 format!("{folder}/{name}.{}", sanitise_ext(ext))
536}
537
538fn sanitise_ext(ext: &str) -> String {
542 let cleaned: String = ext
543 .trim_start_matches('.')
544 .chars()
545 .filter(|c| c.is_ascii_alphanumeric())
546 .flat_map(char::to_lowercase)
547 .take(8)
548 .collect();
549 if cleaned.is_empty() {
550 "mp3".to_string()
551 } else {
552 cleaned
553 }
554}
555
556fn sanitise_component(
557 value: &str,
558 character_set: CharacterSet,
559 max_component_len: usize,
560) -> String {
561 let mut collapsed = String::with_capacity(value.len());
564 let mut pending_space = false;
565 let push = |out: char, buf: &mut String, pending: &mut bool| {
566 if out.is_whitespace() {
567 *pending = !buf.is_empty();
568 } else {
569 if *pending {
570 buf.push(' ');
571 }
572 *pending = false;
573 buf.push(out);
574 }
575 };
576 match character_set {
577 CharacterSet::Unicode => {
578 for ch in value.chars() {
579 push(unicode_char(ch), &mut collapsed, &mut pending_space);
580 }
581 }
582 CharacterSet::Ascii => {
583 for ch in value.chars() {
584 for out in ascii_chars(ch) {
585 push(out, &mut collapsed, &mut pending_space);
586 }
587 }
588 }
589 }
590
591 let trimmed = collapsed.trim_matches([' ', '.']);
592 if trimmed.is_empty() {
593 return String::new();
594 }
595
596 let max = max_component_len.max(1);
599 let end = trimmed
600 .char_indices()
601 .nth(max)
602 .map_or(trimmed.len(), |(index, _)| index);
603 let result = trimmed[..end].trim_matches([' ', '.']);
604 if result.is_empty() {
605 return String::new();
606 }
607 if result == "." || result == ".." {
608 return "item".to_string();
609 }
610 let mut result = result.to_string();
611 if is_reserved_name(&result) {
612 let stem_end = result.find('.').unwrap_or(result.len());
616 result.insert(stem_end, '_');
617 }
618 result
619}
620
621fn unicode_char(ch: char) -> char {
622 if matches!(
623 ch,
624 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0'
625 ) || ch.is_control()
626 {
627 ' '
628 } else {
629 ch
630 }
631}
632
633fn ascii_chars(ch: char) -> Vec<char> {
634 if ch.is_ascii() {
635 return vec![unicode_char(ch)];
636 }
637
638 match ch {
639 'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => vec!['A'],
640 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => vec!['a'],
641 'Ç' => vec!['C'],
642 'ç' => vec!['c'],
643 'È' | 'É' | 'Ê' | 'Ë' => vec!['E'],
644 'è' | 'é' | 'ê' | 'ë' => vec!['e'],
645 'Ì' | 'Í' | 'Î' | 'Ï' => vec!['I'],
646 'ì' | 'í' | 'î' | 'ï' => vec!['i'],
647 'Ñ' => vec!['N'],
648 'ñ' => vec!['n'],
649 'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'Ø' => vec!['O'],
650 'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' => vec!['o'],
651 'Ù' | 'Ú' | 'Û' | 'Ü' => vec!['U'],
652 'ù' | 'ú' | 'û' | 'ü' => vec!['u'],
653 'Ý' | 'Ÿ' => vec!['Y'],
654 'ý' | 'ÿ' => vec!['y'],
655 'Æ' => vec!['A', 'E'],
656 'æ' => vec!['a', 'e'],
657 'Œ' => vec!['O', 'E'],
658 'œ' => vec!['o', 'e'],
659 'ß' => vec!['s', 's'],
660 _ => vec![' '],
661 }
662}
663
664fn truncate_chars(value: &str, max_len: usize) -> String {
665 value.chars().take(max_len).collect()
666}
667
668fn non_blank(value: &str) -> Option<&str> {
669 let trimmed = value.trim();
670 (!trimmed.is_empty()).then_some(trimmed)
671}
672
673pub(crate) fn is_reserved_name(value: &str) -> bool {
680 let stem = value.split('.').next().unwrap_or(value);
681 if !matches!(stem.len(), 3 | 4) {
684 return false;
685 }
686 const RESERVED: [&str; 22] = [
687 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
688 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
689 ];
690 RESERVED.iter().any(|name| name.eq_ignore_ascii_case(stem))
691}
692
693#[cfg(test)]
694mod tests;
695
696#[cfg(test)]
697mod proptests;