Files
radiola-android/app/src/main/java/com/radiola/data/remote/CoverEnrichmentManager.kt
nk 147b3ac81d 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>
2026-06-04 17:11:44 +03:00

101 lines
4.6 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}