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:
@@ -4401,7 +4401,7 @@
|
|||||||
"name": "Love Radio",
|
"name": "Love Radio",
|
||||||
"bitrate": "128",
|
"bitrate": "128",
|
||||||
"site": "http://loveradio.ru/",
|
"site": "http://loveradio.ru/",
|
||||||
"stream": "http://stream.loveradio.ru:9000/12_love_128?type=.aac&UID=",
|
"stream": "https://stream2.n340.com/12_love_128",
|
||||||
"type": "aac",
|
"type": "aac",
|
||||||
"iconText": "",
|
"iconText": "",
|
||||||
"textColor": "#FFFFFF",
|
"textColor": "#FFFFFF",
|
||||||
@@ -4416,7 +4416,7 @@
|
|||||||
"name": "Love RnB",
|
"name": "Love RnB",
|
||||||
"bitrate": "56",
|
"bitrate": "56",
|
||||||
"site": "http://loveradio.ru/",
|
"site": "http://loveradio.ru/",
|
||||||
"stream": "http://stream2.loveradio.ru:9000/6_rnb_56?type=.aac&UID=",
|
"stream": "https://stream2.n340.com/6_rnb_24",
|
||||||
"type": "aac",
|
"type": "aac",
|
||||||
"iconText": "",
|
"iconText": "",
|
||||||
"textColor": "#FFFFFF",
|
"textColor": "#FFFFFF",
|
||||||
@@ -4431,7 +4431,7 @@
|
|||||||
"name": "Love Top40",
|
"name": "Love Top40",
|
||||||
"bitrate": "56",
|
"bitrate": "56",
|
||||||
"site": "http://loveradio.ru/",
|
"site": "http://loveradio.ru/",
|
||||||
"stream": "http://stream2.loveradio.ru:9000/9_top40_56?type=.aac&UID=",
|
"stream": "https://stream2.n340.com/9_top40_24",
|
||||||
"type": "aac",
|
"type": "aac",
|
||||||
"iconText": "",
|
"iconText": "",
|
||||||
"textColor": "#FFFFFF",
|
"textColor": "#FFFFFF",
|
||||||
@@ -4446,7 +4446,7 @@
|
|||||||
"name": "Love Dance",
|
"name": "Love Dance",
|
||||||
"bitrate": "56",
|
"bitrate": "56",
|
||||||
"site": "http://loveradio.ru/",
|
"site": "http://loveradio.ru/",
|
||||||
"stream": "http://stream2.loveradio.ru:9000/7_dance_56?type=.aac&UID=",
|
"stream": "https://stream2.n340.com/7_dance_24",
|
||||||
"type": "aac",
|
"type": "aac",
|
||||||
"iconText": "",
|
"iconText": "",
|
||||||
"textColor": "#FFFFFF",
|
"textColor": "#FFFFFF",
|
||||||
@@ -4461,7 +4461,7 @@
|
|||||||
"name": "Love Gold",
|
"name": "Love Gold",
|
||||||
"bitrate": "56",
|
"bitrate": "56",
|
||||||
"site": "http://loveradio.ru/",
|
"site": "http://loveradio.ru/",
|
||||||
"stream": "http://stream2.loveradio.ru:9000/3_gold_56?type=.aac&UID=",
|
"stream": "https://stream2.n340.com/3_gold_56",
|
||||||
"type": "aac",
|
"type": "aac",
|
||||||
"iconText": "",
|
"iconText": "",
|
||||||
"textColor": "#FFFFFF",
|
"textColor": "#FFFFFF",
|
||||||
@@ -4476,7 +4476,7 @@
|
|||||||
"name": "Love Russian",
|
"name": "Love Russian",
|
||||||
"bitrate": "56",
|
"bitrate": "56",
|
||||||
"site": "http://loveradio.ru/",
|
"site": "http://loveradio.ru/",
|
||||||
"stream": "http://stream2.loveradio.ru:9000/8_russian_56?type=.aac&UID=",
|
"stream": "https://stream2.n340.com/8_russian_24",
|
||||||
"type": "aac",
|
"type": "aac",
|
||||||
"iconText": "",
|
"iconText": "",
|
||||||
"textColor": "#FFFFFF",
|
"textColor": "#FFFFFF",
|
||||||
@@ -4496,8 +4496,8 @@
|
|||||||
"iconText": "",
|
"iconText": "",
|
||||||
"textColor": "#FFFFFF",
|
"textColor": "#FFFFFF",
|
||||||
"bgColor": "#E95F2D",
|
"bgColor": "#E95F2D",
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"notWorked": false,
|
"notWorked": true,
|
||||||
"isNew": false
|
"isNew": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4506,7 +4506,7 @@
|
|||||||
"name": "Love KPOP",
|
"name": "Love KPOP",
|
||||||
"bitrate": "64",
|
"bitrate": "64",
|
||||||
"site": "http://loveradio.ru/",
|
"site": "http://loveradio.ru/",
|
||||||
"stream": "http://stream2.loveradio.ru:9000/11_kpop_64?type=.aac&UID=",
|
"stream": "https://stream2.n340.com/11_kpop_28",
|
||||||
"type": "aac",
|
"type": "aac",
|
||||||
"iconText": "",
|
"iconText": "",
|
||||||
"textColor": "#FFFFFF",
|
"textColor": "#FFFFFF",
|
||||||
@@ -4521,8 +4521,8 @@
|
|||||||
"name": "Love Power",
|
"name": "Love Power",
|
||||||
"bitrate": "28",
|
"bitrate": "28",
|
||||||
"site": "http://loveradio.ru/",
|
"site": "http://loveradio.ru/",
|
||||||
"stream": "http://stream2.loveradio.ru:9000/15_power_56?type=.aac&UID=",
|
"stream": "https://stream2.n340.com/15_power_24",
|
||||||
"type": "mp3",
|
"type": "aac",
|
||||||
"iconText": "",
|
"iconText": "",
|
||||||
"textColor": "#FFFFFF",
|
"textColor": "#FFFFFF",
|
||||||
"bgColor": "#F7D448",
|
"bgColor": "#F7D448",
|
||||||
@@ -4536,7 +4536,7 @@
|
|||||||
"name": "Love Chill",
|
"name": "Love Chill",
|
||||||
"bitrate": "56",
|
"bitrate": "56",
|
||||||
"site": "http://loveradio.ru/",
|
"site": "http://loveradio.ru/",
|
||||||
"stream": "http://stream2.loveradio.ru:9000/4_chill_56?type=.aac&UID=",
|
"stream": "https://stream2.n340.com/4_chill_24",
|
||||||
"type": "aac",
|
"type": "aac",
|
||||||
"iconText": "",
|
"iconText": "",
|
||||||
"textColor": "#FFFFFF",
|
"textColor": "#FFFFFF",
|
||||||
@@ -4551,8 +4551,8 @@
|
|||||||
"name": "Love Summer",
|
"name": "Love Summer",
|
||||||
"bitrate": "28",
|
"bitrate": "28",
|
||||||
"site": "http://loveradio.ru/",
|
"site": "http://loveradio.ru/",
|
||||||
"stream": "http://stream2.loveradio.ru:9000/5_summer_56?type=.aac&UID=",
|
"stream": "https://stream2.n340.com/5_summer_24",
|
||||||
"type": "mp3",
|
"type": "aac",
|
||||||
"iconText": "",
|
"iconText": "",
|
||||||
"textColor": "#FFFFFF",
|
"textColor": "#FFFFFF",
|
||||||
"bgColor": "#B7CD53",
|
"bgColor": "#B7CD53",
|
||||||
|
|||||||
17
app/src/main/java/com/radiola/data/remote/LoveApi.kt
Normal file
17
app/src/main/java/com/radiola/data/remote/LoveApi.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = ""
|
||||||
|
)
|
||||||
@@ -9,6 +9,7 @@ import com.radiola.data.local.MIGRATION_2_3
|
|||||||
import com.radiola.data.local.MIGRATION_3_4
|
import com.radiola.data.local.MIGRATION_3_4
|
||||||
import com.radiola.data.remote.AuthInterceptor
|
import com.radiola.data.remote.AuthInterceptor
|
||||||
import com.radiola.data.remote.LrcLibApi
|
import com.radiola.data.remote.LrcLibApi
|
||||||
|
import com.radiola.data.remote.LoveApi
|
||||||
import com.radiola.data.remote.RecordApi
|
import com.radiola.data.remote.RecordApi
|
||||||
import com.radiola.data.remote.RadiolaApi
|
import com.radiola.data.remote.RadiolaApi
|
||||||
import com.radiola.data.repository.AuthRepositoryImpl
|
import com.radiola.data.repository.AuthRepositoryImpl
|
||||||
@@ -106,6 +107,19 @@ object AppModule {
|
|||||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||||
.build()
|
.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
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideLrcLibApi(@Named("lrclib") retrofit: Retrofit): LrcLibApi = retrofit.create(LrcLibApi::class.java)
|
fun provideLrcLibApi(@Named("lrclib") retrofit: Retrofit): LrcLibApi = retrofit.create(LrcLibApi::class.java)
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ class PlayerViewModel @Inject constructor(
|
|||||||
private val trackHistoryRepository: TrackHistoryRepository,
|
private val trackHistoryRepository: TrackHistoryRepository,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val recordingRepository: RecordingRepository,
|
private val recordingRepository: RecordingRepository,
|
||||||
private val pushHistoryUseCase: PushHistoryUseCase
|
private val pushHistoryUseCase: PushHistoryUseCase,
|
||||||
|
private val loveStreamResolver: com.radiola.data.remote.LoveStreamResolver
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
|
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
|
||||||
@@ -85,7 +86,12 @@ class PlayerViewModel @Inject constructor(
|
|||||||
_currentStation.value = station
|
_currentStation.value = station
|
||||||
_currentTrack.value = null
|
_currentTrack.value = null
|
||||||
_playlist.value = playlist ?: _stations.value
|
_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) }
|
viewModelScope.launch { pushHistoryUseCase(station.id) }
|
||||||
nowPlayingJob?.cancel()
|
nowPlayingJob?.cancel()
|
||||||
nowPlayingJob = viewModelScope.launch {
|
nowPlayingJob = viewModelScope.launch {
|
||||||
|
|||||||
Reference in New Issue
Block a user