feat(love): воспроизведение Love Radio через сессионный UID + now-playing главного

Потоки Love защищены: клиент берёт UID из их player/config (со своего IP) и
подставляет в n340-поток — играет музыка. LoveStreamResolver + LoveApi. Каталог
переведён на n340. Now-playing главного Love Radio по ICY; саб-каналы трек не
отдают нигде — показываем без трека.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-03 20:14:52 +03:00
parent 615e3435e3
commit 320cac546b
6 changed files with 98 additions and 16 deletions

View File

@@ -0,0 +1,17 @@
package com.radiola.data.remote
import com.radiola.data.remote.dto.LoveConfigDto
import retrofit2.http.GET
import retrofit2.http.Headers
interface LoveApi {
// Сессионный UID для доступа к потокам Love Radio (привязан к IP клиента,
// поэтому запрашиваем именно с устройства).
@GET("player/config")
@Headers(
"User-Agent: Mozilla/5.0",
"Referer: https://www.loveradio.ru/",
"Origin: https://www.loveradio.ru"
)
suspend fun getConfig(): LoveConfigDto
}

View File

@@ -0,0 +1,31 @@
package com.radiola.data.remote
import javax.inject.Inject
import javax.inject.Singleton
/**
* Потоки Love Radio (n340.com) отдают музыку только с валидным сессионным UID,
* привязанным к IP клиента. Берём UID с устройства из их player/config и
* подставляем в URL потока. UID кэшируем (он стабилен в рамках сессии).
*/
@Singleton
class LoveStreamResolver @Inject constructor(
private val loveApi: LoveApi
) {
@Volatile
private var cachedUid: String? = null
private fun isLove(url: String): Boolean =
url.contains("n340.com") || url.contains("loveradio")
suspend fun resolve(url: String): String {
if (!isLove(url)) return url
val uid = cachedUid ?: runCatching { loveApi.getConfig().data.uid }
.getOrNull()
?.takeIf { it.isNotBlank() }
?.also { cachedUid = it }
if (uid.isNullOrBlank()) return url // фолбэк: пусть играет что есть
val base = url.substringBefore("?")
return "$base?type=aac&UID=$uid"
}
}

View File

@@ -0,0 +1,14 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
// Ответ player/config Love Radio — нужен только uid сессии (для доступа к потоку)
@Serializable
data class LoveConfigDto(
val data: LoveConfigData = LoveConfigData()
)
@Serializable
data class LoveConfigData(
val uid: String = ""
)

View File

@@ -9,6 +9,7 @@ import com.radiola.data.local.MIGRATION_2_3
import com.radiola.data.local.MIGRATION_3_4
import com.radiola.data.remote.AuthInterceptor
import com.radiola.data.remote.LrcLibApi
import com.radiola.data.remote.LoveApi
import com.radiola.data.remote.RecordApi
import com.radiola.data.remote.RadiolaApi
import com.radiola.data.repository.AuthRepositoryImpl
@@ -106,6 +107,19 @@ object AppModule {
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
@Provides
@Singleton
@Named("love")
fun provideLoveRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit = Retrofit.Builder()
.baseUrl("https://api.loveradio.ru/api/v1/love-radio/")
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
@Provides
@Singleton
fun provideLoveApi(@Named("love") retrofit: Retrofit): LoveApi = retrofit.create(LoveApi::class.java)
@Provides
@Singleton
fun provideLrcLibApi(@Named("lrclib") retrofit: Retrofit): LrcLibApi = retrofit.create(LrcLibApi::class.java)

View File

@@ -35,7 +35,8 @@ class PlayerViewModel @Inject constructor(
private val trackHistoryRepository: TrackHistoryRepository,
private val settingsRepository: SettingsRepository,
private val recordingRepository: RecordingRepository,
private val pushHistoryUseCase: PushHistoryUseCase
private val pushHistoryUseCase: PushHistoryUseCase,
private val loveStreamResolver: com.radiola.data.remote.LoveStreamResolver
) : ViewModel() {
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
@@ -85,7 +86,12 @@ class PlayerViewModel @Inject constructor(
_currentStation.value = station
_currentTrack.value = null
_playlist.value = playlist ?: _stations.value
playerController.play(station.streamUrl, station.prefix, station.name)
// Love Radio: подставляем сессионный UID (иначе поток отдаёт заглушку).
// Для остальных resolve вернёт URL как есть.
viewModelScope.launch {
val url = loveStreamResolver.resolve(station.streamUrl)
playerController.play(url, station.prefix, station.name)
}
viewModelScope.launch { pushHistoryUseCase(station.id) }
nowPlayingJob?.cancel()
nowPlayingJob = viewModelScope.launch {