diff --git a/app/src/main/java/com/radiola/data/remote/CoverEnrichmentManager.kt b/app/src/main/java/com/radiola/data/remote/CoverEnrichmentManager.kt new file mode 100644 index 0000000..1d8d06c --- /dev/null +++ b/app/src/main/java/com/radiola/data/remote/CoverEnrichmentManager.kt @@ -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()) + private val queue = Channel(Channel.UNLIMITED) + + init { + scope.launch { + for (track in queue) { + processOne(track) + delay(THROTTLE_MS) // не долбить iTunes с устройства + } + } + } + + /** Поставить now-playing-треки без обложки в очередь обогащения. */ + fun enqueue(tracks: Collection) { + 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 + } +} diff --git a/app/src/main/java/com/radiola/data/remote/ItunesApi.kt b/app/src/main/java/com/radiola/data/remote/ItunesApi.kt new file mode 100644 index 0000000..01b88ca --- /dev/null +++ b/app/src/main/java/com/radiola/data/remote/ItunesApi.kt @@ -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 +} diff --git a/app/src/main/java/com/radiola/data/remote/RadiolaApi.kt b/app/src/main/java/com/radiola/data/remote/RadiolaApi.kt index fa37d71..1a3fa9c 100644 --- a/app/src/main/java/com/radiola/data/remote/RadiolaApi.kt +++ b/app/src/main/java/com/radiola/data/remote/RadiolaApi.kt @@ -30,6 +30,10 @@ interface RadiolaApi { @GET("now-playing") suspend fun getNowPlaying(): List + // Сабмит обложки, найденной клиентом в 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 diff --git a/app/src/main/java/com/radiola/data/remote/dto/CoverDto.kt b/app/src/main/java/com/radiola/data/remote/dto/CoverDto.kt new file mode 100644 index 0000000..cf96cab --- /dev/null +++ b/app/src/main/java/com/radiola/data/remote/dto/CoverDto.kt @@ -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 = emptyList(), +) + +@Serializable +data class ItunesResult( + @SerialName("artworkUrl100") val artworkUrl100: String? = null, +) diff --git a/app/src/main/java/com/radiola/data/repository/NowPlayingRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/NowPlayingRepositoryImpl.kt index 2ae24a9..5ce794d 100644 --- a/app/src/main/java/com/radiola/data/repository/NowPlayingRepositoryImpl.kt +++ b/app/src/main/java/com/radiola/data/repository/NowPlayingRepositoryImpl.kt @@ -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>(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) diff --git a/app/src/main/java/com/radiola/di/AppModule.kt b/app/src/main/java/com/radiola/di/AppModule.kt index cd10085..5933dd3 100644 --- a/app/src/main/java/com/radiola/di/AppModule.kt +++ b/app/src/main/java/com/radiola/di/AppModule.kt @@ -102,6 +102,20 @@ object AppModule { .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .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 @Singleton @Named("lrclib")