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")
|
@GET("now-playing")
|
||||||
suspend fun getNowPlaying(): List<BackendNowPlayingDto>
|
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 оффлайн-станций — скрываем их в каталоге (мёртвые потоки)
|
// station_id оффлайн-станций — скрываем их в каталоге (мёртвые потоки)
|
||||||
@GET("stations/offline-ids")
|
@GET("stations/offline-ids")
|
||||||
suspend fun getOfflineStationIds(): List<Int>
|
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
|
package com.radiola.data.repository
|
||||||
|
|
||||||
|
import com.radiola.data.remote.CoverEnrichmentManager
|
||||||
import com.radiola.data.remote.NowPlayingSocketClient
|
import com.radiola.data.remote.NowPlayingSocketClient
|
||||||
import com.radiola.data.remote.RadiolaApi
|
import com.radiola.data.remote.RadiolaApi
|
||||||
import com.radiola.domain.model.Track
|
import com.radiola.domain.model.Track
|
||||||
@@ -11,7 +12,8 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
class NowPlayingRepositoryImpl @Inject constructor(
|
class NowPlayingRepositoryImpl @Inject constructor(
|
||||||
private val radiolaApi: RadiolaApi,
|
private val radiolaApi: RadiolaApi,
|
||||||
private val socketClient: NowPlayingSocketClient
|
private val socketClient: NowPlayingSocketClient,
|
||||||
|
private val coverEnrichment: CoverEnrichmentManager
|
||||||
) : NowPlayingRepository {
|
) : NowPlayingRepository {
|
||||||
|
|
||||||
private val _nowPlaying = MutableStateFlow<Map<Int, Track>>(emptyMap())
|
private val _nowPlaying = MutableStateFlow<Map<Int, Track>>(emptyMap())
|
||||||
@@ -45,6 +47,8 @@ class NowPlayingRepositoryImpl @Inject constructor(
|
|||||||
stationName = dto.name
|
stationName = dto.name
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Треки без обложки — обогащаем через iTunes с устройства (наш IP забанен).
|
||||||
|
coverEnrichment.enqueue(_nowPlaying.value.values)
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
|
|||||||
@@ -102,6 +102,20 @@ object AppModule {
|
|||||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@Named("itunes")
|
||||||
|
fun provideItunesRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit = Retrofit.Builder()
|
||||||
|
.baseUrl("https://itunes.apple.com/")
|
||||||
|
.client(okHttpClient)
|
||||||
|
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideItunesApi(@Named("itunes") retrofit: Retrofit): com.radiola.data.remote.ItunesApi =
|
||||||
|
retrofit.create(com.radiola.data.remote.ItunesApi::class.java)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@Named("lrclib")
|
@Named("lrclib")
|
||||||
|
|||||||
Reference in New Issue
Block a user