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:
parent
907d7c8d24
commit
a8ad2bf900
3 changed files with 246 additions and 0 deletions
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue