Обложки наливались общей очередью (1.5с) — играющий трек ждал свою очередь. Добавлена приоритетная дорожка: трек, который слушают сейчас, обогащается первым (PlayerViewModel → NowPlayingRepository.enrichCoverNow). Троттл общей очереди ускорен 1.5с→0.8с. Дедуп разнесён на enqueued/processed, чтобы дорожки не дублировали работу. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
101 lines
4.6 KiB
Kotlin
101 lines
4.6 KiB
Kotlin
package com.radiola.data.remote
|
||
|
||
import com.radiola.data.remote.dto.SubmitCoverDto
|
||
import com.radiola.domain.model.Track
|
||
import kotlinx.coroutines.CoroutineScope
|
||
import kotlinx.coroutines.Dispatchers
|
||
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
|
||
|
||
/**
|
||
* Клиентское обогащение обложек. Серверный IP забанен Apple (429), поэтому
|
||
* iTunes-поиск делаем С УСТРОЙСТВА пользователя (его IP не забанен), а найденную
|
||
* ссылку на арт шлём на наш бэкенд — он скачивает её и кладёт WebP к себе.
|
||
* Дальше обложка приходит ВСЕМ через /now-playing.
|
||
*
|
||
* Две дорожки: приоритетная (трек, который слушают прямо сейчас — обрабатывается
|
||
* первой) и общая (остальные now-playing). Дедуп + троттлинг, чтобы не
|
||
* злоупотреблять iTunes с устройства.
|
||
*/
|
||
@Singleton
|
||
class CoverEnrichmentManager @Inject constructor(
|
||
private val itunesApi: ItunesApi,
|
||
private val radiolaApi: RadiolaApi,
|
||
) {
|
||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||
// Что уже поставлено в общую очередь (чтобы не дублировать пачку 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 {
|
||
while (true) {
|
||
val track = priority.tryReceive().getOrNull() ?: select {
|
||
priority.onReceive { it }
|
||
normal.onReceive { it }
|
||
}
|
||
processOne(track)
|
||
delay(THROTTLE_MS)
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Поставить пачку now-playing-треков без обложки в общую очередь. */
|
||
fun enqueue(tracks: Collection<Track>) {
|
||
for (t in tracks) {
|
||
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
|
||
val art = itunesApi.search(term).results.firstOrNull()?.artworkUrl100 ?: return
|
||
// 100x100 → 600x600 (источник покрупнее, сервер всё равно ресайзит)
|
||
val big = art.replace(Regex("/\\d+x\\d+bb\\."), "/600x600bb.")
|
||
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/таймаут — не критично; снимаем метку, чтобы могли попробовать позже
|
||
processed.remove(key)
|
||
}
|
||
}
|
||
|
||
private fun normKey(t: Track): String =
|
||
"${t.artist.trim().lowercase()}|${t.song.trim().lowercase()}"
|
||
|
||
/** Убираем суффиксы «(Original Mix)», «[... Dub]» и пунктуацию — лучше матчит. */
|
||
private fun clean(s: String): String = s
|
||
.replace(Regex("\\([^)]*\\)|\\[[^\\]]*\\]"), " ")
|
||
.replace(Regex("[^\\p{L}\\p{N}]+"), " ")
|
||
.replace(Regex("\\s+"), " ")
|
||
.trim()
|
||
|
||
companion object {
|
||
private const val THROTTLE_MS = 800L
|
||
}
|
||
}
|