Files
QieKan-3.0/app/src/main/java/io/legato/kazusa/service/HttpReadAloudService.kt
T
HapeLee 8eb4881767 合并上游更改
感谢:@821938089、@zhongerxll、@youke2580c、@Leroy-X、@Dark-Tide、@lhjgege、@3harmony、@mgz0227
2025-09-01 00:57:17 +08:00

596 lines
22 KiB
Kotlin

package io.legato.kazusa.service
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.net.Uri
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.CacheDataSink
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.offline.DefaultDownloaderFactory
import androidx.media3.exoplayer.offline.DownloadRequest
import androidx.media3.exoplayer.offline.Downloader
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import com.script.ScriptException
import io.legato.kazusa.R
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern
import io.legato.kazusa.data.entities.HttpTTS
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.help.exoplayer.InputStreamDataSource
import io.legato.kazusa.help.http.okHttpClient
import io.legato.kazusa.model.ReadAloud
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
import io.legato.kazusa.utils.FileUtils
import io.legato.kazusa.utils.MD5Utils
import io.legato.kazusa.utils.printOnDebug
import io.legato.kazusa.utils.servicePendingIntent
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.Response
import org.mozilla.javascript.WrappedException
import splitties.init.appCtx
import java.io.File
import java.io.InputStream
import java.net.ConnectException
import java.net.SocketTimeoutException
import kotlin.coroutines.coroutineContext
/**
* 在线朗读
*/
@SuppressLint("UnsafeOptInUsageError")
class HttpReadAloudService : BaseReadAloudService(),
Player.Listener {
private val exoPlayer: ExoPlayer by lazy {
ExoPlayer.Builder(this).build()
}
private val ttsFolderPath: String by lazy {
cacheDir.absolutePath + File.separator + "httpTTS" + File.separator
}
private val cache by lazy {
SimpleCache(
File(cacheDir, "httpTTS_cache"),
LeastRecentlyUsedCacheEvictor(128 * 1024 * 1024),
StandaloneDatabaseProvider(appCtx)
)
}
private val cacheDataSinkFactory by lazy {
CacheDataSink.Factory()
.setCache(cache)
}
private val loadErrorHandlingPolicy by lazy {
CustomLoadErrorHandlingPolicy()
}
private var speechRate: Int = AppConfig.speechRatePlay + 5
private var downloadTask: Coroutine<*>? = null
private var playIndexJob: Job? = null
private var downloadErrorNo: Int = 0
private var playErrorNo = 0
private val downloadTaskActiveLock = Mutex()
override fun onCreate() {
super.onCreate()
exoPlayer.addListener(this)
}
override fun onDestroy() {
super.onDestroy()
downloadTask?.cancel()
exoPlayer.release()
cache.release()
Coroutine.async {
removeCacheFile()
}
}
override fun play() {
pageChanged = false
exoPlayer.stop()
if (!requestFocus()) return
if (contentList.isEmpty()) {
AppLog.putDebug("朗读列表为空")
ReadBook.readAloud()
} else {
super.play()
if (AppConfig.streamReadAloudAudio) {
downloadAndPlayAudiosStream()
} else {
downloadAndPlayAudios()
}
}
}
override fun playStop() {
exoPlayer.stop()
playIndexJob?.cancel()
}
private fun updateNextPos() {
readAloudNumber += contentList[nowSpeak].length + 1 - paragraphStartPos
paragraphStartPos = 0
if (nowSpeak < contentList.lastIndex) {
nowSpeak++
} else {
nextChapter()
}
}
private fun downloadAndPlayAudios() {
exoPlayer.clearMediaItems()
downloadTask?.cancel()
downloadTask = execute {
downloadTaskActiveLock.withLock {
ensureActive()
val httpTts = ReadAloud.httpTTS ?: throw NoStackTraceException("tts is null")
contentList.forEachIndexed { index, content ->
ensureActive()
if (index < nowSpeak) return@forEachIndexed
var text = content
if (paragraphStartPos > 0 && index == nowSpeak) {
text = text.substring(paragraphStartPos)
}
val fileName = md5SpeakFileName(text)
val speakText = text.replace(AppPattern.notReadAloudRegex, "")
if (speakText.isEmpty()) {
AppLog.put("阅读段落内容为空,使用无声音频代替。\n朗读文本:$text")
createSilentSound(fileName)
} else if (!hasSpeakFile(fileName)) {
runCatching {
val inputStream = getSpeakStream(httpTts, speakText)
if (inputStream != null) {
createSpeakFile(fileName, inputStream)
} else {
createSilentSound(fileName)
}
}.onFailure {
when (it) {
is CancellationException -> Unit
else -> pauseReadAloud()
}
return@execute
}
}
val file = getSpeakFileAsMd5(fileName)
val mediaItem = MediaItem.fromUri(Uri.fromFile(file))
launch(Main) {
exoPlayer.addMediaItem(mediaItem)
}
}
preDownloadAudios(httpTts)
}
}.onError {
AppLog.put("朗读下载出错\n${it.localizedMessage}", it, true)
}
}
private suspend fun preDownloadAudios(httpTts: HttpTTS) {
val textChapter = ReadBook.nextTextChapter ?: return
val contentList = textChapter.getNeedReadAloud(0, readAloudByPage, 0, 1)
.splitToSequence("\n")
.filter { it.isNotEmpty() }
.take(10)
.toList()
contentList.forEach { content ->
coroutineContext.ensureActive()
val fileName = md5SpeakFileName(content)
val speakText = content.replace(AppPattern.notReadAloudRegex, "")
if (speakText.isEmpty()) {
createSilentSound(fileName)
} else if (!hasSpeakFile(fileName)) {
runCatching {
val inputStream = getSpeakStream(httpTts, speakText)
if (inputStream != null) {
createSpeakFile(fileName, inputStream)
} else {
createSilentSound(fileName)
}
}
}
}
}
private fun downloadAndPlayAudiosStream() {
exoPlayer.clearMediaItems()
downloadTask?.cancel()
downloadTask = execute {
downloadTaskActiveLock.withLock {
ensureActive()
val httpTts = ReadAloud.httpTTS ?: throw NoStackTraceException("tts is null")
val downloaderChannel = Channel<Downloader>()
launch {
for (downloader in downloaderChannel) {
downloader.download(null)
}
}
contentList.forEachIndexed { index, content ->
ensureActive()
if (index < nowSpeak) return@forEachIndexed
var text = content
if (paragraphStartPos > 0 && index == nowSpeak) {
text = text.substring(paragraphStartPos)
}
val speakText = text.replace(AppPattern.notReadAloudRegex, "")
if (speakText.isEmpty()) {
AppLog.put("阅读段落内容为空,使用无声音频代替。\n朗读文本:$speakText")
}
val fileName = md5SpeakFileName(text)
val dataSourceFactory = createDataSourceFactory(httpTts, speakText)
val downloader = createDownloader(dataSourceFactory, fileName)
downloaderChannel.send(downloader)
val mediaSource = createMediaSource(dataSourceFactory, fileName)
launch(Main) {
exoPlayer.addMediaSource(mediaSource)
}
}
preDownloadAudiosStream(httpTts, downloaderChannel)
}
}.onError {
AppLog.put("朗读下载出错\n${it.localizedMessage}", it, true)
}
}
private suspend fun preDownloadAudiosStream(
httpTts: HttpTTS,
downloaderChannel: Channel<Downloader>
) {
val textChapter = ReadBook.nextTextChapter ?: return
val contentList = textChapter.getNeedReadAloud(0, readAloudByPage, 0, 1)
.splitToSequence("\n")
.filter { it.isNotEmpty() }
.take(10)
.toList()
contentList.forEach { content ->
coroutineContext.ensureActive()
val fileName = md5SpeakFileName(content)
val speakText = content.replace(AppPattern.notReadAloudRegex, "")
val dataSourceFactory = createDataSourceFactory(httpTts, speakText)
val downloader = createDownloader(dataSourceFactory, fileName)
downloaderChannel.send(downloader)
}
}
private fun createDataSourceFactory(
httpTts: HttpTTS,
speakText: String
): CacheDataSource.Factory {
val upstreamFactory = DataSource.Factory {
InputStreamDataSource {
if (speakText.isEmpty()) {
null
} else {
kotlin.runCatching {
runBlocking(lifecycleScope.coroutineContext[Job]!!) {
getSpeakStream(httpTts, speakText)
}
}.onFailure {
when (it) {
is InterruptedException,
is CancellationException -> Unit
else -> pauseReadAloud()
}
}.getOrThrow()
} ?: resources.openRawResource(R.raw.silent_sound)
}
}
val factory = CacheDataSource.Factory()
.setCache(cache)
.setUpstreamDataSourceFactory(upstreamFactory)
.setCacheWriteDataSinkFactory(cacheDataSinkFactory)
return factory
}
private fun createDownloader(factory: CacheDataSource.Factory, fileName: String): Downloader {
val uri = fileName.toUri()
val request = DownloadRequest.Builder(fileName, uri).build()
return DefaultDownloaderFactory(factory, okHttpClient.dispatcher.executorService)
.createDownloader(request)
}
private fun createMediaSource(factory: DataSource.Factory, fileName: String): MediaSource {
return DefaultMediaSourceFactory(this)
.setDataSourceFactory(factory)
.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
.createMediaSource(MediaItem.fromUri(fileName))
}
private suspend fun getSpeakStream(
httpTts: HttpTTS,
speakText: String
): InputStream? {
while (true) {
try {
val analyzeUrl = AnalyzeUrl(
httpTts.url,
speakText = speakText,
speakSpeed = speechRate,
source = httpTts,
readTimeout = 300 * 1000L,
coroutineContext = coroutineContext
)
var response = analyzeUrl.getResponseAwait()
coroutineContext.ensureActive()
val checkJs = httpTts.loginCheckJs
if (checkJs?.isNotBlank() == true) {
response = analyzeUrl.evalJS(checkJs, response) as Response
}
response.headers["Content-Type"]?.let { contentType ->
val contentType = contentType.substringBefore(";")
val ct = httpTts.contentType
if (contentType == "application/json" || contentType.startsWith("text/")) {
throw NoStackTraceException(response.body.string())
} else if (ct?.isNotBlank() == true) {
if (!contentType.matches(ct.toRegex())) {
throw NoStackTraceException(
"TTS服务器返回错误:" + response.body.string()
)
}
}
}
coroutineContext.ensureActive()
response.body.byteStream().let { stream ->
downloadErrorNo = 0
return stream
}
} catch (e: Exception) {
when (e) {
is CancellationException -> throw e
is ScriptException, is WrappedException -> {
AppLog.put("js错误\n${e.localizedMessage}", e, true)
e.printOnDebug()
throw e
}
is SocketTimeoutException, is ConnectException -> {
downloadErrorNo++
if (downloadErrorNo > 5) {
val msg = "tts超时或连接错误超过5次\n${e.localizedMessage}"
AppLog.put(msg, e, true)
throw e
}
}
else -> {
downloadErrorNo++
val msg = "tts下载错误\n${e.localizedMessage}"
AppLog.put(msg, e)
e.printOnDebug()
if (downloadErrorNo > 5) {
val msg1 = "TTS服务器连续5次错误,已暂停阅读。"
AppLog.put(msg1, e, true)
throw e
} else {
AppLog.put("TTS下载音频出错,使用无声音频代替。\n朗读文本:$speakText")
break
}
}
}
}
}
return null
}
private fun md5SpeakFileName(content: String): String {
return MD5Utils.md5Encode16(textChapter?.title ?: "") + "_" +
MD5Utils.md5Encode16("${ReadAloud.httpTTS?.url}-|-$speechRate-|-$content")
}
private fun createSilentSound(fileName: String) {
val file = createSpeakFile(fileName)
file.writeBytes(resources.openRawResource(R.raw.silent_sound).readBytes())
}
private fun hasSpeakFile(name: String): Boolean {
return FileUtils.exist("${ttsFolderPath}$name.mp3")
}
private fun getSpeakFileAsMd5(name: String): File {
return File("${ttsFolderPath}$name.mp3")
}
private fun createSpeakFile(name: String): File {
return FileUtils.createFileIfNotExist("${ttsFolderPath}$name.mp3")
}
private fun createSpeakFile(name: String, inputStream: InputStream) {
FileUtils.createFileIfNotExist("${ttsFolderPath}$name.mp3").outputStream().use { out ->
inputStream.use {
it.copyTo(out)
}
}
}
/**
* 移除缓存文件
*/
private fun removeCacheFile() {
val titleMd5 = MD5Utils.md5Encode16(textChapter?.title ?: "")
FileUtils.listDirsAndFiles(ttsFolderPath)?.forEach {
val isSilentSound = it.length() == 2160L
if ((!it.name.startsWith(titleMd5)
&& System.currentTimeMillis() - it.lastModified() > 600000)
|| isSilentSound
) {
FileUtils.delete(it.absolutePath)
}
}
}
override fun pauseReadAloud(abandonFocus: Boolean) {
super.pauseReadAloud(abandonFocus)
kotlin.runCatching {
playIndexJob?.cancel()
exoPlayer.pause()
}
}
override fun resumeReadAloud() {
super.resumeReadAloud()
kotlin.runCatching {
if (pageChanged) {
play()
} else {
exoPlayer.play()
upPlayPos()
}
}
}
private fun upPlayPos() {
playIndexJob?.cancel()
val textChapter = textChapter ?: return
playIndexJob = lifecycleScope.launch {
upTtsProgress(readAloudNumber + 1)
if (exoPlayer.duration <= 0) {
return@launch
}
val speakTextLength = contentList[nowSpeak].length
if (speakTextLength <= 0) {
return@launch
}
val sleep = exoPlayer.duration / speakTextLength
val start = speakTextLength * exoPlayer.currentPosition / exoPlayer.duration
for (i in start..contentList[nowSpeak].length) {
if (pageIndex + 1 < textChapter.pageSize
&& readAloudNumber + i > textChapter.getReadLength(pageIndex + 1)
) {
pageIndex++
ReadBook.moveToNextPage()
upTtsProgress(readAloudNumber + i.toInt())
}
delay(sleep)
}
}
}
/**
* 更新朗读速度
*/
override fun upSpeechRate(reset: Boolean) {
downloadTask?.cancel()
exoPlayer.stop()
speechRate = AppConfig.speechRatePlay + 5
if (AppConfig.streamReadAloudAudio) {
downloadAndPlayAudiosStream()
} else {
downloadAndPlayAudios()
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
when (playbackState) {
Player.STATE_IDLE -> {
// 空闲
}
Player.STATE_BUFFERING -> {
// 缓冲中
}
Player.STATE_READY -> {
// 准备好
if (pause) return
exoPlayer.play()
upPlayPos()
}
Player.STATE_ENDED -> {
// 结束
playErrorNo = 0
updateNextPos()
exoPlayer.stop()
exoPlayer.clearMediaItems()
}
}
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
when (reason) {
Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED -> {
if (!timeline.isEmpty && exoPlayer.playbackState == Player.STATE_IDLE) {
exoPlayer.prepare()
}
}
else -> {}
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) return
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
playErrorNo = 0
}
updateNextPos()
upPlayPos()
}
override fun onPlayerError(error: PlaybackException) {
super.onPlayerError(error)
AppLog.put("朗读错误\n${contentList[nowSpeak]}", error)
deleteCurrentSpeakFile()
playErrorNo++
if (playErrorNo >= 5) {
toastOnUi("朗读连续5次错误, 最后一次错误代码(${error.localizedMessage})")
AppLog.put("朗读连续5次错误, 最后一次错误代码(${error.localizedMessage})", error)
pauseReadAloud()
} else {
if (exoPlayer.hasNextMediaItem()) {
exoPlayer.seekToNextMediaItem()
exoPlayer.prepare()
} else {
exoPlayer.clearMediaItems()
updateNextPos()
}
}
}
private fun deleteCurrentSpeakFile() {
if (AppConfig.streamReadAloudAudio) {
return
}
val mediaItem = exoPlayer.currentMediaItem ?: return
val filePath = mediaItem.localConfiguration!!.uri.path!!
File(filePath).delete()
}
override fun aloudServicePendingIntent(actionStr: String): PendingIntent? {
return servicePendingIntent<HttpReadAloudService>(actionStr)
}
inner class CustomLoadErrorHandlingPolicy : DefaultLoadErrorHandlingPolicy(0) {
override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long {
return C.TIME_UNSET
}
}
}