aede_core/text.rs
1//! Text normalization: this is the piece that decides whether "The Beatles",
2//! "Beatles, The" and "beatles" designate the same entity.
3//!
4//! All entity matching rests on [`normalize`], until M1 brings identifiers
5//! that do not depend on spelling.
6//! When MusicBrainz arrives (M1), these normalized keys will remain the safety
7//! net for unidentified files.
8
9/// Splits multiple artists out of a single tag field.
10///
11/// Real libraries mix every convention: `;`, `/`, ` feat. `, ` & `… We cut on
12/// the safe separators and leave `&` and `and` alone, because "Simon &
13/// Garfunkel" or "Earth, Wind & Fire" are band names, not lists.
14///
15/// **That last rule is why this is the fallback and not the answer.** A tag
16/// reading `Rob Zombie & Ozzy Osbourne` is two artists and one that reads
17/// `Simon & Garfunkel` is one, and no amount of looking at the string can tell
18/// them apart. The tag that can is `ARTISTS`, which taggers write with **one
19/// value per artist** for exactly this reason; see
20/// [`crate::model::builder`], which prefers it wherever a file carries it and
21/// falls back here only when none does.
22pub fn split_artists(raw: &str) -> Vec<String> {
23 const HARD_SEPARATORS: [&str; 4] = [";", " / ", "//", " ; "];
24 // `" w/"` carries no trailing space on purpose: it is written `w/Therapy?`
25 // as often as `w/ Therapy?`, and the space before it is what keeps it from
26 // matching inside a name. Nothing loses by it — a band whose name contains
27 // a space followed by `w/` does not exist as far as anyone has met one.
28 const FEATURE_MARKERS: [&str; 9] = [
29 " feat. ",
30 " feat ",
31 " featuring ",
32 " ft. ",
33 " ft ",
34 " avec ",
35 " with ",
36 " vs. ",
37 " w/",
38 ];
39
40 let mut parts: Vec<String> = vec![raw.trim().to_string()];
41
42 for separator in HARD_SEPARATORS {
43 parts = parts
44 .iter()
45 .flat_map(|p| {
46 p.split(separator)
47 .map(|s| s.to_string())
48 .collect::<Vec<_>>()
49 })
50 .collect();
51 }
52
53 // "feat." mentions are looked up without regard to case.
54 let mut expanded = Vec::new();
55 for part in parts {
56 let mut cut = None;
57 for marker in FEATURE_MARKERS {
58 // Markers are ASCII; Unicode lowercasing can change byte lengths
59 // and would make its offsets unsafe for the original spelling.
60 if let Some(idx) = part
61 .as_bytes()
62 .windows(marker.len())
63 .position(|window| window.eq_ignore_ascii_case(marker.as_bytes()))
64 {
65 cut = Some(match cut {
66 Some((prev, len)) if prev <= idx => (prev, len),
67 _ => (idx, marker.len()),
68 });
69 }
70 }
71 match cut {
72 Some((idx, len)) => {
73 expanded.push(part[..idx].to_string());
74 expanded.push(part[idx + len..].to_string());
75 }
76 None => expanded.push(part),
77 }
78 }
79
80 let mut out = Vec::new();
81 for part in expanded {
82 let cleaned = part
83 .trim()
84 .trim_matches(|c| c == ',' || c == '-')
85 .trim()
86 .to_string();
87 if !cleaned.is_empty() && !out.contains(&cleaned) {
88 out.push(cleaned);
89 }
90 }
91 out
92}
93
94/// Drops, from one tag's list of names, the ones that only say again what the
95/// other entries of that same list already say.
96///
97/// Real tags do this constantly. A file of *War Pigs* carries
98/// `PERFORMER=Ozzy Osbourne; Judas Priest; Judas Priest & Ozzy Osbourne`:
99/// three values, two musicians. The third is the credit written out whole, and
100/// nothing in the string alone tells it from a band name — `&` is never a
101/// separator here, and rightly, or `Kool & the Gang` would be shattered.
102///
103/// **The list itself is what tells them apart, and it is not a heuristic.** A
104/// value made of two or more of the *other* values in the same tag, with
105/// nothing left over but the words that join names together, is the file
106/// saying one thing twice. Keeping the granular form loses nobody: every
107/// person it named is still named, and each is now an artist in their own
108/// right rather than a third party who happens to exist on one track.
109/// `Kool & the Gang` survives any list that does not also hold `Kool` and
110/// `the Gang` on their own — and a list that did would have named them itself.
111///
112/// The comparison is on [`normalize`]d keys, so joining punctuation has
113/// already fallen away and only joining *words* remain to be recognised.
114pub fn without_restatements(names: Vec<String>) -> Vec<String> {
115 // Words that join names and name nobody. `&` is not among them because
116 // `normalize` has already turned it into a space.
117 const JOINERS: [&str; 13] = [
118 "and",
119 "et",
120 "with",
121 "avec",
122 "feat",
123 "featuring",
124 "ft",
125 "vs",
126 "versus",
127 "w",
128 "x",
129 "meets",
130 "presents",
131 ];
132
133 let keys: Vec<String> = names.iter().map(|n| normalize(n)).collect();
134 let mut kept = Vec::with_capacity(names.len());
135 for (me, name) in names.iter().enumerate() {
136 // Longest first, so `Ozzy Osbourne` is consumed whole rather than
137 // leaving `osbourne` stranded because a shorter `Ozzy` matched first.
138 let mut others: Vec<usize> = (0..keys.len())
139 .filter(|&j| j != me && !keys[j].is_empty() && keys[j] != keys[me])
140 .collect();
141 others.sort_by_key(|&j| std::cmp::Reverse(keys[j].len()));
142
143 // A space at each end so a needle only ever matches whole words:
144 // without them `Al` would be eaten out of `Alice`.
145 let mut left = format!(" {} ", keys[me]);
146 let mut eaten = 0;
147 for j in others {
148 let needle = format!(" {} ", keys[j]);
149 if let Some(at) = left.find(&needle) {
150 left.replace_range(at..at + needle.len(), " ");
151 eaten += 1;
152 }
153 }
154 // Two, not one. One would mean that any name containing another name
155 // of the list is a restatement of it, and `Therapy?` sitting beside
156 // `Ozzy Osbourne w/Therapy?` would delete the collaboration instead of
157 // the other way round.
158 let restated = eaten >= 2 && left.split_whitespace().all(|w| JOINERS.contains(&w));
159 if !restated {
160 kept.push(name.clone());
161 }
162 }
163 kept
164}
165
166/// Matching key: lowercase, without diacritics, without punctuation,
167/// with normalized spacing and the leading article moved.
168///
169/// ```
170/// use aede_core::text::normalize;
171/// assert_eq!(normalize("The Beatles"), normalize("Beatles, The"));
172/// assert_eq!(normalize("Björk"), "bjork");
173/// ```
174pub fn normalize(input: &str) -> String {
175 let folded: String = input
176 .trim()
177 .to_lowercase()
178 .chars()
179 .flat_map(fold_char)
180 .collect();
181
182 let mut cleaned = String::with_capacity(folded.len());
183 let mut last_was_space = true;
184 for ch in folded.chars() {
185 if ch.is_alphanumeric() {
186 cleaned.push(ch);
187 last_was_space = false;
188 } else if !last_was_space {
189 cleaned.push(' ');
190 last_was_space = true;
191 }
192 }
193 let cleaned = cleaned.trim().to_string();
194 strip_leading_article(&cleaned)
195}
196
197/// Sort name: "The Beatles" -> "Beatles, The", as discographic usage requires.
198pub fn sort_name(input: &str) -> String {
199 const ARTICLES: [&str; 6] = ["the ", "le ", "la ", "les ", "der ", "die "];
200 let lower = input.to_lowercase();
201 for article in ARTICLES {
202 if lower.starts_with(article) {
203 let rest = input[article.len()..].trim();
204 let art = input[..article.len()].trim();
205 return format!("{rest}, {art}");
206 }
207 }
208 input.to_string()
209}
210
211fn strip_leading_article(s: &str) -> String {
212 const ARTICLES: [&str; 6] = ["the ", "le ", "la ", "les ", "der ", "die "];
213 // The "beatles the" form produced by normalizing "Beatles, The".
214 for article in ARTICLES {
215 let suffix = format!(" {}", article.trim());
216 if let Some(base) = s.strip_suffix(&suffix)
217 && !base.is_empty()
218 {
219 return base.to_string();
220 }
221 }
222 for article in ARTICLES {
223 if let Some(rest) = s.strip_prefix(article)
224 && !rest.is_empty()
225 {
226 return rest.to_string();
227 }
228 }
229 s.to_string()
230}
231
232/// ASCII folding of the accented characters most common in Western artist
233/// names. Deliberately limited: no transliteration of Cyrillic or Greek,
234/// which must stay distinct.
235pub(crate) fn fold_char(c: char) -> std::vec::IntoIter<char> {
236 let replacement: &str = match c {
237 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'ā' | 'ă' | 'ą' => "a",
238 'ç' | 'ć' | 'č' => "c",
239 'è' | 'é' | 'ê' | 'ë' | 'ē' | 'ė' | 'ę' | 'ě' => "e",
240 'ì' | 'í' | 'î' | 'ï' | 'ī' | 'į' => "i",
241 'ñ' | 'ń' | 'ň' => "n",
242 'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' | 'ō' | 'ő' => "o",
243 'ù' | 'ú' | 'û' | 'ü' | 'ū' | 'ů' | 'ű' => "u",
244 'ý' | 'ÿ' => "y",
245 'ž' | 'ź' | 'ż' => "z",
246 'š' | 'ś' => "s",
247 'ř' => "r",
248 'ł' => "l",
249 'đ' | 'ð' => "d",
250 'ť' => "t",
251 'æ' => "ae",
252 'œ' => "oe",
253 'ß' => "ss",
254 'þ' => "th",
255 other => return vec![other].into_iter(),
256 };
257 replacement.chars().collect::<Vec<_>>().into_iter()
258}
259
260/// A count and its noun, agreeing: `1 track`, `2 tracks`, `3 analyses`.
261///
262/// **The count is included**, because a caller that had to write it itself
263/// would sooner or later write it twice or not at all. English plurals are not
264/// a solved problem and this does not pretend otherwise: the `-es` endings and
265/// `analysis`/`analyses` are here because this program says those words, and
266/// anything else gets an `s`.
267pub fn plural(count: usize, singular: &str) -> String {
268 if count <= 1 {
269 return format!("{count} {singular}");
270 }
271 let lower = singular.to_ascii_lowercase();
272 if lower.ends_with("is") {
273 return format!("{count} {}es", &singular[..singular.len() - 2]);
274 }
275 if ["s", "x", "z", "ch", "sh"]
276 .iter()
277 .any(|end| lower.ends_with(end))
278 {
279 format!("{count} {singular}es")
280 } else {
281 format!("{count} {singular}s")
282 }
283}
284
285/// Formats a duration as `h:mm:ss` or `m:ss`.
286///
287/// The count of seconds is **rounded**, not truncated: a track of 4 min 20.7 s
288/// reads 4:21, as it does in every player. Cutting the fraction off would show
289/// a second less on roughly half the tracks of a library, and the sum of the
290/// displayed times would drift away from the announced total.
291pub fn format_duration(ms: u64) -> String {
292 let total = ms / 1000 + u64::from(ms % 1000 >= 500);
293 let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
294 if h > 0 {
295 format!("{h}:{m:02}:{s:02}")
296 } else {
297 format!("{m}:{s:02}")
298 }
299}
300
301/// Formats a size in bytes in a readable way.
302///
303/// **Decimal** units, 1 kB being 1000 bytes: that is what macOS Finder and most
304/// Linux file managers show, so the figure matches what the system says about
305/// the same files. Dividing by 1024 while writing "MB" — the usual shortcut —
306/// understates an album by about 5%, which is exactly the kind of discrepancy a
307/// user reports as a bug.
308pub fn format_size(bytes: u64) -> String {
309 const UNITS: [&str; 4] = ["kB", "MB", "GB", "TB"];
310 const STEP: f64 = 1000.0;
311 if bytes < STEP as u64 {
312 return format!("{bytes} B");
313 }
314 let mut value = bytes as f64 / STEP;
315 let mut unit = 0;
316 while value >= STEP && unit < UNITS.len() - 1 {
317 value /= STEP;
318 unit += 1;
319 }
320 format!("{value:.1} {}", UNITS[unit])
321}
322
323/// Extracts the first year from a tag date, whatever its shape
324/// (`1986`, `1986-03-05`, `05/03/1986`, `1986.03`).
325pub fn extract_year(raw: &str) -> Option<u32> {
326 raw.as_bytes().windows(4).find_map(|window| {
327 if !window.iter().all(u8::is_ascii_digit) {
328 return None;
329 }
330 let year = window
331 .iter()
332 .fold(0u32, |year, digit| year * 10 + u32::from(digit - b'0'));
333 (1500..=2200).contains(&year).then_some(year)
334 })
335}
336
337/// Reads a track number in the `5` or `5/12` form, returning
338/// `(number, total)`.
339pub fn parse_track_number(raw: &str) -> (Option<u32>, Option<u32>) {
340 let mut parts = raw.split('/');
341 let number = parts.next().and_then(|s| s.trim().parse::<u32>().ok());
342 let total = parts.next().and_then(|s| s.trim().parse::<u32>().ok());
343 (number, total)
344}
345
346/// Last component of a path, extension included.
347///
348/// Recognizes native Windows paths even when reading their catalog on Unix.
349/// The original spelling, including verbatim prefixes, is never rewritten.
350pub fn file_name(path: &str) -> &str {
351 match path.rfind(path_separator(path)) {
352 Some(i) => &path[i + 1..],
353 None => path,
354 }
355}
356
357/// Containing directory of a path, without its trailing separator.
358///
359/// Empty when the path has no directory part at all — a bare file name is in no
360/// folder, and inventing `"."` here would group it with every other path that
361/// has no folder either.
362pub fn folder(path: &str) -> &str {
363 match path.rfind(path_separator(path)) {
364 // C: is drive-relative; retaining its root separator is essential.
365 Some(i)
366 if (i == 2 || (i == 6 && path.starts_with(r"\\?\")))
367 && path.as_bytes()[i - 1] == b':'
368 && path.as_bytes()[i - 2].is_ascii_alphabetic() =>
369 {
370 &path[..=i]
371 }
372 Some(i) => &path[..i],
373 None => "",
374 }
375}
376
377fn path_separator(path: &str) -> impl Fn(char) -> bool + Copy {
378 let verbatim = path.starts_with(r"\\?\");
379 let drive = path.as_bytes().get(1) == Some(&b':')
380 && path.as_bytes().first().is_some_and(u8::is_ascii_alphabetic);
381 let windows = cfg!(windows) || drive || path.starts_with(r"\\");
382 move |c| {
383 if verbatim {
384 c == '\\'
385 } else {
386 c == '/' || (windows && c == '\\')
387 }
388 }
389}
390
391fn strip_folder<'a>(path: &'a str, folder: &str) -> Option<&'a str> {
392 if folder.is_empty() {
393 return path.is_empty().then_some(path);
394 }
395 let separator = path_separator(path);
396 let folder_separator = path_separator(folder);
397 let prefix = folder.trim_end_matches(folder_separator);
398 if prefix.is_empty() && !path.starts_with(separator) {
399 return None;
400 }
401 let head = path.get(..prefix.len())?;
402 let matches = head
403 .chars()
404 .zip(prefix.chars())
405 .all(|(left, right)| left == right || (separator(left) && folder_separator(right)));
406 let rest = path.get(prefix.len()..)?;
407 if !matches || (!rest.is_empty() && !rest.starts_with(separator)) {
408 return None;
409 }
410 Some(rest.trim_start_matches(separator))
411}
412
413/// A portable relative path under a catalog folder, or `None` outside it.
414///
415/// Only the relative output uses `/`: native absolute paths must retain their
416/// Windows verbatim spelling when passed to the filesystem or external tools.
417pub fn relative_under(path: &str, folder: &str) -> Option<String> {
418 let rest = strip_folder(path, folder)?;
419 Some(
420 rest.split(path_separator(path))
421 .filter(|part| !part.is_empty())
422 .collect::<Vec<_>>()
423 .join("/"),
424 )
425}
426
427/// The disc a folder name announces, when it announces one.
428///
429/// A box set is very often laid out as `Album/Disc 1`, `Album/CD2`,
430/// `Album/Disque 3`. Those folders are **not** separate albums, and treating
431/// them as such splits one release into three — which is what happened: a
432/// Final Fantasy VII soundtrack came back as two albums of the same name, each
433/// numbering its tracks from one, with nothing on screen saying which disc was
434/// which except the path.
435///
436/// Recognised: `disc`, `disk`, `cd`, `disque`, followed by digits, with or
437/// without a space or a dash. Anything else is a folder like any other — a
438/// name that merely *contains* the word is left alone, because `CD Singles` is
439/// a collection and not a disc of one.
440pub fn disc_folder(name: &str) -> Option<u32> {
441 let lowered = name.trim().to_lowercase();
442 let rest = ["disque", "disc", "disk", "cd"]
443 .iter()
444 .find_map(|word| lowered.strip_prefix(word))?;
445 let digits = rest.trim_start_matches([' ', '-', '_', '.', '#']);
446 // The whole remainder must be the number: `cd2` yes, `cd2 bonus` no, and
447 // `disc one` no — a word this cannot read is a folder it must not claim.
448 match digits.parse::<u32>() {
449 Ok(number) if number > 0 => Some(number),
450 _ => None,
451 }
452}
453
454/// `true` when a path is the folder itself or something inside it.
455///
456/// Written out because the obvious `path.starts_with(folder)` is wrong on
457/// strings: `/music/Rock` would then claim every file of `/music/Rockabilly`.
458/// The test is on a separator boundary, which is the difference between a
459/// folder and a prefix of its name.
460pub fn is_under(path: &str, folder: &str) -> bool {
461 strip_folder(path, folder).is_some()
462}
463
464#[cfg(test)]
465#[path = "text_tests.rs"]
466mod tests;