Phase 2: network scaffold (bookshelf-api + ABS clients, DI, models)

This commit is contained in:
Atte149 2026-06-15 21:07:32 +03:00
parent ad821faecd
commit 2b63ff2bec
13 changed files with 592 additions and 0 deletions

View file

@ -0,0 +1,33 @@
package org.dueattendant149.bookshelf.data.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideJson(): Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient =
OkHttpClient
.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
}

View file

@ -0,0 +1,76 @@
package org.dueattendant149.bookshelf.data.remote.audiobookshelf
import android.util.Log
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.dueattendant149.bookshelf.domain.util.fixUriScheme
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import javax.inject.Inject
import javax.inject.Singleton
/**
* Builds (and caches) a Retrofit-backed [AudiobookshelfApiService] for the configured
* Audiobookshelf instance. All requests are authenticated with the provided bearer token.
*/
@Singleton
class AudiobookshelfApiClientFactory
@Inject
constructor(
private val json: Json,
private val okHttpClient: OkHttpClient,
) {
private var cachedUrl: String? = null
private var cachedToken: String? = null
private var cachedClient: AudiobookshelfApiService? = null
@Synchronized
fun provideClient(
url: String,
token: String,
): AudiobookshelfApiService? {
val fixedUrl = url.fixUriScheme()
if (fixedUrl == cachedUrl && token == cachedToken && cachedClient != null) {
return cachedClient
}
val authClient =
okHttpClient
.newBuilder()
.addInterceptor { chain ->
val request =
chain
.request()
.newBuilder()
.header("Authorization", "Bearer $token")
.build()
chain.proceed(request)
}
.addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC })
.build()
return runCatching {
Retrofit
.Builder()
.client(authClient)
.baseUrl(fixedUrl)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(AudiobookshelfApiService::class.java)
}.onFailure {
Log.e("AudiobookshelfApiFactory", "Failed to create client for $fixedUrl", it)
}.getOrNull().also {
cachedUrl = fixedUrl
cachedToken = token
cachedClient = it
}
}
fun clearCache() {
cachedUrl = null
cachedToken = null
cachedClient = null
}
}

View file

@ -0,0 +1,37 @@
package org.dueattendant149.bookshelf.data.remote.audiobookshelf
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Streaming
/**
* Direct Audiobookshelf API calls for audio files and covers.
* bookshelf-api does not proxy audio streams, so the app talks to ABS directly.
*/
interface AudiobookshelfApiService {
@Streaming
@GET("api/items/{itemId}/file/{fileId}")
suspend fun downloadAudioFile(
@Path("itemId") itemId: String,
@Path("fileId") fileId: String,
): Response<ResponseBody>
@Streaming
@GET("api/items/{itemId}/download")
suspend fun downloadBook(
@Path("itemId") itemId: String,
): Response<ResponseBody>
@GET("api/items/{itemId}/cover")
suspend fun getCover(
@Path("itemId") itemId: String,
): Response<ResponseBody>
@GET("api/me/progress/{itemId}")
suspend fun getProgress(
@Path("itemId") itemId: String,
): Response<String>
}

View file

@ -0,0 +1,62 @@
package org.dueattendant149.bookshelf.data.remote.bookshelfapi
import android.util.Log
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.dueattendant149.bookshelf.domain.util.fixUriScheme
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import javax.inject.Inject
import javax.inject.Singleton
/**
* Builds (and caches) a Retrofit-backed [BookshelfApiService] for the configured
* bookshelf-api instance.
*/
@Singleton
class BookshelfApiClientFactory
@Inject
constructor(
private val json: Json,
private val okHttpClient: OkHttpClient,
) {
private var cachedUrl: String? = null
private var cachedClient: BookshelfApiService? = null
@Synchronized
fun provideClient(url: String): BookshelfApiService? {
val fixedUrl = url.fixUriScheme()
if (fixedUrl == cachedUrl && cachedClient != null) {
return cachedClient
}
return runCatching {
Retrofit
.Builder()
.client(
okHttpClient
.newBuilder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
})
.build(),
)
.baseUrl(fixedUrl)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(BookshelfApiService::class.java)
}.onFailure {
Log.e("BookshelfApiClientFactory", "Failed to create BookshelfApiService for $fixedUrl", it)
}.getOrNull().also {
cachedUrl = fixedUrl
cachedClient = it
}
}
fun clearCache() {
cachedUrl = null
cachedClient = null
}
}

View file

@ -0,0 +1,100 @@
package org.dueattendant149.bookshelf.data.remote.bookshelfapi
import okhttp3.ResponseBody
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsEnginesResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobsResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsVoicesResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
/**
* Retrofit description of the Bookshelf API (bookshelf-api:8073).
* All endpoints are relative to the configured base URL.
*/
interface BookshelfApiService {
// Libraries / books
@GET("api/v1/books/libraries")
suspend fun getLibraries(): Response<List<LibraryResponse>>
@GET("api/v1/books/{itemId}")
suspend fun getBook(
@Path("itemId") itemId: String,
): Response<String>
@GET("api/v1/books/library/{libraryId}/search")
suspend fun searchLibrary(
@Path("libraryId") libraryId: String,
@Query("q") query: String,
): Response<List<BookItemResponse>>
@POST("api/v1/search")
suspend fun search(
@Body request: SearchRequest,
): Response<SearchResponse>
// TTS
@GET("api/v1/tts/engines")
suspend fun getTtsEngines(): Response<TtsEnginesResponse>
@GET("api/v1/tts/voices")
suspend fun getTtsVoices(
@Query("engine") engine: String? = null,
): Response<TtsVoicesResponse>
@POST("api/v1/tts")
suspend fun createTtsJob(
@Body request: TtsCreateRequest,
): Response<TtsJobResponse>
@GET("api/v1/tts/{jobId}")
suspend fun getTtsJobStatus(
@Path("jobId") jobId: String,
): Response<TtsJobResponse>
@GET("api/v1/tts/jobs/list")
suspend fun listTtsJobs(): Response<TtsJobsResponse>
@Streaming
@GET("api/v1/tts/{jobId}/download")
suspend fun downloadTtsAudio(
@Path("jobId") jobId: String,
): Response<ResponseBody>
// Downloads / sources
@POST("api/v1/download")
suspend fun startDownload(
@Body request: DownloadRequest,
): Response<DownloadResponse>
@GET("api/v1/download/list")
suspend fun listDownloads(): Response<DownloadsListResponse>
@POST("api/v1/podcasts")
suspend fun downloadPodcast(
@Body request: PodcastRequest,
): Response<PodcastResponse>
@POST("api/v1/sources/yandex")
suspend fun downloadYandex(
@Body request: YandexRequest,
): Response<YandexResponse>
}

View file

@ -0,0 +1,19 @@
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class BookItemResponse(
val id: String,
val title: String = "",
val author: String = "",
@SerialName("media_type")
val mediaType: String = "",
@SerialName("library_id")
val libraryId: String = "",
@SerialName("cover_url")
val coverUrl: String = "",
val duration: Double = 0.0,
val size: Long = 0L,
)

View file

@ -0,0 +1,75 @@
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class DownloadRequest(
val source: String = "",
val title: String = "",
val author: String = "",
@SerialName("download_url")
val downloadUrl: String? = null,
@SerialName("magnet_url")
val magnetUrl: String? = null,
@SerialName("info_hash")
val infoHash: String? = null,
val md5: String? = null,
val url: String? = null,
@SerialName("media_type")
val mediaType: String = "ebook",
@SerialName("download_protocol")
val downloadProtocol: String = "",
)
@Serializable
data class DownloadResponse(
val success: Boolean = false,
val error: String? = null,
@SerialName("download_id")
val downloadId: String? = null,
)
@Serializable
data class DownloadStatusResponse(
val title: String = "",
val status: String = "",
val progress: Double = 0.0,
val speed: String = "",
val error: String? = null,
)
@Serializable
data class DownloadsListResponse(
val downloads: List<DownloadStatusResponse> = emptyList(),
)
@Serializable
data class PodcastRequest(
val url: String,
val title: String? = null,
val format: String = "m4a",
)
@Serializable
data class PodcastResponse(
val url: String = "",
val title: String = "",
val tracks: Int = 0,
val files: List<String> = emptyList(),
@SerialName("abs_scan_triggered")
val absScanTriggered: Boolean = false,
)
@Serializable
data class YandexRequest(
val bookid: String,
)
@Serializable
data class YandexResponse(
val bookid: String,
val files: List<String> = emptyList(),
@SerialName("abs_scan_triggered")
val absScanTriggered: Boolean = false,
)

View file

@ -0,0 +1,14 @@
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LibraryResponse(
val id: String,
val name: String = "",
@SerialName("media_type")
val mediaType: String = "",
@SerialName("item_count")
val itemCount: Int = 0,
)

View file

@ -0,0 +1,54 @@
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SearchResponse(
val results: List<SearchResultItemResponse> = emptyList(),
@SerialName("search_time_ms")
val searchTimeMs: Int = 0,
val total: Int = 0,
)
@Serializable
data class SearchResultItemResponse(
val source: String = "",
val title: String = "",
val author: String = "",
val format: String = "",
@SerialName("media_type")
val mediaType: String = "",
@SerialName("size_human")
val sizeHuman: String = "",
val seeders: Int? = null,
val score: Double = 0.0,
val guid: String = "",
val md5: String = "",
@SerialName("magnet_url")
val magnetUrl: String = "",
@SerialName("download_url")
val downloadUrl: String = "",
val url: String = "",
@SerialName("cover_url")
val coverUrl: String = "",
@SerialName("info_hash")
val infoHash: String = "",
@SerialName("download_protocol")
val downloadProtocol: String = "",
)
@Serializable
data class SearchRequest(
val query: String,
val author: String? = null,
@SerialName("media_type")
val mediaType: String? = null,
val format: String? = null,
val language: String? = null,
@SerialName("year_from")
val yearFrom: Int? = null,
@SerialName("year_to")
val yearTo: Int? = null,
val limit: Int = 50,
)

View file

@ -0,0 +1,91 @@
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class TtsCreateRequest(
@SerialName("book_id")
val bookId: String,
val engine: String = "silero",
@SerialName("voice_id")
val voiceId: String = "",
val speed: Double = 1.0,
)
@Serializable
data class TtsJobResponse(
@SerialName("job_id")
val jobId: String,
@SerialName("book_id")
val bookId: String,
val title: String = "",
val author: String = "",
val engine: String = "",
@SerialName("voice_id")
val voiceId: String = "",
val speed: Double = 1.0,
val status: String = "",
val progress: Double = 0.0,
@SerialName("current_chapter")
val currentChapter: String = "",
@SerialName("total_chapters")
val totalChapters: Int = 0,
@SerialName("completed_chapters")
val completedChapters: Int = 0,
@SerialName("output_path")
val outputPath: String = "",
val error: String = "",
@SerialName("created_at")
val createdAt: Double = 0.0,
@SerialName("started_at")
val startedAt: Double = 0.0,
@SerialName("completed_at")
val completedAt: Double = 0.0,
)
@Serializable
data class TtsJobsResponse(
val jobs: List<TtsJobResponse> = emptyList(),
)
@Serializable
data class TtsEnginesResponse(
val engines: List<TtsEngineResponse> = emptyList(),
)
@Serializable
data class TtsVoicesResponse(
val voices: List<TtsVoiceResponse> = emptyList(),
)
@Serializable
data class TtsEngineResponse(
val id: String,
val name: String,
val capabilities: TtsEngineCapabilitiesResponse,
)
@Serializable
data class TtsEngineCapabilitiesResponse(
@SerialName("supports_streaming")
val supportsStreaming: Boolean = false,
@SerialName("supports_cloning")
val supportsCloning: Boolean = false,
@SerialName("max_text_length")
val maxTextLength: Int = 5000,
@SerialName("needs_network")
val needsNetwork: Boolean = false,
)
@Serializable
data class TtsVoiceResponse(
val id: String,
val name: String,
val language: String,
val engine: String,
val gender: String = "",
val quality: String = "",
@SerialName("requires_reference")
val requiresReference: Boolean = false,
)

View file

@ -0,0 +1,16 @@
package org.dueattendant149.bookshelf.domain.util
/**
* Ensures the URL ends with a trailing slash so Retrofit can append relative paths.
* Also adds a default scheme if missing.
*/
fun String.fixUriScheme(): String {
var url = this.trim()
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://$url"
}
if (!url.endsWith("/")) {
url = "$url/"
}
return url
}