diff --git a/edge/src/names.rs b/edge/src/names.rs index c530554..70a26bd 100644 --- a/edge/src/names.rs +++ b/edge/src/names.rs @@ -1115,7 +1115,7 @@ fn token_signals(t: &[u8]) -> TokenSignals { let mut lower = 0; for i in 0..t.len() { let c = t[i]; - if c.is_ascii_alphabetic() && !is_vowel(c) { + if c.is_ascii_alphabetic() && !is_vowel(c) && c.to_ascii_lowercase() != b'y' { consonants += 1; s.max_consonants = s.max_consonants.max(consonants); } else { @@ -1181,7 +1181,9 @@ pub fn token_kind(t: &[u8]) -> TokenKind { return if n >= 3 { TokenKind::Word } else { TokenKind::Neutral }; } // Mixed case beyond Capitalization: CamelCase (neutral) or a scramble, which has few vowels and short runs. - if n >= 6 && s.case_switches >= 2 && (s.inner_upper || (s.vowels * 4 < n && (s.runs >= 4 || s.lone >= 1))) { + // Two CamelCase words with few vowels (FastFilms, BestCrypt, SchiffsModell) have four runs and no lone + // letter; a scramble has more runs, a lone letter or an inner capital run. + if n >= 6 && s.case_switches >= 2 && s.vowels * 4 < n && (s.runs >= 5 || s.lone >= 1 || s.inner_upper) { return TokenKind::Random; } return TokenKind::Neutral; @@ -1193,6 +1195,14 @@ pub fn token_kind(t: &[u8]) -> TokenKind { // 51294P1CUKR2R35X; COD4MW2REMASTERED (4 switches) and BEATLES1964REMASTER (2) stay. return if n >= 12 && token_signals(t).switches >= 6 { TokenKind::Random } else { TokenKind::Neutral }; } + // Words with numbers between them (Laura15Juli2026, Back4WinXP): every letter run a word or an acronym. + if t.split(|c: &u8| !c.is_ascii_alphabetic()).filter(|w| !w.is_empty()).all(|w| { + let vowels = w.iter().filter(|c| matches!(c.to_ascii_lowercase(), b'a' | b'e' | b'i' | b'o' | b'u' | b'y')).count(); + (w.len() >= 2 && w.iter().all(u8::is_ascii_uppercase)) + || (w.len() >= 2 && w[1..].iter().all(u8::is_ascii_lowercase) && (w.len() >= 3 || w[0].is_ascii_lowercase()) && vowels * 5 >= w.len()) + }) { + return TokenKind::Neutral; + } let s = token_signals(&scene_case(t)); if n >= 5 && s.switches >= 3 { return TokenKind::Random; @@ -1854,7 +1864,8 @@ mod tests { "Rocketship-Hurt_Feelings-WEB-2026-PreMaxXxing", "Drum-and-BassDJ_Samurai-Sound_With_Fire_Artemis-(FQY026)-WEB-2006-PRESCRiPTiON", "Kleverig-H4rd-N12uk3b18-luc16037-720p-smpwr", "Eclipse_IDE_2023-12_R_win32_x86_64.zip", "PartsHPGL 1.06c", "Go4Customer Fax Machine v1.2b", "AdBeGone 1.2", "3DMark03 320", "COD4MW2REMASTERED", "Movie.2020.1080p-GRP [a3f9e2b1]", "EPL__NZBSPLIT__f1fdb433772a1a7771d6b73c875009ff__NZBSPLIT__EPL.2026.Liverpool.vs.Brighton.1080pEN50fps", - "iPhone", "Some.Show.S01E01.1080p.mkv", + "iPhone", "Some.Show.S01E01.1080p.mkv", "FastFilms 2.04", "BestCrypt NP v4.0", "EasyHTML 2.35 German", "SchiffsModell - September 2026", + "CrossStitcher - Issue 440, October 2026", "Laura15Juli2026", "IHaveAWife - Isa Bella", "DickHDDaily - Santana Red", "1904-SmithsWork-Hasluck", ] { assert!(!is_unreadable_name(n), "{n}"); } diff --git a/lib/release/readable.ts b/lib/release/readable.ts index e193d7f..c2b85f5 100644 --- a/lib/release/readable.ts +++ b/lib/release/readable.ts @@ -43,7 +43,7 @@ function signals(t: string) { let maxConsonants = 0 for (let i = 0; i < t.length; i++) { const c = t[i] - if (/[b-df-hj-np-tv-z]/i.test(c)) maxConsonants = Math.max(maxConsonants, ++consonants) + if (/[b-df-hj-np-tv-xz]/i.test(c)) maxConsonants = Math.max(maxConsonants, ++consonants) else consonants = 0 if (i === 0) continue const a = t[i - 1] @@ -90,11 +90,14 @@ export function tokenKind(t: string): 'word' | 'random' | null { if (t.length >= 10 && s.vowels * 6 < t.length) return 'random' return t.length >= 3 ? 'word' : null } - // Mixed case beyond Capitalization: CamelCase (neutral) or a scramble, which has few vowels and short runs. + // Mixed case beyond Capitalization: CamelCase (neutral) or a scramble. Two CamelCase words with few vowels + // (FastFilms, BestCrypt, SchiffsModell) have four runs and no lone letter; a scramble has more runs, a lone + // letter or an inner capital run. if ( t.length >= 6 && s.caseSwitches >= 2 && - (s.innerUpper || (s.vowels * 4 < t.length && (s.runs >= 4 || s.lone >= 1))) + s.vowels * 4 < t.length && + (s.runs >= 5 || s.lone >= 1 || s.innerUpper) ) return 'random' return null @@ -104,6 +107,15 @@ export function tokenKind(t: string): 'word' | 'random' | null { // 51294P1CUKR2R35X; COD4MW2REMASTERED (4 switches) and BEATLES1964REMASTER (2) stay. return t.length >= 12 && signals(t).switches >= 6 ? 'random' : null } + // Words with numbers between them (Laura15Juli2026, Back4WinXP): every letter run a word or an acronym. + if ( + (t.match(/[A-Za-z]+/g) ?? []).every( + (w) => + /^[A-Z]{2,}$/.test(w) || + (/^[A-Z]?[a-z]{2,}$/.test(w) && (w.match(/[aeiouy]/g) ?? []).length * 5 >= w.length) + ) + ) + return null const s = signals(sceneCase(t)) if (t.length >= 5 && s.switches >= 3) return 'random' if (t.length >= 6 && s.switches >= 2 && (s.lone >= 1 || s.innerUpper || s.maxConsonants >= 4)) diff --git a/tests/unit/release/readable.spec.ts b/tests/unit/release/readable.spec.ts index 90fc525..1642484 100644 --- a/tests/unit/release/readable.spec.ts +++ b/tests/unit/release/readable.spec.ts @@ -52,6 +52,15 @@ test.group('release / isUnreadableName', () => { 'EPL__NZBSPLIT__f1fdb433772a1a7771d6b73c875009ff__NZBSPLIT__EPL.2026.Liverpool.vs.Brighton.1080pEN50fps', 'iPhone', 'Some.Show.S01E01.1080p.mkv', + 'FastFilms 2.04', + 'BestCrypt NP v4.0', + 'EasyHTML 2.35 German', + 'SchiffsModell - September 2026', + 'CrossStitcher - Issue 440, October 2026', + 'Laura15Juli2026', + 'IHaveAWife - Isa Bella', + 'DickHDDaily - Santana Red', + '1904-SmithsWork-Hasluck', ]) { assert.isFalse(isUnreadableName(n), n) }