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

View File

@@ -0,0 +1,18 @@
package com.radiola.data.remote
import com.radiola.data.remote.dto.ItunesSearchResponse
import retrofit2.http.GET
import retrofit2.http.Query
/**
* iTunes Search API — дёргаем С УСТРОЙСТВА пользователя (его IP не забанен
* Apple, в отличие от нашего серверного). Нужна только обложка трека.
*/
interface ItunesApi {
@GET("search")
suspend fun search(
@Query("term") term: String,
@Query("entity") entity: String = "song",
@Query("limit") limit: Int = 1,
): ItunesSearchResponse
}

View File

@@ -30,6 +30,10 @@ interface RadiolaApi {
@GET("now-playing")
suspend fun getNowPlaying(): List<BackendNowPlayingDto>
// Сабмит обложки, найденной клиентом в iTunes (см. CoverEnrichmentManager).
@POST("covers/submit")
suspend fun submitCover(@Body dto: com.radiola.data.remote.dto.SubmitCoverDto): com.radiola.data.remote.dto.SubmitCoverResponse
// station_id оффлайн-станций — скрываем их в каталоге (мёртвые потоки)
@GET("stations/offline-ids")
suspend fun getOfflineStationIds(): List<Int>

View File

@@ -0,0 +1,28 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Тело сабмита найденной клиентом обложки на наш бэкенд. */
@Serializable
data class SubmitCoverDto(
val artist: String,
val song: String,
val artworkUrl: String,
)
@Serializable
data class SubmitCoverResponse(
val coverUrl: String? = null,
)
/** Ответ iTunes Search API (берём только обложку). */
@Serializable
data class ItunesSearchResponse(
val results: List<ItunesResult> = emptyList(),
)
@Serializable
data class ItunesResult(
@SerialName("artworkUrl100") val artworkUrl100: String? = null,
)

View File

@@ -1,5 +1,6 @@
package com.radiola.data.repository
import com.radiola.data.remote.CoverEnrichmentManager
import com.radiola.data.remote.NowPlayingSocketClient
import com.radiola.data.remote.RadiolaApi
import com.radiola.domain.model.Track
@@ -11,7 +12,8 @@ import javax.inject.Inject
class NowPlayingRepositoryImpl @Inject constructor(
private val radiolaApi: RadiolaApi,
private val socketClient: NowPlayingSocketClient
private val socketClient: NowPlayingSocketClient,
private val coverEnrichment: CoverEnrichmentManager
) : NowPlayingRepository {
private val _nowPlaying = MutableStateFlow<Map<Int, Track>>(emptyMap())
@@ -45,6 +47,8 @@ class NowPlayingRepositoryImpl @Inject constructor(
stationName = dto.name
)
}
// Треки без обложки — обогащаем через iTunes с устройства (наш IP забанен).
coverEnrichment.enqueue(_nowPlaying.value.values)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)