feat(recordings): запись HLS-станций (EMG: Европа Плюс и др.)

Раньше запись просто писала тело URL в файл — у HLS это m3u8-плейлист
(текст), а не аудио, поэтому EMG-станции не записывались. Добавлен
HLS-рекордер: резолвит мастер→медиа-плейлист, опрашивает его и докачивает
новые .ts-сегменты, склеивая в файл (валидный MPEG-TS, ExoPlayer играет
и перематывает). На первом проходе пишется только хвост окна — запись
начинается примерно с момента нажатия. Сплошные потоки (ICY) — прежним
путём (recordRaw). Тайм-коды треков работают и для EMG (now-playing с бэка).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-04 13:39:27 +03:00
parent fc63814f97
commit 7df9b62403

View File

@@ -27,6 +27,7 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.coroutineContext
class RecordingRepositoryImpl @Inject constructor( class RecordingRepositoryImpl @Inject constructor(
private val db: AppDatabase, private val db: AppDatabase,
@@ -58,7 +59,9 @@ class RecordingRepositoryImpl @Inject constructor(
val dir = File(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), "radiola_recordings") val dir = File(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), "radiola_recordings")
dir.mkdirs() dir.mkdirs()
val isHls = station.streamUrl.contains(".m3u8", ignoreCase = true)
val ext = when { val ext = when {
isHls -> "ts"
station.streamUrl.contains(".aac", ignoreCase = true) -> "aac" station.streamUrl.contains(".aac", ignoreCase = true) -> "aac"
station.streamUrl.contains(".mp3", ignoreCase = true) -> "mp3" station.streamUrl.contains(".mp3", ignoreCase = true) -> "mp3"
else -> "audio" else -> "audio"
@@ -118,31 +121,12 @@ class RecordingRepositoryImpl @Inject constructor(
recordingJob = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { recordingJob = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
var output: FileOutputStream? = null var output: FileOutputStream? = null
try { 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) output = FileOutputStream(file)
val input = response.body?.byteStream() if (isHls) {
if (input == null) { recordHls(station.streamUrl, output)
Log.e("RecordingRepo", "Empty response body") } else {
return@launch 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) { } catch (e: IOException) {
if (e.message?.contains("Canceled") == true) { if (e.message?.contains("Canceled") == true) {
Log.d("RecordingRepo", "Recording cancelled normally") 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<String>()
var firstPass = true
while (coroutineContext.isActive) {
val text = httpGetText(mediaUrl)
if (text == null) { delay(2000); continue }
var targetDur = 6
val segments = mutableListOf<String>()
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() { override suspend fun stopRecording() {
currentCall?.cancel() currentCall?.cancel()
currentCall = null currentCall = null