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.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
|
||||||
|
|||||||
Reference in New Issue
Block a user