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> {
46 match s.to_ascii_lowercase().as_str() {
47 "unicode" => Ok(Self::Unicode),
48 "ascii" => Ok(Self::Ascii),
49 other => Err(Error::Config(format!(
50 "unknown character_set '{other}'; expected 'unicode' or 'ascii'"
51 ))),
52 }
53 }
54}
55
56impl fmt::Display for CharacterSet {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 match self {
59 Self::Unicode => f.write_str("unicode"),
60 Self::Ascii => f.write_str("ascii"),
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct NamingConfig {
67 pub template: String,
68 pub character_set: CharacterSet,
69 pub max_component_len: usize,
70}
71
72impl Default for NamingConfig {
73 fn default() -> Self {
74 Self {
75 template: DEFAULT_TEMPLATE.to_string(),
76 character_set: CharacterSet::Unicode,
77 max_component_len: DEFAULT_MAX_COMPONENT_LEN,
78 }
79 }
80}
81
82#[derive(Debug, Clone, Copy)]
83pub struct NamingRequest<'a> {
84 pub clip: &'a Clip,
85 pub lineage: &'a LineageContext,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct RenderedName {
90 pub relative_path: PathBuf,
91 pub base_name: String,
92}
93
94pub fn render_clip_name(request: NamingRequest<'_>, config: &NamingConfig) -> RenderedName {
95 let album = album_component(request, config);
96 render_with_album(request, config, &album)
97}
98
99pub fn render_clip_names(
100 requests: &[NamingRequest<'_>],
101 config: &NamingConfig,
102 colliding_albums: &BTreeSet<String>,
103) -> Vec<RenderedName> {
104 let albums = disambiguated_albums(requests, config, colliding_albums);
105 let mut rendered = requests
106 .iter()
107 .zip(&albums)
108 .map(|(request, album)| render_with_album(*request, config, album))
109 .collect::<Vec<_>>();
110
111 for apply_canonical in [false, true] {
116 let mut collisions = BTreeMap::<String, Vec<usize>>::new();
117 for (index, name) in rendered.iter().enumerate() {
118 let key = if apply_canonical {
119 canonical_path_key(&name.relative_path.to_string_lossy())
120 } else {
121 name.relative_path.to_string_lossy().into_owned()
122 };
123 collisions.entry(key).or_default().push(index);
124 }
125 for indexes in collisions.into_values().filter(|v| v.len() > 1) {
126 for index in indexes {
127 let suffix = &requests[index].clip.id;
128 rendered[index] = with_suffix(
129 rendered[index].clone(),
130 suffix,
131 config.character_set,
132 config.max_component_len,
133 );
134 }
135 }
136 }
137
138 rendered
139}
140
141fn disambiguated_albums(
151 requests: &[NamingRequest<'_>],
152 config: &NamingConfig,
153 colliding_albums: &BTreeSet<String>,
154) -> Vec<String> {
155 requests
156 .iter()
157 .map(|request| album_for(*request, config, colliding_albums))
158 .collect()
159}
160
161fn album_for(
163 request: NamingRequest<'_>,
164 config: &NamingConfig,
165 colliding_albums: &BTreeSet<String>,
166) -> String {
167 let raw_album = request.lineage.album(&title_name(request.clip));
168 let album = sanitise_component(&raw_album, config.character_set, config.max_component_len);
169 if colliding_albums.contains(raw_album.trim()) {
170 let suffix = truncate_chars(&request.lineage.root_id, 8);
171 append_suffix(
172 &album,
173 &suffix,
174 config.character_set,
175 config.max_component_len,
176 )
177 } else {
178 album
179 }
180}
181
182fn album_component(request: NamingRequest<'_>, config: &NamingConfig) -> String {
185 let album = request.lineage.album(&title_name(request.clip));
186 sanitise_component(&album, config.character_set, config.max_component_len)
187}
188
189fn render_with_album(
191 request: NamingRequest<'_>,
192 config: &NamingConfig,
193 album: &str,
194) -> RenderedName {
195 let clip = request.clip;
196 let creator = sanitise_component(
197 &creator_name(clip),
198 config.character_set,
199 config.max_component_len,
200 );
201 let handle = sanitise_component(&clip.handle, config.character_set, config.max_component_len);
202 let title = sanitise_component(
203 &title_name(clip),
204 config.character_set,
205 config.max_component_len,
206 );
207 let id = sanitise_component(&clip.id, CharacterSet::Ascii, config.max_component_len);
208 let id8 = sanitise_component(
209 &truncate_chars(&clip.id, 8),
210 CharacterSet::Ascii,
211 config.max_component_len,
212 );
213 let root_id8 = sanitise_component(
214 &truncate_chars(&request.lineage.root_id, 8),
215 CharacterSet::Ascii,
216 config.max_component_len,
217 );
218 let track = request.lineage.track;
219 let track_raw = if track > 0 {
220 track.to_string()
221 } else {
222 String::new()
223 };
224 let track_pad = if track > 0 {
225 format!("{track:02}")
226 } else {
227 String::new()
228 };
229 let substitutions = SegmentSubstitutions {
230 creator: &creator,
231 handle: &handle,
232 album,
233 title: &title,
234 root_id8: &root_id8,
235 id8: &id8,
236 id: &id,
237 track: &track_raw,
238 track2: &track_pad,
239 };
240 let mut components = config
241 .template
242 .split('/')
243 .filter_map(|segment| {
244 let rendered = substitute_segment(segment, substitutions);
245 let sanitised = sanitise_segment(
246 &rendered,
247 config.character_set,
248 config.max_component_len,
249 [id8.as_str(), root_id8.as_str()],
250 );
251 (!sanitised.is_empty()).then_some(sanitised)
252 })
253 .collect::<Vec<_>>();
254
255 if components.is_empty() {
256 components.push(title.clone());
257 }
258
259 let mut base_name = components
260 .pop()
261 .filter(|value| !value.is_empty())
262 .unwrap_or_else(|| title.clone());
263 if base_name.is_empty() {
265 base_name = append_suffix(
266 &base_name,
267 &clip.id,
268 config.character_set,
269 config.max_component_len,
270 );
271 }
272
273 let mut relative_path = PathBuf::new();
274 for component in components {
275 relative_path.push(component);
276 }
277
278 relative_path.push(&base_name);
279 RenderedName {
280 relative_path,
281 base_name,
282 }
283}
284
285#[derive(Clone, Copy)]
286struct SegmentSubstitutions<'a> {
287 creator: &'a str,
288 handle: &'a str,
289 album: &'a str,
290 title: &'a str,
291 root_id8: &'a str,
292 id8: &'a str,
293 id: &'a str,
294 track: &'a str,
295 track2: &'a str,
296}
297
298fn substitute_segment(segment: &str, substitutions: SegmentSubstitutions<'_>) -> String {
299 let mut rendered = String::with_capacity(segment.len());
300 let mut remainder = segment;
301 while let Some(start) = remainder.find('{') {
302 rendered.push_str(&remainder[..start]);
303 remainder = &remainder[start..];
304 if let Some((token_len, value)) = placeholder_match(remainder, substitutions) {
305 rendered.push_str(value);
306 remainder = &remainder[token_len..];
307 if value.is_empty() {
311 remainder = remainder.trim_start_matches([' ', '-', '_', '.']);
312 }
313 } else {
314 rendered.push('{');
315 remainder = &remainder[1..];
316 }
317 }
318 rendered.push_str(remainder);
319 rendered
320}
321
322fn placeholder_match<'a>(
323 segment: &str,
324 substitutions: SegmentSubstitutions<'a>,
325) -> Option<(usize, &'a str)> {
326 if segment.starts_with("{creator}") {
327 Some(("{creator}".len(), substitutions.creator))
328 } else if segment.starts_with("{handle}") {
329 Some(("{handle}".len(), substitutions.handle))
330 } else if segment.starts_with("{album}") {
331 Some(("{album}".len(), substitutions.album))
332 } else if segment.starts_with("{title}") {
333 Some(("{title}".len(), substitutions.title))
334 } else if segment.starts_with("{root_id8}") {
335 Some(("{root_id8}".len(), substitutions.root_id8))
336 } else if segment.starts_with("{id8}") {
337 Some(("{id8}".len(), substitutions.id8))
338 } else if segment.starts_with("{id}") {
339 Some(("{id}".len(), substitutions.id))
340 } else if segment.starts_with("{track2}") {
341 Some(("{track2}".len(), substitutions.track2))
342 } else if segment.starts_with("{track}") {
343 Some(("{track}".len(), substitutions.track))
344 } else {
345 None
346 }
347}
348
349fn with_suffix(
350 mut rendered: RenderedName,
351 suffix: &str,
352 character_set: CharacterSet,
353 max_component_len: usize,
354) -> RenderedName {
355 rendered.base_name = append_suffix(
356 &rendered.base_name,
357 suffix,
358 character_set,
359 max_component_len,
360 );
361 rendered.relative_path.set_file_name(&rendered.base_name);
362 rendered
363}
364
365fn creator_name(clip: &Clip) -> String {
366 non_blank(&clip.display_name)
367 .or_else(|| non_blank(&clip.handle))
368 .unwrap_or("Unknown Creator")
369 .to_string()
370}
371
372fn title_name(clip: &Clip) -> String {
373 let title = clip.title.trim();
374 if title.is_empty() || title.eq_ignore_ascii_case("untitled") {
375 "Untitled".to_string()
376 } else {
377 title.to_string()
378 }
379}
380
381fn append_suffix(
382 base: &str,
383 suffix: &str,
384 character_set: CharacterSet,
385 max_component_len: usize,
386) -> String {
387 let suffix_pattern = format!(" [{suffix}]");
388 if base.ends_with(&suffix_pattern) {
389 return sanitise_component(base, character_set, max_component_len);
390 }
391
392 let max_len =
393 max_component_len.max(suffix_pattern.chars().count() + MIN_BASE_CHARS_WITH_SUFFIX);
394 let allowed = max_len.saturating_sub(suffix_pattern.chars().count());
395 let base = sanitise_component(base, character_set, max_len);
400 let truncated = truncate_chars(base.trim_end(), allowed);
401 let combined = format!("{truncated}{suffix_pattern}");
402 sanitise_component(&combined, character_set, max_len)
403}
404
405fn sanitise_segment(
412 rendered: &str,
413 character_set: CharacterSet,
414 max_component_len: usize,
415 disambiguators: [&str; 2],
416) -> String {
417 for suffix in disambiguators {
418 if suffix.is_empty() {
419 continue;
420 }
421 let pattern = format!(" [{suffix}]");
422 if let Some(prefix) = rendered.strip_suffix(&pattern) {
423 return append_suffix(prefix, suffix, character_set, max_component_len);
424 }
425 }
426 sanitise_component(rendered, character_set, max_component_len)
427}
428
429pub fn sanitise_name(name: &str) -> String {
437 let cleaned = sanitise_component(name, CharacterSet::Unicode, DEFAULT_MAX_COMPONENT_LEN);
438 if cleaned.is_empty() {
439 "playlist".to_string()
440 } else {
441 cleaned
442 }
443}
444
445pub fn stems_folder(base: &str) -> String {
452 format!("{base}.stems")
453}
454
455pub fn stem_file_path(
466 base: &str,
467 label: &str,
468 stem_id: &str,
469 ext: &str,
470 character_set: CharacterSet,
471) -> String {
472 let folder = stems_folder(base);
473 let song_stem = base.rsplit('/').next().unwrap_or(base);
476 let label = sanitise_component(label, character_set, DEFAULT_MAX_COMPONENT_LEN);
477 let id8 = sanitise_component(
478 &truncate_chars(stem_id, 8),
479 CharacterSet::Ascii,
480 DEFAULT_MAX_COMPONENT_LEN,
481 );
482
483 let mut name = song_stem.to_string();
484 if !label.is_empty() {
485 name.push_str(" - ");
486 name.push_str(&label);
487 }
488 if !id8.is_empty() {
489 name.push_str(" [");
490 name.push_str(&id8);
491 name.push(']');
492 }
493 if name.trim().is_empty() {
496 name = "stem".to_string();
497 }
498 format!("{folder}/{name}.{}", sanitise_ext(ext))
499}
500
501fn sanitise_ext(ext: &str) -> String {
505 let cleaned: String = ext
506 .trim_start_matches('.')
507 .chars()
508 .filter(|c| c.is_ascii_alphanumeric())
509 .flat_map(char::to_lowercase)
510 .take(8)
511 .collect();
512 if cleaned.is_empty() {
513 "mp3".to_string()
514 } else {
515 cleaned
516 }
517}
518
519fn sanitise_component(
520 value: &str,
521 character_set: CharacterSet,
522 max_component_len: usize,
523) -> String {
524 let mut collapsed = String::with_capacity(value.len());
527 let mut pending_space = false;
528 let push = |out: char, buf: &mut String, pending: &mut bool| {
529 if out.is_whitespace() {
530 *pending = !buf.is_empty();
531 } else {
532 if *pending {
533 buf.push(' ');
534 }
535 *pending = false;
536 buf.push(out);
537 }
538 };
539 match character_set {
540 CharacterSet::Unicode => {
541 for ch in value.chars() {
542 push(unicode_char(ch), &mut collapsed, &mut pending_space);
543 }
544 }
545 CharacterSet::Ascii => {
546 for ch in value.chars() {
547 for out in ascii_chars(ch) {
548 push(out, &mut collapsed, &mut pending_space);
549 }
550 }
551 }
552 }
553
554 let trimmed = collapsed.trim_matches([' ', '.']);
555 if trimmed.is_empty() {
556 return String::new();
557 }
558
559 let max = max_component_len.max(1);
562 let end = trimmed
563 .char_indices()
564 .nth(max)
565 .map_or(trimmed.len(), |(index, _)| index);
566 let result = trimmed[..end].trim_matches([' ', '.']);
567 if result.is_empty() {
568 return String::new();
569 }
570 if result == "." || result == ".." {
571 return "item".to_string();
572 }
573 let mut result = result.to_string();
574 if is_reserved_name(&result) {
575 let stem_end = result.find('.').unwrap_or(result.len());
579 result.insert(stem_end, '_');
580 }
581 result
582}
583
584fn unicode_char(ch: char) -> char {
585 if matches!(
586 ch,
587 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0'
588 ) || ch.is_control()
589 {
590 ' '
591 } else {
592 ch
593 }
594}
595
596fn ascii_chars(ch: char) -> Vec<char> {
597 if ch.is_ascii() {
598 return vec![unicode_char(ch)];
599 }
600
601 match ch {
602 'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => vec!['A'],
603 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => vec!['a'],
604 'Ç' => vec!['C'],
605 'ç' => vec!['c'],
606 'È' | 'É' | 'Ê' | 'Ë' => vec!['E'],
607 'è' | 'é' | 'ê' | 'ë' => vec!['e'],
608 'Ì' | 'Í' | 'Î' | 'Ï' => vec!['I'],
609 'ì' | 'í' | 'î' | 'ï' => vec!['i'],
610 'Ñ' => vec!['N'],
611 'ñ' => vec!['n'],
612 'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'Ø' => vec!['O'],
613 'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' => vec!['o'],
614 'Ù' | 'Ú' | 'Û' | 'Ü' => vec!['U'],
615 'ù' | 'ú' | 'û' | 'ü' => vec!['u'],
616 'Ý' | 'Ÿ' => vec!['Y'],
617 'ý' | 'ÿ' => vec!['y'],
618 'Æ' => vec!['A', 'E'],
619 'æ' => vec!['a', 'e'],
620 'Œ' => vec!['O', 'E'],
621 'œ' => vec!['o', 'e'],
622 'ß' => vec!['s', 's'],
623 _ => vec![' '],
624 }
625}
626
627fn truncate_chars(value: &str, max_len: usize) -> String {
628 value.chars().take(max_len).collect()
629}
630
631fn non_blank(value: &str) -> Option<&str> {
632 let trimmed = value.trim();
633 (!trimmed.is_empty()).then_some(trimmed)
634}
635
636fn is_reserved_name(value: &str) -> bool {
637 let stem = value.split('.').next().unwrap_or(value);
638 if !matches!(stem.len(), 3 | 4) {
641 return false;
642 }
643 const RESERVED: [&str; 22] = [
644 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
645 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
646 ];
647 RESERVED.iter().any(|name| name.eq_ignore_ascii_case(stem))
648}
649
650#[cfg(test)]
651mod tests;