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:
@@ -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
|
||||
}
|
||||
}
|
||||
18
app/src/main/java/com/radiola/data/remote/ItunesApi.kt
Normal file
18
app/src/main/java/com/radiola/data/remote/ItunesApi.kt
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
28
app/src/main/java/com/radiola/data/remote/dto/CoverDto.kt
Normal file
28
app/src/main/java/com/radiola/data/remote/dto/CoverDto.kt
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user