From 88113d9de6b44ea4bfbe0ff46b1626e305ce974d Mon Sep 17 00:00:00 2001 From: Atte149 Date: Wed, 24 Jun 2026 11:15:24 +0300 Subject: [PATCH] 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). --- app/build.gradle.kts | 11 ++ .../bookreader/reader/MyApplication.kt | 2 + .../AudiobookshelfApiClientFactory.kt | 76 +++++++++ .../AudiobookshelfApiService.kt | 52 ++++++ .../PlaybackProgressUpdateRequest.kt | 21 +++ .../audiobookshelf/model/AbsItemResponse.kt | 35 ++++ .../bookshelfapi/BookshelfApiClientFactory.kt | 62 +++++++ .../bookshelfapi/BookshelfApiService.kt | 158 ++++++++++++++++++ .../bookshelfapi/ServerStatusMonitor.kt | 61 +++++++ .../bookshelfapi/model/AudioTrackResponse.kt | 13 ++ .../bookshelfapi/model/BookItemResponse.kt | 19 +++ .../bookshelfapi/model/DownloadResponses.kt | 75 +++++++++ .../bookshelfapi/model/LibraryResponse.kt | 14 ++ .../model/PlaybackProgressUpdateRequest.kt | 20 +++ .../model/ProgressUpdateRequest.kt | 20 +++ .../bookshelfapi/model/SearchResponse.kt | 54 ++++++ .../remote/bookshelfapi/model/TtsResponses.kt | 91 ++++++++++ .../bookshelfapi/model/UnifiedItemResponse.kt | 67 ++++++++ .../bookshelfapi/model/UploadBookResponse.kt | 14 ++ .../data/settings/ServerSettings.kt | 44 +++++ .../bookreader/di/BackendModule.kt | 37 ++++ .../bookreader/di/NetworkModule.kt | 34 ++++ .../bookreader/domain/util/UriUtils.kt | 76 +++++++++ build.gradle.kts | 1 + gradle/libs.versions.toml | 13 ++ 25 files changed, 1070 insertions(+) create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiClientFactory.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiService.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/model/AbsItemResponse.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiClientFactory.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiService.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/ServerStatusMonitor.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/AudioTrackResponse.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/BookItemResponse.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/DownloadResponses.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/LibraryResponse.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/SearchResponse.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/TtsResponses.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UnifiedItemResponse.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UploadBookResponse.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/data/settings/ServerSettings.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/di/BackendModule.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/di/NetworkModule.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/domain/util/UriUtils.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 02360b8..cbfa2a8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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)) diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/MyApplication.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/MyApplication.kt index 20d419c..56d19a3 100644 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/MyApplication.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/MyApplication.kt @@ -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() diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiClientFactory.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiClientFactory.kt new file mode 100644 index 0000000..db19620 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiClientFactory.kt @@ -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 + } + } diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiService.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiService.kt new file mode 100644 index 0000000..1322334 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiService.kt @@ -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 + + @Streaming + @GET("api/items/{itemId}/download") + suspend fun downloadBook( + @Path("itemId") itemId: String, + ): Response + + @GET("api/items/{itemId}/cover") + suspend fun getCover( + @Path("itemId") itemId: String, + ): Response + + @GET("api/me/progress/{itemId}") + suspend fun getProgress( + @Path("itemId") itemId: String, + ): Response + + @GET("api/items/{itemId}") + suspend fun getItem( + @Path("itemId") itemId: String, + ): Response + + @POST("api/me/progress/{itemId}") + suspend fun updateProgress( + @Path("itemId") itemId: String, + @Body request: PlaybackProgressUpdateRequest, + ): Response +} diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt new file mode 100644 index 0000000..6367890 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt @@ -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, +) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/model/AbsItemResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/model/AbsItemResponse.kt new file mode 100644 index 0000000..3bbb58d --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/model/AbsItemResponse.kt @@ -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 = 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, +) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiClientFactory.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiClientFactory.kt new file mode 100644 index 0000000..9a035ae --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiClientFactory.kt @@ -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 + } + } diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiService.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiService.kt new file mode 100644 index 0000000..1541afe --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiService.kt @@ -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 + + // Libraries / books + @GET("api/v1/books/libraries") + suspend fun getLibraries(): Response> + + @Streaming + @GET("api/v1/books/{itemId}/ebook") + suspend fun downloadEbook( + @Path("itemId") itemId: String, + ): Response + + @GET("api/v1/books/{itemId}/tracks") + suspend fun getAudioTracks( + @Path("itemId") itemId: String, + ): Response> + + @Streaming + @GET("api/v1/books/{itemId}/file/{fileId}") + suspend fun downloadAudioFile( + @Path("itemId") itemId: String, + @Path("fileId") fileId: String, + ): Response + + @Streaming + @GET("api/v1/books/{itemId}") + suspend fun getBook( + @Path("itemId") itemId: String, + ): Response + + @Streaming + @POST("api/v1/books/{itemId}/progress") + suspend fun updateReadingProgress( + @Path("itemId") itemId: String, + @Body request: ProgressUpdateRequest, + ): Response + + @Streaming + @POST("api/v1/books/{itemId}/playback-progress") + suspend fun updatePlaybackProgress( + @Path("itemId") itemId: String, + @Body request: PlaybackProgressUpdateRequest, + ): Response + + @GET("api/v1/books/library/{libraryId}/items") + suspend fun getLibraryItems( + @Path("libraryId") libraryId: String, + ): Response + + @GET("api/v1/books/library/{libraryId}/search") + suspend fun searchLibrary( + @Path("libraryId") libraryId: String, + @Query("q") query: String, + ): Response> + + @POST("api/v1/search") + suspend fun search( + @Body request: SearchRequest, + ): Response + + // TTS + @GET("api/v1/tts/engines") + suspend fun getTtsEngines(): Response + + @GET("api/v1/tts/voices") + suspend fun getTtsVoices( + @Query("engine") engine: String? = null, + ): Response + + @POST("api/v1/tts") + suspend fun createTtsJob( + @Body request: TtsCreateRequest, + ): Response + + @GET("api/v1/tts/{jobId}") + suspend fun getTtsJobStatus( + @Path("jobId") jobId: String, + ): Response + + @GET("api/v1/tts/jobs/list") + suspend fun listTtsJobs(): Response + + @Streaming + @GET("api/v1/tts/{jobId}/download") + suspend fun downloadTtsAudio( + @Path("jobId") jobId: String, + ): Response + + @Multipart + @POST("api/v1/upload/book") + suspend fun uploadBook( + @Part file: MultipartBody.Part, + @Part("book_type") bookType: RequestBody, + ): Response + + // Downloads / sources + @POST("api/v1/download") + suspend fun startDownload( + @Body request: DownloadRequest, + ): Response + + @GET("api/v1/download/list") + suspend fun listDownloads(): Response + + @POST("api/v1/podcasts") + suspend fun downloadPodcast( + @Body request: PodcastRequest, + ): Response + + @POST("api/v1/sources/yandex") + suspend fun downloadYandex( + @Body request: YandexRequest, + ): Response +} diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/ServerStatusMonitor.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/ServerStatusMonitor.kt new file mode 100644 index 0000000..fec67b8 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/ServerStatusMonitor.kt @@ -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) + } + } diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/AudioTrackResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/AudioTrackResponse.kt new file mode 100644 index 0000000..64f8727 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/AudioTrackResponse.kt @@ -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, +) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/BookItemResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/BookItemResponse.kt new file mode 100644 index 0000000..bf2a1d1 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/BookItemResponse.kt @@ -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, +) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/DownloadResponses.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/DownloadResponses.kt new file mode 100644 index 0000000..f7bb64e --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/DownloadResponses.kt @@ -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 = 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 = 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 = emptyList(), + @SerialName("abs_scan_triggered") + val absScanTriggered: Boolean = false, +) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/LibraryResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/LibraryResponse.kt new file mode 100644 index 0000000..af4ce25 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/LibraryResponse.kt @@ -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, +) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt new file mode 100644 index 0000000..8776e6f --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt @@ -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, +) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt new file mode 100644 index 0000000..fb411a5 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt @@ -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, +) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/SearchResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/SearchResponse.kt new file mode 100644 index 0000000..6ecc8d4 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/SearchResponse.kt @@ -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 = 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, +) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/TtsResponses.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/TtsResponses.kt new file mode 100644 index 0000000..6f6cbe4 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/TtsResponses.kt @@ -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 = emptyList(), +) + +@Serializable +data class TtsEnginesResponse( + val engines: List = emptyList(), +) + +@Serializable +data class TtsVoicesResponse( + val voices: List = 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, +) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UnifiedItemResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UnifiedItemResponse.kt new file mode 100644 index 0000000..d157d2b --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UnifiedItemResponse.kt @@ -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 = emptyList(), +) + +@Serializable +data class UnifiedItemResponse( + val id: String, + val title: String = "", + val authors: List = 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, +) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UploadBookResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UploadBookResponse.kt new file mode 100644 index 0000000..755c7e8 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UploadBookResponse.kt @@ -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, +) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/settings/ServerSettings.kt b/app/src/main/java/org/dueattendant149/bookreader/data/settings/ServerSettings.kt new file mode 100644 index 0000000..4bb5b4e --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/data/settings/ServerSettings.kt @@ -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) } + } + } diff --git a/app/src/main/java/org/dueattendant149/bookreader/di/BackendModule.kt b/app/src/main/java/org/dueattendant149/bookreader/di/BackendModule.kt new file mode 100644 index 0000000..d85d184 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/di/BackendModule.kt @@ -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) +} diff --git a/app/src/main/java/org/dueattendant149/bookreader/di/NetworkModule.kt b/app/src/main/java/org/dueattendant149/bookreader/di/NetworkModule.kt new file mode 100644 index 0000000..9398e1b --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/di/NetworkModule.kt @@ -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() +} diff --git a/app/src/main/java/org/dueattendant149/bookreader/domain/util/UriUtils.kt b/app/src/main/java/org/dueattendant149/bookreader/domain/util/UriUtils.kt new file mode 100644 index 0000000..feedbf8 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/domain/util/UriUtils.kt @@ -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/" +} diff --git a/build.gradle.kts b/build.gradle.kts index 8f005dd..90f1ee0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.ksp) apply false + alias(libs.plugins.hilt) apply false alias(libs.plugins.kover) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c4ecad9..d0c1ae8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,11 @@ material3WindowSizeClassAndroid = "1.3.2" credentials = "1.5.0" composeMultiplatform = "1.8.2" ksp = "2.2.0-2.0.2" +hilt = "2.56.1" +hiltNavigationCompose = "1.2.0" +retrofit = "2.11.0" +retrofitKotlinxSerialization = "2.11.0" +okhttpLogging = "4.12.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -56,6 +61,13 @@ androidx-runner = { group = "androidx.test", name = "runner", version.ref = "and androidx-material3-window-size-class1-android = { group = "androidx.compose.material3", name = "material3-window-size-class-android", version.ref = "material3WindowSizeClassAndroid" } androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "credentials" } +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } +hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofitKotlinxSerialization" } +okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttpLogging" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -65,6 +77,7 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } # Add plugins required by pdfiumandroid android-library = { id = "com.android.library", version.ref = "agp" }