P0: live playback progress, per-track audio cache, worker fix, TTS extension fix
- PlayerModel: persist playback position to DB + sync every 30s
- CacheDownloadWorker: download audio tracks individually via /file/{ino}
- ProgressSyncWorker: retry on any failure instead of silent success
- TtsDownloadWorker: save as .m4b instead of .mp3
This commit is contained in:
parent
8405f18f5f
commit
efbd2aff66
4 changed files with 113 additions and 9 deletions
|
|
@ -96,13 +96,14 @@ class CacheDownloadWorker(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasAudio) {
|
if (hasAudio) {
|
||||||
downloadFile(
|
downloadAudioTracks(
|
||||||
database = database,
|
database = database,
|
||||||
bookId = bookId,
|
bookId = bookId,
|
||||||
type = "audio",
|
absUrl = absUrl,
|
||||||
remoteUrl = "$absUrl/api/items/$remoteId/download",
|
remoteId = remoteId,
|
||||||
localFile = File(bookDir, "audio"),
|
bookDir = bookDir,
|
||||||
) { client.downloadBook(remoteId) }
|
client = client,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Result.success()
|
Result.success()
|
||||||
|
|
@ -112,6 +113,65 @@ class CacheDownloadWorker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadAudioTracks(
|
||||||
|
database: BookDatabase,
|
||||||
|
bookId: Int,
|
||||||
|
absUrl: String,
|
||||||
|
remoteId: String,
|
||||||
|
bookDir: File,
|
||||||
|
client: org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiService,
|
||||||
|
) {
|
||||||
|
val audioDir = File(bookDir, "audio").apply { mkdirs() }
|
||||||
|
|
||||||
|
val itemResponse = client.getItem(remoteId)
|
||||||
|
if (!itemResponse.isSuccessful) {
|
||||||
|
throw IOException("Failed to fetch item for audio tracks: ${itemResponse.code()}")
|
||||||
|
}
|
||||||
|
val item = itemResponse.body() ?: throw IOException("Empty item response")
|
||||||
|
val audioFiles = item.media?.audioFiles.orEmpty()
|
||||||
|
|
||||||
|
if (audioFiles.isEmpty()) {
|
||||||
|
Log.w(TAG, "No audio files found for book $bookId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val totalTracks = audioFiles.size
|
||||||
|
audioFiles.forEachIndexed { index, file ->
|
||||||
|
val fileId = file.ino
|
||||||
|
val trackFile = File(audioDir, "${index}_${fileId}")
|
||||||
|
val record = getOrCreateRecord(
|
||||||
|
database,
|
||||||
|
bookId,
|
||||||
|
"audio_${index}",
|
||||||
|
"$absUrl/api/items/$remoteId/file/$fileId",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (record.status == "completed" && record.localPath != null && File(record.localPath).exists()) {
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
|
||||||
|
database.cachedFileDao.update(record.copy(status = "downloading", progress = index.toFloat() / totalTracks))
|
||||||
|
|
||||||
|
val response = client.downloadAudioFile(remoteId, fileId)
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw IOException("Audio track $index download failed: ${response.code()}")
|
||||||
|
}
|
||||||
|
val body = response.body() ?: throw IOException("Empty audio response body")
|
||||||
|
|
||||||
|
body.byteStream().use { input ->
|
||||||
|
trackFile.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
|
||||||
|
database.cachedFileDao.update(
|
||||||
|
record.copy(
|
||||||
|
status = "completed",
|
||||||
|
progress = (index + 1).toFloat() / totalTracks,
|
||||||
|
localPath = trackFile.absolutePath,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun downloadFile(
|
private suspend fun downloadFile(
|
||||||
database: BookDatabase,
|
database: BookDatabase,
|
||||||
bookId: Int,
|
bookId: Int,
|
||||||
|
|
|
||||||
|
|
@ -86,9 +86,10 @@ class ProgressSyncWorker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return if (failures == 0 || failures < books.size) {
|
return if (failures == 0) {
|
||||||
Result.success()
|
Result.success()
|
||||||
} else {
|
} else {
|
||||||
|
Log.w(TAG, "$failures/${books.size} books failed to sync, retrying")
|
||||||
Result.retry()
|
Result.retry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ class TtsDownloadWorker
|
||||||
val safeBookId = bookId.replace(Regex("[^a-zA-Z0-9\\-_]"), "_").take(64)
|
val safeBookId = bookId.replace(Regex("[^a-zA-Z0-9\\-_]"), "_").take(64)
|
||||||
val safeJobId = jobId.replace(Regex("[^a-zA-Z0-9\\-_]"), "_").take(64)
|
val safeJobId = jobId.replace(Regex("[^a-zA-Z0-9\\-_]"), "_").take(64)
|
||||||
val dir = File(applicationContext.cacheDir, "tts")
|
val dir = File(applicationContext.cacheDir, "tts")
|
||||||
return File(dir, "${safeBookId}_${safeJobId}.mp3")
|
return File(dir, "${safeBookId}_${safeJobId}.m4b")
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,14 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
|
||||||
|
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
|
||||||
import org.dueattendant149.bookshelf.data.playback.AudioPlaybackService
|
import org.dueattendant149.bookshelf.data.playback.AudioPlaybackService
|
||||||
import org.dueattendant149.bookshelf.data.playback.di.PlaybackAuthProvider
|
import org.dueattendant149.bookshelf.data.playback.di.PlaybackAuthProvider
|
||||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
|
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.util.fixUriScheme
|
import org.dueattendant149.bookshelf.domain.util.fixUriScheme
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -42,6 +45,9 @@ class PlayerModel
|
||||||
private val serverSettings: ServerSettings,
|
private val serverSettings: ServerSettings,
|
||||||
private val audiobookshelfRepository: AudiobookshelfRepository,
|
private val audiobookshelfRepository: AudiobookshelfRepository,
|
||||||
private val authProvider: PlaybackAuthProvider,
|
private val authProvider: PlaybackAuthProvider,
|
||||||
|
private val database: BookDatabase,
|
||||||
|
private val bookMapper: BookMapper,
|
||||||
|
private val syncPlaybackProgressUseCase: SyncPlaybackProgressUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(PlayerState())
|
private val _state = MutableStateFlow(PlayerState())
|
||||||
val state = _state.asStateFlow()
|
val state = _state.asStateFlow()
|
||||||
|
|
@ -148,23 +154,60 @@ class PlayerModel
|
||||||
exoPlayer.play()
|
exoPlayer.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var lastSyncTimeMs: Long = 0L
|
||||||
|
private var lastSavedPositionMs: Long = 0L
|
||||||
|
|
||||||
private fun startProgressUpdates() {
|
private fun startProgressUpdates() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
if (exoPlayer.isPlaying || exoPlayer.isLoading) {
|
if (exoPlayer.isPlaying || exoPlayer.isLoading) {
|
||||||
|
val pos = exoPlayer.currentPosition.coerceAtLeast(0L)
|
||||||
|
val dur = exoPlayer.duration.coerceAtLeast(0L)
|
||||||
|
val trackIndex = exoPlayer.currentMediaItemIndex
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
currentPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L),
|
currentTrackIndex = trackIndex,
|
||||||
|
currentPositionMs = pos,
|
||||||
bufferedPositionMs = exoPlayer.bufferedPosition.coerceAtLeast(0L),
|
bufferedPositionMs = exoPlayer.bufferedPosition.coerceAtLeast(0L),
|
||||||
durationMs = exoPlayer.duration.coerceAtLeast(0L),
|
durationMs = dur,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastSyncTimeMs >= 30_000L && pos != lastSavedPositionMs) {
|
||||||
|
lastSyncTimeMs = now
|
||||||
|
lastSavedPositionMs = pos
|
||||||
|
persistPlaybackProgress(pos, dur)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
delay(500)
|
delay(500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun persistPlaybackProgress(positionMs: Long, durationMs: Long) {
|
||||||
|
val book = _state.value.book
|
||||||
|
if (book.remoteId.isBlank()) return
|
||||||
|
val currentFile = _state.value.tracks.getOrNull(exoPlayer.currentMediaItemIndex)?.fileId
|
||||||
|
?: book.audioCurrentFile
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val updated = book.copy(
|
||||||
|
audioCurrentFile = currentFile,
|
||||||
|
audioCurrentPosition = positionMs,
|
||||||
|
audioDuration = if (durationMs > 0) durationMs else book.audioDuration,
|
||||||
|
)
|
||||||
|
database.bookDao.updateBook(bookMapper.toBookEntity(updated))
|
||||||
|
|
||||||
|
syncPlaybackProgressUseCase(
|
||||||
|
book = updated,
|
||||||
|
currentFile = currentFile,
|
||||||
|
position = positionMs,
|
||||||
|
duration = updated.audioDuration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun startService(book: Book) {
|
private fun startService(book: Book) {
|
||||||
val intent = Intent(context, AudioPlaybackService::class.java).apply {
|
val intent = Intent(context, AudioPlaybackService::class.java).apply {
|
||||||
putExtra(AudioPlaybackService.EXTRA_BOOK, book)
|
putExtra(AudioPlaybackService.EXTRA_BOOK, book)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue