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:
@@ -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<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() {
|
||||
currentCall?.cancel()
|
||||
currentCall = null
|
||||
|
||||
Reference in New Issue
Block a user