From 6bfbd53bfa46fbca59f3913b52cb2d3f939b1b1d Mon Sep 17 00:00:00 2001 From: ajisth69 Date: Thu, 8 Oct 2026 22:31:18 +0530 Subject: [PATCH 1/2] feat(playback): instant parallel race with lossless priority and list-time videoId pinning - limusic-style fast path goes pure-parallel (stagger 0), stage 0 - lossless keeps first priority via 800ms head start, YT instant fallback, auditioned background hot-swap unchanged - tap never searches known videoIds; fail loud instead of substituting - pin ids at list time everywhere (reco, search, radio, home, playlists) plus one-shot backfill; buffer 500ms first frame - 4.2.3/4.2.4 hybrid match gate (60/35, no second chance), script-aware multilingual matching, feed heavy-rotation taste jitter --- .../lastwave/app/data/feed/FeedRepository.kt | 20 ++++--- .../app/data/generate/GenerateRepository.kt | 10 ++-- .../app/data/generate/GeneratedTrack.kt | 46 ++++++++++++++++ .../app/data/music/InnerTubeMusicApi.kt | 34 +++++++----- .../com/lastwave/app/data/music/TextMatch.kt | 53 +++++++++++++++++-- .../app/data/playlist/CsvPlaylistImporter.kt | 6 ++- .../app/data/playlist/PlaylistRepository.kt | 39 +++++++++++++- .../app/data/repository/HomeRepository.kt | 15 +++++- .../lastwave/app/data/repository/HomeTrack.kt | 3 ++ .../app/data/search/SearchRepository.kt | 1 + .../app/data/ytmusic/YtMusicSyncManager.kt | 10 +++- .../com/lastwave/app/playback/MusicPlayer.kt | 52 ++++++++++++++---- .../app/ui/home/FriendProfileScreen.kt | 2 + .../app/ui/home/FriendProfileViewModel.kt | 2 + .../com/lastwave/app/ui/home/HomeScreen.kt | 1 + .../data/generate/PinnedWithVideoIdsTest.kt | 46 ++++++++++++++++ .../lastwave/app/data/music/TextMatchTest.kt | 44 +++++++++++++++ 17 files changed, 341 insertions(+), 43 deletions(-) create mode 100644 app/src/test/java/com/lastwave/app/data/generate/PinnedWithVideoIdsTest.kt diff --git a/app/src/main/java/com/lastwave/app/data/feed/FeedRepository.kt b/app/src/main/java/com/lastwave/app/data/feed/FeedRepository.kt index 5ffe4624..fdfc3a9f 100644 --- a/app/src/main/java/com/lastwave/app/data/feed/FeedRepository.kt +++ b/app/src/main/java/com/lastwave/app/data/feed/FeedRepository.kt @@ -405,25 +405,33 @@ class FeedRepository @Inject constructor( // Heavy rotation: connected blends long-term taste + liked signals; // guest/disconnected builds it from local Room listening history // (liked + saved) so "Heavy Rotation" never empties. + // Taste-weighted rotation: affinity dominates (40/35-point arms), but a + // ±20 jitter reorders within taste bands and last load's picks sink 20, + // so Heavy Rotation feels fresh every load without going generic. + // Mirrors quickPicks' trackScore jitter/previous-penalty pattern. + val previousHeavyKeys = previous?.heavyRotation.orEmpty().mapTo(mutableSetOf()) { it.key } + fun rotationJitter(t: GeneratedTrack): Double = + random.nextDouble() * 20.0 - (if (t.key in previousHeavyKeys) 20.0 else 0.0) val heavyCandidates = buildList { tasteProfile?.topTracksRaw?.forEachIndexed { i, t -> val aff = ArtistHelper.splitArtists(t.artist).maxOfOrNull { affinity[it.trim().lowercase()] ?: 0.0 } ?: 0.0 - add(t to (aff * 40 + 20.0 / (1 + i / 6.0))) + add(t to (aff * 40 + 20.0 / (1 + i / 6.0) + rotationJitter(t))) } blend(ytRecentSongs, ytLikedSongs) .distinctBy { it.artist.trim().lowercase() to it.title.trim().lowercase() } .forEachIndexed { i, it -> + val generated = GeneratedTrack( + it.title, it.artist, it.artworkUrl, + url = "https://www.youtube.com/watch?v=${it.videoId}", album = it.album, + ) add( - GeneratedTrack( - it.title, it.artist, it.artworkUrl, - url = "https://www.youtube.com/watch?v=${it.videoId}", album = it.album, - ) to (12.0 / (1 + i / 6.0) + (affinity[ArtistHelper.primaryArtist(it.artist).trim().lowercase()] ?: 0.0) * 30), + generated to (12.0 / (1 + i / 6.0) + (affinity[ArtistHelper.primaryArtist(it.artist).trim().lowercase()] ?: 0.0) * 30 + rotationJitter(generated)), ) } // Local-first: Room liked + saved rank by affinity like taste. localLibrary.forEachIndexed { i, t -> val aff = ArtistHelper.splitArtists(t.artist).maxOfOrNull { affinity[it.trim().lowercase()] ?: 0.0 } ?: 0.0 - add(t to (aff * 35 + 16.0 / (1 + i / 6.0))) + add(t to (aff * 35 + 16.0 / (1 + i / 6.0) + rotationJitter(t))) } }.distinctBy { (t, _) -> t.key } .sortedByDescending { it.second } diff --git a/app/src/main/java/com/lastwave/app/data/generate/GenerateRepository.kt b/app/src/main/java/com/lastwave/app/data/generate/GenerateRepository.kt index 29ed3845..fb88cede 100644 --- a/app/src/main/java/com/lastwave/app/data/generate/GenerateRepository.kt +++ b/app/src/main/java/com/lastwave/app/data/generate/GenerateRepository.kt @@ -151,9 +151,11 @@ class GenerateRepository @Inject constructor( } } - /** Instant non-blocking pass-through matching web app.js generation speed. - * Audio resolution is performed on-demand when playing tracks. */ - suspend fun filterPlayable(tracks: List): List = tracks + /** List-time videoId pinning: every reco/queue emission leaves here carrying + * its YouTube id (resolved once, strictly), so playback never searches. + * Already-pinned rows pass through with zero network; strict misses drop. */ + suspend fun filterPlayable(tracks: List): List = + tracks.pinnedWithVideoIds(innerTube) private fun YouTubeMusicTrack.toGeneratedTrack() = GeneratedTrack( name = title, @@ -1423,7 +1425,7 @@ class GenerateRepository @Inject constructor( emptyList() } } - blendSources(youtube.await(), lastFm.await()).take(15) + blendSources(youtube.await(), lastFm.await()).let { filterPlayable(it).take(15) } } suspend fun searchArtists(artist: String): List = kotlinx.coroutines.supervisorScope { diff --git a/app/src/main/java/com/lastwave/app/data/generate/GeneratedTrack.kt b/app/src/main/java/com/lastwave/app/data/generate/GeneratedTrack.kt index c4d642f3..255840a1 100644 --- a/app/src/main/java/com/lastwave/app/data/generate/GeneratedTrack.kt +++ b/app/src/main/java/com/lastwave/app/data/generate/GeneratedTrack.kt @@ -3,6 +3,13 @@ package com.lastwave.app.data.generate import android.net.Uri import androidx.compose.runtime.Immutable import com.lastwave.app.data.artwork.ArtworkNormalizer +import com.lastwave.app.data.music.InnerTubeMusicApi +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -68,6 +75,45 @@ fun List.distinctSongs(): List { return out } +/** + * List-time videoId pinning (the limusic invariant): every track that can play + * must carry its YouTube videoId in [GeneratedTrack.url] BEFORE it reaches a + * queue or a saved playlist, so tap-to-play resolves directly with zero + * search. Already-pinned tracks pass through with no network. Unresolvable + * rows resolve to null and are dropped — a strict miss must never substitute + * a wrong song at play time. Bounded fan-out mirrors the importers. + */ +suspend fun List.pinnedWithVideoIds( + innerTube: InnerTubeMusicApi, + concurrency: Int = 4, +): List = coroutineScope { + if (isEmpty()) return@coroutineScope emptyList() + val limiter = Semaphore(concurrency) + map { track -> + async { + if (track.youtubeVideoIdOrNull() != null) return@async track + if (track.name.isBlank()) return@async null + limiter.withPermit { + try { + innerTube.findBestMatchOrNull(track.name, track.artist, prefetchStreams = false)?.let { match -> + track.copy( + name = track.name.ifBlank { match.title }, + artist = track.artist.ifBlank { match.artist }, + album = track.album ?: match.album, + artworkUrl = track.artworkUrl ?: match.artworkUrl, + url = "https://music.youtube.com/watch?v=${match.videoId}", + ) + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + null + } + } + } + }.awaitAll().filterNotNull() +} + /** Serializable form of [GeneratedTrack], used for Room-persisted playlists * (JSON blob column) and Playlist screen export (CSV/M3U). */ @Serializable diff --git a/app/src/main/java/com/lastwave/app/data/music/InnerTubeMusicApi.kt b/app/src/main/java/com/lastwave/app/data/music/InnerTubeMusicApi.kt index 675de3b9..07f6c77f 100644 --- a/app/src/main/java/com/lastwave/app/data/music/InnerTubeMusicApi.kt +++ b/app/src/main/java/com/lastwave/app/data/music/InnerTubeMusicApi.kt @@ -2129,10 +2129,13 @@ class InnerTubeMusicApi @Inject constructor( val channel = kotlinx.coroutines.channels.Channel(ordered.size) val jobs = ordered.mapIndexed { index, client -> launch(Dispatchers.IO) { - // Hedged stagger: each client gets a short head start over the - // next, but a slow client never serializes a full timeout - // onto the ones behind it. - if (index > 0) { + // Pure parallel race (limusic-style): all three clients fire at + // T=0, first direct URL wins. No stagger — staggering only ever + // delays clients 2..n with zero burst benefit at this fan-out + // (importers already burst 4 concurrent searches). A slow client + // still never blocks the winner: jobs are cancelled on first + // success below. (Stage 2 keeps its stagger: ~8-way fan-out.) + if (index > 0 && DIRECT_FAST_STAGGER_MS > 0L) { delay(DIRECT_FAST_STAGGER_MS * index) if (!isActive) return@launch } @@ -2782,12 +2785,18 @@ class InnerTubeMusicApi @Inject constructor( } val best = validCandidates.asSequence() .filter { candidate -> - val titleMatch = TextMatch.isSafeTitleMatch(candidate.title, title, cleanArtist) - val artistMatch = cleanArtist.isBlank() || - similarity(candidate.artist, cleanArtist) >= 30 || - normalize(candidate.artist).contains(normalize(cleanArtist)) || - normalize(candidate.title).contains(normalize(cleanArtist)) - titleMatch && artistMatch + // Hybrid gate: 4.2.3's recall numbers (title >= 60, artist >= 35 + // with its contains clauses) minus its title-blind second chance + // and minus 4.2.4's loosening (>= 30 + fuzzy subset matcher), + // which substituted wrong tracks. Strict misses throw below. + maxOf( + similarity(candidate.title, title), + similarity(baseTitle(candidate.title), baseTitle(title)), + ) >= 60 && + (cleanArtist.isBlank() || + similarity(candidate.artist, cleanArtist) >= 35 || + normalize(candidate.artist).contains(normalize(cleanArtist)) || + normalize(candidate.title).contains(normalize(cleanArtist))) } .maxByOrNull { candidate -> matchScore(candidate, title, cleanArtist) } ?: throw IOException("No reliable YouTube Music match found for $title by $artist") @@ -3660,8 +3669,9 @@ class InnerTubeMusicApi @Inject constructor( const val HEDGED_CLIENT_STAGGER_DELAY_MS = 300L /** Per-client bound for the direct-URL fast path (single POST, no extras). */ const val DIRECT_FAST_CLIENT_TIMEOUT_MS = 2_500L - /** Stagger between hedged fast-path clients; first success wins. */ - const val DIRECT_FAST_STAGGER_MS = 250L + /** Stagger between hedged fast-path clients; first success wins. Zero: + * pure parallel race, strictly faster-or-equal (see call site). */ + const val DIRECT_FAST_STAGGER_MS = 0L /** Whole fast path must settle inside this budget before heavier stages run. */ const val DIRECT_FAST_PATH_BUDGET_MS = 4_000L /** Whole-call cap for one player-API POST (connect + read + body). */ diff --git a/app/src/main/java/com/lastwave/app/data/music/TextMatch.kt b/app/src/main/java/com/lastwave/app/data/music/TextMatch.kt index 43993fa3..ab5d5166 100644 --- a/app/src/main/java/com/lastwave/app/data/music/TextMatch.kt +++ b/app/src/main/java/com/lastwave/app/data/music/TextMatch.kt @@ -17,25 +17,63 @@ import java.util.Locale */ internal object TextMatch { private val NON_WORD = Regex("[^\\p{L}\\p{N}]+") - private val DIACRITICS = Regex("\\p{M}+") private val MULTI_SPACE = Regex("\\s+") val VARIANT_WORDS = setOf( "live", "remix", "karaoke", "cover", "instrumental", "slowed", "sped", "nightcore", "acoustic", "demo", "edit", "remaster", "remastered", "mono", "stereo", "version", "deluxe", "bonus", "mix", "extended", "radio", "clean", "explicit", "original", "orchestral", "unplugged", "rerecorded", "anniversary", "edition", + // Same rendition markers in the languages this app serves, stored + // normalized to match [normalize] output. They feed the subset matcher + // and [matchScore]'s unexpected-variant penalty like the English ones. + "vivo", "acustico", "direct", "acoustique", "akustik", "canli", + "ライブ", "カバー", "リミックス", "アコースティック", + "라이브", "커버", "리믹스", "어쿠스틱", + "مباشر", "ريمكس", + "लाइव", "कवर", "रीमिक्स", + "现场", "翻唱", "混音", + "лайв", "кавер", "ремикс", ) val MATCH_NOISE_WORDS = setOf("official", "audio", "video", "visualizer", "lyrics", "lyric", "hd", "hq", "4k", "track", "music") private val FEATURING_CLAUSE = Regex("(?i)[(\\[]\\s*(feat(?:uring)?|ft)\\.?\\s+.*?[)\\]]") private val VERSION_OR_LABEL_CLAUSE = Regex("(?i)[(\\[][^)\\]]*(official|music\\s*video|audio|video|visualizer|lyrics?|hd|hq|4k|live|remix|acoustic|demo|edit|remaster(?:ed)?|mono|stereo|deluxe|bonus|version|mix)[^)\\]]*[)\\]]") - fun normalize(value: String): String = - Normalizer.normalize(value.lowercase(Locale.ROOT), Normalizer.Form.NFD) - .replace(DIACRITICS, "") + fun normalize(value: String): String { + val lower = value.lowercase(Locale.ROOT) + val nfd = Normalizer.normalize(lower, Normalizer.Form.NFD) + // Strip diacritics only off Latin bases (Café → cafe). In other scripts + // marks are integral letters: Hindi matras (तुम → तम), Thai vowels, + // Arabic vocalization, Korean jamo (which NFKC below recomposes). + // Stripping them merges distinct words across those languages. + val stripped = nfd.replace(LATIN_DIACRITICS, "") + val composed = Normalizer.normalize(stripped, Normalizer.Form.NFKC) + return foldArabic(composed) .replace(NON_WORD, " ") .trim() .replace(MULTI_SPACE, " ") + } + + /** Combining marks following a Latin base character. Java supports this + * bounded lookbehind; a leading stray mark falls through to NON_WORD. */ + private val LATIN_DIACRITICS = Regex("(?<=[A-Za-z])\\p{M}+") + + /** Standard Arabic search folding: hamza-alef variants have no NFD + * decomposition, so mark-stripping alone never folds them (أ stays أ). + * Tashkeel (optional vocalization) is stripped; base letters are kept. */ + private fun foldArabic(value: String): String = value + .replace(Regex("[أإآٱ]"), "ا") + .replace(Regex("ؤ"), "و") + .replace(Regex("ئ"), "ي") + .replace(Regex("ة"), "ه") + .replace(Regex("ى"), "ي") + .replace(Regex("[\u064B-\u0655\u0670]"), "") + + /** True when the string carries letters outside A-Z: CJK, Thai, Indic, + * Arabic, Cyrillic, Hangul, … — scripts where partial overlap at modest + * ratios is normal, unlike Latin prefix confusion. */ + private fun hasNonLatinLetter(value: String): Boolean = + value.any { it.isLetter() && it !in 'a'..'z' && it !in 'A'..'Z' } fun tokens(value: String): Set = normalize(value) .split(' ') @@ -53,7 +91,12 @@ internal object TextMatch { if (normA.isBlank() || normB.isBlank()) return 0 if (normA.contains(normB) || normB.contains(normA)) { val ratio = (minOf(normA.length, normB.length) * 100) / maxOf(normA.length, normB.length) - if (ratio >= 75) return maxOf(85, ratio) + // 4.0.0 used 45 for everything; 4.2.4 raised it to 75 to stop Latin + // prefix confusion (Belong/Belonging at 66). Spaceless scripts + // (CJK/Thai/…) legitimately overlap at modest ratios, so the 45 bar + // applies only to strings carrying non-Latin letters. + val bar = if (hasNonLatinLetter(normA) || hasNonLatinLetter(normB)) 45 else 75 + if (ratio >= bar) return maxOf(85, ratio) } val left = tokens(a) val right = tokens(b) diff --git a/app/src/main/java/com/lastwave/app/data/playlist/CsvPlaylistImporter.kt b/app/src/main/java/com/lastwave/app/data/playlist/CsvPlaylistImporter.kt index 3045035a..b94a1d94 100644 --- a/app/src/main/java/com/lastwave/app/data/playlist/CsvPlaylistImporter.kt +++ b/app/src/main/java/com/lastwave/app/data/playlist/CsvPlaylistImporter.kt @@ -240,8 +240,10 @@ class CsvPlaylistImporter @Inject constructor( private fun allowsVersion(sourceTitle: String, targetTitle: String): Boolean { val sNorm = normalize(sourceTitle) val tNorm = normalize(targetTitle) - val versionWords = listOf("live", "remix", "cover", "karaoke", "instrumental", "acoustic", "tribute") - for (word in versionWords) { + // Rendition markers share TextMatch.VARIANT_WORDS with the playback + // matcher (multilingual since the language fix); 'tribute' stays + // import-local — never a legitimate suffix, only a mismatch signal. + for (word in TextMatch.VARIANT_WORDS + "tribute") { if (tNorm.contains(word) && !sNorm.contains(word)) return false } return true diff --git a/app/src/main/java/com/lastwave/app/data/playlist/PlaylistRepository.kt b/app/src/main/java/com/lastwave/app/data/playlist/PlaylistRepository.kt index 86617ff7..22b806c4 100644 --- a/app/src/main/java/com/lastwave/app/data/playlist/PlaylistRepository.kt +++ b/app/src/main/java/com/lastwave/app/data/playlist/PlaylistRepository.kt @@ -6,6 +6,7 @@ import com.lastwave.app.data.local.db.SavedPlaylistDao import com.lastwave.app.data.local.db.SavedPlaylistEntity import com.lastwave.app.data.generate.GeneratedTrack import com.lastwave.app.data.generate.StoredTrack +import com.lastwave.app.data.generate.pinnedWithVideoIds import com.lastwave.app.data.generate.sameSongAs import com.lastwave.app.data.generate.toGenerated import com.lastwave.app.data.generate.toStored @@ -33,6 +34,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton +import java.util.concurrent.atomic.AtomicBoolean /** Port of playlist.js's `lw_playlists` model: id, title, subtitle, mode, * tracks, date. [id] doubles as the creation timestamp (matches the @@ -106,7 +108,41 @@ class PlaylistRepository @Inject constructor( } } - private suspend fun filterPlayable(tracks: List): List = tracks + private suspend fun filterPlayable(tracks: List): List = + tracks.pinnedWithVideoIds(innerTube) + + private val videoIdBackfillOnce = AtomicBoolean(false) + + /** One-shot per process: pins ids for rows saved before list-time pinning + * existed, then persists them so the work never repeats. Fire-and-forget + * on [exportScope]; playback never waits on it. */ + private fun backfillMissingVideoIdsOnce(loaded: List) { + if (loaded.none { playlist -> + playlist.tracks.any { it.youtubeVideoIdOrNull() == null && it.name.isNotBlank() } + }) return + if (!videoIdBackfillOnce.compareAndSet(false, true)) return + exportScope.launch { + try { + var changed = false + for (entity in dao.getAll()) { + val domain = runCatching { entity.toDomain() }.getOrNull() ?: continue + if (domain.tracks.none { it.youtubeVideoIdOrNull() == null && it.name.isNotBlank() }) continue + val pinned = domain.tracks.pinnedWithVideoIds(innerTube) + if (pinned.size == domain.tracks.size && + pinned.zip(domain.tracks).all { (newTrack, oldTrack) -> newTrack.url == oldTrack.url } + ) continue + dao.upsert(entity.copy(tracksJson = json.encodeToString(pinned.map { it.toStored() }))) + changed = true + } + if (changed) _changes.tryEmit(Unit) + } catch (error: CancellationException) { + throw error + } catch (e: Exception) { + Log.e(TAG, "VideoId backfill failed; will retry next launch", e) + videoIdBackfillOnce.set(false) + } + } + } /** Newest first — matches _plRenderSaved()'s display order (the @@ -114,6 +150,7 @@ class PlaylistRepository @Inject constructor( suspend fun getAll(): List { return try { dao.getAll().map { it.toDomain() }.sortedByDescending { it.createdAtMillis } + .also { backfillMissingVideoIdsOnce(it) } } catch (error: CancellationException) { throw error } catch (e: Exception) { diff --git a/app/src/main/java/com/lastwave/app/data/repository/HomeRepository.kt b/app/src/main/java/com/lastwave/app/data/repository/HomeRepository.kt index 0017fa7f..1a1b2d0d 100644 --- a/app/src/main/java/com/lastwave/app/data/repository/HomeRepository.kt +++ b/app/src/main/java/com/lastwave/app/data/repository/HomeRepository.kt @@ -90,7 +90,20 @@ class HomeRepository @Inject constructor( val checks = tracks.map { track -> async(Dispatchers.IO) { playableCheckSemaphore.withPermit { - if (innerTube.isPlayable(track.name, track.artist)) track else null + // Same searches the old isPlayable gate paid — but the found + // videoId is kept on the row instead of discarded, so home + // taps resolve directly with zero play-time search. Default + // prefetch retained: it warms the top-2 stream cache per + // row, exactly as before. + try { + innerTube.findBestMatchOrNull(track.name, track.artist)?.let { + track.copy(videoId = it.videoId) + } + } catch (cancellation: kotlinx.coroutines.CancellationException) { + throw cancellation + } catch (_: Exception) { + null + } } } } diff --git a/app/src/main/java/com/lastwave/app/data/repository/HomeTrack.kt b/app/src/main/java/com/lastwave/app/data/repository/HomeTrack.kt index 8bb61122..1eeb7670 100644 --- a/app/src/main/java/com/lastwave/app/data/repository/HomeTrack.kt +++ b/app/src/main/java/com/lastwave/app/data/repository/HomeTrack.kt @@ -16,6 +16,9 @@ data class HomeTrack( val timestampMillis: Long?, val playCount: Int, val isNowPlaying: Boolean = false, + /** YouTube id pinned at list time by [HomeRepository.filterPlayable], so + * home taps resolve directly instead of searching at play time. */ + val videoId: String? = null, ) { val key: String get() = "${name.lowercase()}|${artist.lowercase()}" } diff --git a/app/src/main/java/com/lastwave/app/data/search/SearchRepository.kt b/app/src/main/java/com/lastwave/app/data/search/SearchRepository.kt index abf82a02..dcaf35a0 100644 --- a/app/src/main/java/com/lastwave/app/data/search/SearchRepository.kt +++ b/app/src/main/java/com/lastwave/app/data/search/SearchRepository.kt @@ -153,6 +153,7 @@ class SearchRepository @Inject constructor( artist = track.artist, artworkUrl = track.artworkUrl, album = track.album, + url = "https://music.youtube.com/watch?v=${track.videoId}", ) } } else { diff --git a/app/src/main/java/com/lastwave/app/data/ytmusic/YtMusicSyncManager.kt b/app/src/main/java/com/lastwave/app/data/ytmusic/YtMusicSyncManager.kt index 1b576016..4abc2a59 100644 --- a/app/src/main/java/com/lastwave/app/data/ytmusic/YtMusicSyncManager.kt +++ b/app/src/main/java/com/lastwave/app/data/ytmusic/YtMusicSyncManager.kt @@ -386,7 +386,15 @@ class YtMusicSyncManager @Inject constructor( if (videoId == null) { if (track.key !in resolvedKeys) add(track) } else if (videoId in finalSet && seenVideoIds.add(videoId)) { - add(track) + // The merge resolved this row's id: stamp it so the + // persisted copy plays directly instead of searching. + add( + if (track.youtubeVideoIdOrNull() == null) { + track.copy(url = "https://music.youtube.com/watch?v=$videoId") + } else { + track + }, + ) } } val represented = resolvedVideoIds.filterNotNull().toSet() diff --git a/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt b/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt index 27c8c73e..2eded482 100644 --- a/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt +++ b/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt @@ -95,6 +95,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.selects.select import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.yield import kotlinx.coroutines.withContext @@ -995,7 +996,10 @@ class MusicPlayer @Inject constructor( .setBufferDurationsMs( /* minBufferMs = */ if (handleAudioFocus) 45_000 else 15_000, /* maxBufferMs = */ if (handleAudioFocus) 120_000 else 30_000, - /* bufferForPlaybackMs = */ 1_500, + // Instant start: first frame after ~500ms buffered instead of + // 1500ms. Min/max buffers unchanged, so steady-state stability + // and rebuffer behavior are untouched. + /* bufferForPlaybackMs = */ 500, /* bufferForPlaybackAfterRebufferMs = */ 2_500, ) .setPrioritizeTimeOverSizeThresholds(true) @@ -5485,7 +5489,11 @@ class MusicPlayer @Inject constructor( android.util.Log.i("MusicPlayer", "[PLAYBACK] local-download hit for '${track.title}' key=${it.cacheKey}") } ?: (youtubeDeferred.await() ?: runCatching { resolveYoutubeTrackAudioStream(track, videoId) }.getOrNull() - ?: resolveYoutubeTrackAudioStream(track, null)) + // Title search only when no videoId was ever known. A known + // videoId that failed direct resolve must fail loud, never + // substitute a different videoId by text search. + ?: if (videoId.isNullOrBlank()) resolveYoutubeTrackAudioStream(track, null) + else throw java.io.IOException("Unable to resolve audio stream for $videoId")) } finally { youtubeDeferred.cancel() localDeferred.cancel() @@ -5498,13 +5506,21 @@ class MusicPlayer @Inject constructor( android.util.Log.i("MusicPlayer", "[PLAYBACK] local-download hit for '${track.title}' key=${localStream.cacheKey}") localStream } else { + // Instant parallel race with lossless head start: the addon plays + // directly when it wins inside LOSSLESS_HEAD_START_MS (no swap + // glitch); otherwise limusic-fast YouTube starts now and the + // in-flight lossless promotes via background upgrade below. val losslessStream: ResolvedStream? = if (losslessDeferred.isCompleted) { runCatching { losslessDeferred.await() }.getOrNull() } else if (losslessBudget == LosslessBudget.Unbounded) { runCatching { losslessDeferred.await() }.getOrNull() } else { - withTimeoutOrNull(2000L) { + withTimeoutOrNull(LOSSLESS_HEAD_START_MS) { runCatching { losslessDeferred.await() }.getOrNull() + } ?: select { + losslessDeferred.onAwait { runCatching { it }.getOrNull() } + // YouTube won the race: play it now, upgrade later. + youtubeDeferred.onAwait { null } } } @@ -5522,7 +5538,8 @@ class MusicPlayer @Inject constructor( } val ytStream = youtubeDeferred.await() ?: runCatching { resolveYoutubeTrackAudioStream(track, videoId) }.getOrNull() - ?: resolveYoutubeTrackAudioStream(track, null) + ?: if (videoId.isNullOrBlank()) resolveYoutubeTrackAudioStream(track, null) + else throw java.io.IOException("Unable to resolve audio stream for $videoId") android.util.Log.i( "MusicPlayer", "[PLAYBACK] YouTube stream resolved for '${track.title}' (${SystemClock.elapsedRealtime() - forkStart}ms); playing ${ytStream.audioCodec}, background lossless upgrade pending", @@ -5749,12 +5766,16 @@ class MusicPlayer @Inject constructor( } } } - // Search-only attempts (the direct attempt above already ran). Two, not - // three: each extra attempt re-ran the whole waterfall and multiplied a - // slow network into a 60-90s wait before playback. Attempt 0 keeps the - // artist for match quality; attempt 1 broadens the query by dropping it. + // Search-only attempts when NO videoId was ever known (radio, typed + // query, unresolvable import). When a videoId was known and its direct + // resolve failed, never substitute a different videoId by text search: + // that is the wrong-track bug (title-only attempt 1 matches anyone's + // same-named song). Fail loud so the caller skips instead. + if (!videoId.isNullOrBlank() && resolved == null && lastFailure != null) { + throw lastFailure + } var attempt = 0 - while (resolved == null && canSearch && attempt < 2) { + while (resolved == null && canSearch && attempt < 2 && videoId.isNullOrBlank()) { try { val searchArtist = if (attempt == 0) track.artist else "" val targetVideoId = innerTube.findBestMatch( @@ -5956,7 +5977,10 @@ class MusicPlayer @Inject constructor( val expectedSec = (track.durationMs?.takeIf { it > 0 } ?: currentStream.durationMs)?.div(1000)?.toInt() val upgradedSec = upgraded.durationMs?.div(1000)?.toInt() if (expectedSec != null && upgradedSec != null && expectedSec > 0 && upgradedSec > 0) { - if (kotlin.math.abs(expectedSec - upgradedSec) > 35) { + // ±10s: same song across sources drifts a few seconds + // (approx durations, intros); ±35s admitted different + // edits/recordings sharing a name. + if (kotlin.math.abs(expectedSec - upgradedSec) > 10) { android.util.Log.w("MusicPlayer", "[STREAM UPGRADE] Severe duration mismatch for '${track.title}': expected ${expectedSec}s vs candidate ${upgradedSec}s") return@launch } @@ -6241,7 +6265,7 @@ class MusicPlayer @Inject constructor( if (auditionPlayer.playbackState == Player.STATE_READY) { val candidateDur = auditionPlayer.duration if (expectedDurationMs != null && expectedDurationMs > 0L && candidateDur > 0L) { - if (kotlin.math.abs(candidateDur - expectedDurationMs) > 35_000L) { + if (kotlin.math.abs(candidateDur - expectedDurationMs) > 10_000L) { android.util.Log.w( "MusicPlayer", "[STREAM AUDITION] Duration mismatch: expected ${expectedDurationMs}ms vs candidate ${candidateDur}ms", @@ -7017,6 +7041,12 @@ class MusicPlayer @Inject constructor( const val YOUTUBE_PROMOTE_BUDGET_MS = 12_000L const val MISSING_ARTIST_METADATA_TIMEOUT_MS = 1_200L + /** Lossless head start on interactive playback: when the addon wins + * inside this window it plays directly (no mid-track swap glitch). + * Past it, limusic-fast YouTube starts instantly and lossless + * promotes via the background auditioned hot-swap. Lossless keeps + * first priority; YouTube is the instant parallel fallback. */ + const val LOSSLESS_HEAD_START_MS = 800L /** Total cap for one YouTube fallback chain from fork, covering the * promote wait plus every stacked re-resolve. Normal resolves take * seconds; past this the track fails fast instead of spinning. */ diff --git a/app/src/main/java/com/lastwave/app/ui/home/FriendProfileScreen.kt b/app/src/main/java/com/lastwave/app/ui/home/FriendProfileScreen.kt index 91e9de0d..d1bfa1e0 100644 --- a/app/src/main/java/com/lastwave/app/ui/home/FriendProfileScreen.kt +++ b/app/src/main/java/com/lastwave/app/ui/home/FriendProfileScreen.kt @@ -300,6 +300,7 @@ fun FriendProfileScreen( title = track.name, artist = track.artist, artworkUrl = track.artworkUrl, + videoId = track.videoId, ), ) }, @@ -335,6 +336,7 @@ fun FriendProfileScreen( title = track.name, artist = track.artist, artworkUrl = track.artworkUrl, + videoId = track.videoId, ), ) }, diff --git a/app/src/main/java/com/lastwave/app/ui/home/FriendProfileViewModel.kt b/app/src/main/java/com/lastwave/app/ui/home/FriendProfileViewModel.kt index 4fbcef17..591fca35 100644 --- a/app/src/main/java/com/lastwave/app/ui/home/FriendProfileViewModel.kt +++ b/app/src/main/java/com/lastwave/app/ui/home/FriendProfileViewModel.kt @@ -219,6 +219,7 @@ class FriendProfileViewModel @Inject constructor( title = track.name, artist = track.artist, artworkUrl = track.artworkUrl, + videoId = track.videoId, ), sourceLabel = "${_uiState.value.displayName}'s Profile", ) @@ -230,6 +231,7 @@ class FriendProfileViewModel @Inject constructor( title = it.name, artist = it.artist, artworkUrl = it.artworkUrl, + videoId = it.videoId, ) } if (playable.isNotEmpty()) { diff --git a/app/src/main/java/com/lastwave/app/ui/home/HomeScreen.kt b/app/src/main/java/com/lastwave/app/ui/home/HomeScreen.kt index 6b603bab..26c6fcc2 100644 --- a/app/src/main/java/com/lastwave/app/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/lastwave/app/ui/home/HomeScreen.kt @@ -275,6 +275,7 @@ fun HomeScreen( title = track.name, artist = track.artist, artworkUrl = track.artworkUrl, + videoId = track.videoId, ) } } diff --git a/app/src/test/java/com/lastwave/app/data/generate/PinnedWithVideoIdsTest.kt b/app/src/test/java/com/lastwave/app/data/generate/PinnedWithVideoIdsTest.kt new file mode 100644 index 00000000..6b8cdd61 --- /dev/null +++ b/app/src/test/java/com/lastwave/app/data/generate/PinnedWithVideoIdsTest.kt @@ -0,0 +1,46 @@ +package com.lastwave.app.data.generate + +import com.lastwave.app.data.music.InnerTubeMusicApi +import com.lastwave.app.data.music.YouTubeMusicTrack +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class PinnedWithVideoIdsTest { + private val api = mockk() + + @Test + fun keepsPinnedTracksWithoutNetwork() = runBlocking { + val pinned = GeneratedTrack( + name = "Song", + artist = "Artist", + url = "https://music.youtube.com/watch?v=abcdefghijk", + ) + assertEquals(listOf(pinned), listOf(pinned).pinnedWithVideoIds(api)) + coVerify(exactly = 0) { api.findBestMatchOrNull(any(), any(), any()) } + } + + @Test + fun pinsResolvableAndDropsMisses() = runBlocking { + coEvery { api.findBestMatchOrNull("Song", "Artist", any()) } returns + YouTubeMusicTrack("12345678901", "Song", "Artist") + coEvery { api.findBestMatchOrNull("Missing", "Artist", any()) } returns null + val rows = listOf( + GeneratedTrack(name = "Song", artist = "Artist"), + GeneratedTrack(name = "Missing", artist = "Artist"), + ) + val pinned = rows.pinnedWithVideoIds(api) + assertEquals(1, pinned.size) + assertEquals("https://music.youtube.com/watch?v=12345678901", pinned.single().url) + assertEquals("Song", pinned.single().name) + } + + @Test + fun blankRowsDropWithoutNetwork() = runBlocking { + assertEquals(emptyList(), listOf(GeneratedTrack(name = "", artist = "")).pinnedWithVideoIds(api)) + coVerify(exactly = 0) { api.findBestMatchOrNull(any(), any(), any()) } + } +} diff --git a/app/src/test/java/com/lastwave/app/data/music/TextMatchTest.kt b/app/src/test/java/com/lastwave/app/data/music/TextMatchTest.kt index f9a31a6f..288c6b36 100644 --- a/app/src/test/java/com/lastwave/app/data/music/TextMatchTest.kt +++ b/app/src/test/java/com/lastwave/app/data/music/TextMatchTest.kt @@ -70,4 +70,48 @@ class TextMatchTest { assertEquals(100, TextMatch.similarity("Hello", "hello")) assertEquals(setOf("hello", "world"), TextMatch.tokens("Hello World (Official Video)")) } + + @Test + fun arabicHamzaFoldsToBareAlef() { + // U+0623 has no NFD decomposition, so mark-stripping alone never folds + // it: searching أم must match ام. + assertEquals("ام كلثوم", TextMatch.normalize("أم كلثوم")) + } + + @Test + fun hindiVowelSignsAreNotStripped() { + // Matras are integral letters: तुम and तम are different words. + assertEquals("तुम ही हो", TextMatch.normalize("तुम ही हो")) + assertTrue(TextMatch.normalize("तुम ही हो") != TextMatch.normalize("तम ही हो")) + } + + @Test + fun koreanStaysComposed() { + // NFD explodes 5 syllables into ~14 jamo, distorting every ratio and + // token measure built on the normalized form. + assertEquals(5, TextMatch.normalize("방탄소년단").length) + } + + @Test + fun thaiVowelsAreNotStripped() { + assertEquals("คอนเสิร์ต", TextMatch.normalize("คอนเสิร์ต")) + } + + @Test + fun cjkContainmentMeetsNonLatinBar() { + // Spaceless overlap at ratio 60: accepted for CJK (4.0.0 bar 45)… + assertTrue(TextMatch.similarity("夜に駆ける", "に駆ける") >= 72) + // …while Latin prefix confusion at ratio 66 stays rejected (4.2.4 bar 75). + assertTrue(TextMatch.similarity("Belonging", "Belong") < 72) + } + + @Test + fun spanishLiveCountsAsVariant() { + val exact = YouTubeMusicTrack("vid1", "Cancion", "Artista") + val live = YouTubeMusicTrack("vid2", "Cancion En Vivo", "Artista") + assertTrue( + TextMatch.matchScore(exact, "Cancion", "Artista") > + TextMatch.matchScore(live, "Cancion", "Artista"), + ) + } } From 3a33ac554677420f0caf668ecc4b71974a83a54a Mon Sep 17 00:00:00 2001 From: ajisth69 Date: Thu, 8 Oct 2026 22:43:33 +0530 Subject: [PATCH 2/2] fix(tests): keep non-Latin marks in words and avoid Uri in JVM pinning test - NON_WORD now allows marks: Latin-only diacritic stripping left Hindi matras and Thai vowels to be nuked into spaces by the word splitter - keepsPinnedTracksWithoutNetwork uses a bare video id: android.net.Uri throws on plain JVM unit tests, misclassifying pinned rows --- .../main/java/com/lastwave/app/data/music/TextMatch.kt | 10 ++++++---- .../app/data/generate/PinnedWithVideoIdsTest.kt | 5 ++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/lastwave/app/data/music/TextMatch.kt b/app/src/main/java/com/lastwave/app/data/music/TextMatch.kt index ab5d5166..a3aeca05 100644 --- a/app/src/main/java/com/lastwave/app/data/music/TextMatch.kt +++ b/app/src/main/java/com/lastwave/app/data/music/TextMatch.kt @@ -11,12 +11,14 @@ import java.util.Locale * Latin-only word pattern (`[^a-z0-9]+`), which reduced every non-Latin * query — Cyrillic, CJK, Arabic, … — to blank and broke matching for * those scripts (issue #102). The word pattern is now Unicode-aware - * (`\p{L}` letters + `\p{N}` numbers from any script) and case folding - * uses [Locale.ROOT] so devices in locales like Turkish (where `I` - * lowercases to `ı`) match identically everywhere. + * (`\p{L}` letters + `\p{N}` numbers + `\p{M}` marks from any script) and + * case folding uses [Locale.ROOT] so devices in locales like Turkish (where + * `I` lowercases to `ı`) match identically everywhere. Marks ride along with + * words because [normalize] strips diacritics only off Latin bases — Hindi + * matras, Thai vowels and the like are integral letters, not noise. */ internal object TextMatch { - private val NON_WORD = Regex("[^\\p{L}\\p{N}]+") + private val NON_WORD = Regex("[^\\p{L}\\p{N}\\p{M}]+") private val MULTI_SPACE = Regex("\\s+") val VARIANT_WORDS = setOf( "live", "remix", "karaoke", "cover", "instrumental", "slowed", "sped", "nightcore", diff --git a/app/src/test/java/com/lastwave/app/data/generate/PinnedWithVideoIdsTest.kt b/app/src/test/java/com/lastwave/app/data/generate/PinnedWithVideoIdsTest.kt index 6b8cdd61..79d8ab7c 100644 --- a/app/src/test/java/com/lastwave/app/data/generate/PinnedWithVideoIdsTest.kt +++ b/app/src/test/java/com/lastwave/app/data/generate/PinnedWithVideoIdsTest.kt @@ -14,10 +14,13 @@ class PinnedWithVideoIdsTest { @Test fun keepsPinnedTracksWithoutNetwork() = runBlocking { + // Bare id: youtubeVideoIdOrNull matches the 11-char regex directly + // without touching android.net.Uri, which throws on plain JVM unit + // tests (android.jar stubs). A full watch URL would need Robolectric. val pinned = GeneratedTrack( name = "Song", artist = "Artist", - url = "https://music.youtube.com/watch?v=abcdefghijk", + url = "abcdefghijk", ) assertEquals(listOf(pinned), listOf(pinned).pinnedWithVideoIds(api)) coVerify(exactly = 0) { api.findBestMatchOrNull(any(), any(), any()) }