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#[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 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
140fn 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
160fn 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
181fn 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
188fn 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 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 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 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
404fn 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
428pub 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
444pub fn stems_folder(base: &str) -> String {
451 format!("{base}.stems")
452}
453
454pub 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 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 if name.trim().is_empty() {
495 name = "stem".to_string();
496 }
497 format!("{folder}/{name}.{}", sanitise_ext(ext))
498}
499
500fn 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 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 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 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 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;