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

View File

@@ -7,4 +7,6 @@ interface NowPlayingRepository {
fun getNowPlaying(stationId: Int): Flow<Track?> fun getNowPlaying(stationId: Int): Flow<Track?>
fun getAllNowPlaying(): Flow<Map<Int, Track>> fun getAllNowPlaying(): Flow<Map<Int, Track>>
suspend fun refreshNowPlaying(): Result<Unit> suspend fun refreshNowPlaying(): Result<Unit>
/** Обогатить обложку трека приоритетно (тот, что слушают прямо сейчас). */
fun enrichCoverNow(track: Track)
} }

View File

@@ -125,6 +125,10 @@ class PlayerViewModel @Inject constructor(
.collect { track -> .collect { track ->
if (track != null) { if (track != null) {
_currentTrack.value = track _currentTrack.value = track
// Нет обложки — обогащаем приоритетно (играет прямо сейчас).
if (track.coverUrl.isNullOrBlank()) {
nowPlayingRepository.enrichCoverNow(track)
}
playerController.updateMetadata( playerController.updateMetadata(
track.song, track.song,
track.artist, track.artist,