diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70f96ca2..5d543541 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -164,4 +164,19 @@ dependencies { // Json implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + + // Network (bookshelf-api + ABS) + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + + // Audio (Media3 / ExoPlayer) + implementation("androidx.media3:media3-exoplayer:1.6.0") + implementation("androidx.media3:media3-session:1.6.0") + implementation("androidx.media3:media3-ui:1.6.0") + implementation("androidx.media3:media3-datasource-okhttp:1.6.0") + + // Background work (cache, TTS sync) + implementation("androidx.work:work-runtime-ktx:2.10.0") } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/di/NetworkModule.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/di/NetworkModule.kt new file mode 100644 index 00000000..dc927c99 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/di/NetworkModule.kt @@ -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() + +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiClientFactory.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiClientFactory.kt new file mode 100644 index 00000000..45dfe14e --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiClientFactory.kt @@ -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 + } + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt new file mode 100644 index 00000000..1dbe391f --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt @@ -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 + + @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 +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiClientFactory.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiClientFactory.kt new file mode 100644 index 00000000..9b44499d --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiClientFactory.kt @@ -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 + } + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt new file mode 100644 index 00000000..efd1bce7 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt @@ -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> + + @GET("api/v1/books/{itemId}") + suspend fun getBook( + @Path("itemId") itemId: 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 + + // 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/bookshelf/data/remote/bookshelfapi/model/BookItemResponse.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/BookItemResponse.kt new file mode 100644 index 00000000..fce09c02 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/BookItemResponse.kt @@ -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, +) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/DownloadResponses.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/DownloadResponses.kt new file mode 100644 index 00000000..7d06545d --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/DownloadResponses.kt @@ -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 = 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/bookshelf/data/remote/bookshelfapi/model/LibraryResponse.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/LibraryResponse.kt new file mode 100644 index 00000000..31feeb15 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/LibraryResponse.kt @@ -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, +) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/SearchResponse.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/SearchResponse.kt new file mode 100644 index 00000000..db9c9632 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/SearchResponse.kt @@ -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 = 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/bookshelf/data/remote/bookshelfapi/model/TtsResponses.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/TtsResponses.kt new file mode 100644 index 00000000..6ee73e42 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/TtsResponses.kt @@ -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 = 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/bookshelf/domain/util/UriUtils.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/util/UriUtils.kt new file mode 100644 index 00000000..3987268d --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/util/UriUtils.kt @@ -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 +} diff --git a/gradlew b/gradlew old mode 100644 new mode 100755