From 7df9b62403d66651e887a62d03befb6d62b2fe98 Mon Sep 17 00:00:00 2001 From: nk Date: Thu, 4 Jun 2026 13:39:27 +0300 Subject: [PATCH] =?UTF-8?q?feat(recordings):=20=D0=B7=D0=B0=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D1=8C=20HLS-=D1=81=D1=82=D0=B0=D0=BD=D1=86=D0=B8=D0=B9?= =?UTF-8?q?=20(EMG:=20=D0=95=D0=B2=D1=80=D0=BE=D0=BF=D0=B0=20=D0=9F=D0=BB?= =?UTF-8?q?=D1=8E=D1=81=20=D0=B8=20=D0=B4=D1=80.)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше запись просто писала тело URL в файл — у HLS это m3u8-плейлист (текст), а не аудио, поэтому EMG-станции не записывались. Добавлен HLS-рекордер: резолвит мастер→медиа-плейлист, опрашивает его и докачивает новые .ts-сегменты, склеивая в файл (валидный MPEG-TS, ExoPlayer играет и перематывает). На первом проходе пишется только хвост окна — запись начинается примерно с момента нажатия. Сплошные потоки (ICY) — прежним путём (recordRaw). Тайм-коды треков работают и для EMG (now-playing с бэка). Co-Authored-By: Claude Opus 4.8 --- .../repository/RecordingRepositoryImpl.kt | 118 ++++++++++++++---- 1 file changed, 95 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/radiola/data/repository/RecordingRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/RecordingRepositoryImpl.kt index cb7114f..aaf7e50 100644 --- a/app/src/main/java/com/radiola/data/repository/RecordingRepositoryImpl.kt +++ b/app/src/main/java/com/radiola/data/repository/RecordingRepositoryImpl.kt @@ -27,6 +27,7 @@ import java.io.File import java.io.FileOutputStream import java.io.IOException import javax.inject.Inject +import kotlin.coroutines.coroutineContext class RecordingRepositoryImpl @Inject constructor( private val db: AppDatabase, @@ -58,7 +59,9 @@ class RecordingRepositoryImpl @Inject constructor( val dir = File(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), "radiola_recordings") dir.mkdirs() + val isHls = station.streamUrl.contains(".m3u8", ignoreCase = true) val ext = when { + isHls -> "ts" station.streamUrl.contains(".aac", ignoreCase = true) -> "aac" station.streamUrl.contains(".mp3", ignoreCase = true) -> "mp3" else -> "audio" @@ -118,31 +121,12 @@ class RecordingRepositoryImpl @Inject constructor( recordingJob = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { var output: FileOutputStream? = null try { - val request = Request.Builder().url(station.streamUrl).build() - val call = okHttpClient.newCall(request) - currentCall = call - val response = call.execute() - - if (!response.isSuccessful) { - Log.e("RecordingRepo", "HTTP error: ${response.code}") - return@launch - } - output = FileOutputStream(file) - val input = response.body?.byteStream() - if (input == null) { - Log.e("RecordingRepo", "Empty response body") - return@launch + if (isHls) { + recordHls(station.streamUrl, output) + } else { + recordRaw(station.streamUrl, output) } - - val buffer = ByteArray(8192) - var bytesRead: Int - while (isActive) { - bytesRead = input.read(buffer) - if (bytesRead == -1) break - output.write(buffer, 0, bytesRead) - } - input.close() } catch (e: IOException) { if (e.message?.contains("Canceled") == true) { Log.d("RecordingRepo", "Recording cancelled normally") @@ -155,6 +139,94 @@ class RecordingRepositoryImpl @Inject constructor( } } + /** Запись сплошного потока (ICY/Icecast): просто пишем тело ответа в файл. */ + private suspend fun recordRaw(streamUrl: String, output: FileOutputStream) { + val request = Request.Builder().url(streamUrl).build() + val call = okHttpClient.newCall(request) + currentCall = call + val response = call.execute() + if (!response.isSuccessful) { + Log.e("RecordingRepo", "HTTP error: ${response.code}") + return + } + val input = response.body?.byteStream() ?: run { + Log.e("RecordingRepo", "Empty response body") + return + } + val buffer = ByteArray(8192) + while (coroutineContext.isActive) { + val bytesRead = input.read(buffer) + if (bytesRead == -1) break + output.write(buffer, 0, bytesRead) + } + input.close() + } + + /** + * Запись HLS-станций (EMG: Европа Плюс, Ретро FM и др.). Поток — это m3u8-плейлист, + * а не сплошной поток, поэтому скачиваем .ts-сегменты и склеиваем в файл + * (валидный MPEG-TS, ExoPlayer его проигрывает и перематывает). + */ + private suspend fun recordHls(streamUrl: String, output: FileOutputStream) { + // 1. Резолвим мастер-плейлист в медиа-плейлист (берём первый вариант — у EMG + // он наибольшего битрейта). + var mediaUrl = streamUrl + val firstText = httpGetText(mediaUrl) ?: return + if (firstText.contains("#EXT-X-STREAM-INF")) { + val variant = firstText.lineSequence() + .map { it.trim() } + .firstOrNull { it.isNotEmpty() && !it.startsWith("#") } + if (variant != null) mediaUrl = resolveUrl(mediaUrl, variant) + } + + // 2. Опрашиваем медиа-плейлист, дописываем новые сегменты. + val downloaded = LinkedHashSet() + var firstPass = true + while (coroutineContext.isActive) { + val text = httpGetText(mediaUrl) + if (text == null) { delay(2000); continue } + var targetDur = 6 + val segments = mutableListOf() + for (raw in text.lineSequence()) { + val line = raw.trim() + when { + line.startsWith("#EXT-X-TARGETDURATION:") -> + targetDur = line.substringAfter(":").toIntOrNull() ?: targetDur + line.isNotEmpty() && !line.startsWith("#") -> + segments.add(resolveUrl(mediaUrl, line)) + } + } + segments.forEachIndexed { i, segUrl -> + if (!coroutineContext.isActive || downloaded.contains(segUrl)) return@forEachIndexed + downloaded.add(segUrl) + // На первом проходе пропускаем «прошлое» окно (пишем только хвост), + // чтобы запись начиналась примерно с момента нажатия. + if (firstPass && i < segments.size - 2) return@forEachIndexed + httpGetBytes(segUrl)?.let { output.write(it); output.flush() } + } + firstPass = false + if (downloaded.size > 500) { + val keep = downloaded.toList().takeLast(200) + downloaded.clear(); downloaded.addAll(keep) + } + delay((targetDur * 500L).coerceIn(2000L, 6000L)) + } + } + + private fun httpGetText(url: String): String? = try { + okHttpClient.newCall(Request.Builder().url(url).header("User-Agent", "radiOLA").build()) + .execute().use { if (it.isSuccessful) it.body?.string() else null } + } catch (e: Exception) { Log.w("RecordingRepo", "playlist fetch fail: ${e.message}"); null } + + private fun httpGetBytes(url: String): ByteArray? = try { + okHttpClient.newCall(Request.Builder().url(url).header("User-Agent", "radiOLA").build()) + .execute().use { if (it.isSuccessful) it.body?.bytes() else null } + } catch (e: Exception) { Log.w("RecordingRepo", "segment fetch fail: ${e.message}"); null } + + /** Разрешает относительный URL (вариант/сегмент) относительно базового плейлиста. */ + private fun resolveUrl(base: String, ref: String): String = + try { java.net.URI(base).resolve(ref).toString() } catch (e: Exception) { ref } + override suspend fun stopRecording() { currentCall?.cancel() currentCall = null