Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions app/src/main/java/com/lastwave/app/data/feed/FeedRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<GeneratedTrack>): List<GeneratedTrack> = 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<GeneratedTrack>): List<GeneratedTrack> =
tracks.pinnedWithVideoIds(innerTube)

private fun YouTubeMusicTrack.toGeneratedTrack() = GeneratedTrack(
name = title,
Expand Down Expand Up @@ -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<String> = kotlinx.coroutines.supervisorScope {
Expand Down
46 changes: 46 additions & 0 deletions app/src/main/java/com/lastwave/app/data/generate/GeneratedTrack.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -68,6 +75,45 @@ fun List<GeneratedTrack>.distinctSongs(): List<GeneratedTrack> {
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<GeneratedTrack>.pinnedWithVideoIds(
innerTube: InnerTubeMusicApi,
concurrency: Int = 4,
): List<GeneratedTrack> = 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
Expand Down
18 changes: 12 additions & 6 deletions app/src/main/java/com/lastwave/app/data/music/InnerTubeMusicApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2621,12 +2621,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")
Expand Down
63 changes: 54 additions & 9 deletions app/src/main/java/com/lastwave/app/data/music/TextMatch.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,71 @@ 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 DIACRITICS = Regex("\\p{M}+")
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",
"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<String> = normalize(value)
.split(' ')
Expand All @@ -53,7 +93,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -106,14 +108,49 @@ class PlaylistRepository @Inject constructor(
}
}

private suspend fun filterPlayable(tracks: List<GeneratedTrack>): List<GeneratedTrack> = tracks
private suspend fun filterPlayable(tracks: List<GeneratedTrack>): List<GeneratedTrack> =
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<SavedPlaylist>) {
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
* original reverses its append-ordered array before rendering). */
suspend fun getAll(): List<SavedPlaylist> {
return try {
dao.getAll().map { it.toDomain() }.sortedByDescending { it.createdAtMillis }
.also { backfillMissingVideoIdsOnce(it) }
} catch (error: CancellationException) {
throw error
} catch (e: Exception) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()}"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading