feat(covers): приоритет играющего трека + троттл 0.8с

Обложки наливались общей очередью (1.5с) — играющий трек ждал свою очередь.
Добавлена приоритетная дорожка: трек, который слушают сейчас, обогащается
первым (PlayerViewModel → NowPlayingRepository.enrichCoverNow). Троттл общей
очереди ускорен 1.5с→0.8с. Дедуп разнесён на enqueued/processed, чтобы
дорожки не дублировали работу.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-04 17:11:44 +03:00
parent 4a33aa6fb5
commit 147b3ac81d
4 changed files with 44 additions and 12 deletions

View File

@@ -8,6 +8,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import java.util.Collections
import javax.inject.Inject
import javax.inject.Singleton
@@ -16,8 +17,11 @@ import javax.inject.Singleton
* Клиентское обогащение обложек. Серверный IP забанен Apple (429), поэтому
* iTunes-поиск делаем С УСТРОЙСТВА пользователя (его IP не забанен), а найденную
* ссылку на арт шлём на наш бэкенд — он скачивает её и кладёт WebP к себе.
* Дальше обложка приходит ВСЕМ через /now-playing. Дедуп по треку (в рамках
* сессии) + троттлинг, чтобы не злоупотреблять iTunes с устройства.
* Дальше обложка приходит ВСЕМ через /now-playing.
*
* Две дорожки: приоритетная (трек, который слушают прямо сейчас — обрабатывается
* первой) и общая (остальные now-playing). Дедуп + троттлинг, чтобы не
* злоупотреблять iTunes с устройства.
*/
@Singleton
class CoverEnrichmentManager @Inject constructor(
@@ -25,28 +29,47 @@ class CoverEnrichmentManager @Inject constructor(
private val radiolaApi: RadiolaApi,
) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val seen = Collections.synchronizedSet(HashSet<String>())
private val queue = Channel<Track>(Channel.UNLIMITED)
// Что уже поставлено в общую очередь (чтобы не дублировать пачку now-playing).
private val enqueued = Collections.synchronizedSet(HashSet<String>())
// Что уже обработали (чтобы приоритет и общая дорожка не делали двойную работу).
private val processed = Collections.synchronizedSet(HashSet<String>())
private val priority = Channel<Track>(Channel.UNLIMITED)
private val normal = Channel<Track>(Channel.UNLIMITED)
init {
scope.launch {
for (track in queue) {
while (true) {
val track = priority.tryReceive().getOrNull() ?: select {
priority.onReceive { it }
normal.onReceive { it }
}
processOne(track)
delay(THROTTLE_MS) // не долбить iTunes с устройства
delay(THROTTLE_MS)
}
}
}
/** Поставить now-playing-треки без обложки в очередь обогащения. */
/** Поставить пачку now-playing-треков без обложки в общую очередь. */
fun enqueue(tracks: Collection<Track>) {
for (t in tracks) {
if (!t.coverUrl.isNullOrBlank()) continue
if (t.artist.isBlank() || t.song.isBlank()) continue
if (seen.add(normKey(t))) queue.trySend(t)
if (!isEnrichable(t)) continue
if (enqueued.add(normKey(t))) normal.trySend(t)
}
}
/** Трек, который слушают прямо сейчас — вперёд очереди (вызывать при смене трека). */
fun enqueuePriority(track: Track?) {
if (track == null || !isEnrichable(track)) return
priority.trySend(track)
}
private fun isEnrichable(t: Track): Boolean =
t.coverUrl.isNullOrBlank() && t.artist.isNotBlank() && t.song.isNotBlank()
private suspend fun processOne(track: Track) {
val key = normKey(track)
if (!processed.add(key)) return // уже обрабатывали (другая дорожка)
try {
val term = clean("${track.artist} ${track.song}")
if (term.isBlank()) return
@@ -56,7 +79,8 @@ class CoverEnrichmentManager @Inject constructor(
val resp = radiolaApi.submitCover(SubmitCoverDto(track.artist, track.song, big))
android.util.Log.d("CoverEnrich", "submit '${track.artist} - ${track.song}' -> ${resp.coverUrl}")
} catch (_: Exception) {
// сеть/429/таймаут — не критично, добёрут другие клиенты или попытки
// сеть/429/таймаут — не критично; снимаем метку, чтобы могли попробовать позже
processed.remove(key)
}
}
@@ -71,6 +95,6 @@ class CoverEnrichmentManager @Inject constructor(
.trim()
companion object {
private const val THROTTLE_MS = 1500L
private const val THROTTLE_MS = 800L
}
}

View File

@@ -54,4 +54,6 @@ class NowPlayingRepositoryImpl @Inject constructor(
Result.failure(e)
}
}
override fun enrichCoverNow(track: Track) = coverEnrichment.enqueuePriority(track)
}