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) {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue