From f44fdd6ee77c4de6d24ae7f060d64dbfee7ff4f4 Mon Sep 17 00:00:00 2001 From: DynamycSound Date: Mon, 20 Jul 2026 17:00:50 +0000 Subject: [PATCH] v1.7.0: fast mode full-reliability + batch default, player fix, fluke closure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fast mode no longer truncates to 2 providers: all enabled providers race in parallel with tight budgets (the truncation silently skipped LRCLib and cost real matches: Ili Ili, Peta Brzina). Added batch fast-mode toggle at the top of batch options, default on, marked Recommended. - Player: currentIndex derivedStateOf was unkeyed and captured the initially-empty async-loaded lines list forever (Home entry) — the highlight and auto-scroll never moved. Keyed on lines. Enhanced-LRC word stamps stripped from display; word-stamp-only lines now play. - Batch impostor guard: a hit or rescue whose artist agrees with no local reading now requires positive cover-art confirmation — a duration coincidence alone no longer saves wrong lyrics ('200' by Tveth case). Plain fallback gets the same guard. - Cyrillic→Latin transliteration in normalizeForCompare (Секси == Seksi). - Double quotes stripped from queries ('Summer Cem - "TMM TMM"'). - Version 1.7.0 (170); release notes; 2 new regression tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QADiJzVyNycc1w28sirc1a --- RELEASE_NOTES.md | 28 ++++++++----- app/build.gradle.kts | 4 +- .../songsync/data/UserSettingsController.kt | 7 ++++ .../lyrics_providers/SmartLyricsMatcher.kt | 16 ++++--- .../ui/screens/batch/BatchOptionsScreen.kt | 8 ++++ .../components/LyricsSuccessContent.kt | 3 +- .../player/SyncedLyricsPlayerScreen.kt | 24 +++++++++-- .../pl/lambada/songsync/util/LyricsUtils.kt | 42 ++++++++++++------- .../songsync/util/matching/TextMatch.kt | 21 ++++++++++ app/src/main/res/values/strings.xml | 4 +- .../matching/MessyNameRecoveryTest.kt | 20 +++++++++ 11 files changed, 138 insertions(+), 39 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 61029c8..706cafb 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,16 +1,24 @@ -## MusicResync v1.6.4 +## MusicResync v1.7.0 -### New: cover-art identification — the file's own thumbnail now tells us who sings it -A file like **"Bounce"** with artist "Unknown" is textually indistinguishable from dozens of other songs called Bounce — that's how it got Russian lyrics from a completely different track. But its embedded cover art *is* distinguishable. When a file gives the matcher no usable artist at all, MusicResync now searches iTunes/Deezer widely with the bare title, compares each result's album art against the file's own cover, and treats a visual match (plus a compatible runtime) as the file's real identity. For the reported "Bounce", that discovers **Voyage** — whose synced lyrics are right there on LRCLib — and the search leads with "Voyage Bounce" instead of a blind "Bounce". +### Fast mode, fixed and promoted — now the recommended way to batch download +Fast mode was silently searching only your top **two** providers — that's why songs like **"Ili Ili"** and **"Peta Brzina"** (which are on LRCLib, synced) came back "not found" in fast batch runs while the provider that has them never even got asked. Fast mode now races **all** enabled providers in parallel with tight time budgets: same providers, same candidate parsing, same precision guards — the speed comes from not waiting on slow servers, not from skipping sources. The toggle now also lives at the top of the **batch download options**, marked *Recommended*, and applies to batch runs. -The same check now guards against runtime coincidences: a result whose artist agrees with *none* of the file's artist readings (like **"CHECK"** by WAV3POP getting Portuguese lyrics from a same-length "Check" by The Boy) gets a second opinion from the cover before being trusted — if the art identifies a different artist, the impostor is skipped. +### Lyrics player actually follows the song now +The real cause of the "player stuck at the first line" bug: when opening a song from Home, the lyrics load in the background — and the highlight logic kept watching the initial *empty* lyrics list forever, so neither the highlight nor the scroll ever moved. Fixed. Word-by-word files (`<00:40.49>` inline stamps) also no longer show those raw stamps as text — and files carrying *only* word stamps now play instead of showing nothing. -### Messy names that now resolve (verified against the live catalogues) -- **"DEVITO - FLEX ????"** — runs of `?` left by characters the filesystem couldn't store are stripped from queries; the track ("Taj flex" by Devito) is on LRCLib. -- **"CRNI CERAK - CC #2 (JUŽNI VETAR 2022)"** — bracketed junk the noise list doesn't know now falls back to a bare-title query; "CC #2" by Crni Cerak is on LRCLib. -- **"GRCA X FOX - BELE ŠARE"** — *every* collab artist is now tried, not just the first: the track is indexed under Fox (with GRCA as the guest), and Fox no longer counts as a stranger to the wrong-singer veto. -- **"CHECK" by "WAV3POP - Topic"** — YouTube channel decorations (" - Topic", " TV", " Official"…) are stripped into an extra artist reading, so the real artist name reaches the providers. +### The "200" fluke, and the class it belongs to, is closed +"200" by Ourmoney got Russian lyrics from a same-*length* "200" by a different act — a runtime coincidence was still enough to override a disagreeing artist. In batch, it isn't anymore: a match whose artist agrees with **none** of the file's artist readings must now be positively confirmed by the file's cover art, or it's skipped. (The correct "200" exists on LRCLib as unsynced — enable "Add unsynced if no synced" to pick it up.) -Songs genuinely absent from every lyrics catalogue still fail honestly — no fluke lyrics. +### Cyrillic and Latin finally speak the same language +Provider entries written in Cyrillic ("Секси") now compare equal to their Latin spelling ("Seksi") instead of counting as strangers — matching now works across scripts in both directions, for artists and titles. + +### More recovered names +- **'Summer Cem - "TMM TMM"'** — quotes in a title no longer break provider search (the song is on LRCLib, synced). +- Channel-artist and collab-splitting fixes from 1.6.4 now actually reach every provider thanks to the fast-mode fix. + +### Honest about the rest +We verified the missing Serbian catalogue directly: QQ Music *indexes* many of these songs (Prazan Disko, Volim Pare…) but has **no lyric content** for them, Genius has no usable public API, and tekstovi.net has no machine-readable search. Songs that no catalogue carries still fail honestly — no fluke lyrics, ever. + +**Samsung Music note:** Samsung's player only reads *synced* `.lrc` sidecars and *embedded* tags. If you want unsynced lyrics visible there, enable **Embed lyrics in file** in batch options — a plain text `.lrc` alone won't show up. Install over your existing app — it's signed with the same key, so it upgrades in place. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ba4e9b3..76adffa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,8 +15,8 @@ android { minSdk = 21 //noinspection OldTargetApi targetSdk = 35 - versionCode = 164 - versionName = "1.6.4" + versionCode = 170 + versionName = "1.7.0" vectorDrawables { useSupportLibrary = true diff --git a/app/src/main/java/pl/lambada/songsync/data/UserSettingsController.kt b/app/src/main/java/pl/lambada/songsync/data/UserSettingsController.kt index be54c7a..4e1d7c8 100644 --- a/app/src/main/java/pl/lambada/songsync/data/UserSettingsController.kt +++ b/app/src/main/java/pl/lambada/songsync/data/UserSettingsController.kt @@ -138,6 +138,7 @@ class UserSettingsController(private val dataStore: DataStore) { private set var batchAutoTryProviders by mutableStateOf(prefs[batchAutoTryProvidersKey] ?: true) + var batchFastMode by mutableStateOf(prefs[batchFastModeKey] ?: true) private set // Fast mode for the single-song search: race only the top providers with a short timeout. Off by default — @@ -262,6 +263,11 @@ class UserSettingsController(private val dataStore: DataStore) { batchAutoTryProviders = to } + fun updateBatchFastMode(to: Boolean) { + dataStore.set(batchFastModeKey, to) + batchFastMode = to + } + fun updateSingleFastMode(to: Boolean) { dataStore.set(singleFastModeKey, to) singleFastMode = to @@ -302,4 +308,5 @@ private val batchCorrectMetadataKey = booleanPreferencesKey("batch_correct_metad private val batchSkipExistingKey = booleanPreferencesKey("batch_skip_existing") private val batchSkipNoLyricsKey = booleanPreferencesKey("batch_skip_no_lyrics") private val batchAutoTryProvidersKey = booleanPreferencesKey("batch_auto_try_providers") +private val batchFastModeKey = booleanPreferencesKey("batch_fast_mode") private val singleFastModeKey = booleanPreferencesKey("single_fast_mode") \ No newline at end of file diff --git a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/SmartLyricsMatcher.kt b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/SmartLyricsMatcher.kt index 9b8dc64..1561b05 100644 --- a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/SmartLyricsMatcher.kt +++ b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/SmartLyricsMatcher.kt @@ -51,15 +51,19 @@ data class MatchConfig( ) { companion object { /** - * "Fast mode" for the single-song search: only the top [maxProviders] providers, one retry, a short - * per-provider budget and minimal politeness delay. Trades a little reliability for speed — the regular - * config stays the default for batch runs. + * Fast mode: ALL enabled providers race in parallel (they do anyway) with one retry, a short + * per-provider budget and minimal politeness delay. v1.6.x additionally truncated the provider list + * to 2, which silently skipped LRCLib for some provider orders and cost real matches ("Ili Ili", + * "Peta Brzina" were reported "not found" purely because the provider that has them never ran) — speed + * comes from the caps and the early auto-accept stop, NOT from dropping providers, so reliability for + * indexed songs now equals the regular config. The full candidate ladder is kept: parsing messy names + * is what finds these files at all. */ - fun fast(providerOrder: List, maxProviders: Int = 2) = MatchConfig( - providerOrder = providerOrder.take(maxProviders), + fun fast(providerOrder: List) = MatchConfig( + providerOrder = providerOrder, maxRetries = 1, requestDelayMs = 50, - maxCandidatesPerProvider = 3, + maxCandidatesPerProvider = 4, retryBaseDelayMs = 250, providerTimeoutMs = 8_000, ) diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/batch/BatchOptionsScreen.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/batch/BatchOptionsScreen.kt index 187e07e..d82ce92 100644 --- a/app/src/main/java/pl/lambada/songsync/ui/screens/batch/BatchOptionsScreen.kt +++ b/app/src/main/java/pl/lambada/songsync/ui/screens/batch/BatchOptionsScreen.kt @@ -147,6 +147,14 @@ fun BatchOptionsScreen( SettingsHeadLabel(label = stringResource(R.string.batch_options_general)) + // Fast mode leads the list: same providers, same matching guards, tighter time budgets — the + // recommended default for every batch run. + SwitchItem( + label = stringResource(R.string.fast_mode) + " • " + stringResource(R.string.recommended), + description = stringResource(R.string.batch_fast_mode_desc), + selected = settings.batchFastMode, + ) { settings.updateBatchFastMode(!settings.batchFastMode) } + SwitchItem( label = stringResource(R.string.skip_existing_lyrics), description = stringResource(R.string.skip_existing_lyrics_desc), diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/components/LyricsSuccessContent.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/components/LyricsSuccessContent.kt index c123903..dbd554a 100644 --- a/app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/components/LyricsSuccessContent.kt +++ b/app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/components/LyricsSuccessContent.kt @@ -48,7 +48,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import pl.lambada.songsync.R -private val lrcTag = Regex("""\[[^]]*]""") +// Line tags "[...]" AND enhanced-LRC word stamps "<00:40.49>" — neither belongs in a text preview. +private val lrcTag = Regex("""\[[^]]*]|<\d{1,2}:\d{2}[.:]\d{1,3}>""") /** * Result-screen lyric panel. The heavy lifting (play-along preview + offset correction) now lives in the diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/player/SyncedLyricsPlayerScreen.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/player/SyncedLyricsPlayerScreen.kt index 97aae6e..6af6aaa 100644 --- a/app/src/main/java/pl/lambada/songsync/ui/screens/player/SyncedLyricsPlayerScreen.kt +++ b/app/src/main/java/pl/lambada/songsync/ui/screens/player/SyncedLyricsPlayerScreen.kt @@ -101,12 +101,27 @@ private const val OFFSET_RANGE_MS = 10000f private val timestamp = Regex("""\[(\d{1,2}):(\d{2})[.:](\d{1,3})]""") +/** Enhanced-LRC word timestamps ("<00:40.490>word") — line-level display must not show them as text. */ +private val wordTimestamp = Regex("""<(\d{1,2}):(\d{2})[.:](\d{1,3})>""") + private fun parseSyncedLrc(raw: String): List { val out = ArrayList() for (line in raw.lineSequence()) { val matches = timestamp.findAll(line).toList() - if (matches.isEmpty()) continue - val text = line.substring(matches.last().range.last + 1).trim() + if (matches.isEmpty()) { + // Word-by-word files may carry ONLY inline "" stamps on a line: use the first as the + // line time so these files still play instead of parsing to nothing. + val word = wordTimestamp.find(line) ?: continue + val (mm, ss, frac) = word.destructured + val ms = mm.toLong() * 60_000 + ss.toLong() * 1_000 + (frac.padEnd(3, '0').take(3)).toLong() + val text = wordTimestamp.replace(line, " ").replace(Regex("""\s+"""), " ").trim() + out.add(LyricLine(ms, text)) + continue + } + // Strip inline word stamps from the display text — they belong to the timing model, not the lyrics + // (they were being shown raw: "<00:40.490>I <00:40.666>was ..."). + val text = wordTimestamp.replace(line.substring(matches.last().range.last + 1), "") + .replace(Regex("""\s+"""), " ").trim() for (m in matches) { val (mm, ss, frac) = m.destructured val ms = mm.toLong() * 60_000 + ss.toLong() * 1_000 + @@ -307,7 +322,10 @@ fun SyncedLyricsPlayerScreen( val listState = rememberLazyListState() // derivedStateOf: the lyric list recomposes only when the highlighted line actually changes, NOT on every // 120ms position poll. That per-poll recomposition of every visible line was the source of the scroll jank. - val currentIndex by remember { + // MUST be keyed on `lines`: when opening from Home the lyrics load asynchronously, and an unkeyed remember + // captured the initial EMPTY list forever — the highlight (and therefore the auto-scroll) stayed frozen on + // the first line for the entire song. + val currentIndex by remember(lines) { derivedStateOf { if (lines.isEmpty()) 0 // Lyrics are neutral, so the slider value is the effective absolute offset applied to the preview. diff --git a/app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt b/app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt index a6245b1..f1b715d 100644 --- a/app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt +++ b/app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt @@ -367,7 +367,10 @@ suspend fun downloadLyrics( // The chain is tried strictly in the user's configured order (issue #6) — no adaptive reshuffling, so // the behaviour always matches what Settings shows. val providerOrder = if (!autoTryProviders) listOf(configuredProviders.first()) else configuredProviders - val songConfig = MatchConfig(providerOrder = providerOrder) + // Fast mode (recommended): same providers, same candidate ladder, same guards — just tighter retry/ + // timeout budgets, so a hung host costs seconds instead of half a minute. + val songConfig = if (settings.batchFastMode) MatchConfig.fast(providerOrder) + else MatchConfig(providerOrder = providerOrder) // Always overwrite once we've decided to process: replaces a stale/plain .lrc with the synced result. val info = matchAndSaveSong(song, settings, context, matcher, songConfig, correctMetadata, overwriteExisting = true, saveLrc = saveLrc, embedLyrics = embedLyrics, addUnsyncedFallback = addUnsyncedFallback(), onMetadataCorrected = onMetadataCorrected, onPlainAvailable = onPlainAvailable) @@ -479,22 +482,26 @@ suspend fun matchAndSaveSong( // than give up we try the next hit/provider. var chosen: ScoredHit? = null var lyrics: String? = null + // A hit whose artist agrees with none of our guesses survived only on the exact-runtime escape ("200" by + // Tveth landing on Ourmoney's "200", "Check" by The Boy on WAV3POP's "CHECK" — the lengths coincided). + // Batch saves unattended, so a runtime coincidence alone is NOT enough anymore: the file's cover art must + // positively confirm the hit's artist, otherwise the hit is treated as an impostor and skipped. The + // single-song screen keeps the escape (the user sees the result and judges it). + suspend fun confirmedByCover(resultArtist: String?): Boolean { + if (!artistDisagreesWithAllGuesses(artistGuesses, resultArtist)) return true + if (!discoveryRan) { + discoveryRan = true + discovered = runCatching { + CoverIdentity.discover(context, song.filePath, candidates.map { it.asSearchString() }, local.durationSec) + }.getOrNull() + } + val idArtist = discovered?.artist ?: return false + return TextMatch.similarity(idArtist, resultArtist) >= 0.50 + } + for (hit in nonRejected) { coroutineContext.ensureActive() - // A hit whose artist agrees with none of our guesses survived only on the exact-runtime escape - // ("Check" by The Boy landing on WAV3POP's "CHECK" because the lengths coincided). Before trusting - // it, ask the cover art for a second opinion: if the file's own art identifies a DIFFERENT artist, - // the hit is an impostor — skip it. No art / no identification keeps the existing behaviour. - if (artistDisagreesWithAllGuesses(artistGuesses, hit.result.artist)) { - if (!discoveryRan) { - discoveryRan = true - discovered = runCatching { - CoverIdentity.discover(context, song.filePath, candidates.map { it.asSearchString() }, local.durationSec) - }.getOrNull() - } - val idArtist = discovered?.artist - if (idArtist != null && TextMatch.similarity(idArtist, hit.result.artist) < 0.50) continue - } + if (!confirmedByCover(hit.result.artist)) continue val l = runCatching { matcher.fetchLyrics(hit, config) }.getOrNull() if (!l.isNullOrBlank()) { chosen = hit; lyrics = l; break } } @@ -510,6 +517,9 @@ suspend fun matchAndSaveSong( rescued = runCatching { matcher.lastResort(local, candidates, config, coverBonus = CoverArtCompare.bonusFor(context, song.filePath)) }.getOrNull()?.takeIf { it.synced } + // The same impostor guard applies to rescues: a canonicalized identity whose artist agrees with none + // of the file's readings must be confirmed by the cover before batch saves it unattended. + if (rescued != null && !confirmedByCover(rescued.artist)) rescued = null } if (rescued != null) { val songInfo = SongInfo(songName = rescued.title, artistName = rescued.artist) @@ -534,7 +544,7 @@ suspend fun matchAndSaveSong( // "N unsynced available" and let the user add them all with one tap later. if (addUnsyncedFallback || onPlainAvailable != null) { val plain = runCatching { matcher.fetchPlainLyrics(local, candidates, config) }.getOrNull() - val plainBody = plain?.plainLyrics + val plainBody = plain?.plainLyrics?.takeIf { confirmedByCover(plain.result.artist) } if (plainBody != null) { val pTitle = plain.result.title val pArtist = plain.result.artist diff --git a/app/src/main/java/pl/lambada/songsync/util/matching/TextMatch.kt b/app/src/main/java/pl/lambada/songsync/util/matching/TextMatch.kt index b6b2fda..56ecbb5 100644 --- a/app/src/main/java/pl/lambada/songsync/util/matching/TextMatch.kt +++ b/app/src/main/java/pl/lambada/songsync/util/matching/TextMatch.kt @@ -83,6 +83,9 @@ object TextMatch { s = noiseBrackets.replace(s, " ") s = leadingTrackNo.replace(s, "") s = garbledChars.replace(s, " ") + // Double quotes around a title ('Summer Cem - "TMM TMM"') defeat provider search — drop them. + // Apostrophes are kept: they are part of words ("don't"). + s = s.replace(Regex("""["“”„«»]"""), " ") s = s.replace('_', ' ') s = s.replace(Regex("""[\s]+"""), " ").trim() // Drop dangling separators/brackets left behind by removals. @@ -90,10 +93,28 @@ object TextMatch { return s.trim() } + /** + * Serbian/Russian Cyrillic -> Latin, aligned with what the accent-strip does to Latin diacritics + * (ж->z like ž->z, ш->s like š->s), so "Секси" compares equal to "Seksi" and a Cyrillic-indexed + * provider entry stops looking like a stranger to a Latin-tagged file. + */ + private val cyrillicMap = mapOf( + 'а' to "a", 'б' to "b", 'в' to "v", 'г' to "g", 'д' to "d", 'ђ' to "dj", 'е' to "e", 'ё' to "e", + 'ж' to "z", 'з' to "z", 'и' to "i", 'й' to "j", 'ј' to "j", 'к' to "k", 'л' to "l", 'љ' to "lj", + 'м' to "m", 'н' to "n", 'њ' to "nj", 'о' to "o", 'п' to "p", 'р' to "r", 'с' to "s", 'т' to "t", + 'ћ' to "c", 'у' to "u", 'ф' to "f", 'х' to "h", 'ц' to "c", 'ч' to "c", 'џ' to "dz", 'ш' to "s", + 'щ' to "sh", 'ъ' to "", 'ы' to "y", 'ь' to "", 'э' to "e", 'ю' to "ju", 'я' to "ja", + ) + + private fun transliterateCyrillic(s: String): String = + if (s.none { it in 'Ѐ'..'ӿ' }) s + else buildString { for (c in s) append(cyrillicMap[c] ?: c.toString()) } + /** Aggressive normalization used only for *comparison* (not for display or queries). */ fun normalizeForCompare(raw: String?): String { if (raw.isNullOrBlank()) return "" var s = raw.lowercase(Locale.ROOT) + s = transliterateCyrillic(s) s = noiseBrackets.replace(s, " ") s = s.replace('_', ' ') s = featRegex.replace(s, " ") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2cb9b3e..728a8b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -284,7 +284,9 @@ Fast matching - When searching a single song, race only the top two providers with a short timeout. Faster answers, but may find fewer songs than the full search. + Race all providers in parallel with tighter timeouts. Same providers and matching accuracy — just less waiting on slow servers. + Same providers, same matching accuracy — tighter time budgets so slow servers cost seconds, not minutes. + Recommended No lyrics found in this file diff --git a/app/src/test/java/pl/lambada/songsync/matching/MessyNameRecoveryTest.kt b/app/src/test/java/pl/lambada/songsync/matching/MessyNameRecoveryTest.kt index e9d9bf2..1d035a6 100644 --- a/app/src/test/java/pl/lambada/songsync/matching/MessyNameRecoveryTest.kt +++ b/app/src/test/java/pl/lambada/songsync/matching/MessyNameRecoveryTest.kt @@ -69,6 +69,26 @@ class MessyNameRecoveryTest { assertFalse(artistDisagreesWithAllGuesses(guesses, "WAV3POP")) } + @Test + fun `quoted titles are searchable`() { + // 'Summer Cem - "TMM TMM" (official 4K)': the quotes defeated provider search even though LRCLib has + // the song synced under "TMM TMM". + val cands = FilenameParser.candidates("Summer Cem - “TMM TMM” (official 4K)", "BangerChannel", null) + assertTrue( + "no clean TMM TMM candidate in: ${cands.map { "${it.artist} / ${it.title}" }}", + cands.any { it.title.equals("TMM TMM", true) && it.artist.equals("Summer Cem", true) }, + ) + } + + @Test + fun `Cyrillic artists compare as their Latin transliteration`() { + // "Секси" IS "Seksi" — a Cyrillic-indexed provider entry must not look like a stranger... + assertTrue(TextMatch.similarity("Секси", "Seksi") > 0.9) + assertFalse(artistDisagreesWithAllGuesses(listOf("Seksi"), "Секси")) + // ...while a genuinely different Cyrillic act still disagrees ("200" by Tveth/Sevnz/Лора vs Ourmoney). + assertTrue(artistDisagreesWithAllGuesses(listOf("Ourmoney - Topic", "Ourmoney"), "Tveth, Sevnz, Лора")) + } + @Test fun `no artist anywhere means no guesses and no veto`() { // "Bounce" / Unknown: identification is the cover's job — text alone must neither guess nor veto.