feat(covers): клиентское обогащение обложек через iTunes (обход бана сервера)

Серверный IP забанен Apple (iTunes search 429), а Deezer из РФ пуст — обложки
перестали наливаться. Теперь iTunes-поиск делает КЛИЕНТ (его IP не забанен):
для now-playing-треков без обложки ищет арт в iTunes и шлёт ССЫЛКУ на наш
бэкенд (POST /covers/submit), сервер качает её (CDN из РФ доступен) и кладёт
WebP — дальше обложка приходит всем через /now-playing. Дедуп по треку +
троттлинг 1.5с (CoverEnrichmentManager). Сервер: host-whitelist (SSRF),
идемпотентность (first-write-wins).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-04 16:59:32 +03:00
parent 4612a8f33c
commit 4a33aa6fb5
6 changed files with 145 additions and 1 deletions

View File

@@ -0,0 +1,76 @@
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 java.util.Collections
import javax.inject.Inject
import javax.inject.Singleton
/**
* Клиентское обогащение обложек. Серверный IP забанен Apple (429), поэтому
* iTunes-поиск делаем С УСТРОЙСТВА пользователя (его IP не забанен), а найденную
* ссылку на арт шлём на наш бэкенд — он скачивает её и кладёт WebP к себе.
* Дальше обложка приходит ВСЕМ через /now-playing. Дедуп по треку (в рамках
* сессии) + троттлинг, чтобы не злоупотреблять iTunes с устройства.
*/
@Singleton
class CoverEnrichmentManager @Inject constructor(
private val itunesApi: ItunesApi,
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)
init {
scope.launch {
for (track in queue) {
processOne(track)
delay(THROTTLE_MS) // не долбить iTunes с устройства
}
}
}
/** Поставить 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)
}
}
private suspend fun processOne(track: Track) {
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/таймаут — не критично, добёрут другие клиенты или попытки
}
}
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 = 1500L
}
}