feat(backend): Phase 2.1 scaffolding for bookshelf-api + ABS
- Port BookshelfApiService, AudiobookshelfApiService and all models from Book's Story. - Add Hilt 2.56.1 plugin, @HiltAndroidApp, NetworkModule, BackendModule. - Add Retrofit/OkHttp/Kotlinx Serialization dependencies. - Provide minimal SharedPreferences-based ServerSettings for URL/token storage. - Copy UriUtils (fixUriScheme/normalizeUri) from Book's Story. - No wiring into UI yet; OPDS/Gutenberg/LocalFolder still present. - ':app:assembleOssDebug' passes (APK ~80 MB).
This commit is contained in:
parent
9e30933077
commit
88113d9de6
25 changed files with 1070 additions and 0 deletions
|
|
@ -11,6 +11,7 @@ plugins {
|
|||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kotlin.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
id("com.diffplug.spotless") version "8.2.1"
|
||||
alias(libs.plugins.kover)
|
||||
}
|
||||
|
|
@ -228,6 +229,16 @@ dependencies {
|
|||
implementation(libs.androidx.material3.window.size.class1.android)
|
||||
implementation(libs.androidx.credentials)
|
||||
|
||||
// Hilt
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.hilt.navigation.compose)
|
||||
|
||||
// Networking
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.kotlinx.serialization)
|
||||
implementation(libs.okhttp.logging)
|
||||
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
|
|
|
|||
|
|
@ -24,9 +24,11 @@ import android.webkit.WebView
|
|||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.decode.SvgDecoder
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import org.dueattendant149.bookreader.paginatedreader.SvgStringFetcher
|
||||
import timber.log.Timber // Add this
|
||||
|
||||
@HiltAndroidApp
|
||||
class MyApplication : Application(), ImageLoaderFactory {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
package org.dueattendant149.bookreader.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.bookreader.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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package org.dueattendant149.bookreader.data.remote.audiobookshelf
|
||||
|
||||
import okhttp3.ResponseBody
|
||||
import org.dueattendant149.bookreader.data.remote.audiobookshelf.PlaybackProgressUpdateRequest
|
||||
import org.dueattendant149.bookreader.data.remote.audiobookshelf.model.AbsItemResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
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>
|
||||
|
||||
@GET("api/items/{itemId}")
|
||||
suspend fun getItem(
|
||||
@Path("itemId") itemId: String,
|
||||
): Response<AbsItemResponse>
|
||||
|
||||
@POST("api/me/progress/{itemId}")
|
||||
suspend fun updateProgress(
|
||||
@Path("itemId") itemId: String,
|
||||
@Body request: PlaybackProgressUpdateRequest,
|
||||
): Response<String>
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package org.dueattendant149.bookreader.data.remote.audiobookshelf
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Request body for updating media progress directly on an Audiobookshelf server.
|
||||
*
|
||||
* Field names follow the ABS `/api/me/progress/{itemId}` endpoint contract.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlaybackProgressUpdateRequest(
|
||||
@SerialName("currentTime")
|
||||
val currentTime: Double,
|
||||
@SerialName("duration")
|
||||
val duration: Double,
|
||||
@SerialName("progress")
|
||||
val progress: Double,
|
||||
@SerialName("episodeId")
|
||||
val episodeId: String? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package org.dueattendant149.bookreader.data.remote.audiobookshelf.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AbsItemResponse(
|
||||
val id: String = "",
|
||||
val media: AbsMedia? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsMedia(
|
||||
val metadata: AbsMediaMetadata? = null,
|
||||
@SerialName("audioFiles")
|
||||
val audioFiles: List<AbsAudioFile> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsMediaMetadata(
|
||||
val title: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsAudioFile(
|
||||
val ino: String = "",
|
||||
val metadata: AbsAudioFileMetadata? = null,
|
||||
val duration: Double = 0.0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsAudioFileMetadata(
|
||||
val filename: String? = null,
|
||||
val ext: String? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package org.dueattendant149.bookreader.data.remote.bookshelfapi
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import org.dueattendant149.bookreader.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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
package org.dueattendant149.bookreader.data.remote.bookshelfapi
|
||||
|
||||
import okhttp3.ResponseBody
|
||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.AudioTrackResponse
|
||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse
|
||||
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.PlaybackProgressUpdateRequest
|
||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PodcastRequest
|
||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PodcastResponse
|
||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.ProgressUpdateRequest
|
||||
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.TtsCreateRequest
|
||||
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.remote.bookshelfapi.model.YandexRequest
|
||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.YandexResponse
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.Streaming
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Retrofit description of the Bookshelf API (bookshelf-api:8073).
|
||||
* All endpoints are relative to the configured base URL.
|
||||
*/
|
||||
interface BookshelfApiService {
|
||||
|
||||
// Health
|
||||
@GET("health")
|
||||
suspend fun health(): Response<Unit>
|
||||
|
||||
// Libraries / books
|
||||
@GET("api/v1/books/libraries")
|
||||
suspend fun getLibraries(): Response<List<LibraryResponse>>
|
||||
|
||||
@Streaming
|
||||
@GET("api/v1/books/{itemId}/ebook")
|
||||
suspend fun downloadEbook(
|
||||
@Path("itemId") itemId: String,
|
||||
): Response<ResponseBody>
|
||||
|
||||
@GET("api/v1/books/{itemId}/tracks")
|
||||
suspend fun getAudioTracks(
|
||||
@Path("itemId") itemId: String,
|
||||
): Response<List<AudioTrackResponse>>
|
||||
|
||||
@Streaming
|
||||
@GET("api/v1/books/{itemId}/file/{fileId}")
|
||||
suspend fun downloadAudioFile(
|
||||
@Path("itemId") itemId: String,
|
||||
@Path("fileId") fileId: String,
|
||||
): Response<ResponseBody>
|
||||
|
||||
@Streaming
|
||||
@GET("api/v1/books/{itemId}")
|
||||
suspend fun getBook(
|
||||
@Path("itemId") itemId: String,
|
||||
): Response<ResponseBody>
|
||||
|
||||
@Streaming
|
||||
@POST("api/v1/books/{itemId}/progress")
|
||||
suspend fun updateReadingProgress(
|
||||
@Path("itemId") itemId: String,
|
||||
@Body request: ProgressUpdateRequest,
|
||||
): Response<ResponseBody>
|
||||
|
||||
@Streaming
|
||||
@POST("api/v1/books/{itemId}/playback-progress")
|
||||
suspend fun updatePlaybackProgress(
|
||||
@Path("itemId") itemId: String,
|
||||
@Body request: PlaybackProgressUpdateRequest,
|
||||
): Response<ResponseBody>
|
||||
|
||||
@GET("api/v1/books/library/{libraryId}/items")
|
||||
suspend fun getLibraryItems(
|
||||
@Path("libraryId") libraryId: String,
|
||||
): Response<LibraryItemsResponse>
|
||||
|
||||
@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>
|
||||
|
||||
@Multipart
|
||||
@POST("api/v1/upload/book")
|
||||
suspend fun uploadBook(
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("book_type") bookType: RequestBody,
|
||||
): Response<UploadBookResponse>
|
||||
|
||||
// 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>
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookreader.data.remote.bookshelfapi
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.dueattendant149.bookreader.data.settings.ServerSettings
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ServerStatusMonitor
|
||||
@Inject
|
||||
constructor(
|
||||
private val clientFactory: BookshelfApiClientFactory,
|
||||
private val serverSettings: ServerSettings,
|
||||
) {
|
||||
private val _isOnline = MutableStateFlow(false)
|
||||
val isOnline = _isOnline.asStateFlow()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var pingJob: Job? = null
|
||||
|
||||
fun start() {
|
||||
if (pingJob != null) return
|
||||
pingJob = scope.launch {
|
||||
while (true) {
|
||||
val online = checkHealth()
|
||||
_isOnline.value = online
|
||||
delay(30_000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
pingJob?.cancel()
|
||||
pingJob = null
|
||||
}
|
||||
|
||||
private suspend fun checkHealth(): Boolean {
|
||||
val url = serverSettings.getBookshelfUrl() ?: return false
|
||||
val client = clientFactory.provideClient(url) ?: return false
|
||||
return runCatching {
|
||||
val response = client.health()
|
||||
response.isSuccessful
|
||||
}.onFailure {
|
||||
Log.d("ServerStatusMonitor", "Health check failed: ${it.message}")
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AudioTrackResponse(
|
||||
@SerialName("file_id")
|
||||
val fileId: String,
|
||||
val title: String = "",
|
||||
val duration: Double = 0.0,
|
||||
val size: Long = 0L,
|
||||
)
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package org.dueattendant149.bookreader.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,
|
||||
)
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package org.dueattendant149.bookreader.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,
|
||||
)
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package org.dueattendant149.bookreader.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,
|
||||
)
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Request body for updating the audio playback progress of a book on the bookshelf-api.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlaybackProgressUpdateRequest(
|
||||
val itemId: String,
|
||||
val libraryId: String,
|
||||
@SerialName("current_file")
|
||||
val currentFile: String,
|
||||
@SerialName("current_position")
|
||||
val currentPosition: Long,
|
||||
val duration: Long,
|
||||
@SerialName("updated_at")
|
||||
val updatedAt: Long,
|
||||
)
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Request body for updating the local reading progress of a book on the bookshelf-api.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProgressUpdateRequest(
|
||||
val itemId: String,
|
||||
val libraryId: String,
|
||||
val scrollIndex: Int,
|
||||
val scrollOffset: Int,
|
||||
val progress: Float,
|
||||
@SerialName("last_chapter")
|
||||
val lastChapter: String? = null,
|
||||
@SerialName("updated_at")
|
||||
val updatedAt: Long,
|
||||
)
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package org.dueattendant149.bookreader.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,
|
||||
)
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package org.dueattendant149.bookreader.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,
|
||||
)
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LibraryItemsResponse(
|
||||
val library: LibraryResponse = LibraryResponse(""),
|
||||
val items: List<UnifiedItemResponse> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UnifiedItemResponse(
|
||||
val id: String,
|
||||
val title: String = "",
|
||||
val authors: List<String> = emptyList(),
|
||||
val author: String = "",
|
||||
val type: 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,
|
||||
@SerialName("progress")
|
||||
val progress: UnifiedItemProgressResponse? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UnifiedItemProgressResponse(
|
||||
val reading: ReadingProgressResponse? = null,
|
||||
val playback: PlaybackProgressResponse? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ReadingProgressResponse(
|
||||
@SerialName("itemId")
|
||||
val itemId: String = "",
|
||||
@SerialName("libraryId")
|
||||
val libraryId: String = "",
|
||||
@SerialName("scrollIndex")
|
||||
val scrollIndex: Int = 0,
|
||||
@SerialName("scrollOffset")
|
||||
val scrollOffset: Int = 0,
|
||||
val progress: Float = 0f,
|
||||
@SerialName("last_chapter")
|
||||
val lastChapter: String? = null,
|
||||
@SerialName("updatedAt")
|
||||
val updatedAt: Long = 0L,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlaybackProgressResponse(
|
||||
@SerialName("itemId")
|
||||
val itemId: String = "",
|
||||
@SerialName("libraryId")
|
||||
val libraryId: String = "",
|
||||
@SerialName("current_file")
|
||||
val currentFile: String = "",
|
||||
@SerialName("current_position")
|
||||
val currentPosition: Long = 0L,
|
||||
val duration: Long = 0L,
|
||||
@SerialName("updatedAt")
|
||||
val updatedAt: Long = 0L,
|
||||
)
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UploadBookResponse(
|
||||
val success: Boolean = false,
|
||||
val filename: String = "",
|
||||
val type: String = "",
|
||||
val path: String = "",
|
||||
@SerialName("error")
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package org.dueattendant149.bookreader.data.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val PREFS_NAME = "book_reader_server_settings"
|
||||
private const val KEY_BOOKSHELF_URL = "bookshelf_url"
|
||||
private const val KEY_ABS_URL = "abs_url"
|
||||
private const val KEY_ABS_TOKEN = "abs_token"
|
||||
|
||||
/**
|
||||
* Minimal server URL/token storage. Replaces the full DataStore-based settings
|
||||
* from Book's Story for Phase 2 backend scaffolding.
|
||||
*/
|
||||
@Singleton
|
||||
class ServerSettings
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext context: Context,
|
||||
) {
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun getBookshelfUrl(): String? = prefs.getString(KEY_BOOKSHELF_URL, null)
|
||||
|
||||
fun setBookshelfUrl(url: String) {
|
||||
prefs.edit { putString(KEY_BOOKSHELF_URL, url) }
|
||||
}
|
||||
|
||||
fun getAbsUrl(): String? = prefs.getString(KEY_ABS_URL, null)
|
||||
|
||||
fun setAbsUrl(url: String) {
|
||||
prefs.edit { putString(KEY_ABS_URL, url) }
|
||||
}
|
||||
|
||||
fun getAbsToken(): String? = prefs.getString(KEY_ABS_TOKEN, null)
|
||||
|
||||
fun setAbsToken(token: String) {
|
||||
prefs.edit { putString(KEY_ABS_TOKEN, token) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package org.dueattendant149.bookreader.di
|
||||
|
||||
import android.content.Context
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
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.bookshelfapi.BookshelfApiClientFactory
|
||||
import org.dueattendant149.bookreader.data.settings.ServerSettings
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object BackendModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideServerSettings(@ApplicationContext context: Context): ServerSettings = ServerSettings(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideBookshelfApiClientFactory(
|
||||
json: Json,
|
||||
okHttpClient: OkHttpClient,
|
||||
): BookshelfApiClientFactory = BookshelfApiClientFactory(json, okHttpClient)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAudiobookshelfApiClientFactory(
|
||||
json: Json,
|
||||
okHttpClient: OkHttpClient,
|
||||
): AudiobookshelfApiClientFactory = AudiobookshelfApiClientFactory(json, okHttpClient)
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package org.dueattendant149.bookreader.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 okhttp3.logging.HttpLoggingInterceptor
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideJson(): Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(): OkHttpClient = OkHttpClient
|
||||
.Builder()
|
||||
.addInterceptor(
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
}
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package org.dueattendant149.bookreader.domain.util
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
private val URL_SCHEME_REGEX = Regex("^[hH][tT][tT][pP][sS]?://")
|
||||
|
||||
/**
|
||||
* Returns true if the string is non-blank, has an http/https scheme,
|
||||
* and can be parsed by [Uri.parse] into a host-bearing URI.
|
||||
*/
|
||||
fun String.isValidUri(): Boolean {
|
||||
val trimmed = trim()
|
||||
if (trimmed.isBlank()) return false
|
||||
if (!trimmed.hasUriScheme()) return false
|
||||
|
||||
val uri = runCatching { Uri.parse(trimmed) }.getOrNull() ?: return false
|
||||
return !uri.host.isNullOrBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the string starts with http:// or https:// (case-insensitive).
|
||||
*/
|
||||
fun String.hasUriScheme(): Boolean = URL_SCHEME_REGEX.containsMatchIn(this)
|
||||
|
||||
private val IP_PATTERN = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$")
|
||||
|
||||
/**
|
||||
* Adds https:// (or http:// for bare IP addresses) if the string has no scheme.
|
||||
*/
|
||||
fun String.ensureUriScheme(): String {
|
||||
val trimmed = trim()
|
||||
if (trimmed.hasUriScheme()) return trimmed
|
||||
val hostPart = trimmed.substringBefore("/")
|
||||
val scheme = if (IP_PATTERN.matches(hostPart)) "http://" else "https://"
|
||||
return "$scheme$trimmed"
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a trailing slash if missing.
|
||||
*/
|
||||
fun String.ensureTrailingSlash(): String {
|
||||
val trimmed = trim()
|
||||
return if (trimmed.endsWith("/")) trimmed else "$trimmed/"
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a URL for Retrofit: trims whitespace, adds a scheme if missing,
|
||||
* and ensures a trailing slash.
|
||||
*/
|
||||
fun String.normalizeUri(): String = ensureUriScheme().ensureTrailingSlash()
|
||||
|
||||
/**
|
||||
* Legacy alias for [normalizeUri].
|
||||
*/
|
||||
fun String.fixUriScheme(): String = normalizeUri()
|
||||
|
||||
/**
|
||||
* Derives a likely Audiobookshelf URL from a Bookshelf API URL.
|
||||
* Replaces a known bookshelf-api port (8073) with the default ABS port (13378),
|
||||
* or appends :13378 when no port is present. Returns null if the input is invalid.
|
||||
*/
|
||||
fun String.deriveAbsUrl(): String? {
|
||||
if (!isValidUri()) return null
|
||||
val normalized = normalizeUri()
|
||||
val uri = Uri.parse(normalized)
|
||||
val host = uri.host ?: return null
|
||||
val port = uri.port
|
||||
val scheme = uri.scheme?.lowercase() ?: "https"
|
||||
|
||||
val derivedPort = when (port) {
|
||||
8073 -> 13378
|
||||
-1 -> 13378
|
||||
else -> port
|
||||
}
|
||||
return "$scheme://$host:$derivedPort/"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue