From 320cac546b9634de775c4063f82c92f6c0897229 Mon Sep 17 00:00:00 2001 From: nk Date: Wed, 3 Jun 2026 20:14:52 +0300 Subject: [PATCH] =?UTF-8?q?feat(love):=20=D0=B2=D0=BE=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B8=D0=B7=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?Love=20Radio=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D1=81=D0=B5?= =?UTF-8?q?=D1=81=D1=81=D0=B8=D0=BE=D0=BD=D0=BD=D1=8B=D0=B9=20UID=20+=20no?= =?UTF-8?q?w-playing=20=D0=B3=D0=BB=D0=B0=D0=B2=D0=BD=D0=BE=D0=B3=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Потоки Love защищены: клиент берёт UID из их player/config (со своего IP) и подставляет в n340-поток — играет музыка. LoveStreamResolver + LoveApi. Каталог переведён на n340. Now-playing главного Love Radio по ICY; саб-каналы трек не отдают нигде — показываем без трека. Co-Authored-By: Claude Opus 4.8 --- app/src/main/assets/stations.json | 28 ++++++++--------- .../java/com/radiola/data/remote/LoveApi.kt | 17 ++++++++++ .../radiola/data/remote/LoveStreamResolver.kt | 31 +++++++++++++++++++ .../radiola/data/remote/dto/LoveConfigDto.kt | 14 +++++++++ app/src/main/java/com/radiola/di/AppModule.kt | 14 +++++++++ .../com/radiola/ui/player/PlayerViewModel.kt | 10 ++++-- 6 files changed, 98 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/radiola/data/remote/LoveApi.kt create mode 100644 app/src/main/java/com/radiola/data/remote/LoveStreamResolver.kt create mode 100644 app/src/main/java/com/radiola/data/remote/dto/LoveConfigDto.kt diff --git a/app/src/main/assets/stations.json b/app/src/main/assets/stations.json index 40974b8..c3300c9 100644 --- a/app/src/main/assets/stations.json +++ b/app/src/main/assets/stations.json @@ -4401,7 +4401,7 @@ "name": "Love Radio", "bitrate": "128", "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", "iconText": "", "textColor": "#FFFFFF", @@ -4416,7 +4416,7 @@ "name": "Love RnB", "bitrate": "56", "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", "iconText": "", "textColor": "#FFFFFF", @@ -4431,7 +4431,7 @@ "name": "Love Top40", "bitrate": "56", "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", "iconText": "", "textColor": "#FFFFFF", @@ -4446,7 +4446,7 @@ "name": "Love Dance", "bitrate": "56", "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", "iconText": "", "textColor": "#FFFFFF", @@ -4461,7 +4461,7 @@ "name": "Love Gold", "bitrate": "56", "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", "iconText": "", "textColor": "#FFFFFF", @@ -4476,7 +4476,7 @@ "name": "Love Russian", "bitrate": "56", "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", "iconText": "", "textColor": "#FFFFFF", @@ -4496,8 +4496,8 @@ "iconText": "", "textColor": "#FFFFFF", "bgColor": "#E95F2D", - "enabled": true, - "notWorked": false, + "enabled": false, + "notWorked": true, "isNew": false }, { @@ -4506,7 +4506,7 @@ "name": "Love KPOP", "bitrate": "64", "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", "iconText": "", "textColor": "#FFFFFF", @@ -4521,8 +4521,8 @@ "name": "Love Power", "bitrate": "28", "site": "http://loveradio.ru/", - "stream": "http://stream2.loveradio.ru:9000/15_power_56?type=.aac&UID=", - "type": "mp3", + "stream": "https://stream2.n340.com/15_power_24", + "type": "aac", "iconText": "", "textColor": "#FFFFFF", "bgColor": "#F7D448", @@ -4536,7 +4536,7 @@ "name": "Love Chill", "bitrate": "56", "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", "iconText": "", "textColor": "#FFFFFF", @@ -4551,8 +4551,8 @@ "name": "Love Summer", "bitrate": "28", "site": "http://loveradio.ru/", - "stream": "http://stream2.loveradio.ru:9000/5_summer_56?type=.aac&UID=", - "type": "mp3", + "stream": "https://stream2.n340.com/5_summer_24", + "type": "aac", "iconText": "", "textColor": "#FFFFFF", "bgColor": "#B7CD53", diff --git a/app/src/main/java/com/radiola/data/remote/LoveApi.kt b/app/src/main/java/com/radiola/data/remote/LoveApi.kt new file mode 100644 index 0000000..79aa113 --- /dev/null +++ b/app/src/main/java/com/radiola/data/remote/LoveApi.kt @@ -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 +} diff --git a/app/src/main/java/com/radiola/data/remote/LoveStreamResolver.kt b/app/src/main/java/com/radiola/data/remote/LoveStreamResolver.kt new file mode 100644 index 0000000..b67f0b1 --- /dev/null +++ b/app/src/main/java/com/radiola/data/remote/LoveStreamResolver.kt @@ -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" + } +} diff --git a/app/src/main/java/com/radiola/data/remote/dto/LoveConfigDto.kt b/app/src/main/java/com/radiola/data/remote/dto/LoveConfigDto.kt new file mode 100644 index 0000000..640f7cc --- /dev/null +++ b/app/src/main/java/com/radiola/data/remote/dto/LoveConfigDto.kt @@ -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 = "" +) diff --git a/app/src/main/java/com/radiola/di/AppModule.kt b/app/src/main/java/com/radiola/di/AppModule.kt index a23219e..4b0a7af 100644 --- a/app/src/main/java/com/radiola/di/AppModule.kt +++ b/app/src/main/java/com/radiola/di/AppModule.kt @@ -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) diff --git a/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt b/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt index 16bf993..164e86d 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt @@ -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 = 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 {