From a8ad2bf9007a9ead52eb5d81359b0eb1210c1325 Mon Sep 17 00:00:00 2001 From: Atte149 Date: Wed, 24 Jun 2026 11:38:44 +0300 Subject: [PATCH] feat(backend): add thin BookshelfApiRepository and AudiobookshelfRepository - BookshelfApiRepository exposes all bookshelf-api endpoints as Result. - AudiobookshelfRepository exposes direct ABS audio/cover/progress endpoints. - Wire both repositories into BackendModule for Hilt injection. - ':app:assembleOssDebug' still passes. --- .../AudiobookshelfRepository.kt | 72 ++++++++ .../bookshelfapi/BookshelfApiRepository.kt | 158 ++++++++++++++++++ .../bookreader/di/BackendModule.kt | 16 ++ 3 files changed, 246 insertions(+) create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfRepository.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiRepository.kt diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfRepository.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfRepository.kt new file mode 100644 index 0000000..266ab4d --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfRepository.kt @@ -0,0 +1,72 @@ +package org.dueattendant149.bookreader.data.remote.audiobookshelf + +import okhttp3.ResponseBody +import org.dueattendant149.bookreader.data.remote.audiobookshelf.model.AbsItemResponse +import org.dueattendant149.bookreader.data.settings.ServerSettings +import retrofit2.Response +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Thin repository over [AudiobookshelfApiService]. Direct ABS calls for audio, + * cover and progress. bookshelf-api does not proxy audio streams. + */ +@Singleton +class AudiobookshelfRepository + @Inject + constructor( + private val clientFactory: AudiobookshelfApiClientFactory, + private val serverSettings: ServerSettings, + ) { + private suspend fun client() = + serverSettings.getAbsUrl()?.let { url -> + serverSettings.getAbsToken()?.let { token -> + clientFactory.provideClient(url, token) + } + } + + suspend fun getItem(itemId: String): Result { + val client = client() ?: return Result.failure(absNotConfigured()) + return safe { client.getItem(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") } + } + + suspend fun getCover(itemId: String): Result { + val client = client() ?: return Result.failure(absNotConfigured()) + return safe { client.getCover(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") } + } + + suspend fun downloadBook(itemId: String): Result { + val client = client() ?: return Result.failure(absNotConfigured()) + return safe { client.downloadBook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") } + } + + suspend fun downloadAudioFile( + itemId: String, + fileId: String, + ): Result { + val client = client() ?: return Result.failure(absNotConfigured()) + return safe { client.downloadAudioFile(itemId, fileId).unwrapBody() ?: throw IllegalStateException("Empty body") } + } + + suspend fun getProgress(itemId: String): Result { + val client = client() ?: return Result.failure(absNotConfigured()) + return safe { client.getProgress(itemId).unwrapBody() ?: "" } + } + + suspend fun updateProgress( + itemId: String, + request: PlaybackProgressUpdateRequest, + ): Result { + val client = client() ?: return Result.failure(absNotConfigured()) + return safe { client.updateProgress(itemId, request).unwrapBody() ?: "" } + } + + private fun absNotConfigured() = IllegalStateException("Audiobookshelf URL/token not configured") + + private inline fun safe(block: () -> T): Result = runCatching(block) + + private fun Response.unwrapBody(): T? { + if (!isSuccessful) throw IllegalStateException("HTTP ${code()}: ${message()}") + return body() + } + } diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiRepository.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiRepository.kt new file mode 100644 index 0000000..050c12f --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiRepository.kt @@ -0,0 +1,158 @@ +package org.dueattendant149.bookreader.data.remote.bookshelfapi + +import okhttp3.MultipartBody +import okhttp3.RequestBody +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadRequest +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadResponse +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadsListResponse +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryItemsResponse +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchRequest +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchResponse +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobsResponse +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UploadBookResponse +import org.dueattendant149.bookreader.data.settings.ServerSettings +import retrofit2.Response +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Thin repository over [BookshelfApiService]. Returns raw Retrofit responses + * so callers can decide how to map them to local entities. + */ +@Singleton +class BookshelfApiRepository + @Inject + constructor( + private val clientFactory: BookshelfApiClientFactory, + private val serverSettings: ServerSettings, + ) { + private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) } + + suspend fun health(): Boolean { + val client = client() ?: return false + return runCatching { client.health().isSuccessful }.getOrDefault(false) + } + + suspend fun getLibraries(): Result> { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.getLibraries().unwrapBody().orEmpty() } + } + + suspend fun getLibraryItems(libraryId: String): Result { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.getLibraryItems(libraryId).unwrapBody() ?: LibraryItemsResponse() } + } + + suspend fun searchLibrary( + libraryId: String, + query: String, + ): Result> { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.searchLibrary(libraryId, query).unwrapBody().orEmpty() } + } + + suspend fun search(request: SearchRequest): Result { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.search(request).unwrapBody() ?: SearchResponse() } + } + + suspend fun getBook(itemId: String): Result { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.getBook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") } + } + + suspend fun downloadEbook(itemId: String): Result { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.downloadEbook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") } + } + + suspend fun getAudioTracks(itemId: String): Result> { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.getAudioTracks(itemId).unwrapBody().orEmpty() } + } + + suspend fun downloadAudioFile( + itemId: String, + fileId: String, + ): Result { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.downloadAudioFile(itemId, fileId).unwrapBody() ?: throw IllegalStateException("Empty body") } + } + + suspend fun updateReadingProgress( + itemId: String, + request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.ProgressUpdateRequest, + ): Result { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.updateReadingProgress(itemId, request).let {} } + } + + suspend fun updatePlaybackProgress( + itemId: String, + request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest, + ): Result { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.updatePlaybackProgress(itemId, request).let {} } + } + + suspend fun getTtsEngines(): Result { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.getTtsEngines().unwrapBody() ?: TtsEnginesResponse() } + } + + suspend fun getTtsVoices(engine: String? = null): Result { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.getTtsVoices(engine).unwrapBody() ?: TtsVoicesResponse() } + } + + suspend fun createTtsJob(request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest): Result { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.createTtsJob(request).unwrapBody() ?: throw IllegalStateException("Empty body") } + } + + suspend fun getTtsJobStatus(jobId: String): Result { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.getTtsJobStatus(jobId).unwrapBody() ?: throw IllegalStateException("Empty body") } + } + + suspend fun listTtsJobs(): Result { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.listTtsJobs().unwrapBody() ?: TtsJobsResponse() } + } + + suspend fun downloadTtsAudio(jobId: String): Result { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.downloadTtsAudio(jobId).unwrapBody() ?: throw IllegalStateException("Empty body") } + } + + suspend fun startDownload(request: DownloadRequest): Result { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.startDownload(request).unwrapBody() ?: DownloadResponse() } + } + + suspend fun listDownloads(): Result { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.listDownloads().unwrapBody() ?: DownloadsListResponse() } + } + + suspend fun uploadBook( + file: MultipartBody.Part, + bookType: RequestBody, + ): Result { + val client = client() ?: return Result.failure(serverNotConfigured()) + return safe { client.uploadBook(file, bookType).unwrapBody() ?: UploadBookResponse(success = false) } + } + + private fun serverNotConfigured() = IllegalStateException("Bookshelf API URL not configured") + + private inline fun safe(block: () -> T): Result = runCatching(block) + + private fun Response.unwrapBody(): T? { + if (!isSuccessful) throw IllegalStateException("HTTP ${code()}: ${message()}") + return body() + } + } diff --git a/app/src/main/java/org/dueattendant149/bookreader/di/BackendModule.kt b/app/src/main/java/org/dueattendant149/bookreader/di/BackendModule.kt index d85d184..9f8b78b 100644 --- a/app/src/main/java/org/dueattendant149/bookreader/di/BackendModule.kt +++ b/app/src/main/java/org/dueattendant149/bookreader/di/BackendModule.kt @@ -9,7 +9,9 @@ import dagger.hilt.components.SingletonComponent import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import org.dueattendant149.bookreader.data.remote.audiobookshelf.AudiobookshelfApiClientFactory +import org.dueattendant149.bookreader.data.remote.audiobookshelf.AudiobookshelfRepository import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiClientFactory +import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository import org.dueattendant149.bookreader.data.settings.ServerSettings import javax.inject.Singleton @@ -28,10 +30,24 @@ object BackendModule { okHttpClient: OkHttpClient, ): BookshelfApiClientFactory = BookshelfApiClientFactory(json, okHttpClient) + @Provides + @Singleton + fun provideBookshelfApiRepository( + clientFactory: BookshelfApiClientFactory, + serverSettings: ServerSettings, + ): BookshelfApiRepository = BookshelfApiRepository(clientFactory, serverSettings) + @Provides @Singleton fun provideAudiobookshelfApiClientFactory( json: Json, okHttpClient: OkHttpClient, ): AudiobookshelfApiClientFactory = AudiobookshelfApiClientFactory(json, okHttpClient) + + @Provides + @Singleton + fun provideAudiobookshelfRepository( + clientFactory: AudiobookshelfApiClientFactory, + serverSettings: ServerSettings, + ): AudiobookshelfRepository = AudiobookshelfRepository(clientFactory, serverSettings) }