diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c42814d..61029c8 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,14 +1,16 @@ -## MusicResync v1.6.3 — hotfix +## MusicResync v1.6.4 -### Fixed: wrong lyrics saved for songs with short, generic titles -v1.6.2's looser matching (which judges hits against the parsed filename, not just the raw tags) opened a precision hole: a song whose title matches some unrelated track exactly — "PETROV - RARI" getting the German "Rari" by lil doggo, "Numero - BMW" getting "BMW" by Cecilio G., "UKIC X PETROV - A TI?" getting "A Ti" by Dyango, "QuESt - Automatic" getting a Japanese "Automatic (Live)" — could ride in on the title alone, because the artist-less view of the track had nothing left to object to the wrong singer. +### 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". -New **wrong-singer veto**: whenever your file gives the matcher *any* artist guess (from the tags or the parsed filename) and a provider's result names an artist that agrees with **none** of those guesses, the result is rejected — across the normal search, the plain-lyrics fallback, and the last-resort rescue. An exact runtime match still overrides a disagreeing artist (that trust is what makes junk-tagged rips matchable at all), and a result with no artist at all is unaffected. A provider artist written in a different script (e.g. CJK vs. a Latin filename) now counts as disagreeing too. +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. -### Fixed: lyrics player not following the song / janky scrolling -The synced-lyrics player (the adjust-timing screen) only scrolled once the highlighted line left a visibility window, which looked like the list wasn't following the song at all — and then it lurched a whole screen at a time. It now glides continuously: each new line smoothly eases the list so the active line settles just above centre, Samsung-Music style. Seeks and taps on the progress bar still jump straight to the right spot. +### 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. -### Renamed leftovers -All user-facing "SongSync" texts now say **MusicResync** — including the `[by:]` tag written into generated .lrc files, the settings/about texts, and the welcome screen (all languages). The credit to the original SongSync project by Lambada10 remains, as it should. +Songs genuinely absent from every lyrics catalogue still fail honestly — no fluke lyrics. 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 7058a6e..ba4e9b3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,8 +15,8 @@ android { minSdk = 21 //noinspection OldTargetApi targetSdk = 35 - versionCode = 163 - versionName = "1.6.3" + versionCode = 164 + versionName = "1.6.4" vectorDrawables { useSupportLibrary = true 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 3162e5e..9b8dc64 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 @@ -537,6 +537,15 @@ internal fun wrongSingerVetoed( conf: ConfidenceBreakdown, ): Boolean { if (conf.durationMatched) return false + return artistDisagreesWithAllGuesses(artistGuesses, resultArtist) +} + +/** + * True when [resultArtist] is present and agrees with NONE of [artistGuesses]. This is the veto's core test, + * exposed separately so callers can also spot a hit that survived only on the exact-duration escape and ask + * for extra evidence (e.g. cover-art identification) before trusting it. + */ +internal fun artistDisagreesWithAllGuesses(artistGuesses: List, resultArtist: String?): Boolean { if (resultArtist.isNullOrBlank()) return false val guesses = artistGuesses.filter { TextMatch.normalizeForCompare(it).isNotEmpty() } if (guesses.isEmpty()) return false diff --git a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/LastResortAPI.kt b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/LastResortAPI.kt index 85ec178..f20aab0 100644 --- a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/LastResortAPI.kt +++ b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/LastResortAPI.kt @@ -35,6 +35,7 @@ class LastResortAPI { val trackName: String? = null, val artistName: String? = null, val artworkUrl100: String? = null, + val trackTimeMillis: Double? = null, ) @Serializable @@ -45,6 +46,7 @@ class LastResortAPI { val title: String? = null, val artist: DeezerArtist? = null, val album: DeezerAlbum? = null, + val duration: Double? = null, ) @Serializable @@ -104,6 +106,40 @@ class LastResortAPI { return out.values.toList() } + /** A possible identity for a bare-title query: canonical names plus cover art + runtime for verification. */ + data class IdCandidate(val title: String, val artist: String, val coverUrl: String?, val durationSec: Double?) + + /** + * Wide search used by cover-driven identification. Unlike [canonicalize] (top few rows only), this returns + * up to [limit] rows per service WITH duration and album art, so a niche track buried deep in the results + * ("Bounce" by Voyage sits at ~#18 for the bare query "bounce") can still be picked out by its cover. + */ + suspend fun identityCandidates(query: String, limit: Int = 25): List { + if (query.isBlank()) return emptyList() + val (itunesItems, deezerItems) = coroutineScope { + val i = async { itunes(query, limit) } + val d = async { deezer(query, limit) } + i.await() to d.await() + } + val out = LinkedHashMap() + for (item in deezerItems) { + val title = item.title?.takeIf { it.isNotBlank() } ?: continue + val artist = item.artist?.name?.takeIf { it.isNotBlank() } ?: continue + val cover = item.album?.cover_big?.takeIf { it.isNotBlank() } ?: item.album?.cover_xl + out.putIfAbsent("${artist.lowercase()}|${title.lowercase()}", IdCandidate(title, artist, cover, item.duration)) + } + for (item in itunesItems) { + val title = item.trackName?.takeIf { it.isNotBlank() } ?: continue + val artist = item.artistName?.takeIf { it.isNotBlank() } ?: continue + val cover = item.artworkUrl100?.takeIf { it.isNotBlank() }?.let(::upscaleITunes) + out.putIfAbsent( + "${artist.lowercase()}|${title.lowercase()}", + IdCandidate(title, artist, cover, item.trackTimeMillis?.let { it / 1000.0 }) + ) + } + return out.values.toList() + } + /** Extra album-cover candidates for the thumbnail picker, beyond what Apple/Spotify already provided. */ suspend fun covers(title: String, artist: String, max: Int = 4): List { val query = "$artist $title" diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/LyricsFetchViewModel.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/LyricsFetchViewModel.kt index d9e92a0..29d1cf9 100644 --- a/app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/LyricsFetchViewModel.kt +++ b/app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/LyricsFetchViewModel.kt @@ -17,6 +17,7 @@ import pl.lambada.songsync.data.remote.lyrics_providers.LyricsProviderService import pl.lambada.songsync.data.remote.lyrics_providers.MatchConfig import pl.lambada.songsync.data.remote.lyrics_providers.ScoredHit import pl.lambada.songsync.data.remote.lyrics_providers.SmartLyricsMatcher +import pl.lambada.songsync.data.remote.lyrics_providers.artistGuessesFor import pl.lambada.songsync.domain.model.SongInfo import pl.lambada.songsync.ui.LocalSong import pl.lambada.songsync.util.Providers @@ -31,6 +32,7 @@ import pl.lambada.songsync.util.matching.QueryCandidate import pl.lambada.songsync.util.matching.TextMatch import com.kyant.taglib.TagLib import pl.lambada.songsync.util.CoverArtCompare +import pl.lambada.songsync.util.CoverIdentity import pl.lambada.songsync.util.embedCoverInFile import pl.lambada.songsync.util.embedLyricsInFile import pl.lambada.songsync.util.ext.getVersion @@ -124,9 +126,19 @@ class LyricsFetchViewModel( queryArtistName.takeIf { !TextMatch.isJunkArtist(it) }, durationSec, ) - val candidates = FilenameParser.candidates(querySongName, queryArtistName, source?.filePath) + val parsed = FilenameParser.candidates(querySongName, queryArtistName, source?.filePath) .ifEmpty { listOf(QueryCandidate(querySongName, queryArtistName ?: "", MatchStrategy.TAGS)) } + // Cover-driven identification (same as batch): a file with no usable artist anywhere is only + // identifiable by its embedded album art — discover the identity and lead the search with it. + val candidates = if (artistGuessesFor(local, parsed).isEmpty()) { + val id = runCatching { + CoverIdentity.discover(context, source?.filePath, parsed.map { it.asSearchString() }, durationSec) + }.getOrNull() + if (id != null) listOf(QueryCandidate(id.title, id.artist, MatchStrategy.COVER_IDENTITY)) + parsed + else parsed + } else parsed + // Selected provider first, then the user's configured fallback chain (Settings > Provider order). // Providers the user disabled are not queried automatically (they stay available via the // per-provider dropdown/retry). diff --git a/app/src/main/java/pl/lambada/songsync/util/CoverArtCompare.kt b/app/src/main/java/pl/lambada/songsync/util/CoverArtCompare.kt index 344bf0b..500d9ee 100644 --- a/app/src/main/java/pl/lambada/songsync/util/CoverArtCompare.kt +++ b/app/src/main/java/pl/lambada/songsync/util/CoverArtCompare.kt @@ -18,7 +18,7 @@ import java.net.URL object CoverArtCompare { /** Hamming distance (of 64 bits) at or below which two covers count as "the same picture". */ - private const val MATCH_MAX_DISTANCE = 10 + const val MATCH_MAX_DISTANCE = 10 /** 64-bit average hash of a bitmap: downscale to 8x8, gray, threshold on the mean. */ fun aHash64(bitmap: Bitmap): Long { diff --git a/app/src/main/java/pl/lambada/songsync/util/CoverIdentity.kt b/app/src/main/java/pl/lambada/songsync/util/CoverIdentity.kt new file mode 100644 index 0000000..527cbef --- /dev/null +++ b/app/src/main/java/pl/lambada/songsync/util/CoverIdentity.kt @@ -0,0 +1,61 @@ +package pl.lambada.songsync.util + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import pl.lambada.songsync.data.remote.lyrics_providers.others.LastResortAPI +import kotlin.math.abs + +/** + * Cover-driven identification for files whose metadata carries no usable artist at all (title tag "Bounce", + * artist "Unknown", filename "Bounce.mp3"). Such a file is indistinguishable from every other same-titled + * song by text alone — but its embedded album art isn't. This searches iTunes/Deezer widely with the bare + * title, hashes each result's cover, and treats a perceptual match against the file's own art (plus a + * compatible runtime) as the file's real identity — recovering the artist the tags never had. + * + * The discovered identity is used in two ways by the matcher: + * - as a leading query candidate ("Voyage Bounce" instead of just "Bounce"), and + * - as a trusted artist guess, so a same-titled song by someone else can finally be vetoed. + */ +object CoverIdentity { + + data class Discovered(val title: String, val artist: String, val distance: Int) + + /** Cap on cover-image downloads per song — identification must stay cheap even for common titles. */ + private const val MAX_COVER_FETCHES = 12 + + /** Runtime window for identity candidates. Lenient on purpose: the cover is the primary evidence. */ + private const val DURATION_WINDOW_SEC = 15.0 + + suspend fun discover( + context: Context, + filePath: String?, + titleQueries: List, + durationSec: Double?, + api: LastResortAPI = LastResortAPI(), + ): Discovered? = withContext(Dispatchers.IO) { + if (filePath == null) return@withContext null + val localHash = CoverArtCompare.localCoverHash(context, filePath) ?: return@withContext null + val seenUrls = HashSet() + var fetches = 0 + var best: Discovered? = null + for (query in titleQueries.filter { it.isNotBlank() }.distinct().take(2)) { + val rows = runCatching { api.identityCandidates(query) }.getOrDefault(emptyList()) + val plausible = rows.filter { r -> + durationSec == null || r.durationSec == null || abs(r.durationSec - durationSec) <= DURATION_WINDOW_SEC + } + for (row in plausible) { + if (fetches >= MAX_COVER_FETCHES) break + val url = row.coverUrl ?: continue + if (!seenUrls.add(url)) continue + fetches++ + val remote = CoverArtCompare.remoteCoverHash(url) ?: continue + val dist = CoverArtCompare.hammingDistance(localHash, remote) + if (dist <= CoverArtCompare.MATCH_MAX_DISTANCE && dist < (best?.distance ?: Int.MAX_VALUE)) + best = Discovered(row.title, row.artist, dist) + } + if (best != null) break + } + best + } +} 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 6e812cd..a6245b1 100644 --- a/app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt +++ b/app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt @@ -18,6 +18,8 @@ import pl.lambada.songsync.data.remote.lyrics_providers.LyricsProviderService import pl.lambada.songsync.data.remote.lyrics_providers.MatchConfig import pl.lambada.songsync.data.remote.lyrics_providers.ScoredHit import pl.lambada.songsync.data.remote.lyrics_providers.SmartLyricsMatcher +import pl.lambada.songsync.data.remote.lyrics_providers.artistDisagreesWithAllGuesses +import pl.lambada.songsync.data.remote.lyrics_providers.artistGuessesFor import pl.lambada.songsync.data.UserSettingsController import pl.lambada.songsync.domain.model.Song import pl.lambada.songsync.domain.model.SongInfo @@ -27,7 +29,9 @@ import pl.lambada.songsync.util.matching.FilenameParser import pl.lambada.songsync.util.matching.LocalTrack import pl.lambada.songsync.util.matching.LrcPrescan import pl.lambada.songsync.util.matching.LyricState +import pl.lambada.songsync.util.matching.MatchStrategy import pl.lambada.songsync.util.matching.MatchTier +import pl.lambada.songsync.util.matching.QueryCandidate import pl.lambada.songsync.util.matching.SongMatchInfo import pl.lambada.songsync.util.matching.TextMatch import kotlinx.coroutines.ensureActive @@ -432,7 +436,25 @@ suspend fun matchAndSaveSong( durationSec = song.durationMs?.let { it / 1000.0 }, album = song.album, ) - val candidates = FilenameParser.candidates(song.title, song.artist, song.filePath) + val parsed = FilenameParser.candidates(song.title, song.artist, song.filePath) + + // Cover-driven identification: a file with NO usable artist anywhere ("Bounce", artist "Unknown") cannot + // be told apart from every other same-titled song by text — but its embedded cover can. Discover the real + // identity up front and lead with it, both as a query ("Voyage Bounce") and as an artist guess the + // wrong-singer veto can finally use. + var discovered: CoverIdentity.Discovered? = null + var discoveryRan = false + var candidates = parsed + if (artistGuessesFor(local, parsed).isEmpty()) { + discoveryRan = true + discovered = runCatching { + CoverIdentity.discover(context, song.filePath, parsed.map { it.asSearchString() }, local.durationSec) + }.getOrNull() + discovered?.let { + candidates = listOf(QueryCandidate(it.title, it.artist, MatchStrategy.COVER_IDENTITY)) + parsed + } + } + val artistGuesses = artistGuessesFor(local, candidates) // Which providers were actually queried for this song, in order. Persisted on the outcome so the // song's provider list can later show "found / no match / not tried" per provider. @@ -459,6 +481,20 @@ suspend fun matchAndSaveSong( var lyrics: String? = null 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 + } val l = runCatching { matcher.fetchLyrics(hit, config) }.getOrNull() if (!l.isNullOrBlank()) { chosen = hit; lyrics = l; break } } diff --git a/app/src/main/java/pl/lambada/songsync/util/matching/FilenameParser.kt b/app/src/main/java/pl/lambada/songsync/util/matching/FilenameParser.kt index c5febee..c89ac1a 100644 --- a/app/src/main/java/pl/lambada/songsync/util/matching/FilenameParser.kt +++ b/app/src/main/java/pl/lambada/songsync/util/matching/FilenameParser.kt @@ -10,6 +10,8 @@ enum class MatchStrategy(val label: String) { FILENAME_TITLE_ARTIST("filename: Title - Artist"), FILENAME_LOOSE("filename: loosened"), FILENAME_TITLE_ONLY("filename: title only"), + /** Identity discovered by matching the file's embedded cover art against iTunes/Deezer results. */ + COVER_IDENTITY("cover identity"), } /** @@ -84,6 +86,20 @@ object FilenameParser { private fun stripTrailingJunkNumber(s: String): String = trailingJunkNumber.replace(s, "").trim() + /** + * Any bracketed clause, regardless of content — the nuclear fallback for junk the noise list misses. + * Also matches an UNTERMINATED trailing clause ("CC #2 (JUŽNI VETAR 2022"): earlier cleanup trims a + * closing bracket off the end of the string, leaving the opener dangling. + */ + private val anyBracketClause = Regex("""\s*[(\[][^()\[\]]*(?:[)\]]|$)""") + + /** + * "CC #2 (JUŽNI VETAR 2022)" -> "CC #2": strips EVERY bracketed clause. Providers index the bare title; + * a leftover uploader note in brackets (not on the known-noise list) otherwise defeats the query. + */ + private fun stripAllBrackets(s: String): String = + anyBracketClause.replace(s, " ").replace(Regex("""\s+"""), " ").trim().trim('-', '|', '/').trim() + private fun candidate(title: String, artist: String?, strategy: MatchStrategy): QueryCandidate? { val cleanTitle = TextMatch.cleanTitleArtist(title) if (cleanTitle.isBlank()) return null @@ -125,6 +141,14 @@ object FilenameParser { if (!primary.equals(artistPart, ignoreCase = true)) add(candidate(titlePart, primary, MatchStrategy.FILENAME_PRIMARY_ARTIST)) + // EVERY collab part, not just the first: "GRCA X FOX - BELE ŠARE" is indexed under Fox (with + // GRCA as the guest), so the second name may be the one providers know — and it must count as + // a legitimate artist guess so the wrong-singer veto doesn't treat the real artist as a stranger. + val collabParts = collabSeparator.split(artistPart).map { it.trim() }.filter { it.isNotBlank() } + if (collabParts.size > 1) collabParts.drop(1).take(2).forEach { part -> + add(candidate(titlePart, part, MatchStrategy.FILENAME_PRIMARY_ARTIST)) + } + // Loosened: drop "(... Remix)/(... Version)" so the BASE track can be found as a fallback. val loose = loosenTitle(titlePart) if (!loose.equals(titlePart, ignoreCase = true) && loose.isNotBlank()) @@ -137,6 +161,13 @@ object FilenameParser { if (!noJunkNo.equals(titlePart, ignoreCase = true) && noJunkNo.isNotBlank()) add(candidate(noJunkNo, artistPart, MatchStrategy.FILENAME_LOOSE)) + // Bracket junk the noise list doesn't know ("CC #2 (JUŽNI VETAR 2022)"): try the bare title too. + val noBrackets = stripAllBrackets(titlePart) + if (!noBrackets.equals(titlePart, ignoreCase = true) && noBrackets.isNotBlank()) { + add(candidate(noBrackets, artistPart, MatchStrategy.FILENAME_LOOSE)) + add(candidate(noBrackets, primary, MatchStrategy.FILENAME_LOOSE)) + } + // Collapsed artist (alphanumerics only, no spaces) for stylized names whose decorations get // mangled on disk: "_UICIDEBOY_" / "$uicideboy$" -> "uicideboy", "P!nk" -> "pnk", "deadmau5". // LRCLib indexes these as one token, so a spaced/decorated query misses them entirely. @@ -157,6 +188,9 @@ object FilenameParser { val noJunkPlain = stripTrailingJunkNumber(cleaned) if (!noJunkPlain.equals(cleaned, ignoreCase = true) && noJunkPlain.isNotBlank()) add(candidate(noJunkPlain, null, MatchStrategy.FILENAME_TITLE_ONLY)) + val noBracketsPlain = stripAllBrackets(cleaned) + if (!noBracketsPlain.equals(cleaned, ignoreCase = true) && noBracketsPlain.isNotBlank()) + add(candidate(noBracketsPlain, null, MatchStrategy.FILENAME_TITLE_ONLY)) } // 1) Trust tags first when they look real. Placeholder artists ("Unknown", "Various Artists"…) are @@ -166,6 +200,12 @@ object FilenameParser { val artist = tagArtist?.takeIf { !TextMatch.isJunkArtist(it) } if (title != null) { add(candidate(title, artist, MatchStrategy.TAGS)) + // A tag artist that is really a YouTube channel name ("WAV3POP - Topic", "Crni Cerak TV"): also + // try the decoration-stripped reading. The raw value stays first, so nothing is lost if the artist + // genuinely ends in "TV". + val channelless = artist?.let { TextMatch.stripChannelSuffix(it) } + if (!channelless.isNullOrBlank() && !channelless.equals(artist, ignoreCase = true)) + add(candidate(title, channelless, MatchStrategy.TAGS)) // If the tag title itself is "Artist - Title", split it too. if (title.contains(Regex("""\s+-\s+"""))) addDashCandidates(title) } 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 bd85b56..b6b2fda 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 @@ -50,6 +50,30 @@ object TextMatch { /** Leading track number like "07.", "05 - ", "1) ", "12_". */ private val leadingTrackNo = Regex("""^\s*\d{1,2}\s*[.)\-_]\s*""") + /** + * Runs of '?' or U+FFFD left where a filesystem/tagger couldn't represent the original characters + * ("DEVITO - FLEX ????" was "FLEX" plus emoji/Cyrillic). They poison provider queries; the words around + * them are what's searchable. + */ + private val garbledChars = Regex("""\?{2,}|�+""") + + /** + * YouTube channel-name decorations that are not part of the real artist ("WAV3POP - Topic", + * "Crni Cerak TV", "La Kojot Official"). Only used to derive an EXTRA artist reading — the raw value + * always stays as a candidate too, so a real artist that happens to end in one of these loses nothing. + */ + private val channelSuffix = Regex( + """\s*(?:-\s*Topic|TV|Official(?:\s+(?:Music|Channel))?|Music|Records|Media)\s*$""", + RegexOption.IGNORE_CASE + ) + + /** Strips channel decorations off a tag artist; returns "" when nothing real remains. */ + fun stripChannelSuffix(raw: String): String { + var s = raw.trim() + repeat(2) { s = channelSuffix.replace(s, "").trim(' ', '-', '_') } + return s + } + /** * Light cleanup that keeps the human-readable shape: strips noise brackets, collapses the SnapTube * underscore-for-punctuation convention into spaces, and trims leading track numbers. @@ -58,6 +82,7 @@ object TextMatch { var s = raw s = noiseBrackets.replace(s, " ") s = leadingTrackNo.replace(s, "") + s = garbledChars.replace(s, " ") s = s.replace('_', ' ') s = s.replace(Regex("""[\s]+"""), " ").trim() // Drop dangling separators/brackets left behind by removals. diff --git a/app/src/test/java/pl/lambada/songsync/matching/MessyNameRecoveryTest.kt b/app/src/test/java/pl/lambada/songsync/matching/MessyNameRecoveryTest.kt new file mode 100644 index 0000000..e9d9bf2 --- /dev/null +++ b/app/src/test/java/pl/lambada/songsync/matching/MessyNameRecoveryTest.kt @@ -0,0 +1,80 @@ +package pl.lambada.songsync.matching + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import pl.lambada.songsync.data.remote.lyrics_providers.artistDisagreesWithAllGuesses +import pl.lambada.songsync.data.remote.lyrics_providers.artistGuessesFor +import pl.lambada.songsync.util.matching.FilenameParser +import pl.lambada.songsync.util.matching.LocalTrack +import pl.lambada.songsync.util.matching.TextMatch + +/** + * v1.6.4: recovery of real-world messy names verified against the live catalogues — + * - "DEVITO - FLEX ????" (garbled chars; the real track "Taj flex" by Devito needs the query "DEVITO FLEX") + * - "CRNI CERAK - CC #2 (JUŽNI VETAR 2022)" (unknown bracket junk; LRCLib has "CC #2" by Crni Cerak) + * - "GRCA X FOX - BELE ŠARE" (indexed under Fox with GRCA as guest — the second collab part matters) + * - "CHECK" by channel artist "WAV3POP - Topic" (the stripped channel name is the real artist) + */ +class MessyNameRecoveryTest { + + @Test + fun `garbled question-mark runs are stripped from queries`() { + val cands = FilenameParser.candidates("DEVITO - FLEX ????", "La_Kojot Official", null) + assertTrue( + "no clean DEVITO/FLEX candidate in: ${cands.map { "${it.artist} / ${it.title}" }}", + cands.any { it.title.equals("FLEX", true) && it.artist.equals("DEVITO", true) }, + ) + // A single legitimate '?' ("A TI?") must survive cleaning. + val single = FilenameParser.candidates("UKIC - A TI?", null, null) + assertTrue(single.any { it.title.equals("A TI?", true) }) + } + + @Test + fun `unknown bracket junk falls back to a bare-title candidate`() { + val cands = FilenameParser.candidates("CRNI CERAK - CC #2 (JUŽNI VETAR 2022)", "Južni vetar", null) + assertTrue( + "no bare CC #2 candidate in: ${cands.map { "${it.artist} / ${it.title}" }}", + cands.any { it.title.equals("CC #2", true) && it.artist.equals("CRNI CERAK", true) }, + ) + } + + @Test + fun `second collab artist becomes a candidate and a veto guess`() { + val local = LocalTrack("GRCA X FOX - BELE ŠARE", null, 122.0) + val cands = FilenameParser.candidates("GRCA X FOX - BELE ŠARE (OFFICIAL VIDEO)", "GRCA", null) + assertTrue( + "FOX not among candidates: ${cands.map { "${it.artist} / ${it.title}" }}", + cands.any { it.artist.equals("FOX", true) }, + ) + // The real provider entry is "BELE ŠARE (feat. GRCA)" by Fox — Fox must NOT count as a stranger. + val guesses = artistGuessesFor(local, cands) + assertFalse(artistDisagreesWithAllGuesses(guesses, "Fox")) + } + + @Test + fun `channel decorations are stripped into an extra artist reading`() { + assertEquals("WAV3POP", TextMatch.stripChannelSuffix("WAV3POP - Topic")) + assertEquals("Crni Cerak", TextMatch.stripChannelSuffix("Crni Cerak TV")) + assertEquals("La Kojot", TextMatch.stripChannelSuffix("La Kojot Official")) + // Nothing real left -> blank, caller keeps the original. + assertEquals("", TextMatch.stripChannelSuffix("Official Music")) + + val cands = FilenameParser.candidates("CHECK", "WAV3POP - Topic", null) + assertTrue(cands.any { it.artist.equals("WAV3POP", true) }) + // "The Boy" (the wrong singer the escape let through) still disagrees with every reading. + val guesses = artistGuessesFor(LocalTrack("CHECK", "WAV3POP - Topic", 140.0), cands) + assertTrue(artistDisagreesWithAllGuesses(guesses, "The Boy")) + assertFalse(artistDisagreesWithAllGuesses(guesses, "WAV3POP")) + } + + @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. + val local = LocalTrack("Bounce", null, 139.0) + val cands = FilenameParser.candidates("Bounce", "Unknown", "/m/Bounce.mp3") + assertTrue(artistGuessesFor(local, cands).isEmpty()) + assertFalse(artistDisagreesWithAllGuesses(emptyList(), "essaluv feat. Экси")) + } +}