feat(backend): add thin BookshelfApiRepository and AudiobookshelfRepository

- BookshelfApiRepository exposes all bookshelf-api endpoints as Result<T>.
- AudiobookshelfRepository exposes direct ABS audio/cover/progress endpoints.
- Wire both repositories into BackendModule for Hilt injection.
- ':app:assembleOssDebug' still passes.
This commit is contained in:
Atte149 2026-06-24 11:38:44 +03:00
parent 907d7c8d24
commit a8ad2bf900
3 changed files with 246 additions and 0 deletions

View file

@ -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<AbsItemResponse> {
val client = client() ?: return Result.failure(absNotConfigured())
return safe { client.getItem(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun getCover(itemId: String): Result<ResponseBody> {
val client = client() ?: return Result.failure(absNotConfigured())
return safe { client.getCover(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun downloadBook(itemId: String): Result<ResponseBody> {
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<ResponseBody> {
val client = client() ?: return Result.failure(absNotConfigured())
return safe { client.downloadAudioFile(itemId, fileId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun getProgress(itemId: String): Result<String> {
val client = client() ?: return Result.failure(absNotConfigured())
return safe { client.getProgress(itemId).unwrapBody() ?: "" }
}
suspend fun updateProgress(
itemId: String,
request: PlaybackProgressUpdateRequest,
): Result<String> {
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 <T> safe(block: () -> T): Result<T> = runCatching(block)
private fun <T> Response<T>.unwrapBody(): T? {
if (!isSuccessful) throw IllegalStateException("HTTP ${code()}: ${message()}")
return body()
}
}

View file

@ -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<List<LibraryResponse>> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getLibraries().unwrapBody().orEmpty() }
}
suspend fun getLibraryItems(libraryId: String): Result<LibraryItemsResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getLibraryItems(libraryId).unwrapBody() ?: LibraryItemsResponse() }
}
suspend fun searchLibrary(
libraryId: String,
query: String,
): Result<List<org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse>> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.searchLibrary(libraryId, query).unwrapBody().orEmpty() }
}
suspend fun search(request: SearchRequest): Result<SearchResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.search(request).unwrapBody() ?: SearchResponse() }
}
suspend fun getBook(itemId: String): Result<okhttp3.ResponseBody> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getBook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun downloadEbook(itemId: String): Result<okhttp3.ResponseBody> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.downloadEbook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun getAudioTracks(itemId: String): Result<List<org.dueattendant149.bookreader.data.remote.bookshelfapi.model.AudioTrackResponse>> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getAudioTracks(itemId).unwrapBody().orEmpty() }
}
suspend fun downloadAudioFile(
itemId: String,
fileId: String,
): Result<okhttp3.ResponseBody> {
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<Unit> {
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<Unit> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.updatePlaybackProgress(itemId, request).let {} }
}
suspend fun getTtsEngines(): Result<TtsEnginesResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getTtsEngines().unwrapBody() ?: TtsEnginesResponse() }
}
suspend fun getTtsVoices(engine: String? = null): Result<TtsVoicesResponse> {
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<TtsJobResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.createTtsJob(request).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun getTtsJobStatus(jobId: String): Result<TtsJobResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getTtsJobStatus(jobId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun listTtsJobs(): Result<TtsJobsResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.listTtsJobs().unwrapBody() ?: TtsJobsResponse() }
}
suspend fun downloadTtsAudio(jobId: String): Result<okhttp3.ResponseBody> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.downloadTtsAudio(jobId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun startDownload(request: DownloadRequest): Result<DownloadResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.startDownload(request).unwrapBody() ?: DownloadResponse() }
}
suspend fun listDownloads(): Result<DownloadsListResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.listDownloads().unwrapBody() ?: DownloadsListResponse() }
}
suspend fun uploadBook(
file: MultipartBody.Part,
bookType: RequestBody,
): Result<UploadBookResponse> {
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 <T> safe(block: () -> T): Result<T> = runCatching(block)
private fun <T> Response<T>.unwrapBody(): T? {
if (!isSuccessful) throw IllegalStateException("HTTP ${code()}: ${message()}")
return body()
}
}

View file

@ -9,7 +9,9 @@ import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.dueattendant149.bookreader.data.remote.audiobookshelf.AudiobookshelfApiClientFactory 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.BookshelfApiClientFactory
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
import org.dueattendant149.bookreader.data.settings.ServerSettings import org.dueattendant149.bookreader.data.settings.ServerSettings
import javax.inject.Singleton import javax.inject.Singleton
@ -28,10 +30,24 @@ object BackendModule {
okHttpClient: OkHttpClient, okHttpClient: OkHttpClient,
): BookshelfApiClientFactory = BookshelfApiClientFactory(json, okHttpClient) ): BookshelfApiClientFactory = BookshelfApiClientFactory(json, okHttpClient)
@Provides
@Singleton
fun provideBookshelfApiRepository(
clientFactory: BookshelfApiClientFactory,
serverSettings: ServerSettings,
): BookshelfApiRepository = BookshelfApiRepository(clientFactory, serverSettings)
@Provides @Provides
@Singleton @Singleton
fun provideAudiobookshelfApiClientFactory( fun provideAudiobookshelfApiClientFactory(
json: Json, json: Json,
okHttpClient: OkHttpClient, okHttpClient: OkHttpClient,
): AudiobookshelfApiClientFactory = AudiobookshelfApiClientFactory(json, okHttpClient) ): AudiobookshelfApiClientFactory = AudiobookshelfApiClientFactory(json, okHttpClient)
@Provides
@Singleton
fun provideAudiobookshelfRepository(
clientFactory: AudiobookshelfApiClientFactory,
serverSettings: ServerSettings,
): AudiobookshelfRepository = AudiobookshelfRepository(clientFactory, serverSettings)
} }