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;