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:
Atte149 2026-06-19 16:39:31 +03:00
parent 8405f18f5f
commit efbd2aff66
4 changed files with 113 additions and 9 deletions

View file

@ -96,13 +96,14 @@ class CacheDownloadWorker(
}
if (hasAudio) {
downloadFile(
downloadAudioTracks(
database = database,
bookId = bookId,
type = "audio",
remoteUrl = "$absUrl/api/items/$remoteId/download",
localFile = File(bookDir, "audio"),
) { client.downloadBook(remoteId) }
absUrl = absUrl,
remoteId = remoteId,
bookDir = bookDir,
client = client,
)
}
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(
database: BookDatabase,
bookId: Int,

View file

@ -86,9 +86,10 @@ class ProgressSyncWorker(
}
}
return if (failures == 0 || failures < books.size) {
return if (failures == 0) {
Result.success()
} else {
Log.w(TAG, "$failures/${books.size} books failed to sync, retrying")
Result.retry()
}
}

View file

@ -92,7 +92,7 @@ class TtsDownloadWorker
val safeBookId = bookId.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")
return File(dir, "${safeBookId}_${safeJobId}.mp3")
return File(dir, "${safeBookId}_${safeJobId}.m4b")
}
companion object {

View file

@ -24,11 +24,14 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
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.di.PlaybackAuthProvider
import org.dueattendant149.bookshelf.data.settings.ServerSettings
import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase
import org.dueattendant149.bookshelf.domain.util.fixUriScheme
import javax.inject.Inject
@ -42,6 +45,9 @@ class PlayerModel
private val serverSettings: ServerSettings,
private val audiobookshelfRepository: AudiobookshelfRepository,
private val authProvider: PlaybackAuthProvider,
private val database: BookDatabase,
private val bookMapper: BookMapper,
private val syncPlaybackProgressUseCase: SyncPlaybackProgressUseCase,
) : ViewModel() {
private val _state = MutableStateFlow(PlayerState())
val state = _state.asStateFlow()
@ -148,23 +154,60 @@ class PlayerModel
exoPlayer.play()
}
private var lastSyncTimeMs: Long = 0L
private var lastSavedPositionMs: Long = 0L
private fun startProgressUpdates() {
viewModelScope.launch {
while (isActive) {
if (exoPlayer.isPlaying || exoPlayer.isLoading) {
val pos = exoPlayer.currentPosition.coerceAtLeast(0L)
val dur = exoPlayer.duration.coerceAtLeast(0L)
val trackIndex = exoPlayer.currentMediaItemIndex
_state.update {
it.copy(
currentPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L),
currentTrackIndex = trackIndex,
currentPositionMs = pos,
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)
}
}
}
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) {
val intent = Intent(context, AudioPlaybackService::class.java).apply {
putExtra(AudioPlaybackService.EXTRA_BOOK, book)