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
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ private const val TAG = "DiscoverRepository"
private const val MAX_REFILL_ATTEMPTS = 2
private const val MAX_EXPLORATION_SEEDS = 120
private const val MAX_TRACK_SEEDS = 6
private const val DISCOVER_ARTIST_CAP = 3
private const val DISCOVER_ARTIST_CAP = 1
private const val MAX_SHOWN_KEYS = 3_000
private const val SAVED_TRACK_SCORE_PENALTY = 22.0

Expand Down Expand Up @@ -186,16 +186,43 @@ class DiscoverRepository @Inject constructor(
}
}

val savedPlaylistKeys = generateRepository.savedPlaylistTrackKeys()
val listenedKeys = buildSet {
profile?.recentTracksRaw?.forEach { add(it.key) }
profile?.topTracksRaw?.forEach { add(it.key) }
profile?.ytMusicRecentRaw?.forEach { add(it.key) }
profile?.ytMusicLikedRaw?.forEach { add(it.key) }
profile?.ytMusicFeedRaw?.forEach { add(it.key) }
addAll(savedPlaylistKeys)
}
val listenedTitlesByArtist = buildMap<String, MutableSet<String>> {
fun record(artist: String, title: String) {
val a = artist.trim().lowercase()
val t = title.trim().lowercase()
if (a.isNotBlank() && t.isNotBlank()) {
getOrPut(a) { mutableSetOf() }.add(t)
}
}
profile?.recentTracksRaw?.forEach { record(it.artist, it.name) }
profile?.topTracksRaw?.forEach { record(it.artist, it.name) }
profile?.ytMusicRecentRaw?.forEach { record(it.artist, it.name) }
profile?.ytMusicLikedRaw?.forEach { record(it.artist, it.name) }
profile?.ytMusicFeedRaw?.forEach { record(it.artist, it.name) }
}

fun isAlreadyListened(track: GeneratedTrack): Boolean {
if (track.key in listenedKeys) return true
val a = track.artist.trim().lowercase()
val t = track.name.trim().lowercase()
return listenedTitlesByArtist[a]?.contains(t) == true
}

val queuedKeys = queue.mapTo(mutableSetOf()) { it.key }
val candidates = LinkedHashMap<String, RankedCandidate>()
val sourceBatches = jobs.awaitAll().toMutableList()
profile?.ytMusicFeedRaw
?.take(40)
?.takeIf { it.isNotEmpty() }
?.let { sourceBatches += SourceBatch(it, 92, "yt-feed") }
for (batch in sourceBatches) {
for (track in batch.tracks) {
if (track.name.isBlank() || track.artist.isBlank() || track.key in queuedKeys) continue
if (track.name.isBlank() || track.artist.isBlank() || track.key in queuedKeys || isAlreadyListened(track)) continue
val current = candidates[track.key]
if (current == null) {
candidates[track.key] = RankedCandidate(
Expand All @@ -216,12 +243,11 @@ class DiscoverRepository @Inject constructor(
val allowedKeys = generateRepository.filterRecommendationExclusions(
candidates.values.map(RankedCandidate::track),
).mapTo(mutableSetOf(), GeneratedTrack::key)
val savedPlaylistKeys = generateRepository.savedPlaylistTrackKeys()

class ScoredCandidate(val candidate: RankedCandidate, val score: Double)

val ranked = candidates.values
.filterNot { it.track.key in shownKeys }
.filterNot { it.track.key in shownKeys || isAlreadyListened(it.track) }
.map { candidate ->
val affinity = profile?.artistAffinity?.get(candidate.track.artist.trim().lowercase()) ?: 0.0
val consensus = minOf(24, (candidate.sources.size - 1).coerceAtLeast(0) * 8)
Expand All @@ -236,16 +262,16 @@ class DiscoverRepository @Inject constructor(
.map { it.candidate }

val preferred = ranked.filter { it.track.key in allowedKeys }
val ytFeedCandidates = preferred.filter { "yt-feed" in it.sources || "yt-new" in it.sources }
val otherCandidates = preferred.filterNot { "yt-feed" in it.sources || "yt-new" in it.sources }
val trendingCandidates = preferred.filter { "yt-new" in it.sources }
val similarCandidates = preferred.filterNot { "yt-new" in it.sources }
val balanced = buildList {
var otherIndex = 0
var ytIndex = 0
while (otherIndex < otherCandidates.size || ytIndex < ytFeedCandidates.size) {
var simIndex = 0
var trendIndex = 0
while (simIndex < similarCandidates.size || trendIndex < trendingCandidates.size) {
repeat(2) {
if (otherIndex < otherCandidates.size) add(otherCandidates[otherIndex++])
if (simIndex < similarCandidates.size) add(similarCandidates[simIndex++])
}
if (ytIndex < ytFeedCandidates.size) add(ytFeedCandidates[ytIndex++])
if (trendIndex < trendingCandidates.size) add(trendingCandidates[trendIndex++])
}
}
val artistCounts = mutableMapOf<String, Int>()
Expand Down
65 changes: 31 additions & 34 deletions app/src/main/java/com/lastwave/app/data/feed/FeedRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ import com.lastwave.app.data.playlist.PlaylistRepository
import com.lastwave.app.data.repository.HomeRepository
import com.lastwave.app.data.repository.RelatedGraphRepository
import com.lastwave.app.data.ytmusic.YtMusicAuthManager
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton

Expand Down Expand Up @@ -113,6 +115,7 @@ class FeedRepository @Inject constructor(
) {
private var cachedFeed: FeedData? = null
private var cachedKey: String? = null
private val artistEntityCache = ConcurrentHashMap<String, FeedArtist>()

fun getCachedFeed(): FeedData? = cachedFeed

Expand Down Expand Up @@ -280,15 +283,13 @@ class FeedRepository @Inject constructor(
}

val familiarCandidates = buildList {
ytQuickPicks.forEachIndexed { i, t -> add(t to trackScore(t, i, 3.0)) }
ytLikedSongs.forEachIndexed { i, t -> add(t to trackScore(t, i, 2.2)) }
ytRecentSongs.forEachIndexed { i, t -> add(t to trackScore(t, i, 1.6)) }
regularPicks.forEachIndexed { i, t -> add(t to trackScore(t, i, 2.6)) }
// Local-first: liked + saved Room tracks score as personal picks
// for guest/disconnected so Quick Picks never looks generic.
localQuickPicks.forEachIndexed { i, t -> add(t to trackScore(t, i, 2.4)) }
homeSongs.forEachIndexed { i, t -> add(t to trackScore(t, i, 1.2)) }
charts.forEachIndexed { i, t -> add(t to trackScore(t, i, 1.0)) }
ytQuickPicks.forEachIndexed { i, t -> add(t to trackScore(t, i, 2.8)) }
homeSongs.forEachIndexed { i, t -> add(t to trackScore(t, i, 2.2)) }
charts.forEachIndexed { i, t -> add(t to trackScore(t, i, 2.0)) }
ytLikedSongs.forEachIndexed { i, t -> add(t to trackScore(t, i, 1.8)) }
regularPicks.forEachIndexed { i, t -> add(t to trackScore(t, i, 1.6)) }
localQuickPicks.forEachIndexed { i, t -> add(t to trackScore(t, i, 1.5)) }
ytRecentSongs.forEachIndexed { i, t -> add(t to trackScore(t, i, 1.2)) }
}
.distinctBy { (t, _) -> t.artist.trim().lowercase() to t.title.trim().lowercase() }
.sortedByDescending { it.second }
Expand All @@ -308,7 +309,7 @@ class FeedRepository @Inject constructor(
// First paint: familiar-only picks. These need no network beyond the
// parallel batch above, so a cold feed shows shelves immediately.
// Blending discovery into Quick Picks must never gate this
// emission — the related-songs round-trips (3 x up to 30 tracks) are
// emission — the related-songs round-trips (3 x up to 30 tracks) are
// the slowest calls in the load, and waiting on them left the whole
// feed blank for seconds on a cold start.
val familiarQuickPicks = diversify(familiarCandidates, YouTubeMusicTrack::artist, maxPerArtist = 2)
Expand All @@ -321,8 +322,11 @@ class FeedRepository @Inject constructor(
// Discovery: blends fresh picks over the familiar ones once the
// round-trips land, and feeds Fresh Finds / Mixes below. It overlaps
// with the artist fetches that follow instead of blocking paint.
val discoveryResults = discoverySeeds.zip(discoveryDef.await())
val discoveryTracks = discoveryResults.flatMap { it.second }.distinctBy { it.videoId }
val discoveryBatches = withTimeoutOrNull(3000L) { discoveryDef.await() } ?: emptyList()
val discoveryResults = discoverySeeds.zip(
if (discoveryBatches.size == discoverySeeds.size) discoveryBatches else List(discoverySeeds.size) { emptyList<YouTubeMusicTrack>() }
)
val discoveryTracks = discoveryBatches.flatten().distinctBy { it.videoId }

val familiarVideoIds = (ytLikedSongs + ytRecentSongs + regularPicks + localQuickPicks + homeSongs).mapTo(mutableSetOf()) { it.videoId }
val familiarKeys = (ytLikedSongs + ytRecentSongs + regularPicks + localQuickPicks + homeSongs).mapTo(mutableSetOf()) {
Expand All @@ -341,9 +345,11 @@ class FeedRepository @Inject constructor(
.sortedByDescending { it.second }
.map { it.first }

val freshOrChart = (newCandidates + charts.filter { it.videoId !in familiarVideoIds })
.distinctBy { it.videoId }
val quickCandidates = blend(
first = diversify(familiarCandidates, YouTubeMusicTrack::artist, maxPerArtist = 2),
second = diversify(newCandidates, YouTubeMusicTrack::artist, maxPerArtist = 1),
first = diversify(familiarCandidates, YouTubeMusicTrack::artist, maxPerArtist = 1),
second = diversify(freshOrChart, YouTubeMusicTrack::artist, maxPerArtist = 1),
)

val quickPicks = diversify(quickCandidates, YouTubeMusicTrack::artist, maxPerArtist = 2)
Expand Down Expand Up @@ -395,42 +401,33 @@ class FeedRepository @Inject constructor(

val topArtists = topArtistNames.map { name ->
async(Dispatchers.IO) {
val cached = artistEntityCache[name.lowercase()]
if (cached != null) return@async cached
val trackArtwork = artistSignalTracks
.firstOrNull { ArtistHelper.splitArtists(it.artist).any { artist -> artist.equals(name, ignoreCase = true) } }
?.artworkUrl
previous?.topArtists?.firstOrNull { it.name.equals(name, ignoreCase = true) && it.browseId != null }
?.let { return@async it }
?.let {
artistEntityCache[name.lowercase()] = it
return@async it
}
val entity = runCatching {
innerTube.searchArtists(name, limit = 3)
.firstOrNull { it.name.trim().equals(name, ignoreCase = true) }
}.getOrNull()
FeedArtist(
val feedArtist = FeedArtist(
name = name,
browseId = entity?.browseId,
artworkUrl = entity?.artworkUrl?.takeIf(ArtworkNormalizer::isRealImage) ?: trackArtwork,
)
artistEntityCache[name.lowercase()] = feedArtist
feedArtist
}
}.awaitAll()

val releaseYear = java.time.Year.now().value.toString()
val artistReleases = if (artistRanks.isEmpty()) emptyList() else topArtists
.filter { it.name.lowercase() in artistRanks }
.take(4).map { artist ->
async(Dispatchers.IO) {
val page = artist.browseId?.let { id ->
runCatching { innerTube.fetchArtistPage(id, artist.name) }.getOrNull()
}
(page?.albums.orEmpty() + page?.singles.orEmpty())
.filter { it.year == releaseYear && it.browseId.isNotBlank() }
.take(3).map { release ->
YouTubePlaylistSummary(id = release.browseId, title = release.title,
author = artist.name, artworkUrl = release.artworkUrl)
}
}
}.awaitAll().flatten()
val newReleases = (matchedReleases + artistReleases)
val newReleases = (matchedReleases.ifEmpty { releaseCandidates })
.ifEmpty { previous?.newReleases.orEmpty() }
.distinctBy { it.id }.shuffled(random).take(15)
.distinctBy { it.id }.take(15)

val familiarIds = (ytLikedSongs + ytRecentSongs + regularPicks).mapTo(mutableSetOf()) { it.videoId }
val freshPool = (discoveryTracks + charts + homeSongs + ytQuickPicks)
Expand Down
127 changes: 127 additions & 0 deletions app/src/main/java/com/lastwave/app/data/ytmusic/CookieNormalizer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package com.lastwave.app.data.ytmusic

/**
* Normalizes user-pasted YouTube Music session cookies into a clean `Cookie` header string.
*
* Direct port of the desktop app's [InnerTubeMusicApi.normalizeCookies]:
* Accepts:
* - Raw Cookie header: `A=1; B=2`
* - Netscape cookies.txt (TSV format with `#HttpOnly` lines, tab columns)
* - JSON object or array exported from browser extensions (e.g. Cookie-Editor, EditThisCookie)
* - Multiline pastes (newlines treated as separators)
*
* Validates that at least one SAPISID token (`__Secure-3PAPISID`, `SAPISID`, or `APISID`)
* is present, which is required to build the `SAPISIDHASH` authorization header.
*/
object CookieNormalizer {

private val SAPISID_KEYS = listOf("__Secure-3PAPISID", "SAPISID", "APISID")

/**
* Normalizes [raw] into a standard `; `-separated cookie header.
*
* @throws IllegalArgumentException if input is empty, unparseable, or missing SAPISID tokens.
*/
fun normalize(raw: String?): String {
val input = raw?.trim().orEmpty()
if (input.isEmpty()) {
throw IllegalArgumentException("Please enter cookie text.")
}

val pairs = mutableMapOf<String, String>()

fun addPair(name: String, rawVal: String) {
val n = name.trim()
var v = rawVal.trim()
if (n.isEmpty() || v.isEmpty()) return
// Strip surrounding quotes some exporters add
if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith('\'') && v.endsWith('\'')))) {
v = v.substring(1, v.length - 1).trim()
}
if (v.isNotEmpty()) {
pairs[n] = v
}
}

// 1. JSON export? e.g. [{"name":"SID","value":"..."}] or {"cookies": [...]} or {"SID":"..."}
// Uses pure Kotlin regex parsing so unit tests on the JVM don't fail due to Android SDK org.json stubs.
if (input.startsWith("[") || input.startsWith("{")) {
val objPattern = Regex("""\{[^{}]*\}""")
val namePattern = Regex(""""name"\s*:\s*"([^"]+)"""")
val valuePattern = Regex(""""value"\s*:\s*"([^"]*)"""")
for (match in objPattern.findAll(input)) {
val block = match.value
val n = namePattern.find(block)?.groupValues?.getOrNull(1)
val v = valuePattern.find(block)?.groupValues?.getOrNull(1)
if (n != null && v != null) {
addPair(n, v)
}
}
if (pairs.isEmpty()) {
val kvPattern = Regex(""""([^"]+)"\s*:\s*"([^"]*)"""")
val ignoredKeys = setOf("name", "value", "cookies", "domain", "path", "expirationDate", "hostOnly", "httpOnly", "sameSite", "secure", "session", "storeId")
for (m in kvPattern.findAll(input)) {
val k = m.groupValues[1]
val v = m.groupValues[2]
if (k !in ignoredKeys) {
addPair(k, v)
}
}
}
}

// 2. Line-oriented parse: handles header strings, multiline pastes,
// Netscape cookies.txt (7 tab columns), and `Cookie: ...` prefixes.
if (pairs.isEmpty()) {
for (rawLine in input.lines()) {
var line = rawLine.trim()
if (line.isEmpty()) continue

// `#HttpOnly` lines are DATA in Netscape files, not comments
if (line.startsWith("#HttpOnly_", ignoreCase = true)) {
line = line.substring(10).trim()
} else if (line.startsWith("#HttpOnly", ignoreCase = true)) {
line = line.substring(9).trim()
} else if (line.startsWith("#")) {
continue
}

if (line.startsWith("cookie:", ignoreCase = true)) {
line = line.substring(7).trim()
}

// Netscape format: domain, flag, path, secure, expiry, name, value
if ('\t' in line) {
val cols = line.split('\t')
if (cols.size >= 7) {
addPair(cols[5], cols[6])
continue
}
line = line.replace('\t', ';')
}

// Semicolon-separated header chunks on this line
for (part in line.split(';')) {
val chunk = part.trim()
if (chunk.isEmpty() || chunk.startsWith("#")) continue
val idx = chunk.indexOf('=')
if (idx <= 0) continue
addPair(chunk.substring(0, idx), chunk.substring(idx + 1))
}
}
}

if (pairs.isEmpty()) {
throw IllegalArgumentException("No cookies found — paste a Cookie header, cookies.txt, or JSON")
}

val hasSapisid = SAPISID_KEYS.any { pairs.containsKey(it) }
if (!hasSapisid) {
throw IllegalArgumentException(
"Missing SAPISID — export cookies from music.youtube.com while signed in (need __Secure-3PAPISID or SAPISID)"
)
}

return pairs.keys.sorted().joinToString("; ") { "$it=${pairs[it]}" }
}
}
Loading
Loading