diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/CacheDownloadWorker.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/CacheDownloadWorker.kt index 03fd76e7..67b26258 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/CacheDownloadWorker.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/CacheDownloadWorker.kt @@ -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, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/ProgressSyncWorker.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/ProgressSyncWorker.kt index 4faf9903..362729e1 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/ProgressSyncWorker.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/ProgressSyncWorker.kt @@ -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() } } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/TtsDownloadWorker.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/TtsDownloadWorker.kt index f8de212a..6c88e57b 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/TtsDownloadWorker.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/TtsDownloadWorker.kt @@ -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 { diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerModel.kt index 8a280a79..990a29fa 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerModel.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerModel.kt @@ -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)